From cf40482b96ec948836697f21a230b0454f386222 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 11:35:23 +0900 Subject: [PATCH 01/13] docs(devlog): re-verify wp6 client-initiated plan --- .../060_wp6_client_initiated.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/devlog/_plan/260925_remote_home_child_link/060_wp6_client_initiated.md b/devlog/_plan/260925_remote_home_child_link/060_wp6_client_initiated.md index 948302bdf60..39a1739cbed 100644 --- a/devlog/_plan/260925_remote_home_child_link/060_wp6_client_initiated.md +++ b/devlog/_plan/260925_remote_home_child_link/060_wp6_client_initiated.md @@ -417,3 +417,18 @@ GUI의 `gui/tests/remote-link-client.test.tsx`는 별도 GUI test runner 대상 - K12에 맞춰 local connect 실패 rollback을 Home SSH의 `ocx link revoke --link-id ` admin-token CLI 경로로 고정했다. - K16 status DTO의 `role`, `listener`, `links`, `child` 전체를 고정하고 GUI의 `home/child`와 `hub/client` 매핑을 명시했다. - K1에 맞춰 Home-side issue가 dashboard session이 아닌 Home 로컬 admin-token CLI 경로를 사용하도록 join 순서와 테스트 증거를 갱신했다. + +## wp6 P 재검증 (아키텍트 Confucius, gpt-6-sol high, 2026-09-25) — 이 절이 앞선 내용보다 우선한다 + +| ID | 제안 | 처분 | +|---|---|---| +| W6-1 | `POST /api/link/join {alias}` 추가, 페어링 대시보드 세션 전용, standalone 전용, 인증 후 상태 해석, route-registry 등록 | 수용 | +| W6-2 | 로컬 `ocx link port` 계약(`{"port":P}`)과 원격 `ocx link issue --alias --tunnel-port P`(buildExecArgv + SshRunner). P는 1024-65535로 통일(runPort 수정) | 수용. this-machine 별칭은 `os.hostname()`을 별칭 규칙에 맞게 정규화한 값, 규칙 위반이면 `client-<8hex>` | +| W6-3 | 로컬 connect는 셸이 아니라 in-process `connectClient`(transport link, `http://127.0.0.1:P`), 실패 시 SSH로 `ocx link revoke --link-id `, 회수 확인 후에만 sidecar 삭제 | 수용 | +| W6-4 | `src/client/link-state.ts` 신설: K11 다섯 필드, 0600, 손상 거부, 소유 확인 삭제, 시작 시 fail-closed 복구 | 수용 | +| W6-5 | 클라이언트 소유 `-L` supervisor: client 런타임(src/client/runtime.ts)이 시작·종료, 재시도·종결 실패, 한정 종료, 리스너보다 먼저 정지 | 수용. 기존 src/link/supervisor.ts 재사용(방향 L 레코드 추가 모드) 또는 같은 리듀서를 쓰는 작은 client supervisor 중 구현자가 선택하되 테스트는 동일 | +| W6-6 | issue(관리자 토큰+신뢰 루프백)와 연결된 machine listener의 읽기 전용 규칙 유지 | 유지 | +| W6-7 | GUI Child 역할은 standalone일 때만 활성(`isStandaloneRuntime` export), 서버는 hub/client에서 join 시 409 `standalone_required` | 수용. `standalone_required`는 LINK_ERROR_CODES에 추가되어 GUI parity 테스트가 따라온다 | +| W6-8 | 키 zeroize 표현 제거(K18) | 수용 | +| W6-9 | 8개 docs 가이드의 "coming soon" 문장을 실제 자식 시작 흐름으로 교체, 테스트 배치 등록, 줄 수 재측정 | 수용 | + From 36bc1cb5d8fc73bf7a5c5dbebd91dadf33b7488c Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:01:19 +0900 Subject: [PATCH 02/13] docs(devlog): close the wp6 orphan-tunnel audit residual --- .../060_wp6_client_initiated.md | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/devlog/_plan/260925_remote_home_child_link/060_wp6_client_initiated.md b/devlog/_plan/260925_remote_home_child_link/060_wp6_client_initiated.md index 39a1739cbed..699f4673240 100644 --- a/devlog/_plan/260925_remote_home_child_link/060_wp6_client_initiated.md +++ b/devlog/_plan/260925_remote_home_child_link/060_wp6_client_initiated.md @@ -432,3 +432,70 @@ GUI의 `gui/tests/remote-link-client.test.tsx`는 별도 GUI test runner 대상 | W6-8 | 키 zeroize 표현 제거(K18) | 수용 | | W6-9 | 8개 docs 가이드의 "coming soon" 문장을 실제 자식 시작 흐름으로 교체, 테스트 배치 등록, 줄 수 재측정 | 수용 | + +## 감사 반영 (Anscombe FAIL r1, wp6 계획 감사) — 이 절이 앞선 모든 내용보다 우선한다 + +차단 1(join/게이트 부재)과 5(문서 미갱신)는 구현 전 상태를 지적한 것으로, 이 계획이 B에서 만들 산출물이다. 아래 7번 테스트와 8번 문서 목록으로 완료 조건을 고정한다. + +1. join 순서(확정, 각 화살표 뒤 실패 시 되돌림 명시): + a. 권한: 페어링 대시보드 세션, Tailscale 세션 거부, `runtimeRole`이 standalone이 아니면 409 `standalone_required`. + b. probe → confirm-host(허브 호스트 키를 링크 known_hosts에 기록) — 실패 시 아무것도 남기지 않음. + c. 로컬 포트 P 선택(공용 할당기, 1024 이상). + d. SSH로 허브의 `ocx link issue --alias --tunnel-port P` 실행, stdout JSON `{linkId, apiKeyId, key, listenerPort}` 파싱(키는 로그·오류에 쓰지 않음) — 실패 시 되돌릴 것 없음. + e. sidecar `client-link.json` 기록(K11) — 실패 시 원격 revoke. + f. 클라이언트 소유 `ssh -N -L 127.0.0.1:P:127.0.0.1:` 시작 후, 인증된 `GET /readyz`가 `http://127.0.0.1:P`에서 응답할 때까지 최대 15초 대기 — 실패 시 터널 중지 → 원격 revoke → sidecar 삭제. + g. in-process `connectClient`(transport link, key는 메모리에서 전달) — 실패 시 터널 중지 → 원격 revoke → sidecar 삭제(connectClient 자체 롤백은 기존대로). + h. 성공 시 응답 후 standalone→client 전환 재시작 예약(기존 `acceptSystemRestart`/recycle 경로). +2. 재시작·해제 경로의 터널 정리: + - 클라이언트 런타임(src/client/runtime.ts)은 시작 시 sidecar가 있고 client transport가 link이면 `-L` supervisor를 시작하고, 종료(정상 stop, `scheduleStandaloneRecycle` 경로 포함)에서 supervisor를 리스너보다 먼저 멈춘다(TERM 후 최대 5초 대기, 이어 KILL). + - 로컬 `ocx disconnect`(src/client/connect.ts disconnect 경로)는 연결 상태를 지우기 전에 sidecar가 있으면 터널을 멈추고, SSH로 허브에 `ocx link revoke --link-id `를 한 번 시도(실패해도 해제는 진행, 결과를 disconnect 출력과 receipt에 "home revoke failed: run ocx link revoke on the home" 형태로 남김, 키는 쓰지 않음), 그 뒤 sidecar 삭제. + - sidecar가 있는데 시작 시 손상·권한 불량이면 fail closed: 터널을 띄우지 않고 status child.state failed, reason `sidecar_invalid`. +3. 포트 계약: `src/link/ports.ts`(NEW)에 `MIN_LINK_PORT = 1024`, `MAX_LINK_PORT = 65535`, `isLinkPort(n)`. `ocx link port` 할당, `ocx link issue --tunnel-port` 파싱, link-routes issue 본문 검증, sidecar 검증, connect 검증이 모두 이 함수를 쓴다. 테스트: 1023 거부, 1024 허용, 65536 거부(각 경로). +4. 문서 소유: `structure/remote-link.md`에 클라이언트 시작 흐름·재시작·해제 정리를 현재형으로 추가하고, `structure/runtime.md`(600줄 예산)에는 client 런타임을 서술한 기존 줄 끝에 "A client with a link sidecar owns its SSH tunnel; see [Remote Link](remote-link.md)." 한 문장만 덧붙여 줄 수를 늘리지 않는다. +5. 비차단: 앞 절의 key zeroize 표현(:146 부근)과 "L4/L5 파일 없음" 서술은 무효. connect.ts 1037줄, runtime.ts 129줄(상한 없음). +6. GUI: `gui/src/api-targets.ts`에서 `isStandaloneRuntime()` export, Child 역할은 standalone일 때만 선택 가능, "홈 찾기" 흐름은 wp5의 추가 시트를 재사용(후보 → probe → 지문 확인 → join), 진행 중·실패·재시도 상태, 성공 시 "이 컴퓨터가 재시작됩니다" 안내 후 재연결 대기. 새 문구 키 10개 로케일. +7. 테스트(파일과 등록): + - `tests/server/link-join-route.test.ts`: 세션 없음 401/403, Tailscale 403, hub·client 역할 409, 순서 a→h를 가짜 SshRunner·가짜 터널·가짜 connectClient로 검증, 단계 e/f/g 실패마다 revoke 호출과 sidecar 부재. + - `tests/clients/client-link-state.test.ts`: sidecar 필드·권한·손상 거부·소유 확인 삭제. + - `tests/clients/client-link-tunnel.test.ts`: 런타임 시작 시 -L 시작, 재시작 경로에서 supervisor가 리스너보다 먼저 멈춤, TERM→KILL 한정 대기, 로컬 disconnect가 터널 중지·revoke 시도·sidecar 삭제, revoke 실패 시에도 해제 완료와 안내 문구. + - `tests/clients/link-ports.test.ts`: 포트 계약. + - gui `tests/remote-link.test.tsx`에 Child 활성 조건·join 흐름·standalone_required 문구. + - 모두 layout.json explicit와 test-layout-expected.json에 등록. +8. 문서: docs-site 8개 로케일 remote-link 가이드의 "coming soon" 문장을 자식 시작 흐름(요구 사항: 자식에서 홈으로 SSH 키 로그인, 홈에 ocx 실행 중)과 재시작·해제 동작으로 교체. + + +## 감사 반영 (Anscombe FAIL r2) — 이 절이 앞선 모든 내용보다 우선한다 + +1. 해제 오케스트레이션: NEW `src/client/link-teardown.ts`의 `teardownClientLink(deps: { readSidecar, stopTunnel, runner, knownHostsFile, deleteSidecar }): Promise<{ homeRevoke: "revoked" | "failed" | "not_applicable" }>`. 순서: sidecar 읽기(없으면 not_applicable) → `stopTunnel()`(런타임 supervisor의 stop, TERM→최대 5초→KILL) → SSH로 허브의 `ocx link revoke --link-id ` 1회(30초 제한) → sidecar 삭제. 호출 지점 두 곳: (a) machine API의 disconnect 처리(src/client/machine-api.ts:131-134 부근)가 `disconnectClient`를 부르기 **전에** 런타임이 주입한 `linkTeardown`을 호출, (b) CLI `ocx disconnect`(src/cli/connect.ts의 disconnect 경로)는 런타임이 떠 있으면 machine API를 쓰고, 아니면 프로세스 안에서 같은 함수를 직접 호출(stopTunnel은 pidfile 기반 정지). 잠금: teardown은 client lifecycle 잠금 밖에서 실행하고, 이어지는 `disconnectClient`가 기존 잠금을 잡는다(teardown이 연결 상태를 건드리지 않으므로 경합 없음). 결과 필드: disconnect의 JSON 출력과 receipt에 `homeRevoke`, 사람용 출력은 failed일 때만 "Home revoke failed; run ocx link revoke --link-id on the home." 키는 어디에도 쓰지 않는다. +2. 준비 확인: join f단계의 확인은 `GET http://127.0.0.1:P/readyz`에 `x-opencodex-api-key: <발급 키>`를 붙여 보내고 HTTP 200을 성공으로 본다(503은 준비 중으로 재시도, 401은 즉시 실패 `admission_failed`). 키는 로그·오류 문자열에 넣지 않는다. +3. 포트 계약 범위: `isLinkPort`(1024-65535)는 **클라이언트 쪽 터널 포트 P**에만 적용한다: `ocx link port` 할당, `ocx link issue --tunnel-port`, link-routes issue 본문, `LinkRecord.tunnelPort`(src/link/store.ts:77-82의 tunnelPort 검증), `link.tunnelPort` 설정 스키마와 connect, link-relay 목적지(src/client/link-relay.ts:44-48), sidecar의 tunnelPort. **허브 리스너 포트 L**(`LinkStore.listenerPort`, sidecar의 peerListenerPort)은 기존 1-65535 검증을 유지한다(OS가 고른 포트). +4. 테스트 파일 정본(앞 절의 client-link-join/client-link-supervisor 이름 폐기): `tests/server/link-join-route.test.ts`(server), `tests/clients/client-link-state.test.ts`, `tests/clients/client-link-tunnel.test.ts`, `tests/clients/client-link-teardown.test.ts`, `tests/clients/link-ports.test.ts`(clients) + gui `tests/remote-link.test.tsx`. 검증 명령은 이 다섯 파일과 기존 link·client-link·listener·routes·CLI 스위트를 모두 돌린다. +5. structure/runtime.md 줄 수(반박): 문장은 **기존 줄의 끝에** 덧붙이므로 파일 줄 수는 600 그대로다. C 단계에서 `wc -l structure/runtime.md`가 600, `bun run structure:check` 통과로 증명한다. +6. 비차단 반영: docs 8개 경로 = `docs-site/src/content/docs/guides/remote-link.md`, `docs-site/src/content/docs/{fr,ko,zh-cn,zh-tw,ru,ja,tr}/guides/remote-link.md`. join 라우트는 인증·Tailscale·역할 검사를 lifecycle 상태 조회(`stateFor`)보다 먼저 한다(현재 dispatch가 먼저 조회하므로 join은 그 앞에서 분기). + + +## 감사 반영 (Anscombe FAIL r3) — r2 절 1번을 다음으로 대체한다 + +1. 해제는 CLI `ocx disconnect` 한 경로뿐이다(연결된 machine listener는 변경 요청을 403으로 막으므로 대시보드 해제 경로는 없다, src/client/machine-listener.ts:125). CLI는 프로세스 안에서 `teardownClientLink`를 부른다: sidecar 읽기(없으면 not_applicable) → sidecar의 linkId가 현재 `config.client.link.linkId`와 같은지 확인(다르면 건드리지 않고 not_applicable) → SSH로 허브의 `ocx link revoke --link-id ` 1회(30초) → 이어지는 `disconnectClient`가 잠금 아래에서 연결 상태를 지운 뒤, 같은 잠금 안에서 sidecar를 다시 읽어 linkId가 같을 때만 삭제. 터널 프로세스는 CLI가 죽이지 않는다: 소유자인 client 런타임이 해제 뒤 기존 재시작 경로(src/client/runtime.ts:44-79 recycle)에서 supervisor를 리스너보다 먼저 멈춘다(TERM→5초→KILL). 런타임이 떠 있지 않으면 터널도 없다. +2. `homeRevoke`는 disconnect의 JSON 출력(`--json`)과 사람용 출력에만 싣는다. Desktop receipt 스키마(src/claude/desktop-remote-store-state.ts:113)는 바꾸지 않는다. 사람용 문구는 failed일 때만 "Home revoke failed; run ocx link revoke --link-id on the home." +3. 테스트(client-link-teardown.test.ts): revoke 성공·실패 두 경우 모두 해제 완료, 출력 필드, linkId 불일치 시 sidecar 보존, 잠금 안 재확인. client-link-tunnel.test.ts: recycle 경로에서 supervisor가 리스너보다 먼저 멈춤. + + +## 감사 반영 (Anscombe FAIL r4) — 터널 정지 트리거 + +- client 런타임의 `-L` supervisor는 기존 주기 타이머(1초)마다 `readClientConnectionState()`와 sidecar를 확인한다. 연결 상태가 connected가 아니거나, transport가 link가 아니거나, `link.linkId`가 sidecar의 linkId와 다르거나, sidecar가 없으면: 자식 ssh를 멈추고(TERM→5초→KILL) 기존 `scheduleStandaloneRecycle`(src/client/runtime.ts:44-79)을 예약한다. 새 인증 경로나 CLI→런타임 신호는 만들지 않는다. 런타임이 없으면 터널도 없다. +- 결과: CLI `ocx disconnect`는 원격 revoke 시도 → `disconnectClient`(잠금 안 sidecar 재확인·삭제)만 하고, 터널은 최대 한두 주기 안에 소유 런타임이 정리한다. +- 테스트(client-link-tunnel.test.ts): 실제 런타임 수명주기(가짜 SshRunner 자식, 주입 시계)에서 연결 상태를 disconnected로 바꾸면 2주기 안에 자식 정지와 recycle 예약이 일어남, linkId 불일치도 같음, 연결 유지 중에는 아무 일도 없음. + + + +## 감사 반영 (Anscombe NEAR-PASS r5) — 고아 터널 잔여 + +- r4의 "런타임이 없으면 터널도 없다"는 보장이 아니다. 런타임이 SIGKILL로 죽으면 자식 `ssh -N -L`이 남을 수 있다. 계약을 다음으로 좁힌다. +- supervisor는 자식을 띄울 때 `/client-link-tunnel.pid`(0600)에 `{ pid, linkId, argv }`를 쓰고, 정상 정지 뒤 지운다. +- 정리 시점은 두 곳이다. (a) client 런타임 시작 시 supervisor가 자식을 띄우기 전, (b) CLI `ocx disconnect`의 teardown. 두 곳 모두 같은 함수 `reapOrphanTunnel()`을 부른다. +- 확인 방법: Linux에서만 `/proc//cmdline`을 NUL로 나눈 argv가 pidfile의 argv와 **정확히** 같을 때 TERM→최대 5초→KILL 후 pidfile을 지우고 `tunnel: "reaped"`. 다르거나 프로세스가 없으면 pidfile만 지우고 `tunnel: "absent"`. Linux가 아니면(macOS·Windows) 프로세스를 건드리지 않고 pidfile을 남긴 채 `tunnel: "unresolved"`와 pid를 보고한다. +- CLI 출력: `--json`에 `tunnel` 필드, 사람용 출력은 unresolved일 때만 "A link tunnel (pid ) may still be running; stop it if it is." 한 줄. +- 남은 위험: macOS의 고아 터널은 자동 정리하지 않는다. 허브 revoke 뒤에는 발급 키가 무효라서 고아 터널로 들어오는 요청은 허브 리스너에서 401로 막힌다. +- 테스트(client-link-teardown.test.ts): 주입한 플랫폼·procfs 읽기로 Linux 일치(reaped), 불일치(absent, 프로세스 미접촉), 비Linux(unresolved, pidfile 유지) 세 경우. + From 5639de7b03646bf82a373f7be4bf40df1c052fac Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:06:51 +0900 Subject: [PATCH 03/13] feat(link): fix the wp6 shared contracts for ports, the client sidecar, and the tunnel API --- .../060_wp6_client_initiated.md | 9 ++ src/client/link-state.ts | 110 ++++++++++++++++++ src/client/link-tunnel.ts | 99 ++++++++++++++++ src/link/ports.ts | 12 ++ 4 files changed, 230 insertions(+) create mode 100644 src/client/link-state.ts create mode 100644 src/client/link-tunnel.ts create mode 100644 src/link/ports.ts diff --git a/devlog/_plan/260925_remote_home_child_link/060_wp6_client_initiated.md b/devlog/_plan/260925_remote_home_child_link/060_wp6_client_initiated.md index 699f4673240..5324814d9d6 100644 --- a/devlog/_plan/260925_remote_home_child_link/060_wp6_client_initiated.md +++ b/devlog/_plan/260925_remote_home_child_link/060_wp6_client_initiated.md @@ -499,3 +499,12 @@ GUI의 `gui/tests/remote-link-client.test.tsx`는 별도 GUI test runner 대상 - 남은 위험: macOS의 고아 터널은 자동 정리하지 않는다. 허브 revoke 뒤에는 발급 키가 무효라서 고아 터널로 들어오는 요청은 허브 리스너에서 401로 막힌다. - 테스트(client-link-teardown.test.ts): 주입한 플랫폼·procfs 읽기로 Linux 일치(reaped), 불일치(absent, 프로세스 미접촉), 비Linux(unresolved, pidfile 유지) 세 경우. + + +## 구현 정정 (B 착수, 2026-09-25) — r3와 r5의 충돌 해소 + +- r3는 "CLI는 터널을 죽이지 않는다", r5는 "disconnect teardown에서 고아 터널 회수"라서, 런타임이 살아 있을 때 Linux에서 회수하면 런타임 소유 터널까지 argv가 일치해 죽는다. +- pidfile 경로는 `/link/client-tunnel.pid`, 본문은 `{ version: 1, linkId, pid, argv, ownerPid }`다. `ownerPid`는 터널을 띄운 프로세스(client 런타임 또는 join 중인 standalone 프로세스)다. +- `reapOrphanTunnel()`은 `ownerPid`가 살아 있으면 아무것도 건드리지 않고 `{ tunnel: "owned" }`를 돌려준다. 소유자가 죽었을 때만 r5 규칙(Linux 정확 argv 일치 → reaped, 불일치·부재 → absent, 비Linux → unresolved)을 적용한다. +- 공용 인터페이스: `src/link/ports.ts`, `src/client/link-state.ts`, `src/client/link-tunnel.ts`(시그니처)를 B 첫 커밋에서 고정하고 병렬 실행자가 이 시그니처에 맞춰 구현한다. + diff --git a/src/client/link-state.ts b/src/client/link-state.ts new file mode 100644 index 00000000000..c81a1c0c951 --- /dev/null +++ b/src/client/link-state.ts @@ -0,0 +1,110 @@ +import { chmodSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { atomicWriteFile, isMissingPathError } from "../config/atomic-write"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; +import { hardenSecretDir } from "../lib/windows-secret-acl"; +import { linkDir } from "../link/paths"; +import { isLinkPort } from "../link/ports"; +import { assertSshAlias } from "../link/ssh-argv"; + +/** + * The client-owned half of a client-initiated link, stored beside the hub-side `links.json` + * as `/link/client-link.json` (directory 0700, file 0600). It holds no key: the + * data key lives only in the client connection config, which already owns it. + */ +export interface ClientLinkState { + linkId: string; + alias: string; + hubHostKeyFingerprint: string; + peerListenerPort: number; + tunnelPort: number; +} + +export class ClientLinkStateError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "ClientLinkStateError"; + } +} + +const FIELDS = new Set(["linkId", "alias", "hubHostKeyFingerprint", "peerListenerPort", "tunnelPort"]); +const LINK_ID = /^lnk_[0-9a-f]{16}$/; +const FINGERPRINT = /^[A-Z0-9]+:[A-Za-z0-9+/=]{16,128}$/; + +const isPort = (value: unknown): value is number => + typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535; + +export function clientLinkStatePath(configDir?: string): string { + return join(linkDir(configDir), "client-link.json"); +} + +export function parseClientLinkState(value: unknown): ClientLinkState { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new ClientLinkStateError("client-link.json is not an object"); + const raw = value as Record; + const fail = (field: string): never => { throw new ClientLinkStateError(`client-link.${field} is invalid`); }; + for (const field of Object.keys(raw)) if (!FIELDS.has(field)) fail(field); + if (typeof raw.linkId !== "string" || !LINK_ID.test(raw.linkId)) fail("linkId"); + if (typeof raw.alias !== "string") fail("alias"); + try { + assertSshAlias(raw.alias as string); + } catch { + fail("alias"); + } + if (typeof raw.hubHostKeyFingerprint !== "string" || !FINGERPRINT.test(raw.hubHostKeyFingerprint)) fail("hubHostKeyFingerprint"); + if (!isPort(raw.peerListenerPort)) fail("peerListenerPort"); + if (!isLinkPort(raw.tunnelPort)) fail("tunnelPort"); + return { + linkId: raw.linkId as string, + alias: raw.alias as string, + hubHostKeyFingerprint: raw.hubHostKeyFingerprint as string, + peerListenerPort: raw.peerListenerPort as number, + tunnelPort: raw.tunnelPort as number, + }; +} + +/** `null` when no sidecar exists. A present but unreadable or malformed sidecar throws. */ +export function readClientLinkState(path: string = clientLinkStatePath()): ClientLinkState | null { + let text: string; + try { + text = readFileSync(path, "utf8"); + } catch (error) { + if (isMissingPathError(error)) return null; + throw error; + } + let raw: unknown; + try { + raw = JSON.parse(text); + } catch (error) { + throw new ClientLinkStateError("client-link.json is not valid JSON", { cause: error }); + } + return parseClientLinkState(raw); +} + +export function writeClientLinkState(state: ClientLinkState, path: string = clientLinkStatePath()): void { + const normalized = parseClientLinkState(state); + const dir = dirname(path); + assertNotRealHomeUnderTest(dirname(dir)); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + if (process.platform === "win32") hardenSecretDir(dir, { required: true }); + else chmodSync(dir, 0o700); + // atomicWriteFile hardens its private temp on Windows before the rename, so only POSIX needs + // the explicit mode on the final path. + atomicWriteFile(path, `${JSON.stringify(normalized, null, 2)}\n`); + if (process.platform !== "win32") chmodSync(path, 0o600); +} + +/** + * Deletes the sidecar only while it still names `expectedLinkId`. Returns false when there was + * nothing to delete or it belongs to a different link, which callers treat as "not ours". + */ +export function clearClientLinkState(expectedLinkId: string, path: string = clientLinkStatePath()): boolean { + const current = readClientLinkState(path); + if (!current || current.linkId !== expectedLinkId) return false; + try { + unlinkSync(path); + } catch (error) { + if (isMissingPathError(error)) return false; + throw error; + } + return true; +} diff --git a/src/client/link-tunnel.ts b/src/client/link-tunnel.ts new file mode 100644 index 00000000000..e43d53760d5 --- /dev/null +++ b/src/client/link-tunnel.ts @@ -0,0 +1,99 @@ +import { join } from "node:path"; +import { linkDir } from "../link/paths"; +import type { SshRunner } from "../link/ssh-runner"; +import type { TunnelState } from "../link/tunnel-state"; +import type { ClientLinkState } from "./link-state"; + +/** + * The client-owned `ssh -N -L 127.0.0.1::127.0.0.1: ` + * of a client-initiated link. Two owners use it: the dashboard join (a short-lived tunnel that + * lives only until the in-process connect finishes) and the client runtime supervisor. + */ +export interface ClientLinkTunnelSpec { + linkId: string; + alias: string; + tunnelPort: number; + peerListenerPort: number; +} + +export interface ClientLinkTunnelHandle { + readonly pid: number; + readonly exited: Promise; + /** TERM, wait up to 5 s, then KILL; removes the pidfile this handle wrote. Idempotent. */ + stop(): Promise; +} + +export interface ClientLinkTunnelDeps { + runner?: SshRunner; + configDir?: string; + knownHostsFile?: string; + setTimer?: (callback: () => void, ms: number) => ReturnType; + clearTimer?: (timer: ReturnType) => void; +} + +export type OrphanTunnelResult = + | { tunnel: "reaped" } + | { tunnel: "absent" } + | { tunnel: "owned" } + | { tunnel: "unresolved"; pid: number }; + +export interface OrphanReapDeps { + configDir?: string; + platform?: NodeJS.Platform; + readProcessArgv?: (pid: number) => readonly string[] | null; + isAlive?: (pid: number) => boolean; + signal?: (pid: number, signal: NodeJS.Signals) => void; + sleep?: (ms: number) => Promise; +} + +export type ClientLinkSupervisorStatus = + | { kind: "stopped" } + | { kind: "tunnel"; linkId: string; state: TunnelState; pid: number | null } + | { kind: "failed"; reason: "sidecar_invalid" }; + +export interface ClientLinkSupervisor { + start(): void; + /** Stops the tunnel (TERM, up to 5 s, KILL). The runtime calls this before stopping its listener. */ + stop(): Promise; + status(): ClientLinkSupervisorStatus; +} + +export interface ClientLinkSupervisorDeps extends ClientLinkTunnelDeps, OrphanReapDeps { + readSidecar?: () => ClientLinkState | null; + /** Current link id of a connected link-transport client, or null when that no longer holds. */ + connectedLinkId?: () => string | null; + /** Called once after the tunnel stopped because the link ended (the runtime recycles here). */ + onLinkEnded?: () => void; + now?: () => number; + random?: () => number; + warn?: (message: string) => void; +} + +export function clientTunnelPidfilePath(configDir?: string): string { + return join(linkDir(configDir), "client-tunnel.pid"); +} + +/** + * Pidfile body at `clientTunnelPidfilePath()`: `{ version: 1, linkId, pid, argv, ownerPid }`. + * `ownerPid` is the process that spawned the tunnel. A tunnel is an orphan only while its owner + * is gone; a live owner means the tunnel is managed and `reapOrphanTunnel` reports "owned". + */ +export interface ClientTunnelPidfile { + version: 1; + linkId: string; + pid: number; + argv: string[]; + ownerPid: number; +} + +export function spawnClientLinkTunnel(_spec: ClientLinkTunnelSpec, _deps: ClientLinkTunnelDeps = {}): ClientLinkTunnelHandle { + throw new Error("spawnClientLinkTunnel: implemented by the client runtime lane"); +} + +export async function reapOrphanTunnel(_deps: OrphanReapDeps = {}): Promise { + throw new Error("reapOrphanTunnel: implemented by the client runtime lane"); +} + +export function createClientLinkSupervisor(_deps: ClientLinkSupervisorDeps = {}): ClientLinkSupervisor { + throw new Error("createClientLinkSupervisor: implemented by the client runtime lane"); +} diff --git a/src/link/ports.ts b/src/link/ports.ts new file mode 100644 index 00000000000..5dbd56ab366 --- /dev/null +++ b/src/link/ports.ts @@ -0,0 +1,12 @@ +/** + * Port contract for the client side of a link: the loopback port P that the client's + * `ssh -L` tunnel listens on. Privileged ports are refused so that an ordinary user + * process can always bind it. The hub listener port L is chosen by the OS and keeps the + * plain 1-65535 range; it does not use this contract. + */ +export const MIN_LINK_PORT = 1024; +export const MAX_LINK_PORT = 65535; + +export function isLinkPort(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= MIN_LINK_PORT && value <= MAX_LINK_PORT; +} From a1d379dcfb48edfed7918241957a65ffddc98a60 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:44:39 +0900 Subject: [PATCH 04/13] feat(link): run the client link tunnel from the client runtime The client runtime starts a supervised ssh -L tunnel when a link sidecar exists, stops it before the listener on every shutdown path, and recycles to standalone once the link ends. A pidfile with the owner pid lets a dead owner's tunnel be reaped on Linux by exact argv; other platforms report it as unresolved. An invalid sidecar fails closed and surfaces as a failed child with reason sidecar_invalid. --- scripts/test-layout/layout.json | 5 + src/client/link-tunnel.ts | 388 ++++++++++++++++++- src/client/runtime.ts | 105 +++-- src/link/status-projection.ts | 10 +- tests/clients/client-link-tunnel.test.ts | 251 ++++++++++++ tests/clients/link-status-projection.test.ts | 10 + tests/fixtures/test-layout-expected.json | 5 + 7 files changed, 727 insertions(+), 47 deletions(-) create mode 100644 tests/clients/client-link-tunnel.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ab0b7a2cab1..c00e2b7d86c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -180,6 +180,7 @@ "account-pool-management-api.test.ts": "server", "link-listener-admission.test.ts": "server", "link-listener-lifecycle.test.ts": "server", + "link-join-route.test.ts": "server", "core-link-boundary.test.ts": "lab", "acl-error-classification.test.ts": "lib", "active-registry-admission.test.ts": "codex-integration", @@ -1809,6 +1810,10 @@ "client-link-relay.test.ts": "clients", "client-link-runtime.test.ts": "clients", "link-routes.test.ts": "clients", + "client-link-state.test.ts": "clients", + "client-link-teardown.test.ts": "clients", + "client-link-tunnel.test.ts": "clients", + "link-ports.test.ts": "clients", "injection-link-websocket.test.ts": "codex-integration", "link-supervisor.test.ts": "clients", "link-status-projection.test.ts": "clients", diff --git a/src/client/link-tunnel.ts b/src/client/link-tunnel.ts index e43d53760d5..d41d898a1eb 100644 --- a/src/client/link-tunnel.ts +++ b/src/client/link-tunnel.ts @@ -1,8 +1,19 @@ -import { join } from "node:path"; -import { linkDir } from "../link/paths"; -import type { SshRunner } from "../link/ssh-runner"; -import type { TunnelState } from "../link/tunnel-state"; -import type { ClientLinkState } from "./link-state"; +import { chmodSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { atomicWriteFile, isMissingPathError } from "../config/atomic-write"; +import { linkDir, linkKnownHostsPath } from "../link/paths"; +import { buildTunnelArgv } from "../link/ssh-argv"; +import { createSshRunner, type SshChild, type SshRunner } from "../link/ssh-runner"; +import { + classifySshStderr, + dueForSpawn, + IDLE, + reduceTunnel, + type TunnelState, +} from "../link/tunnel-state"; +import { isLinkPort } from "../link/ports"; +import { isLinkConnection, readClientConnectionState } from "./state"; +import { clientLinkStatePath, readClientLinkState, type ClientLinkState } from "./link-state"; /** * The client-owned `ssh -N -L 127.0.0.1::127.0.0.1: ` @@ -51,6 +62,13 @@ export type ClientLinkSupervisorStatus = | { kind: "tunnel"; linkId: string; state: TunnelState; pid: number | null } | { kind: "failed"; reason: "sidecar_invalid" }; +export interface ClientLinkTunnelStatusProjection { + alias: string; + state: "failed"; + since: string; + reason: "sidecar_invalid"; +} + export interface ClientLinkSupervisor { start(): void; /** Stops the tunnel (TERM, up to 5 s, KILL). The runtime calls this before stopping its listener. */ @@ -58,6 +76,19 @@ export interface ClientLinkSupervisor { status(): ClientLinkSupervisorStatus; } +/** Read-only status bridge for a client whose persisted sidecar cannot be trusted. */ +export function clientLinkTunnelStatus( + path: string = clientLinkStatePath(), + now: () => number = Date.now, +): ClientLinkTunnelStatusProjection | null { + try { + readClientLinkState(path); + return null; + } catch { + return { alias: "unknown", state: "failed", since: new Date(now()).toISOString(), reason: "sidecar_invalid" }; + } +} + export interface ClientLinkSupervisorDeps extends ClientLinkTunnelDeps, OrphanReapDeps { readSidecar?: () => ClientLinkState | null; /** Current link id of a connected link-transport client, or null when that no longer holds. */ @@ -86,14 +117,349 @@ export interface ClientTunnelPidfile { ownerPid: number; } -export function spawnClientLinkTunnel(_spec: ClientLinkTunnelSpec, _deps: ClientLinkTunnelDeps = {}): ClientLinkTunnelHandle { - throw new Error("spawnClientLinkTunnel: implemented by the client runtime lane"); +const STOP_TIMEOUT_MS = 5_000; +const TIMER_MS = 1_000; +const SPAWN_GRACE_MS = 5_000; + +function sameArgv(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function parsePidfile(value: unknown): ClientTunnelPidfile | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const raw = value as Record; + if (raw.version !== 1 || typeof raw.linkId !== "string" || typeof raw.pid !== "number" + || !Number.isSafeInteger(raw.pid) || raw.pid < 1 || !Array.isArray(raw.argv) + || raw.argv.length === 0 || raw.argv.some(item => typeof item !== "string") + || typeof raw.ownerPid !== "number" || !Number.isSafeInteger(raw.ownerPid) || raw.ownerPid < 1) return null; + return { + version: 1, + linkId: raw.linkId, + pid: raw.pid, + argv: raw.argv as string[], + ownerPid: raw.ownerPid, + }; +} + +function readPidfile(path: string): ClientTunnelPidfile | null { + try { + return parsePidfile(JSON.parse(readFileSync(path, "utf8")) as unknown); + } catch (error) { + if (isMissingPathError(error)) return null; + return null; + } } -export async function reapOrphanTunnel(_deps: OrphanReapDeps = {}): Promise { - throw new Error("reapOrphanTunnel: implemented by the client runtime lane"); +function writePidfile(path: string, value: ClientTunnelPidfile): void { + const dir = dirname(path); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + atomicWriteFile(path, `${JSON.stringify(value)}\n`); + if (process.platform !== "win32") chmodSync(path, 0o600); } -export function createClientLinkSupervisor(_deps: ClientLinkSupervisorDeps = {}): ClientLinkSupervisor { - throw new Error("createClientLinkSupervisor: implemented by the client runtime lane"); +function removePidfileIfPid(path: string, pid: number): void { + const current = readPidfile(path); + if (current?.pid !== pid) return; + try { + unlinkSync(path); + } catch (error) { + if (!isMissingPathError(error)) throw error; + } +} + +function defaultSignal(pid: number, signal: NodeJS.Signals): void { + try { + process.kill(pid, signal); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ESRCH") throw error; + } +} + +function defaultIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +function linuxProcessArgv(pid: number): readonly string[] | null { + try { + const values = readFileSync(`/proc/${pid}/cmdline`).toString().split("\0"); + if (values.at(-1) === "") values.pop(); + return values.length > 0 ? values : null; + } catch (error) { + if (isMissingPathError(error)) return null; + return null; + } +} + +function timerDeps(deps: ClientLinkTunnelDeps): Required> { + return { + setTimer: deps.setTimer ?? ((callback, ms) => setTimeout(callback, ms)), + clearTimer: deps.clearTimer ?? (timer => clearTimeout(timer)), + }; +} + +async function stopChild(child: SshChild, deps: ClientLinkTunnelDeps): Promise { + const { setTimer, clearTimer } = timerDeps(deps); + try { + child.kill("SIGTERM"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + let timer: ReturnType | undefined; + let exited = false; + const exitedPromise = child.exited.then(() => { exited = true; }, () => { exited = true; }); + const timeout = new Promise(resolve => { + timer = setTimer(resolve, STOP_TIMEOUT_MS); + }); + await Promise.race([exitedPromise, timeout]); + if (timer !== undefined) clearTimer(timer); + if (!exited) { + try { + child.kill("SIGKILL"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + } +} + +export function spawnClientLinkTunnel(spec: ClientLinkTunnelSpec, deps: ClientLinkTunnelDeps = {}): ClientLinkTunnelHandle { + if (!isLinkPort(spec.tunnelPort)) throw new Error("client tunnel port is outside the link range"); + const knownHostsFile = deps.knownHostsFile ?? linkKnownHostsPath(deps.configDir); + const runner = deps.runner ?? createSshRunner(); + const argv = buildTunnelArgv({ + alias: spec.alias, + direction: "L", + bindPort: spec.tunnelPort, + targetPort: spec.peerListenerPort, + knownHostsFile, + }); + const child = runner.spawnTunnel(argv); + const pidfile = clientTunnelPidfilePath(deps.configDir); + try { + writePidfile(pidfile, { version: 1, linkId: spec.linkId, pid: child.pid, argv: [...argv], ownerPid: process.pid }); + } catch (error) { + try { child.kill("SIGTERM"); } catch (killError) { if ((killError as NodeJS.ErrnoException).code !== "ESRCH") throw killError; } + throw error; + } + + let stopPromise: Promise | undefined; + const handleExit = (): void => { + removePidfileIfPid(pidfile, child.pid); + }; + const handle = { + pid: child.pid, + exited: child.exited, + stderr: child.stderr, + stop(): Promise { + if (stopPromise) return stopPromise; + stopPromise = stopChild(child, deps).finally(() => removePidfileIfPid(pidfile, child.pid)); + return stopPromise; + }, + } satisfies ClientLinkTunnelHandle & { stderr?: Promise }; + void child.exited.then(handleExit, handleExit); + return handle; +} + +export async function reapOrphanTunnel(deps: OrphanReapDeps = {}): Promise { + const path = clientTunnelPidfilePath(deps.configDir); + const pidfile = readPidfile(path); + if (!pidfile) return { tunnel: "absent" }; + const isAlive = deps.isAlive ?? defaultIsAlive; + if (isAlive(pidfile.ownerPid)) return { tunnel: "owned" }; + const platform = deps.platform ?? process.platform; + if (platform !== "linux") return { tunnel: "unresolved", pid: pidfile.pid }; + const readProcessArgv = deps.readProcessArgv ?? linuxProcessArgv; + const actualArgv = readProcessArgv(pidfile.pid); + if (!actualArgv || !sameArgv(actualArgv, pidfile.argv)) { + try { unlinkSync(path); } catch (error) { if (!isMissingPathError(error)) throw error; } + return { tunnel: "absent" }; + } + const signal = deps.signal ?? defaultSignal; + const sleep = deps.sleep ?? ((ms: number) => new Promise(resolve => setTimeout(resolve, ms))); + try { signal(pidfile.pid, "SIGTERM"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; } + await sleep(STOP_TIMEOUT_MS); + if (isAlive(pidfile.pid)) { + try { signal(pidfile.pid, "SIGKILL"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; } + } + try { unlinkSync(path); } catch (error) { if (!isMissingPathError(error)) throw error; } + return { tunnel: "reaped" }; +} + +function defaultConnectedLinkId(): string | null { + const state = readClientConnectionState(); + if (state.kind !== "connected" || !isLinkConnection(state.value)) return null; + return state.value.link?.linkId ?? null; +} + +export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}): ClientLinkSupervisor { + const readSidecar = deps.readSidecar ?? (() => readClientLinkState(clientLinkStatePath(deps.configDir))); + const connectedLinkId = deps.connectedLinkId ?? defaultConnectedLinkId; + const now = deps.now ?? (() => Date.now()); + const random = deps.random ?? Math.random; + const setSupervisorTimer = deps.setTimer + ?? ((callback: () => void, ms: number) => setInterval(callback, ms) as unknown as ReturnType); + const clearSupervisorTimer = deps.clearTimer + ?? ((timer: ReturnType) => clearInterval(timer as unknown as ReturnType)); + let timer: ReturnType | undefined; + let started = false; + let stopping = false; + let initialized = false; + let initializing = false; + let onLinkEndedCalled = false; + let child: ClientLinkTunnelHandle | undefined; + let state: TunnelState = IDLE; + let linkId: string | null = null; + let failure: ClientLinkSupervisorStatus | undefined; + let tickFlight: Promise | undefined; + + const readCurrent = (): { sidecar: ClientLinkState | null; invalid: boolean } => { + try { + return { sidecar: readSidecar(), invalid: false }; + } catch (error) { + deps.warn?.("client link sidecar could not be read"); + return { sidecar: null, invalid: true }; + } + }; + + const stopTunnel = async (): Promise => { + const current = child; + child = undefined; + state = reduceTunnel(state, { type: "stop" }); + if (current) await current.stop(); + }; + + const endLink = async (): Promise => { + await stopTunnel(); + if (onLinkEndedCalled || stopping) return; + linkId = null; + onLinkEndedCalled = true; + deps.onLinkEnded?.(); + }; + + const spawn = (sidecar: ClientLinkState): void => { + if (stopping || child || state.kind === "failed") return; + try { + child = spawnClientLinkTunnel({ + linkId: sidecar.linkId, + alias: sidecar.alias, + tunnelPort: sidecar.tunnelPort, + peerListenerPort: sidecar.peerListenerPort, + }, deps); + linkId = sidecar.linkId; + state = reduceTunnel(state, { type: "spawn", now: now() }, random); + const current = child; + void current.exited.then(async () => { + if (child !== current) return; + child = undefined; + const stderr = (current as ClientLinkTunnelHandle & { stderr?: Promise }).stderr + ? await (current as ClientLinkTunnelHandle & { stderr?: Promise }).stderr!.catch(() => "") + : ""; + const next = reduceTunnel(state, { type: "exit", now: now(), stderrClass: classifySshStderr(stderr) }, random); + state = next; + }).catch(() => { + if (child !== current) return; + child = undefined; + state = reduceTunnel(state, { type: "exit", now: now(), stderrClass: "network" }, random); + }); + } catch (error) { + state = { kind: "failed", since: now(), reason: "forward" }; + deps.warn?.("client link tunnel could not be started"); + } + }; + + const tick = async (): Promise => { + if (stopping || !initialized) return; + const current = readCurrent(); + if (current.invalid) { + failure = { kind: "failed", reason: "sidecar_invalid" }; + await stopTunnel(); + return; + } + const connected = connectedLinkId(); + if (!current.sidecar) { + if (child || linkId) await endLink(); + return; + } + if (connected !== current.sidecar.linkId) { + if (child || linkId) await endLink(); + return; + } + failure = undefined; + const timestamp = now(); + state = reduceTunnel(state, { type: "tick", now: timestamp }, random); + if (child && state.kind === "connecting" && timestamp - state.since >= SPAWN_GRACE_MS) { + state = reduceTunnel(state, { type: "ready", now: timestamp }, random); + } + if (state.kind === "failed" && child) await stopTunnel(); + else if (!child && (state.kind === "idle" || dueForSpawn(state, timestamp))) spawn(current.sidecar); + }; + + const runTick = (): void => { + if (tickFlight) return; + tickFlight = tick().finally(() => { tickFlight = undefined; }); + }; + + const initialize = async (): Promise => { + if (initializing || initialized || stopping) return; + initializing = true; + const current = readCurrent(); + if (current.invalid) { + failure = { kind: "failed", reason: "sidecar_invalid" }; + initialized = true; + initializing = false; + return; + } + if (current.sidecar && connectedLinkId() === current.sidecar.linkId) { + const orphan = await reapOrphanTunnel(deps); + if (orphan.tunnel === "owned" || orphan.tunnel === "unresolved") { + linkId = current.sidecar.linkId; + state = { kind: "connected", since: now() }; + initialized = true; + initializing = false; + return; + } + const afterReap = readCurrent(); + if (!afterReap.invalid && afterReap.sidecar && connectedLinkId() === afterReap.sidecar.linkId) spawn(afterReap.sidecar); + } + initialized = true; + initializing = false; + }; + + return { + start(): void { + if (started) return; + started = true; + stopping = false; + timer = setSupervisorTimer(runTick, TIMER_MS); + void initialize().catch(() => { + failure = { kind: "failed", reason: "sidecar_invalid" }; + initialized = true; + initializing = false; + }); + }, + async stop(): Promise { + if (stopping) { + if (tickFlight) await tickFlight; + return; + } + stopping = true; + if (timer !== undefined) { + clearSupervisorTimer(timer); + timer = undefined; + } + if (tickFlight) await tickFlight; + await stopTunnel(); + failure = undefined; + }, + status(): ClientLinkSupervisorStatus { + if (failure) return failure; + if (!child && !linkId) return { kind: "stopped" }; + return { kind: "tunnel", linkId: linkId ?? "", state, pid: child?.pid ?? null }; + }, + }; } diff --git a/src/client/runtime.ts b/src/client/runtime.ts index 5dfe70dda01..ce3d726a8b2 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; import type { Server } from "bun"; import { loadConfig } from "../config"; import { removePid, removeRuntimePort, writePid, writeRuntimePort } from "../config/process-state"; @@ -6,11 +7,14 @@ import { installCrashGuards } from "../lib/crash-guard"; import { selfLaunchArgv } from "../lib/self-launch-argv"; import { loadServiceTokenFromFile, serviceApiTokenFingerprint } from "../lib/service-secrets"; import { findAvailablePort, PortUnavailableError } from "../server/ports"; +import { createClientLinkSupervisor, type ClientLinkSupervisor } from "./link-tunnel"; +import { clientLinkStatePath } from "./link-state"; import { startMachineListener } from "./machine-listener"; import { isLinkConnection, readClientConnectionState } from "./state"; let activeServer: Server | null = null; let activePort: number | null = null; +let activeSupervisor: ClientLinkSupervisor | null = null; let recycleScheduled = false; function cleanup(): void { @@ -45,40 +49,54 @@ export function scheduleStandaloneRecycle(disconnectedTokenFingerprint: string): if (recycleScheduled) return; recycleScheduled = true; const timer = setTimeout(() => { - const port = activePort; - try { activeServer?.stop(true); } catch { /* best effort */ } - cleanup(); - // Recycling back to standalone after `ocx disconnect` must actually bring a standalone - // proxy back, under either launch shape. - // - // Unsupervised: spawn the replacement ourselves and exit 0. - // - // Supervised (`OCX_SERVICE=1`): do NOT spawn — the supervisor owns the process, and a - // second copy would fight it for the port. But exit 0 does not work either: the real - // supervisor configs are failure-only (systemd `Restart=on-failure`, WinSW - // ``, the Task Scheduler ERRORLEVEL loop), so a clean exit - // reads as "the service finished" and nothing restarts. The client stayed down until the - // operator noticed. Exit 1 is what those configs are watching for, and it is the same - // policy the dashboard recycle already uses (src/server/management/system-restart.ts). - // - // launchd's KeepAlive restarts on any exit, so it is correct under both branches. - if (process.env.OCX_SERVICE === "1") { - process.exit(1); - } - if (port) { - const child = spawn(process.execPath, selfLaunchArgv(["start", "--port", String(port)]), { - detached: true, - stdio: "ignore", - windowsHide: true, - env: standaloneRecycleEnv(process.env, disconnectedTokenFingerprint), - }); - child.unref(); - } - process.exit(0); + void recycleStandalone(disconnectedTokenFingerprint); }, 50); if (typeof timer === "object" && "unref" in timer) timer.unref(); } +async function recycleStandalone(disconnectedTokenFingerprint: string): Promise { + const port = activePort; + try { + await activeSupervisor?.stop(); + } catch (error) { + console.warn(`[client] link supervisor stop failed: ${error instanceof Error ? error.message : String(error)}`); + } + activeSupervisor = null; + try { + activeServer?.stop(true); + } catch (error) { + console.warn(`[client] listener stop failed: ${error instanceof Error ? error.message : String(error)}`); + } + cleanup(); + // Recycling back to standalone after `ocx disconnect` must actually bring a standalone + // proxy back, under either launch shape. + // + // Unsupervised: spawn the replacement ourselves and exit 0. + // + // Supervised (`OCX_SERVICE=1`): do NOT spawn — the supervisor owns the process, and a + // second copy would fight it for the port. But exit 0 does not work either: the real + // supervisor configs are failure-only (systemd `Restart=on-failure`, WinSW + // ``, the Task Scheduler ERRORLEVEL loop), so a clean exit + // reads as "the service finished" and nothing restarts. The client stayed down until the + // operator noticed. Exit 1 is what those configs are watching for, and it is the same + // policy the dashboard recycle already uses (src/server/management/system-restart.ts). + // + // launchd's KeepAlive restarts on any exit, so it is correct under both branches. + if (process.env.OCX_SERVICE === "1") { + process.exit(1); + } + if (port) { + const child = spawn(process.execPath, selfLaunchArgv(["start", "--port", String(port)]), { + detached: true, + stdio: "ignore", + windowsHide: true, + env: standaloneRecycleEnv(process.env, disconnectedTokenFingerprint), + }); + child.unref(); + } + process.exit(0); +} + export async function startClientRuntime( options: { port?: number; block?: boolean } = {}, ): Promise { @@ -107,6 +125,13 @@ export async function startClientRuntime( const boundPort = server.port ?? port; activeServer = server; activePort = boundPort; + const supervisor = linkMode && existsSync(clientLinkStatePath()) + ? createClientLinkSupervisor({ + onLinkEnded: () => scheduleStandaloneRecycle(state.value.tokenFingerprint), + }) + : null; + activeSupervisor = supervisor; + supervisor?.start(); installCrashGuards(); writePid(process.pid); writeRuntimePort({ pid: process.pid, port: boundPort, hostname: "127.0.0.1" }); @@ -115,10 +140,22 @@ export async function startClientRuntime( const shutdown = () => { if (shuttingDown) return; shuttingDown = true; - try { server.stop(true); } finally { - cleanup(); - process.exit(0); - } + void (async () => { + try { + await supervisor?.stop(); + } catch (error) { + console.warn(`[client] link supervisor stop failed: ${error instanceof Error ? error.message : String(error)}`); + } + activeSupervisor = null; + try { + server.stop(true); + } catch (error) { + console.warn(`[client] listener stop failed: ${error instanceof Error ? error.message : String(error)}`); + } finally { + cleanup(); + process.exit(0); + } + })(); }; process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); diff --git a/src/link/status-projection.ts b/src/link/status-projection.ts index 745e49ccaf4..e4dfab5e517 100644 --- a/src/link/status-projection.ts +++ b/src/link/status-projection.ts @@ -58,13 +58,18 @@ function stateDetails( return { state: asWireState(tunnel), since, reason }; } -/** Project private link state into the exact K16 wire DTO. */ +/** + * Project private link state into the exact K16 wire DTO. `clientChild` lets a client-side caller + * report a child state this module cannot read itself (an invalid client sidecar); link code + * stays free of client imports, so the caller supplies it. + */ export function projectLinkStatus( store: LinkStore, supervisorStates: readonly LinkTunnelStatus[], listenerStatus: LinkListenerStatusProjection, config: LinkStatusConfig, compensation: CompensationStore = readCompensation(), + clientChild: LinkStatusDto["child"] = null, ): LinkStatusDto { const byId = new Map(supervisorStates.map(status => [status.linkId, status])); const links = store.links.map(record => ({ @@ -75,6 +80,7 @@ export function projectLinkStatus( tunnelPort: record.tunnelPort, })); const childRecord = config.runtimeRole === "client" ? store.links[0] : undefined; + const invalidClientSidecar = config.runtimeRole === "client" ? clientChild : null; const childStatus = childRecord ? stateDetails(childRecord, byId.get(childRecord.id), compensation) : undefined; @@ -85,6 +91,6 @@ export function projectLinkStatus( role, listener: { state: listenerStatus.state, port: listenerStatus.port }, links, - child: childRecord && childStatus ? { alias: childRecord.alias, ...childStatus } : null, + child: invalidClientSidecar ?? (childRecord && childStatus ? { alias: childRecord.alias, ...childStatus } : null), }; } diff --git a/tests/clients/client-link-tunnel.test.ts b/tests/clients/client-link-tunnel.test.ts new file mode 100644 index 00000000000..4e4c8eb6305 --- /dev/null +++ b/tests/clients/client-link-tunnel.test.ts @@ -0,0 +1,251 @@ +import { expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clientLinkTunnelStatus, + clientTunnelPidfilePath, + createClientLinkSupervisor, + reapOrphanTunnel, + spawnClientLinkTunnel, +} from "../../src/client/link-tunnel"; +import type { ClientLinkState } from "../../src/client/link-state"; +import type { SshChild, SshRunner } from "../../src/link/ssh-runner"; +import { buildTunnelArgv } from "../../src/link/ssh-argv"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(next => { resolve = next; }); + return { promise, resolve }; +} + +function fakeRunner(resolveOnTerm = true) { + const children: Array<{ child: SshChild; resolve: (code: number) => void; signals: NodeJS.Signals[] }> = []; + const runner: SshRunner = { + async run() { return { code: 0, stdout: "", stderr: "" }; }, + spawnTunnel(argv) { + const exit = deferred(); + const item = { child: undefined as unknown as SshChild, resolve: exit.resolve, signals: [] as NodeJS.Signals[] }; + item.child = { + pid: 30_000 + children.length, + argv: [...argv], + exited: exit.promise, + stderr: Promise.resolve(""), + kill(signal = "SIGTERM") { item.signals.push(signal); if (signal === "SIGTERM" && resolveOnTerm) exit.resolve(143); }, + }; + children.push(item); + return item.child; + }, + }; + return { runner, children }; +} + +function sidecar(linkId = "lnk_0123456789abcdef"): ClientLinkState { + return { + linkId, + alias: "home.example.test", + hubHostKeyFingerprint: "SHA256:abcdefghijklmnop", + peerListenerPort: 19001, + tunnelPort: 19002, + }; +} + +function tempConfigDir(): string { + return mkdtempSync(join(tmpdir(), "ocx-client-link-tunnel-")); +} + +test("spawns the client tunnel with the exact local forward argv and writes a private pidfile", async () => { + const configDir = tempConfigDir(); + const fake = fakeRunner(); + try { + const handle = spawnClientLinkTunnel({ ...sidecar() }, { + configDir, + knownHostsFile: join(configDir, "link", "known_hosts"), + runner: fake.runner, + }); + expect(fake.children[0]!.child.argv).toEqual(buildTunnelArgv({ + alias: "home.example.test", + direction: "L", + bindPort: 19002, + targetPort: 19001, + knownHostsFile: join(configDir, "link", "known_hosts"), + })); + const pidfile = clientTunnelPidfilePath(configDir); + expect(JSON.parse(readFileSync(pidfile, "utf8"))).toEqual({ + version: 1, + linkId: sidecar().linkId, + pid: fake.children[0]!.child.pid, + argv: fake.children[0]!.child.argv, + ownerPid: process.pid, + }); + expect(statSync(pidfile).mode & 0o777).toBe(0o600); + fake.children[0]!.resolve(0); + await handle.stop(); + expect(existsSync(pidfile)).toBe(false); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } +}); + +test("sends TERM and then KILL after the bounded stop wait", async () => { + const configDir = tempConfigDir(); + const fake = fakeRunner(false); + const timers: Array<() => void> = []; + try { + const handle = spawnClientLinkTunnel({ ...sidecar() }, { + configDir, + runner: fake.runner, + setTimer: (callback, ms) => { + expect(ms).toBe(5_000); + timers.push(callback); + return timers.length as unknown as ReturnType; + }, + clearTimer: () => {}, + }); + const stopping = handle.stop(); + await Promise.resolve(); + expect(fake.children[0]!.signals).toEqual(["SIGTERM"]); + timers[0]!(); + await stopping; + expect(fake.children[0]!.signals).toEqual(["SIGTERM", "SIGKILL"]); + fake.children[0]!.resolve(137); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } +}); + +test("reaps only a dead owner's exact Linux tunnel and preserves non-Linux ambiguity", async () => { + const configDir = tempConfigDir(); + const path = clientTunnelPidfilePath(configDir); + const argv = ["ssh", "-N", "-T"]; + const writePidfile = (ownerPid: number, pid: number, value = argv) => { + mkdirSync(join(configDir, "link"), { recursive: true }); + writeFileSync(path, JSON.stringify({ version: 1, linkId: sidecar().linkId, pid, argv: value, ownerPid })); + }; + try { + writePidfile(41, 42); + expect(await reapOrphanTunnel({ configDir, isAlive: pid => pid === 41, platform: "linux" })).toEqual({ tunnel: "owned" }); + expect(existsSync(path)).toBe(true); + + const signals: NodeJS.Signals[] = []; + writePidfile(41, 42); + expect(await reapOrphanTunnel({ + configDir, + isAlive: pid => pid === 42, + platform: "linux", + readProcessArgv: () => argv, + signal: (_pid, signal) => signals.push(signal), + sleep: async () => {}, + })).toEqual({ tunnel: "reaped" }); + expect(signals).toEqual(["SIGTERM", "SIGKILL"]); + expect(existsSync(path)).toBe(false); + + writePidfile(41, 42, ["ssh", "-R"]); + expect(await reapOrphanTunnel({ + configDir, + isAlive: () => false, + platform: "linux", + readProcessArgv: () => argv, + })).toEqual({ tunnel: "absent" }); + expect(existsSync(path)).toBe(false); + + writePidfile(41, 42); + expect(await reapOrphanTunnel({ configDir, isAlive: () => false, platform: "darwin" })).toEqual({ tunnel: "unresolved", pid: 42 }); + expect(existsSync(path)).toBe(true); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } +}); + +test("supervisor starts once while connected and ends once after a missing or mismatched sidecar", async () => { + const configDir = tempConfigDir(); + const fake = fakeRunner(); + const timers: Array<() => void> = []; + let currentSidecar: ClientLinkState | null = sidecar(); + let connected = sidecar().linkId; + let ended = 0; + try { + const supervisor = createClientLinkSupervisor({ + configDir, + runner: fake.runner, + readSidecar: () => currentSidecar, + connectedLinkId: () => connected, + setTimer: (callback, ms) => { + if (ms === 1_000) timers.push(callback); + return timers.length as unknown as ReturnType; + }, + clearTimer: () => {}, + onLinkEnded: () => { ended += 1; }, + now: () => 0, + random: () => 0.5, + }); + supervisor.start(); + await Promise.resolve(); + await Promise.resolve(); + expect(fake.children).toHaveLength(1); + timers[0]!(); + await Promise.resolve(); + expect(fake.children).toHaveLength(1); + currentSidecar = null; + timers[0]!(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(fake.children[0]!.signals).toContain("SIGTERM"); + expect(ended).toBe(1); + timers[0]!(); + await Promise.resolve(); + expect(ended).toBe(1); + + await supervisor.stop(); + + currentSidecar = sidecar(); + connected = "different"; + const second = createClientLinkSupervisor({ + configDir: tempConfigDir(), + runner: fake.runner, + readSidecar: () => currentSidecar, + connectedLinkId: () => connected, + setTimer: callback => { timers.push(callback); return timers.length as unknown as ReturnType; }, + clearTimer: () => {}, + }); + second.start(); + await Promise.resolve(); + await Promise.resolve(); + expect(fake.children).toHaveLength(1); + await second.stop(); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } +}); + +test("corrupt sidecar fails closed without spawning", async () => { + const fake = fakeRunner(); + const supervisor = createClientLinkSupervisor({ + runner: fake.runner, + readSidecar: () => { throw new Error("invalid sidecar"); }, + connectedLinkId: () => "lnk_0123456789abcdef", + setTimer: callback => setTimeout(callback, 1_000), + clearTimer: timer => clearTimeout(timer), + }); + supervisor.start(); + await Promise.resolve(); + expect(fake.children).toHaveLength(0); + expect(supervisor.status()).toEqual({ kind: "failed", reason: "sidecar_invalid" }); + await supervisor.stop(); +}); + +test("projects an invalid sidecar as a failed child status", () => { + const configDir = tempConfigDir(); + const sidecarPath = join(configDir, "link", "client-link.json"); + mkdirSync(join(configDir, "link"), { recursive: true }); + writeFileSync(sidecarPath, "{broken"); + try { + expect(clientLinkTunnelStatus(sidecarPath, () => Date.parse("2026-09-25T00:00:00.000Z"))).toEqual({ + alias: "unknown", + state: "failed", + since: "2026-09-25T00:00:00.000Z", + reason: "sidecar_invalid", + }); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } +}); diff --git a/tests/clients/link-status-projection.test.ts b/tests/clients/link-status-projection.test.ts index 1659801d24b..3f74690a054 100644 --- a/tests/clients/link-status-projection.test.ts +++ b/tests/clients/link-status-projection.test.ts @@ -66,3 +66,13 @@ test("projects a persisted compensation failure across supervisor restarts", () const dto = projectLinkStatus(store, [], { state: "listening", port: 19001 }, { runtimeRole: "hub" }, compensation); expect(dto.links[0]).toMatchObject({ state: "failed", since: "2026-09-25T00:02:00.000Z", reason: "compensation_failed" }); }); + +test("a client caller's invalid-sidecar child overrides the record, and only in the client role", () => { + const empty: CompensationStore = { version: 1, entries: {} }; + const invalid = { alias: "unknown", state: "failed" as const, since: "2026-09-25T00:00:00.000Z", reason: "sidecar_invalid" }; + const client = projectLinkStatus(store, [], { state: "off", port: null }, { runtimeRole: "client" }, empty, invalid); + expect(client.role).toBe("child"); + expect(client.child).toEqual(invalid); + const hub = projectLinkStatus(store, [], { state: "listening", port: 19001 }, { runtimeRole: "hub" }, empty, invalid); + expect(hub.child).toBeNull(); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 5d01cdc84c7..2aa04f95a2e 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -6,6 +6,7 @@ "account-pool-management-api.test.ts": "server", "link-listener-admission.test.ts": "server", "link-listener-lifecycle.test.ts": "server", + "link-join-route.test.ts": "server", "core-link-boundary.test.ts": "lab", "acl-error-classification.test.ts": "lib", "active-registry-admission.test.ts": "codex-integration", @@ -1640,6 +1641,10 @@ "client-link-relay.test.ts": "clients", "client-link-runtime.test.ts": "clients", "link-routes.test.ts": "clients", + "client-link-state.test.ts": "clients", + "client-link-teardown.test.ts": "clients", + "client-link-tunnel.test.ts": "clients", + "link-ports.test.ts": "clients", "injection-link-websocket.test.ts": "codex-integration", "link-supervisor.test.ts": "clients", "link-status-projection.test.ts": "clients", From 33d8385a25fe1d5da162697f96aa37f979fa5a2f Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:44:39 +0900 Subject: [PATCH 05/13] feat(link): revoke the Home link on ocx disconnect and share one client port contract ocx disconnect on a Child makes one SSH attempt to run ocx link revoke on the Home, reports homeRevoke and tunnel in --json, and prints the manual revoke command when the attempt fails. disconnectClient removes the sidecar under the lifecycle lock only when it names the disconnected link. The client tunnel port is 1024-65535 everywhere it is accepted. --- src/cli/connect.ts | 40 +++++- src/cli/link.ts | 15 ++- src/client/connect.ts | 15 ++- src/client/link-relay.ts | 3 +- src/client/link-teardown.ts | 64 +++++++++ src/config/schema/leaf-validators.ts | 2 + src/link/store.ts | 7 +- tests/clients/client-link-state.test.ts | 68 ++++++++++ tests/clients/client-link-teardown.test.ts | 150 +++++++++++++++++++++ tests/clients/link-ports.test.ts | 105 +++++++++++++++ 10 files changed, 455 insertions(+), 14 deletions(-) create mode 100644 src/client/link-teardown.ts create mode 100644 tests/clients/client-link-state.test.ts create mode 100644 tests/clients/client-link-teardown.test.ts create mode 100644 tests/clients/link-ports.test.ts diff --git a/src/cli/connect.ts b/src/cli/connect.ts index 27e34ab0dbc..04665ee6549 100644 --- a/src/cli/connect.ts +++ b/src/cli/connect.ts @@ -15,8 +15,14 @@ import { connectClient, } from "../client/connect"; import { inspectClientRotationRecoveryGate, readClientConnectionState } from "../client/state"; +import { readClientLinkState } from "../client/link-state"; +import { teardownClientLink } from "../client/link-teardown"; +import { reapOrphanTunnel } from "../client/link-tunnel"; import { readServiceApiTokenState } from "../lib/service-secrets"; import type { ClientLifecycleLockDeps } from "../client/lifecycle-lock"; +import { linkKnownHostsPath } from "../link/paths"; +import { createSshRunner, type SshRunner } from "../link/ssh-runner"; +import type { OrphanTunnelResult } from "../client/link-tunnel"; import { inspectRemoteDesktopStore } from "../claude/desktop-remote-store"; import type { OcxConnectedClientId } from "../types"; import { @@ -38,6 +44,12 @@ import { export interface ClientCommandDeps extends RuntimeApiDeps { lifecycleLockDeps?: ClientLifecycleLockDeps; catalogProbeDeps?: ClientCatalogProbeDeps; + linkTeardownDeps?: { + runner?: Pick; + reapOrphanTunnel?: () => Promise; + knownHostsFile?: string; + timeoutMs?: number; + }; } export interface ClientCatalogProbeDeps extends CatalogCompatibilityDeps { @@ -496,15 +508,33 @@ export async function handleConnectCommand(argv: string[], deps: ClientCommandDe }); } -export async function handleDisconnectCommand(argv: string[], deps: Pick = {}): Promise { +export async function handleDisconnectCommand( + argv: string[], + deps: Pick = {}, +): Promise { return runCliAction(async () => { const args = [...argv]; const keepCatalog = takeFlag(args, "--keep-catalog"); const wantsJson = takeFlag(args, "--json"); rejectArgs(args, DISCONNECT_USAGE, { redactValues: true }); + const teardown = await teardownClientLink({ + readSidecar: readClientLinkState, + connectedLinkId: () => { + const state = readClientConnectionState(); + return state.kind === "connected" && state.value.transport === "link" + ? state.value.link?.linkId ?? null + : null; + }, + reapOrphanTunnel: deps.linkTeardownDeps?.reapOrphanTunnel ?? (() => reapOrphanTunnel()), + runner: deps.linkTeardownDeps?.runner ?? createSshRunner(), + knownHostsFile: deps.linkTeardownDeps?.knownHostsFile ?? linkKnownHostsPath(), + timeoutMs: deps.linkTeardownDeps?.timeoutMs, + }); const result = await disconnectClient({ keepCatalog }, deps); const payload = { ...result, + homeRevoke: teardown.homeRevoke, + tunnel: teardown.tunnel, revoke: { apiKeyId: result.apiKeyId, location: "Integrations → API Keys", @@ -515,7 +545,13 @@ export async function handleDisconnectCommand(argv: string[], deps: Pick, keys: readonly string[] } } -function validPort(value: unknown): value is number { +function validListenerPort(value: unknown): value is number { return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535; } @@ -96,7 +97,7 @@ function validateStatus(value: unknown): LinkStatusPayload { if (value.listener.state !== "off" && value.listener.state !== "listening" && value.listener.state !== "failed") { throw new Error("invalid link API response: listener state"); } - if (value.listener.port !== null && !validPort(value.listener.port)) { + if (value.listener.port !== null && !validListenerPort(value.listener.port)) { throw new Error("invalid link API response: listener port"); } if (!Array.isArray(value.links)) throw new Error("invalid link API response: links"); @@ -106,7 +107,7 @@ function validateStatus(value: unknown): LinkStatusPayload { if (typeof candidate.id !== "string" || !LINK_ID.test(candidate.id) || !validString(candidate.alias) || (candidate.direction !== "hub-initiated" && candidate.direction !== "client-initiated") || (candidate.state !== "connecting" && candidate.state !== "connected" && candidate.state !== "reconnecting" && candidate.state !== "failed" && candidate.state !== "idle") - || !validString(candidate.since) || !validNullableString(candidate.reason) || !validPort(candidate.tunnelPort)) { + || !validString(candidate.since) || !validNullableString(candidate.reason) || !isLinkPort(candidate.tunnelPort)) { throw new Error(`invalid link API response: link ${index} fields`); } return { @@ -149,7 +150,7 @@ function validateIssue(value: unknown): LinkIssuePayload { if (typeof value.linkId !== "string" || !LINK_ID.test(value.linkId) || typeof value.apiKeyId !== "string" || !API_KEY_ID.test(value.apiKeyId) || typeof value.key !== "string" || !DATA_KEY.test(value.key) - || !validPort(value.listenerPort)) { + || !validListenerPort(value.listenerPort)) { throw new Error("invalid link API response: issue fields"); } return { @@ -209,15 +210,15 @@ async function runPort(args: string[], deps: LinkCliDeps): Promise { takeJsonFlag(args); rejectArgs(args, LINK_USAGE); const port = await (deps.choosePort ?? (() => findAvailablePort(0, "127.0.0.1")))(); - if (!validPort(port)) throw new Error("port allocator returned an invalid port"); + if (!isLinkPort(port)) throw new Error("port allocator returned an invalid link port"); console.log(JSON.stringify({ port })); } async function runIssue(args: string[], deps: LinkCliDeps): Promise { takeJsonFlag(args); const alias = takeOption(args, "--alias"); - const tunnelPort = takeIntegerOption(args, "--tunnel-port", { min: 1 }); - if (!alias || tunnelPort === undefined || tunnelPort > 65535) { + const tunnelPort = takeIntegerOption(args, "--tunnel-port", { min: 1024 }); + if (!alias || tunnelPort === undefined || !isLinkPort(tunnelPort)) { throw new CliUsageError("issue requires --alias and --tunnel-port", LINK_USAGE); } try { assertSshAlias(alias); } diff --git a/src/client/connect.ts b/src/client/connect.ts index 44c60fc54ff..dbcc2b3792f 100644 --- a/src/client/connect.ts +++ b/src/client/connect.ts @@ -72,6 +72,8 @@ import { } from "./state"; import { assertClientCatalogCompatible, type CatalogCompatibilityDeps } from "./catalog-compatibility"; import { hubStateCachePath } from "./hub-state"; +import { ClientLinkStateError, clearClientLinkState, readClientLinkState } from "./link-state"; +import { isLinkPort } from "../link/ports"; class RotationRecoveryRequiredError extends Error { constructor(message: string, options?: ErrorOptions) { @@ -540,7 +542,7 @@ export async function connectClient( linkMode = (options.transport ?? "hub") === "link"; if (linkMode) { if (options.credential.kind !== "link" || !options.link - || !Number.isInteger(options.link.tunnelPort) || options.link.tunnelPort < 1024 || options.link.tunnelPort > 65535 + || !isLinkPort(options.link.tunnelPort) || !/^lnk_[0-9a-f]{16}$/.test(options.link.linkId) || !/^ocx_data_[0-9a-f]{40}$/.test(options.credential.key) || !options.credential.apiKeyId.trim() || options.credential.apiKeyId.length > 256) { @@ -990,6 +992,17 @@ export async function disconnectClient( if (!disconnectAtLeast(receipt, "clearing_connection")) advance("clearing_connection"); if (clearClientConnection(receipt.owner) === "conflict") throw new Error("client_disconnect_owner_changed"); if (!disconnectAtLeast(receipt, "connection_cleared")) advance("connection_cleared"); + if (connection?.transport === "link" && connection.link) { + // A corrupt sidecar is left in place: the connection is already cleared, the client runtime + // will not start a tunnel for a disconnected client, and the next join overwrites the file. + let sidecarLinkId: string | null = null; + try { + sidecarLinkId = readClientLinkState()?.linkId ?? null; + } catch (error) { + if (!(error instanceof ClientLinkStateError)) throw error; + } + if (sidecarLinkId === connection.link.linkId) clearClientLinkState(connection.link.linkId); + } removeHubStateCache(); requireDesktopResult(finishRemoteDesktopCleanup(held, receipt.owner)); if (receipt.phase !== "complete") advance("complete"); diff --git a/src/client/link-relay.ts b/src/client/link-relay.ts index be134e8e74f..147a9b250a9 100644 --- a/src/client/link-relay.ts +++ b/src/client/link-relay.ts @@ -9,6 +9,7 @@ import { validateHubRelayRequestHeaders, } from "./hub-relay"; import { linkRouteAllowed } from "../link/routes"; +import { isLinkPort } from "../link/ports"; export interface LinkRelayTarget { tunnelPort: number; @@ -42,7 +43,7 @@ function jsonError(status: number, error: string, retry = false): Response { } export function linkRelayDestination(url: URL, target: LinkRelayTarget): string { - if (!Number.isInteger(target.tunnelPort) || target.tunnelPort < 1024 || target.tunnelPort > 65535) { + if (!isLinkPort(target.tunnelPort)) { throw new RangeError("invalid link tunnel port"); } return `http://127.0.0.1:${target.tunnelPort}${url.pathname}${url.search}`; diff --git a/src/client/link-teardown.ts b/src/client/link-teardown.ts new file mode 100644 index 00000000000..b3a1b3b0e22 --- /dev/null +++ b/src/client/link-teardown.ts @@ -0,0 +1,64 @@ +import { buildExecArgv } from "../link/ssh-argv"; +import type { SshRunner } from "../link/ssh-runner"; +import type { ClientLinkState } from "./link-state"; +import type { OrphanTunnelResult } from "./link-tunnel"; + +const HOME_REVOKE_TIMEOUT_MS = 30_000; + +export interface ClientLinkTeardownDeps { + readSidecar: () => ClientLinkState | null; + connectedLinkId: () => string | null; + reapOrphanTunnel: () => Promise; + runner: Pick; + knownHostsFile: string; + timeoutMs?: number; +} + +export interface ClientLinkTeardownResult { + linkId: string | null; + homeRevoke: "revoked" | "failed" | "not_applicable"; + tunnel: OrphanTunnelResult | null; +} + +/** Reap the client tunnel and make one best-effort Home-side revoke attempt. */ +export async function teardownClientLink( + deps: ClientLinkTeardownDeps, +): Promise { + let sidecar: ClientLinkState | null; + try { + sidecar = deps.readSidecar(); + } catch { + // An unreadable sidecar no longer names the Home alias, so the revoke cannot run here. The + // disconnect still proceeds, and a link connection gets the manual revoke instruction. + const linkId = deps.connectedLinkId(); + return { linkId, homeRevoke: linkId ? "failed" : "not_applicable", tunnel: null }; + } + if (!sidecar || sidecar.linkId !== deps.connectedLinkId()) { + return { linkId: null, homeRevoke: "not_applicable", tunnel: null }; + } + + let tunnel: OrphanTunnelResult | null = null; + try { + tunnel = await deps.reapOrphanTunnel(); + } catch { + // A reap failure must not prevent the one allowed Home revoke attempt. + } + + try { + const result = await deps.runner.run( + buildExecArgv({ + alias: sidecar.alias, + argv: ["ocx", "link", "revoke", "--link-id", sidecar.linkId], + knownHostsFile: deps.knownHostsFile, + }), + { timeoutMs: deps.timeoutMs ?? HOME_REVOKE_TIMEOUT_MS }, + ); + return { + linkId: sidecar.linkId, + homeRevoke: result.code === 0 ? "revoked" : "failed", + tunnel, + }; + } catch { + return { linkId: sidecar.linkId, homeRevoke: "failed", tunnel }; + } +} diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 0b771be1ec8..5f739205d76 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -854,6 +854,8 @@ const connectedClientIdSchema = z.enum(["codex", "claude"]); const clientTimestampSchema = z.string().datetime({ offset: true }); const clientTransportSchema = z.enum(["hub", "link"]); const linkTransportSchema = z.object({ + // Same range as isLinkPort in src/link/ports.ts, restated here because the config schema sits on + // every install's core path and must not import link code (tests/lab/core-link-boundary.test.ts). tunnelPort: z.number().int().min(1024).max(65535), linkId: z.string().regex(/^lnk_[0-9a-f]{16}$/), }).strict(); diff --git a/src/link/store.ts b/src/link/store.ts index 6d20961d435..48016ef909b 100644 --- a/src/link/store.ts +++ b/src/link/store.ts @@ -4,6 +4,7 @@ import { dirname } from "node:path"; import { atomicWriteFile, isMissingPathError } from "../config/atomic-write"; import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import { hardenSecretDir } from "../lib/windows-secret-acl"; +import { isLinkPort } from "./ports"; import { assertSshAlias } from "./ssh-argv"; /** @@ -62,7 +63,7 @@ function assertOnlyKeys(raw: Record, allowed: Set, wher } } -const isPort = (value: unknown): value is number => +const isListenerPort = (value: unknown): value is number => typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535; function parseRecord(value: unknown, index: number): LinkRecord { @@ -77,7 +78,7 @@ function parseRecord(value: unknown, index: number): LinkRecord { const fingerprint = raw.hostKeyFingerprint; if (fingerprint === null ? raw.direction !== "client-initiated" : typeof fingerprint !== "string" || !FINGERPRINT.test(fingerprint)) fail("hostKeyFingerprint"); - if (!isPort(raw.tunnelPort)) fail("tunnelPort"); + if (!isLinkPort(raw.tunnelPort)) fail("tunnelPort"); if (typeof raw.apiKeyId !== "string" || !API_KEY_ID.test(raw.apiKeyId)) fail("apiKeyId"); if (typeof raw.createdAt !== "string" || Number.isNaN(Date.parse(raw.createdAt))) fail("createdAt"); return { @@ -98,7 +99,7 @@ export function parseLinkStore(text: string): LinkStore { const body = raw as Record; assertOnlyKeys(body, STORE_KEYS, "links.json"); if (body.version !== 1) throw new LinkStoreError("links.json has an unsupported version"); - if (body.listenerPort !== null && !isPort(body.listenerPort)) throw new LinkStoreError("listenerPort is invalid"); + if (body.listenerPort !== null && !isListenerPort(body.listenerPort)) throw new LinkStoreError("listenerPort is invalid"); if (!Array.isArray(body.links)) throw new LinkStoreError("links is not an array"); const links = body.links.map(parseRecord); const ids = new Set(links.map(link => link.id)); diff --git a/tests/clients/client-link-state.test.ts b/tests/clients/client-link-state.test.ts new file mode 100644 index 00000000000..17cada029b9 --- /dev/null +++ b/tests/clients/client-link-state.test.ts @@ -0,0 +1,68 @@ +import { afterEach, expect, test } from "bun:test"; +import { existsSync, statSync, writeFileSync } from "node:fs"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import { + clearClientLinkState, + clientLinkStatePath, + ClientLinkStateError, + readClientLinkState, + writeClientLinkState, + type ClientLinkState, +} from "../../src/client/link-state"; + +let home: TempHome | undefined; + +afterEach(() => { + home?.remove(); + home = undefined; +}); + +function fixture(): ClientLinkState { + return { + linkId: "lnk_0123456789abcdef", + alias: "home.example.test", + hubHostKeyFingerprint: "SHA256:ABCDEFGHIJKLMNOP", + peerListenerPort: 1, + tunnelPort: 1024, + }; +} + +test("client link sidecar round-trips with private POSIX permissions", () => { + home = createTempHome("ocx-client-link-state-"); + const path = clientLinkStatePath(home.configDir); + writeClientLinkState(fixture(), path); + expect(readClientLinkState(path)).toEqual(fixture()); + if (process.platform !== "win32") { + expect(statSync(path).mode & 0o777).toBe(0o600); + expect(statSync(home.path("link")).mode & 0o777).toBe(0o700); + } +}); + +test("client link sidecar rejects unknown fields, invalid ports, fingerprints, and JSON", () => { + home = createTempHome("ocx-client-link-state-invalid-"); + const path = clientLinkStatePath(home.configDir); + const base = fixture(); + writeClientLinkState(base, path); + const invalid: unknown[] = [ + { ...base, extra: true }, + { ...base, tunnelPort: 1023 }, + { ...base, hubHostKeyFingerprint: "not-a-fingerprint" }, + ]; + for (const value of invalid) { + writeFileSync(path, JSON.stringify(value)); + expect(() => readClientLinkState(path)).toThrow(ClientLinkStateError); + } + writeFileSync(path, "{"); + expect(() => readClientLinkState(path)).toThrow(ClientLinkStateError); +}); + +test("client link sidecar clear is owner checked", () => { + home = createTempHome("ocx-client-link-state-owner-"); + const path = clientLinkStatePath(home.configDir); + writeClientLinkState(fixture(), path); + expect(clearClientLinkState("lnk_fedcba9876543210", path)).toBe(false); + expect(existsSync(path)).toBe(true); + expect(clearClientLinkState(fixture().linkId, path)).toBe(true); + expect(existsSync(path)).toBe(false); + expect(clearClientLinkState(fixture().linkId, path)).toBe(false); +}); diff --git a/tests/clients/client-link-teardown.test.ts b/tests/clients/client-link-teardown.test.ts new file mode 100644 index 00000000000..1e0f33d4f9c --- /dev/null +++ b/tests/clients/client-link-teardown.test.ts @@ -0,0 +1,150 @@ +import { expect, spyOn, test } from "bun:test"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { getDefaultConfig, saveConfig } from "../../src/config"; +import { handleDisconnectCommand } from "../../src/cli/connect"; +import { connectClient } from "../../src/client/connect"; +import { teardownClientLink } from "../../src/client/link-teardown"; +import { clientLinkStatePath, writeClientLinkState } from "../../src/client/link-state"; +import { createTempHome } from "../helpers/temp-home"; + +const linkId = "lnk_0123456789abcdef"; +const key = `ocx_data_${"a".repeat(40)}`; +const knownHostsFile = "/tmp/opencodex-link-known-hosts"; + +function sidecar() { + return { + linkId, + alias: "home.example.test", + hubHostKeyFingerprint: "SHA256:ABCDEFGHIJKLMNOP", + peerListenerPort: 20100, + tunnelPort: 34567, + }; +} + +test("teardown revokes the matching link once and returns tunnel state", async () => { + const calls: Array<{ argv: readonly string[]; timeoutMs?: number }> = []; + const result = await teardownClientLink({ + readSidecar: () => sidecar(), + connectedLinkId: () => linkId, + reapOrphanTunnel: async () => ({ tunnel: "owned" }), + runner: { + run: async (argv, options) => { + calls.push({ argv, timeoutMs: options?.timeoutMs }); + return { code: 0, stdout: "", stderr: "" }; + }, + }, + knownHostsFile, + }); + expect(result).toEqual({ linkId, homeRevoke: "revoked", tunnel: { tunnel: "owned" } }); + expect(calls).toHaveLength(1); + expect(calls[0]?.timeoutMs).toBe(30_000); + expect(calls[0]?.argv).toContain("home.example.test"); + expect(calls[0]?.argv.join(" ")).toContain("ocx"); + expect(calls[0]?.argv.join(" ")).toContain(linkId); +}); + +test("teardown reports revoke failure and leaves a mismatched sidecar alone", async () => { + let calls = 0; + const failed = await teardownClientLink({ + readSidecar: () => sidecar(), + connectedLinkId: () => linkId, + reapOrphanTunnel: async () => ({ tunnel: "absent" }), + runner: { + run: async () => { + calls += 1; + return { code: 1, stdout: "", stderr: "" }; + }, + }, + knownHostsFile, + }); + expect(failed).toEqual({ linkId, homeRevoke: "failed", tunnel: { tunnel: "absent" } }); + + const mismatch = await teardownClientLink({ + readSidecar: () => sidecar(), + connectedLinkId: () => "lnk_fedcba9876543210", + reapOrphanTunnel: async () => ({ tunnel: "reaped" }), + runner: { run: async () => { calls += 1; return { code: 0, stdout: "", stderr: "" }; } }, + knownHostsFile, + }); + expect(mismatch).toEqual({ linkId: null, homeRevoke: "not_applicable", tunnel: null }); + expect(calls).toBe(1); +}); + +test("disconnect output reports an unresolved tunnel", async () => { + const home = createTempHome("ocx-client-link-teardown-"); + try { + const config = getDefaultConfig(); + config.port = 10100; + saveConfig(config); + mkdirSync(home.codexHome, { recursive: true }); + writeFileSync(home.path(".codex", "config.toml"), "model_provider = \"openai\"\n"); + await connectClient({ + serverUrl: "http://127.0.0.1:34567", + managementUrl: "http://127.0.0.1:34567", + managementTransport: "direct", + transport: "link", + link: { tunnelPort: 34567, linkId }, + credential: { kind: "link", apiKeyId: "key-1", key }, + selectedClients: ["codex"], + noSync: true, + }, { + fetchImpl: async input => String(input).endsWith("/readyz") + ? Response.json({ status: "ready", protocol: 1, minimumClientProtocol: 1, managementUrl: "http://127.0.0.1:34567" }) + : Response.json({ models: [] }), + catalogCompatibility: { supportedEfforts: () => new Set() }, + lifecycleLockDeps: { lockPath: home.path("lifecycle.sqlite") }, + }); + writeClientLinkState(sidecar(), clientLinkStatePath(home.configDir)); + const logs = spyOn(console, "log").mockImplementation(() => {}); + const errors = spyOn(console, "error").mockImplementation(() => {}); + let exit = -1; + let output = ""; + try { + exit = await handleDisconnectCommand([], { + lifecycleLockDeps: { lockPath: home.path("lifecycle.sqlite") }, + linkTeardownDeps: { + reapOrphanTunnel: async () => ({ tunnel: "unresolved", pid: 4242 }), + runner: { run: async () => ({ code: 1, stdout: "", stderr: "" }) }, + knownHostsFile, + }, + }); + output = logs.mock.calls.flat().join("\n"); + } finally { + logs.mockRestore(); + errors.mockRestore(); + } + expect(exit).toBe(0); + expect(output).toContain( + "A link tunnel (pid 4242) may still be running; stop it if it is.", + ); + expect(output).toContain( + `Home revoke failed; run ocx link revoke --link-id ${linkId} on the home.`, + ); + expect(existsSync(clientLinkStatePath(home.configDir))).toBe(false); + } finally { + home.remove(); + } +}); + +test("an unreadable sidecar skips the revoke but still reports the manual revoke for a link client", async () => { + let calls = 0; + const runner = { run: async () => { calls += 1; return { code: 0, stdout: "", stderr: "" }; } }; + const unreadable = () => { throw new Error("client-link.json is not valid JSON"); }; + const linked = await teardownClientLink({ + readSidecar: unreadable, + connectedLinkId: () => linkId, + reapOrphanTunnel: async () => ({ tunnel: "absent" }), + runner, + knownHostsFile, + }); + expect(linked).toEqual({ linkId, homeRevoke: "failed", tunnel: null }); + const standalone = await teardownClientLink({ + readSidecar: unreadable, + connectedLinkId: () => null, + reapOrphanTunnel: async () => ({ tunnel: "absent" }), + runner, + knownHostsFile, + }); + expect(standalone).toEqual({ linkId: null, homeRevoke: "not_applicable", tunnel: null }); + expect(calls).toBe(0); +}); diff --git a/tests/clients/link-ports.test.ts b/tests/clients/link-ports.test.ts new file mode 100644 index 00000000000..d5ea13c01d7 --- /dev/null +++ b/tests/clients/link-ports.test.ts @@ -0,0 +1,105 @@ +import { expect, spyOn, test } from "bun:test"; +import { clientConnectionSchema } from "../../src/config/schema/leaf-validators"; +import { connectClient } from "../../src/client/connect"; +import { linkRelayDestination } from "../../src/client/link-relay"; +import { isLinkPort } from "../../src/link/ports"; +import { parseLinkStore } from "../../src/link/store"; +import { runLinkCommand } from "../../src/cli/link"; + +const linkId = "lnk_0123456789abcdef"; +const linkKey = `ocx_data_${"a".repeat(40)}`; + +function linkConfig(tunnelPort: number) { + return { + serverUrl: `http://127.0.0.1:${tunnelPort}`, + managementUrl: `http://127.0.0.1:${tunnelPort}`, + managementTransport: "direct", + transport: "link", + link: { tunnelPort, linkId }, + selectedClients: ["codex"], + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + apiKeyId: "key-1", + tokenFingerprint: "a".repeat(64), + protocolVersion: 1, + connectedAt: "2026-09-25T00:00:00.000Z", + }; +} + +async function cliExit(args: string[], deps: Parameters[1]): Promise { + const error = spyOn(console, "error").mockImplementation(() => {}); + try { return await runLinkCommand(args, deps); } + finally { error.mockRestore(); } +} + +test("isLinkPort accepts 1024 and rejects privileged and oversized values", () => { + expect(isLinkPort(1023)).toBe(false); + expect(isLinkPort(1024)).toBe(true); + expect(isLinkPort(65535)).toBe(true); + expect(isLinkPort(65536)).toBe(false); +}); + +test("CLI link port allocation and issue parsing use the client port contract", async () => { + const output = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await runLinkCommand(["port"], { choosePort: async () => 1024 })).toBe(0); + expect(output.mock.calls.flat().join(" ")).toContain('"port":1024'); + } finally { + output.mockRestore(); + } + expect(await cliExit(["port"], { choosePort: async () => 1023 })).toBe(1); + expect(await cliExit(["issue", "--alias", "home.example.test", "--tunnel-port", "1023"], { + baseUrl: "http://127.0.0.1:10100", + fetchImpl: async () => Response.json({}), + })).toBe(2); + expect(await cliExit(["issue", "--alias", "home.example.test", "--tunnel-port", "65536"], { + baseUrl: "http://127.0.0.1:10100", + fetchImpl: async () => Response.json({}), + })).toBe(2); + const calls: Request[] = []; + const issueOutput = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await runLinkCommand(["issue", "--alias", "home.example.test", "--tunnel-port", "1024"], { + baseUrl: "http://127.0.0.1:10100", + fetchImpl: async (input, init) => { + calls.push(new Request(input, init)); + return Response.json({ linkId, apiKeyId: "key-1", key: linkKey, listenerPort: 1 }); + }, + })).toBe(0); + } finally { + issueOutput.mockRestore(); + } + expect(JSON.parse(await calls[0]!.text())).toEqual({ alias: "home.example.test", tunnelPort: 1024 }); +}); + +test("store and relay keep listener ports broad while enforcing tunnel ports", () => { + const record = { + id: linkId, + alias: "home.example.test", + direction: "hub-initiated", + hostKeyFingerprint: "SHA256:ABCDEFGHIJKLMNOP", + tunnelPort: 1024, + apiKeyId: "key-1", + createdAt: "2026-09-25T00:00:00.000Z", + }; + expect(parseLinkStore(JSON.stringify({ version: 1, listenerPort: 1, links: [record] })).links[0]?.tunnelPort).toBe(1024); + expect(() => parseLinkStore(JSON.stringify({ version: 1, listenerPort: 1, links: [{ ...record, tunnelPort: 1023 }] }))).toThrow(); + expect(linkRelayDestination(new URL("http://127.0.0.1/v1/models"), { tunnelPort: 1024 })) + .toBe("http://127.0.0.1:1024/v1/models"); + expect(() => linkRelayDestination(new URL("http://127.0.0.1/v1/models"), { tunnelPort: 1023 })).toThrow(); + expect(() => linkRelayDestination(new URL("http://127.0.0.1/v1/models"), { tunnelPort: 65536 })).toThrow(); +}); + +test("config and connect validation use isLinkPort", async () => { + expect(clientConnectionSchema.safeParse(linkConfig(1024)).success).toBe(true); + expect(clientConnectionSchema.safeParse(linkConfig(1023)).success).toBe(false); + expect(clientConnectionSchema.safeParse(linkConfig(65536)).success).toBe(false); + await expect(connectClient({ + serverUrl: "http://127.0.0.1:1023", + managementUrl: "http://127.0.0.1:1023", + managementTransport: "direct", + transport: "link", + link: { tunnelPort: 1023, linkId }, + credential: { kind: "link", apiKeyId: "key-1", key: linkKey }, + selectedClients: ["codex"], + })).rejects.toThrow("invalid link credential"); +}); From be628f3393733adcc27deb15ebb622d499d5883f Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:44:40 +0900 Subject: [PATCH 06/13] feat(link): join a Home from a standalone dashboard POST /api/link/join {alias} runs the client-initiated sequence: confirmed host, local port, remote ocx link issue over SSH, sidecar, tunnel readiness with the issued key, in-process connect, then a restart into the client runtime. Failures after the issue roll back the tunnel, the Home link, and the sidecar. Only a paired dashboard session on a standalone runtime may call it. --- src/client/link-join.ts | 283 ++++++++++++++++++++++++ src/server/management/link-routes.ts | 68 +++++- src/server/management/route-registry.ts | 1 + tests/server/link-join-route.test.ts | 232 +++++++++++++++++++ 4 files changed, 582 insertions(+), 2 deletions(-) create mode 100644 src/client/link-join.ts create mode 100644 tests/server/link-join-route.test.ts diff --git a/src/client/link-join.ts b/src/client/link-join.ts new file mode 100644 index 00000000000..8c75801059e --- /dev/null +++ b/src/client/link-join.ts @@ -0,0 +1,283 @@ +import { randomBytes } from "node:crypto"; +import { hostname } from "node:os"; +import { findAvailablePort } from "../server/ports"; +import { isLinkPort } from "../link/ports"; +import { buildExecArgv } from "../link/ssh-argv"; +import type { SshRunner } from "../link/ssh-runner"; +import { connectClient, type ClientConnectDeps } from "./connect"; +import { + clearClientLinkState, + clientLinkStatePath, + writeClientLinkState, + type ClientLinkState, +} from "./link-state"; +import { + spawnClientLinkTunnel, + type ClientLinkTunnelDeps, + type ClientLinkTunnelHandle, +} from "./link-tunnel"; +import type { OcxConnectedClientId } from "../types"; + +const JOIN_TUNNEL_READY_TIMEOUT_MS = 15_000; +const JOIN_TUNNEL_POLL_MS = 100; +const JOIN_REVOKE_TIMEOUT_MS = 30_000; +const JOIN_CONFIRM_TTL_MS = 5 * 60_000; +const LINK_ID = /^lnk_[0-9a-f]{16}$/; +const API_KEY_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/; +const DATA_KEY = /^ocx_data_[0-9a-f]{40}$/; +const VALID_ALIAS = /^[A-Za-z0-9_][A-Za-z0-9._@%+:\[\]-]{0,252}$/; + +export interface JoinConfirmedHost { + alias: string; + fingerprint: string; + probedAt: number; +} + +export type JoinFailureCode = + | "host_not_confirmed" + | "host_confirmation_expired" + | "join_port_failed" + | "join_issue_failed" + | "join_tunnel_failed" + | "admission_failed" + | "join_connect_failed"; + +export class ClientLinkJoinError extends Error { + constructor(readonly code: JoinFailureCode) { + super(code); + this.name = "ClientLinkJoinError"; + } +} + +interface IssuedLink { + linkId: string; + apiKeyId: string; + key: string; + listenerPort: number; +} + +export interface ClientLinkJoinDeps { + runner: SshRunner; + knownHostsFile: string; + confirmedHost?: JoinConfirmedHost; + configDir?: string; + choosePort?: () => Promise; + now?: () => number; + sleep?: (ms: number) => Promise; + hostname?: () => string; + randomBytes?: (size: number) => Uint8Array; + fetchImpl?: typeof fetch; + spawnTunnel?: (spec: { + linkId: string; + alias: string; + tunnelPort: number; + peerListenerPort: number; + }, deps?: ClientLinkTunnelDeps) => ClientLinkTunnelHandle; + writeState?: (state: ClientLinkState) => void; + clearState?: (linkId: string) => void; + connect?: typeof connectClient; + connectDeps?: ClientConnectDeps; + selectedClients?: OcxConnectedClientId[]; + /** Hands the standalone process to the client runtime once the join has committed. */ + scheduleRestart: () => void; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function validPort(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535; +} + +function parseIssuedLink(stdout: string): IssuedLink | null { + let parsed: unknown; + try { + parsed = JSON.parse(stdout.trim()); + } catch { + return null; + } + if (!isRecord(parsed) || Object.keys(parsed).length !== 4 + || typeof parsed.linkId !== "string" || !LINK_ID.test(parsed.linkId) + || typeof parsed.apiKeyId !== "string" || !API_KEY_ID.test(parsed.apiKeyId) + || typeof parsed.key !== "string" || !DATA_KEY.test(parsed.key) + || !validPort(parsed.listenerPort)) { + return null; + } + return { + linkId: parsed.linkId, + apiKeyId: parsed.apiKeyId, + key: parsed.key, + listenerPort: parsed.listenerPort, + }; +} + +function localAlias(deps: ClientLinkJoinDeps): string { + const raw = (deps.hostname ?? hostname)().trim(); + const normalized = raw.replace(/[^A-Za-z0-9_\.\-]/g, "-").replace(/^-+/, "").slice(0, 253); + if (VALID_ALIAS.test(normalized)) return normalized; + const bytes = (deps.randomBytes ?? randomBytes)(4); + return `client-${Buffer.from(bytes).toString("hex")}`; +} + +function defaultWriteState(configDir: string | undefined, state: ClientLinkState): void { + writeClientLinkState(state, clientLinkStatePath(configDir)); +} + +function defaultClearState(configDir: string | undefined, linkId: string): void { + clearClientLinkState(linkId, clientLinkStatePath(configDir)); +} + +async function stopTunnel(tunnel: ClientLinkTunnelHandle | null): Promise { + if (!tunnel) return; + try { + await tunnel.stop(); + } catch (error) { + void error; + } +} + +async function revokeIssuedLink(deps: ClientLinkJoinDeps, linkId: string): Promise { + try { + await deps.runner.run( + buildExecArgv({ + alias: deps.confirmedHost?.alias ?? "", + argv: ["ocx", "link", "revoke", "--link-id", linkId], + knownHostsFile: deps.knownHostsFile, + }), + { timeoutMs: JOIN_REVOKE_TIMEOUT_MS }, + ); + } catch (error) { + void error; + } +} + +async function rollback( + deps: ClientLinkJoinDeps, + linkId: string, + tunnel: ClientLinkTunnelHandle | null, +): Promise { + await stopTunnel(tunnel); + await revokeIssuedLink(deps, linkId); + try { + (deps.clearState ?? (id => defaultClearState(deps.configDir, id)))(linkId); + } catch (error) { + void error; + } +} + +async function waitForReady( + deps: ClientLinkJoinDeps, + port: number, + key: string, +): Promise { + const fetchImpl = deps.fetchImpl ?? fetch; + const now = deps.now ?? Date.now; + const sleep = deps.sleep ?? ((ms: number) => new Promise(resolve => setTimeout(resolve, ms))); + const deadline = now() + JOIN_TUNNEL_READY_TIMEOUT_MS; + for (;;) { + try { + const response = await fetchImpl(`http://127.0.0.1:${port}/readyz`, { + headers: { "x-opencodex-api-key": key }, + }); + if (response.status === 200) return; + if (response.status === 401) throw new ClientLinkJoinError("admission_failed"); + } catch (error) { + if (error instanceof ClientLinkJoinError) throw error; + } + const remaining = deadline - now(); + if (remaining <= 0) throw new ClientLinkJoinError("join_tunnel_failed"); + await sleep(Math.min(JOIN_TUNNEL_POLL_MS, remaining)); + } +} + +function requireConfirmedHost(deps: ClientLinkJoinDeps, alias: string): JoinConfirmedHost { + const confirmed = deps.confirmedHost; + if (!confirmed || confirmed.alias !== alias) throw new ClientLinkJoinError("host_not_confirmed"); + if ((deps.now ?? Date.now)() - confirmed.probedAt > JOIN_CONFIRM_TTL_MS) { + throw new ClientLinkJoinError("host_confirmation_expired"); + } + return confirmed; +} + +export async function joinHome(deps: ClientLinkJoinDeps, input: { alias: string }): Promise<{ linkId: string; apiKeyId: string }> { + const confirmed = requireConfirmedHost(deps, input.alias); + let tunnelPort: number; + try { + tunnelPort = await (deps.choosePort ?? (() => findAvailablePort(0, "127.0.0.1")))(); + if (!isLinkPort(tunnelPort)) throw new Error("invalid link port"); + } catch (error) { + void error; + throw new ClientLinkJoinError("join_port_failed"); + } + + const thisAlias = localAlias(deps); + let issued: IssuedLink; + try { + const result = await deps.runner.run( + buildExecArgv({ + alias: input.alias, + argv: ["ocx", "link", "issue", "--alias", thisAlias, "--tunnel-port", String(tunnelPort), "--json"], + knownHostsFile: deps.knownHostsFile, + }), + { timeoutMs: JOIN_REVOKE_TIMEOUT_MS }, + ); + if (result.code !== 0) throw new Error("issue failed"); + const parsed = parseIssuedLink(result.stdout); + if (!parsed) throw new Error("invalid issue response"); + issued = parsed; + } catch (error) { + void error; + throw new ClientLinkJoinError("join_issue_failed"); + } + + let tunnel: ClientLinkTunnelHandle | null = null; + try { + const state: ClientLinkState = { + linkId: issued.linkId, + alias: input.alias, + hubHostKeyFingerprint: confirmed.fingerprint, + peerListenerPort: issued.listenerPort, + tunnelPort, + }; + (deps.writeState ?? (value => defaultWriteState(deps.configDir, value)))(state); + tunnel = (deps.spawnTunnel ?? spawnClientLinkTunnel)({ + linkId: issued.linkId, + alias: input.alias, + tunnelPort, + peerListenerPort: issued.listenerPort, + }, { + runner: deps.runner, + configDir: deps.configDir, + knownHostsFile: deps.knownHostsFile, + }); + await waitForReady(deps, tunnelPort, issued.key); + } catch (error) { + const code = error instanceof ClientLinkJoinError ? error.code : "join_tunnel_failed"; + await rollback(deps, issued.linkId, tunnel); + throw new ClientLinkJoinError(code); + } + + try { + const connect = deps.connect ?? connectClient; + await connect({ + serverUrl: `http://127.0.0.1:${tunnelPort}`, + managementUrl: `http://127.0.0.1:${tunnelPort}`, + credential: { kind: "link", apiKeyId: issued.apiKeyId, key: issued.key }, + transport: "link", + link: { tunnelPort, linkId: issued.linkId }, + selectedClients: deps.selectedClients ?? ["codex", "claude"], + managementTransport: "direct", + }, { + fetchImpl: deps.fetchImpl, + ...deps.connectDeps, + }); + } catch { + await rollback(deps, issued.linkId, tunnel); + throw new ClientLinkJoinError("join_connect_failed"); + } + + await stopTunnel(tunnel); + deps.scheduleRestart(); + return { linkId: issued.linkId, apiKeyId: issued.apiKeyId }; +} diff --git a/src/server/management/link-routes.ts b/src/server/management/link-routes.ts index 2500e3481db..a81ba2046b8 100644 --- a/src/server/management/link-routes.ts +++ b/src/server/management/link-routes.ts @@ -11,9 +11,13 @@ import { loadHostCandidates } from "../../link/ssh-config"; import { newLinkId, readLinkStore, writeLinkStore, type LinkStore } from "../../link/store"; import { createSshRunner, type SshRunner } from "../../link/ssh-runner"; import { projectLinkStatus, type LinkStatusDto } from "../../link/status-projection"; +import { isLinkPort } from "../../link/ports"; +import { joinHome, type ClientLinkJoinDeps } from "../../client/link-join"; +import { clientLinkTunnelStatus } from "../../client/link-tunnel"; import type { ManagementContext } from "./context"; import { readManagementJsonBodyOr } from "./body"; import { issueApiKeyInProcess, revokeApiKeyInProcess, type IssuedApiKey } from "./oauth-account-routes"; +import { acceptSystemRestart } from "./system-restart"; const PROBE_TTL_MS = 5 * 60_000; const APPLY_ADMISSION_TIMEOUT_MS = 15_000; @@ -48,6 +52,8 @@ export interface LinkRouteState { } const states = new WeakMap(); +// One process runs at most one join: a join ends by restarting this process as a client. +let joinInProgress = false; function fail(code: string, message: string, status: number): Response { return Response.json({ error: { code, message } }, { status, headers: { "cache-control": "no-store" } }); @@ -327,7 +333,7 @@ async function apply(ctx: ManagementContext, state: LinkRouteState): Promise { const body = exactBody(await readManagementJsonBodyOr(ctx.req, null), ["alias", "tunnelPort"]); - if (!body || typeof body.alias !== "string" || !port(body.tunnelPort)) return fail("invalid_body", "alias and tunnelPort are required.", 400); + if (!body || typeof body.alias !== "string" || !isLinkPort(body.tunnelPort)) return fail("invalid_body", "alias and tunnelPort are required.", 400); try { assertSshAlias(body.alias); } catch { return fail("invalid_alias", "alias must be a valid SSH host alias.", 400); } let issued: IssuedApiKey; try { issued = issueKey(ctx, `link:${body.alias}`); } @@ -365,6 +371,53 @@ async function issue(ctx: ManagementContext): Promise { } } +type LinkJoinRouteOverrides = { + joinHome?: typeof joinHome; +}; + +function joinRouteOverrides(ctx: ManagementContext): LinkJoinRouteOverrides { + return ctx.deps as ManagementApiDepsWithJoinOverrides; +} + +type ManagementApiDepsWithJoinOverrides = ManagementContext["deps"] & LinkJoinRouteOverrides; + +function joinFailure(error: unknown): Response { + const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : ""; + switch (code) { + case "host_not_confirmed": return fail("host_not_confirmed", "Confirm the SSH host before joining the link.", 409); + case "host_confirmation_expired": return fail("host_confirmation_expired", "The SSH host confirmation has expired.", 409); + case "join_port_failed": return fail("join_port_failed", "No local port is available for the link tunnel.", 503); + case "join_issue_failed": return fail("join_issue_failed", "The home could not issue a link.", 502); + case "join_tunnel_failed": return fail("join_tunnel_failed", "The SSH tunnel to the home did not become ready.", 502); + case "admission_failed": return fail("admission_failed", "The home refused the issued link key.", 502); + default: return fail("join_connect_failed", "The client link join could not be completed.", 502); + } +} + +async function handleJoin(ctx: ManagementContext, state: LinkRouteState): Promise { + const body = exactBody(await readManagementJsonBodyOr(ctx.req, null), ["alias"]); + if (!body || typeof body.alias !== "string") return fail("invalid_body", "alias is required.", 400); + try { assertSshAlias(body.alias); } catch { return fail("invalid_alias", "alias must be a valid SSH host alias.", 400); } + if (joinInProgress) return fail("join_in_progress", "A link join is already in progress.", 409); + joinInProgress = true; + try { + const confirmed = state.confirmedHosts?.get(body.alias); + const overrides = joinRouteOverrides(ctx); + const deps: ClientLinkJoinDeps = { + runner: runnerFor(ctx), + knownHostsFile: knownHostsFile(ctx), + scheduleRestart: () => { acceptSystemRestart(); }, + ...(confirmed ? { confirmedHost: confirmed } : {}), + }; + const result = await (overrides.joinHome ?? joinHome)(deps, { alias: body.alias }); + return Response.json({ linkId: result.linkId, alias: body.alias, restarting: true }, { status: 202 }); + } catch (error) { + return joinFailure(error); + } finally { + joinInProgress = false; + } +} + async function remove(ctx: ManagementContext, state: LinkRouteState, id: string): Promise { if (!LINK_ID.test(id)) return fail("invalid_link_id", "The link id is invalid.", 400); const current = readStoreFor(ctx); @@ -412,6 +465,14 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L const path = url.pathname; if (!isLinkPath(path)) return null; if (ctx.guiSessionIssuance === "tailscale-identity") return fail("tailscale_session_refused", "Tailscale identity sessions cannot use link routes.", 403); + if (url.pathname === "/api/link/join" && req.method === "POST") { + const denied = auth(ctx, "dashboard"); + if (denied) return denied; + if ((ctx.config.runtimeRole ?? "standalone") !== "standalone") return fail("standalone_required", "Client initiated links require standalone runtime mode.", 409); + const state = suppliedState ?? stateFor(ctx); + if (!state) return fail("link_unavailable", "The link lifecycle is unavailable.", 503); + return handleJoin(ctx, state); + } const state = suppliedState ?? stateFor(ctx); if (!state) return fail("link_unavailable", "The link lifecycle is unavailable.", 503); if (url.pathname === "/api/link/status" && req.method === "GET") { @@ -419,7 +480,10 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L if (denied) return denied; const store = readStoreFor(ctx); const listenerStatus = state.listener.status(); - const dto: LinkStatusDto = projectLinkStatus(store, state.supervisor.status(), listenerStatus, ctx.config, readCompensation()); + const dto: LinkStatusDto = projectLinkStatus( + store, state.supervisor.status(), listenerStatus, ctx.config, readCompensation(), + ctx.config.runtimeRole === "client" ? clientLinkTunnelStatus() : null, + ); for (const link of dto.links) { const failure = state.compensationFailures?.get(link.id); if (failure) Object.assign(link, { state: "failed" as const, since: failure.since, reason: failure.reason }); diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index ff46f8774a6..cdb972d91d5 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -368,6 +368,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/link/candidates", module: "server/management/link-routes", mutates: false, exempt: { reason: "session-only", why: "SSH candidates are a dashboard pairing surface and are withheld from admin-token and Tailscale identity sessions." } }, { method: "POST", path: "/api/link/probe", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "SSH probing and host-key presentation are part of the interactive pairing consent flow." } }, { method: "POST", path: "/api/link/confirm-host", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Persisting a host key requires the paired dashboard session that saw the fingerprint." } }, + { method: "POST", path: "/api/link/join", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Joining a confirmed Home issues a link credential and restarts this standalone runtime as a client." } }, { method: "POST", path: "/api/link/apply", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Applying a link issues a data key and starts a remote tunnel, so it requires the paired dashboard session." } }, { method: "DELETE", path: "/api/link/{id}", module: "server/management/link-routes", mutates: true, mechanism: "regex" }, { method: "POST", path: "/api/link/issue", module: "server/management/link-routes", mutates: true }, diff --git a/tests/server/link-join-route.test.ts b/tests/server/link-join-route.test.ts new file mode 100644 index 00000000000..b768e0ed877 --- /dev/null +++ b/tests/server/link-join-route.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, test, spyOn } from "bun:test"; +import { joinHome, type ClientLinkJoinDeps } from "../../src/client/link-join"; +import { handleLinkRoutes, type LinkRouteState } from "../../src/server/management/link-routes"; +import type { ManagementContext } from "../../src/server/management/context"; +import type { SshRunner } from "../../src/link/ssh-runner"; + +const LINK_ID = "lnk_0123456789abcdef"; +const API_KEY_ID = "link-key-1"; +const KEY = `ocx_data_${"a".repeat(40)}`; +const FINGERPRINT = `SHA256:${"a".repeat(32)}`; + +function runnerFor(calls: string[][], issueResult = true): SshRunner { + return { + run: async argv => { + calls.push([...argv]); + if (argv.some(value => value.includes("issue"))) { + return issueResult + ? { code: 0, stdout: JSON.stringify({ linkId: LINK_ID, apiKeyId: API_KEY_ID, key: KEY, listenerPort: 45678 }), stderr: "" } + : { code: 1, stdout: "", stderr: "failed" }; + } + return { code: 0, stdout: "", stderr: "" }; + }, + spawnTunnel: () => ({ + pid: 123, + argv: [], + exited: Promise.resolve(0), + kill: () => {}, + }), + }; +} + +function tunnelFor(order: string[]) { + return { + pid: 123, + exited: Promise.resolve(0), + stop: async () => { order.push("stop-tunnel"); }, + }; +} + +function joinDeps(overrides: Partial = {}): ClientLinkJoinDeps { + const calls = overrides.runner ? [] : []; + return { + runner: overrides.runner ?? runnerFor(calls), + knownHostsFile: "/tmp/ocx-known-hosts", + confirmedHost: { alias: "home", fingerprint: FINGERPRINT, probedAt: 0 }, + choosePort: async () => 23456, + now: () => 1, + sleep: async () => {}, + hostname: () => "client-host", + ...overrides, + }; +} + +function routeState(confirmed = true): LinkRouteState { + return { + pendingHosts: new Map(), + confirmedHosts: confirmed + ? new Map([["home", { alias: "home", fingerprint: FINGERPRINT, keyType: "ed25519", knownHostLine: "home ssh-ed25519 AAAA", probedAt: 0, ocxVersion: "2.0.0" }]]) + : new Map(), + supervisor: {} as LinkRouteState["supervisor"], + listener: {} as LinkRouteState["listener"], + }; +} + +function context(options: { + role?: "standalone" | "hub" | "client"; + principal?: ManagementContext["principal"]; + paired?: boolean; + issuance?: ManagementContext["guiSessionIssuance"]; + body?: unknown; + deps?: Record; +} = {}): ManagementContext { + const body = options.body ?? { alias: "home" }; + const req = new Request("http://127.0.0.1/api/link/join", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return { + req, + url: new URL(req.url), + config: { runtimeRole: options.role ?? "standalone" } as ManagementContext["config"], + deps: options.deps ?? {}, + version: "test", + principal: options.principal, + sessionControl: { isPaired: () => options.paired === true }, + trustedLoopbackIngress: true, + guiSessionIssuance: options.issuance ?? null, + convergeCodexCatalog: async () => ({ status: "unchanged" } as never), + syncClaudeAgentDefsBestEffort: async () => {}, + }; +} + +describe("client initiated link join", () => { + test("rejects unauthenticated, Tailscale, and non-standalone requests before lifecycle state", async () => { + const state = routeState(); + const unauthenticated = await handleLinkRoutes(context(), state); + expect(unauthenticated?.status).toBe(403); + + const tailscale = await handleLinkRoutes(context({ issuance: "tailscale-identity", principal: "gui-session", paired: true }), state); + expect(tailscale?.status).toBe(403); + expect(await tailscale?.json()).toMatchObject({ error: { code: "tailscale_session_refused" } }); + + for (const role of ["hub", "client"] as const) { + const response = await handleLinkRoutes(context({ role, principal: "gui-session", paired: true }), state); + expect(response?.status).toBe(409); + expect(await response?.json()).toMatchObject({ error: { code: "standalone_required" } }); + } + }); + + test("requires the exact body and a confirmed host", async () => { + const exact = await handleLinkRoutes(context({ principal: "gui-session", paired: true, body: { alias: "home", extra: true } }), routeState()); + expect(exact?.status).toBe(400); + + const missing = await handleLinkRoutes(context({ principal: "gui-session", paired: true }), routeState(false)); + expect(missing?.status).toBe(409); + expect(await missing?.json()).toMatchObject({ error: { code: "host_not_confirmed" } }); + }); + + test("allows only one join for a runtime at a time", async () => { + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const deps = { + joinHome: async () => { + await gate; + return { linkId: LINK_ID, apiKeyId: API_KEY_ID }; + }, + }; + const first = context({ principal: "gui-session", paired: true, deps }); + const second = context({ principal: "gui-session", paired: true, deps }); + second.config = first.config; + const pending = handleLinkRoutes(first, routeState()); + await Promise.resolve(); + const duplicate = await handleLinkRoutes(second, routeState()); + expect(duplicate?.status).toBe(409); + expect(await duplicate?.json()).toMatchObject({ error: { code: "join_in_progress" } }); + release(); + expect((await pending)?.status).toBe(202); + }); + + test("runs issue, sidecar, tunnel readiness, connect, stop, and restart in order", async () => { + const order: string[] = []; + const calls: string[][] = []; + const sidecar: Record = {}; + const response = await handleLinkRoutes(context({ + principal: "gui-session", + paired: true, + deps: { + sshRunner: runnerFor(calls), + linkKnownHostsPath: () => "/tmp/ocx-known-hosts", + joinHome: (async deps => joinHome({ + ...deps, + choosePort: async () => 23456, + now: () => 1, + sleep: async () => {}, + hostname: () => "client-host", + writeState: state => { order.push("write-state"); Object.assign(sidecar, state); }, + spawnTunnel: () => { order.push("spawn-tunnel"); return tunnelFor(order); }, + fetchImpl: async (_input, init) => { + order.push(`readyz:${new Headers(init?.headers).get("x-opencodex-api-key") === KEY ? "key" : "missing"}`); + return new Response(null, { status: 200 }); + }, + connect: (async () => { order.push("connect"); }) as typeof import("../../src/client/connect").connectClient, + scheduleRestart: () => { order.push("restart"); }, + }, { alias: "home" })), + }, + }), routeState()); + const responseBody = await response?.json(); + expect(response?.status).toBe(202); + expect(responseBody).toEqual({ linkId: LINK_ID, alias: "home", restarting: true }); + expect(sidecar).toMatchObject({ linkId: LINK_ID, tunnelPort: 23456, peerListenerPort: 45678 }); + expect(order).toEqual(["write-state", "spawn-tunnel", "readyz:key", "connect", "stop-tunnel", "restart"]); + expect(calls[0]?.some(value => value.includes("issue"))).toBe(true); + expect(calls[0]?.some(value => value.includes("--json"))).toBe(true); + }); + + test("rolls back sidecar and remote issue when sidecar write fails", async () => { + const calls: string[][] = []; + let cleared = false; + await expect(joinHome(joinDeps({ + runner: runnerFor(calls), + writeState: () => { throw new Error("sidecar write failed"); }, + clearState: () => { cleared = true; }, + spawnTunnel: () => { throw new Error("must not start"); }, + }), { alias: "home" })).rejects.toMatchObject({ code: "join_tunnel_failed" }); + expect(calls.filter(argv => argv.some(value => value.includes("revoke")))).toHaveLength(1); + expect(cleared).toBe(true); + }); + + test("rolls back on readiness timeout and admission rejection", async () => { + for (const readiness of ["timeout", "unauthorized"] as const) { + const calls: string[][] = []; + let stopped = 0; + let cleared = 0; + let ticks = 0; + const deps = joinDeps({ + runner: runnerFor(calls), + now: () => readiness === "timeout" ? (ticks++ === 0 ? 0 : 15_002 * ticks) : 1, + sleep: async () => {}, + writeState: () => {}, + clearState: () => { cleared += 1; }, + spawnTunnel: () => ({ pid: 1, exited: Promise.resolve(0), stop: async () => { stopped += 1; } }), + fetchImpl: async () => readiness === "unauthorized" ? new Response(null, { status: 401 }) : new Response(null, { status: 503 }), + }); + await expect(joinHome(deps, { alias: "home" })).rejects.toMatchObject({ + code: readiness === "timeout" ? "join_tunnel_failed" : "admission_failed", + }); + expect(calls.filter(argv => argv.some(value => value.includes("revoke")))).toHaveLength(1); + expect(stopped).toBe(1); + expect(cleared).toBe(1); + } + }); + + test("rolls back on connect failure and never exposes the issued key", async () => { + const calls: string[][] = []; + const logs = spyOn(console, "log").mockImplementation(() => {}); + try { + await expect(joinHome(joinDeps({ + runner: runnerFor(calls), + writeState: () => {}, + clearState: () => {}, + spawnTunnel: () => tunnelFor([]), + fetchImpl: async () => new Response(null, { status: 200 }), + connect: (async () => { throw new Error(`connect failed ${KEY}`); }) as typeof import("../../src/client/connect").connectClient, + }), { alias: "home" })).rejects.toMatchObject({ code: "join_connect_failed" }); + } finally { + logs.mockRestore(); + } + expect(calls.filter(argv => argv.some(value => value.includes("revoke")))).toHaveLength(1); + expect(logs.mock.calls.flat().join(" ")).not.toContain(KEY); + }); +}); From 81e556fb7b749eef84461dd1197ab8d37a0a2e12 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:44:40 +0900 Subject: [PATCH 07/13] feat(link): Find Home in the Remote Link page The Child role is available only on a standalone runtime and reuses the add sheet: candidates, probe, fingerprint confirmation, then join. Joining, failure with Retry, and the restart wait have their own states and strings in all ten locales, and the fixture server can show each of them. --- gui/scripts/remote-link-fixture.ts | 11 ++- gui/src/api-targets.ts | 9 ++- gui/src/i18n/de.ts | 16 ++++ gui/src/i18n/en.ts | 16 ++++ gui/src/i18n/fr.ts | 16 ++++ gui/src/i18n/ja.ts | 16 ++++ gui/src/i18n/ko.ts | 16 ++++ gui/src/i18n/ru.ts | 16 ++++ gui/src/i18n/tr.ts | 16 ++++ gui/src/i18n/vi.ts | 16 ++++ gui/src/i18n/zh-TW.ts | 16 ++++ gui/src/i18n/zh.ts | 16 ++++ gui/src/pages/RemoteLink.tsx | 59 ++++++++++++--- gui/src/remote-link-api.ts | 7 ++ gui/src/styles-remote-link.css | 4 + gui/tests/remote-link.test.tsx | 115 ++++++++++++++++++++++++++++- 16 files changed, 352 insertions(+), 13 deletions(-) diff --git a/gui/scripts/remote-link-fixture.ts b/gui/scripts/remote-link-fixture.ts index c80d2816cbc..9103eae0725 100644 --- a/gui/scripts/remote-link-fixture.ts +++ b/gui/scripts/remote-link-fixture.ts @@ -6,10 +6,12 @@ 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 standaloneFixtures = new Set(["standalone", "standalone-find-home", "standalone-joining", "standalone-join-failure", "standalone-restart-waiting"]); 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 }; + if (fixture === "standalone-restart-waiting") return { role: "standalone", listener: { state: "off", port: null }, links: [], child: null }; return { role: "standalone", listener: { state: "off", port: null }, links: [], child: null }; }; @@ -38,7 +40,9 @@ async function staticFile(pathname: string): Promise { async function appDocument(request: Request): Promise { const body = await readFile(join(root, "index.html"), "utf8"); const origin = new URL(request.url).origin; - const tags = ``; + const fixture = fixtureFor(request, new URL(request.url)); + const runtimeRole = standaloneFixtures.has(fixture) ? "standalone" : "hub"; + const tags = ``; return new Response(body.replace("", `${tags}`), { headers: { "content-type": "text/html" } }); } @@ -54,6 +58,11 @@ const server = Bun.serve({ 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 === "/api/link/join" && request.method === "POST") { + if (fixture === "standalone-join-failure") return json({ error: { code: "join_tunnel_failed" } }, { status: 502 }); + if (fixture === "standalone-joining") await Bun.sleep(60_000); + return json({ linkId: "fixture-child-link", alias: "child-workstation", restarting: true }, { 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); diff --git a/gui/src/api-targets.ts b/gui/src/api-targets.ts index c6be74c6d27..f58d5efbdb7 100644 --- a/gui/src/api-targets.ts +++ b/gui/src/api-targets.ts @@ -7,10 +7,15 @@ export type SharedTransport = "same-origin" | "direct" | "relay"; * Read without removing the tag: unlike the session meta, which is consumed once so a * credential does not linger in the DOM, the role is non-secret and may be read again. */ -function runtimeRoleFromDocument(): string | null { +export function runtimeRoleFromDocument(): "standalone" | "hub" | "client" | null { if (typeof document === "undefined") return null; const meta = document.querySelector('meta[name="opencodex-runtime-role"]'); - return meta?.getAttribute("content")?.trim() || null; + const role = meta?.getAttribute("content")?.trim(); + return role === "standalone" || role === "hub" || role === "client" ? role : null; +} + +export function isStandaloneRuntime(): boolean { + return runtimeRoleFromDocument() === "standalone"; } /** diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index f91ee8102f4..6054a3cf957 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -3171,6 +3171,22 @@ export const de: Record = { "link.close": "Schließen", "link.cancel": "Abbrechen", "link.childPending": "Die Einrichtung als Kind ist im clientgestarteten Ablauf verfügbar.", + "remoteLink.childDisabled": "Kind-Verbindungen können nur von einer eigenständigen Laufzeit gestartet werden.", + "remoteLink.findHome.title": "Home suchen", + "remoteLink.findHome.body": "Wählen Sie den Home-Computer für dieses Kind aus.", + "remoteLink.findHome.action": "Home suchen", + "remoteLink.findHome.connect": "Als Kind verbinden", + "remoteLink.joining": "Home wird verbunden …", + "remoteLink.restart.title": "Dieser Computer wird neu gestartet, um sich als Kind zu verbinden.", + "remoteLink.restart.body": "Das Dashboard verbindet sich automatisch wieder, sobald das Kind bereit ist.", + "remoteLink.restart.waiting": "Warten auf die erneute Verbindung als Kind …", + "remoteLink.error.standalone_required": "Kind-Verbindungen können nur von einer eigenständigen Laufzeit gestartet werden.", + "remoteLink.error.join_tunnel_failed": "Der Tunnel zu Home konnte nicht gestartet werden. Prüfen Sie den SSH-Zugriff und versuchen Sie es erneut.", + "remoteLink.error.admission_failed": "Home hat die neue Verbindung nicht angenommen. Prüfen Sie, ob Home läuft, und versuchen Sie es erneut.", + "remoteLink.error.join_issue_failed": "Home konnte keinen Link ausstellen. Prüfen Sie, ob OpenCodex auf Home läuft, und versuchen Sie es erneut.", + "remoteLink.error.join_in_progress": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.join_port_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.join_connect_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", "link.noChildren": "Keine Kindercomputer verbunden.", "remoteLink.status.connecting": "Verbindung wird hergestellt", "remoteLink.status.connected": "Verbunden", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 419c1004c9d..60bbb20a8c8 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3205,6 +3205,22 @@ export const en = { "link.close": "Close", "link.cancel": "Cancel", "link.childPending": "Child setup is available in the client-started flow.", + "remoteLink.childDisabled": "Child links can only be started from a standalone runtime.", + "remoteLink.findHome.title": "Find Home", + "remoteLink.findHome.body": "Choose the Home computer to connect this Child to.", + "remoteLink.findHome.action": "Find Home", + "remoteLink.findHome.connect": "Connect as Child", + "remoteLink.joining": "Joining Home…", + "remoteLink.restart.title": "This computer will restart to connect as a Child.", + "remoteLink.restart.body": "The dashboard will reconnect automatically when the Child is ready.", + "remoteLink.restart.waiting": "Waiting for this computer to reconnect as Child…", + "remoteLink.error.standalone_required": "Child links can only be started from a standalone runtime.", + "remoteLink.error.join_tunnel_failed": "The tunnel to Home could not be started. Check SSH access and retry.", + "remoteLink.error.admission_failed": "Home did not accept the new link. Check that it is running and retry.", + "remoteLink.error.join_issue_failed": "Home could not issue a link. Check that OpenCodex is running on Home and retry.", + "remoteLink.error.join_in_progress": "Remote link request could not be completed.", + "remoteLink.error.join_port_failed": "Remote link request could not be completed.", + "remoteLink.error.join_connect_failed": "Remote link request could not be completed.", "link.noChildren": "No child computers are connected.", "remoteLink.status.connecting": "Connecting", "remoteLink.status.connected": "Connected", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 45e984d2008..562194322f2 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -3160,6 +3160,22 @@ export const fr: Record = { "link.close": "Fermer", "link.cancel": "Annuler", "link.childPending": "La configuration Enfant sera disponible dans le flux lancé par le client.", + "remoteLink.childDisabled": "Les liens Enfant ne peuvent être lancés que depuis un runtime autonome.", + "remoteLink.findHome.title": "Trouver le Home", + "remoteLink.findHome.body": "Choisissez l’ordinateur Home auquel connecter cet Enfant.", + "remoteLink.findHome.action": "Trouver le Home", + "remoteLink.findHome.connect": "Connecter comme Enfant", + "remoteLink.joining": "Connexion au Home…", + "remoteLink.restart.title": "Cet ordinateur va redémarrer pour se connecter comme Enfant.", + "remoteLink.restart.body": "Le tableau de bord se reconnectera automatiquement lorsque l’Enfant sera prêt.", + "remoteLink.restart.waiting": "En attente de la reconnexion comme Enfant…", + "remoteLink.error.standalone_required": "Les liens Enfant ne peuvent être lancés que depuis un runtime autonome.", + "remoteLink.error.join_tunnel_failed": "Le tunnel vers le Home n’a pas pu être démarré. Vérifiez l’accès SSH puis réessayez.", + "remoteLink.error.admission_failed": "Le Home n’a pas accepté le nouveau lien. Vérifiez qu’il fonctionne puis réessayez.", + "remoteLink.error.join_issue_failed": "Le Home n’a pas pu émettre le lien. Vérifiez qu’OpenCodex y fonctionne puis réessayez.", + "remoteLink.error.join_in_progress": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.join_port_failed": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.join_connect_failed": "La demande de lien distant n’a pas pu aboutir.", "link.noChildren": "Aucun ordinateur enfant connecté.", "remoteLink.status.connecting": "Connexion", "remoteLink.status.connected": "Connecté", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 4579ca39dfb..813792fdac6 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -3193,6 +3193,22 @@ export const ja: Record = { "link.close": "閉じる", "link.cancel": "キャンセル", "link.childPending": "子の設定はクライアント開始フローで提供されます。", + "remoteLink.childDisabled": "子リンクを開始できるのはスタンドアロンランタイムだけです。", + "remoteLink.findHome.title": "Home を探す", + "remoteLink.findHome.body": "この子コンピューターを接続する Home を選択してください。", + "remoteLink.findHome.action": "Home を探す", + "remoteLink.findHome.connect": "子として接続", + "remoteLink.joining": "Home に接続しています…", + "remoteLink.restart.title": "このコンピューターは子として接続するため再起動します。", + "remoteLink.restart.body": "子の準備ができるとダッシュボードは自動的に再接続します。", + "remoteLink.restart.waiting": "子として再接続するまで待機しています…", + "remoteLink.error.standalone_required": "子リンクを開始できるのはスタンドアロンランタイムだけです。", + "remoteLink.error.join_tunnel_failed": "Home へのトンネルを開始できませんでした。SSH 接続を確認して再試行してください。", + "remoteLink.error.admission_failed": "Home が新しいリンクを受け付けませんでした。Home が起動しているか確認して再試行してください。", + "remoteLink.error.join_issue_failed": "Home がリンクを発行できませんでした。Home で OpenCodex が起動しているか確認して再試行してください。", + "remoteLink.error.join_in_progress": "リモートリンク要求を完了できませんでした。", + "remoteLink.error.join_port_failed": "リモートリンク要求を完了できませんでした。", + "remoteLink.error.join_connect_failed": "リモートリンク要求を完了できませんでした。", "link.noChildren": "接続された子コンピューターはありません。", "remoteLink.status.connecting": "接続中", "remoteLink.status.connected": "接続済み", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 7cf007a3882..b90cd20a010 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3193,6 +3193,22 @@ export const ko: Record = { "link.close": "닫기", "link.cancel": "취소", "link.childPending": "자식 설정은 클라이언트 시작 흐름에서 제공될 예정입니다.", + "remoteLink.childDisabled": "자식 링크는 독립형 런타임에서만 시작할 수 있습니다.", + "remoteLink.findHome.title": "홈 찾기", + "remoteLink.findHome.body": "이 자식 컴퓨터를 연결할 홈 컴퓨터를 선택하세요.", + "remoteLink.findHome.action": "홈 찾기", + "remoteLink.findHome.connect": "자식으로 연결", + "remoteLink.joining": "홈에 연결하는 중…", + "remoteLink.restart.title": "이 컴퓨터는 자식으로 연결하기 위해 재시작합니다.", + "remoteLink.restart.body": "자식이 준비되면 대시보드가 자동으로 다시 연결됩니다.", + "remoteLink.restart.waiting": "자식으로 다시 연결되기를 기다리는 중…", + "remoteLink.error.standalone_required": "자식 링크는 독립형 런타임에서만 시작할 수 있습니다.", + "remoteLink.error.join_tunnel_failed": "홈으로 가는 터널을 시작하지 못했습니다. SSH 연결을 확인한 뒤 다시 시도하세요.", + "remoteLink.error.admission_failed": "홈이 새 링크를 수락하지 않았습니다. 홈이 실행 중인지 확인한 뒤 다시 시도하세요.", + "remoteLink.error.join_issue_failed": "홈에서 링크를 발급하지 못했습니다. 홈에서 OpenCodex가 실행 중인지 확인한 뒤 다시 시도하세요.", + "remoteLink.error.join_in_progress": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.join_port_failed": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.join_connect_failed": "원격 연결 요청을 완료하지 못했습니다.", "link.noChildren": "연결된 자식 컴퓨터가 없습니다.", "remoteLink.status.connecting": "연결 중", "remoteLink.status.connected": "연결됨", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 3af97d8c242..8081ef95768 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -3194,6 +3194,22 @@ export const ru: Record = { "link.close": "Закрыть", "link.cancel": "Отмена", "link.childPending": "Настройка дочернего режима появится в потоке, запущенном клиентом.", + "remoteLink.childDisabled": "Связь с дочерним компьютером можно начать только из автономного режима.", + "remoteLink.findHome.title": "Найти Home", + "remoteLink.findHome.body": "Выберите компьютер Home, к которому подключить этот Child.", + "remoteLink.findHome.action": "Найти Home", + "remoteLink.findHome.connect": "Подключить как Child", + "remoteLink.joining": "Подключение к Home…", + "remoteLink.restart.title": "Компьютер перезапустится для подключения как Child.", + "remoteLink.restart.body": "Панель управления подключится автоматически, когда Child будет готов.", + "remoteLink.restart.waiting": "Ожидание повторного подключения как Child…", + "remoteLink.error.standalone_required": "Связь с дочерним компьютером можно начать только из автономного режима.", + "remoteLink.error.join_tunnel_failed": "Не удалось запустить туннель к Home. Проверьте SSH-доступ и повторите попытку.", + "remoteLink.error.admission_failed": "Home не принял новую связь. Убедитесь, что он запущен, и повторите попытку.", + "remoteLink.error.join_issue_failed": "Home не смог выдать связь. Убедитесь, что на Home запущен OpenCodex, и повторите попытку.", + "remoteLink.error.join_in_progress": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.join_port_failed": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.join_connect_failed": "Не удалось завершить запрос удалённой связи.", "link.noChildren": "Дочерние компьютеры не подключены.", "remoteLink.status.connecting": "Подключение", "remoteLink.status.connected": "Подключено", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index cba018ea8f1..3277869107d 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -3194,6 +3194,22 @@ export const tr: Record = { "link.close": "Kapat", "link.cancel": "İptal", "link.childPending": "Çocuk kurulumu istemci başlatmalı akışta sunulacak.", + "remoteLink.childDisabled": "Çocuk bağlantıları yalnızca bağımsız çalışma zamanından başlatılabilir.", + "remoteLink.findHome.title": "Home\u0027u bul", + "remoteLink.findHome.body": "Bu Çocuk bilgisayarının bağlanacağı Home bilgisayarını seçin.", + "remoteLink.findHome.action": "Home'u bul", + "remoteLink.findHome.connect": "Çocuk olarak bağlan", + "remoteLink.joining": "Home'a bağlanılıyor…", + "remoteLink.restart.title": "Bu bilgisayar Çocuk olarak bağlanmak için yeniden başlatılacak.", + "remoteLink.restart.body": "Çocuk hazır olduğunda pano otomatik olarak yeniden bağlanır.", + "remoteLink.restart.waiting": "Çocuk olarak yeniden bağlanılması bekleniyor…", + "remoteLink.error.standalone_required": "Çocuk bağlantıları yalnızca bağımsız çalışma zamanından başlatılabilir.", + "remoteLink.error.join_tunnel_failed": "Home tüneli başlatılamadı. SSH erişimini kontrol edip yeniden deneyin.", + "remoteLink.error.admission_failed": "Home yeni bağlantıyı kabul etmedi. Çalıştığını kontrol edip yeniden deneyin.", + "remoteLink.error.join_issue_failed": "Home bağlantı veremedi. Home üzerinde OpenCodex'in çalıştığını kontrol edip yeniden deneyin.", + "remoteLink.error.join_in_progress": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.join_port_failed": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.join_connect_failed": "Uzak bağlantı isteği tamamlanamadı.", "link.noChildren": "Bağlı çocuk bilgisayarı yok.", "remoteLink.status.connecting": "Bağlanıyor", "remoteLink.status.connected": "Bağlandı", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 7963d3f1a78..19e652a87c4 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -3129,6 +3129,22 @@ export const vi: Record = { "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.", + "remoteLink.childDisabled": "Chỉ có thể bắt đầu liên kết máy con từ runtime độc lập.", + "remoteLink.findHome.title": "Tìm Home", + "remoteLink.findHome.body": "Chọn máy Home để kết nối máy con này.", + "remoteLink.findHome.action": "Tìm Home", + "remoteLink.findHome.connect": "Kết nối với vai trò máy con", + "remoteLink.joining": "Đang kết nối với Home…", + "remoteLink.restart.title": "Máy tính này sẽ khởi động lại để kết nối với vai trò máy con.", + "remoteLink.restart.body": "Bảng điều khiển sẽ tự động kết nối lại khi máy con sẵn sàng.", + "remoteLink.restart.waiting": "Đang chờ máy tính này kết nối lại với vai trò máy con…", + "remoteLink.error.standalone_required": "Chỉ có thể bắt đầu liên kết máy con từ runtime độc lập.", + "remoteLink.error.join_tunnel_failed": "Không thể khởi động đường hầm tới Home. Hãy kiểm tra quyền truy cập SSH rồi thử lại.", + "remoteLink.error.admission_failed": "Home không chấp nhận liên kết mới. Hãy kiểm tra Home đang chạy rồi thử lại.", + "remoteLink.error.join_issue_failed": "Home không thể cấp liên kết. Hãy kiểm tra OpenCodex đang chạy trên Home rồi thử lại.", + "remoteLink.error.join_in_progress": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.join_port_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.join_connect_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", "link.noChildren": "Chưa có máy con nào được kết nối.", "remoteLink.status.connecting": "Đang kết nối", "remoteLink.status.connected": "Đã kết nối", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index a1f403554a6..557f0147f31 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3157,6 +3157,22 @@ export const zhTW: Record = { "link.close": "關閉", "link.cancel": "取消", "link.childPending": "子裝置設定將在用戶端啟動流程中提供。", + "remoteLink.childDisabled": "只有獨立執行環境才能發起子裝置連線。", + "remoteLink.findHome.title": "尋找 Home", + "remoteLink.findHome.body": "選擇要連線此子裝置的 Home 電腦。", + "remoteLink.findHome.action": "尋找 Home", + "remoteLink.findHome.connect": "以子裝置身分連線", + "remoteLink.joining": "正在連線 Home…", + "remoteLink.restart.title": "此電腦將重新啟動,以子裝置身分連線。", + "remoteLink.restart.body": "子裝置準備好後,控制面板會自動重新連線。", + "remoteLink.restart.waiting": "正在等待此電腦以子裝置身分重新連線…", + "remoteLink.error.standalone_required": "只有獨立執行環境才能發起子裝置連線。", + "remoteLink.error.join_tunnel_failed": "無法啟動通往 Home 的通道。請檢查 SSH 存取權限後重試。", + "remoteLink.error.admission_failed": "Home 未接受新連線。請確認 Home 正在執行後重試。", + "remoteLink.error.join_issue_failed": "Home 無法簽發連線。請確認 Home 上正在執行 OpenCodex 後重試。", + "remoteLink.error.join_in_progress": "無法完成遠端連線要求。", + "remoteLink.error.join_port_failed": "無法完成遠端連線要求。", + "remoteLink.error.join_connect_failed": "無法完成遠端連線要求。", "link.noChildren": "沒有已連線的子裝置。", "remoteLink.status.connecting": "連線中", "remoteLink.status.connected": "已連線", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index e9a03e5f607..2db3d5ef4a7 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -3192,6 +3192,22 @@ export const zh: Record = { "link.close": "关闭", "link.cancel": "取消", "link.childPending": "子设备设置将在客户端发起流程中提供。", + "remoteLink.childDisabled": "只有独立运行时才能发起子设备连接。", + "remoteLink.findHome.title": "查找 Home", + "remoteLink.findHome.body": "选择要连接此子设备的 Home 电脑。", + "remoteLink.findHome.action": "查找 Home", + "remoteLink.findHome.connect": "以子设备身份连接", + "remoteLink.joining": "正在连接 Home…", + "remoteLink.restart.title": "此电脑将重启,以子设备身份连接。", + "remoteLink.restart.body": "子设备准备就绪后,控制面板会自动重新连接。", + "remoteLink.restart.waiting": "正在等待此电脑以子设备身份重新连接…", + "remoteLink.error.standalone_required": "只有独立运行时才能发起子设备连接。", + "remoteLink.error.join_tunnel_failed": "无法启动到 Home 的隧道。请检查 SSH 访问权限后重试。", + "remoteLink.error.admission_failed": "Home 未接受新连接。请确认 Home 正在运行后重试。", + "remoteLink.error.join_issue_failed": "Home 无法签发连接。请确认 Home 上正在运行 OpenCodex 后重试。", + "remoteLink.error.join_in_progress": "无法完成远程连接请求。", + "remoteLink.error.join_port_failed": "无法完成远程连接请求。", + "remoteLink.error.join_connect_failed": "无法完成远程连接请求。", "link.noChildren": "没有已连接的子设备。", "remoteLink.status.connecting": "连接中", "remoteLink.status.connected": "已连接", diff --git a/gui/src/pages/RemoteLink.tsx b/gui/src/pages/RemoteLink.tsx index 491e8bbf0ef..18cbf20fe61 100644 --- a/gui/src/pages/RemoteLink.tsx +++ b/gui/src/pages/RemoteLink.tsx @@ -7,10 +7,11 @@ import { IconLink, IconPlus, IconRefresh, IconTrash, IconX } from "../icons"; import { Trans } from "../i18n/provider"; import { type TKey, useT } from "../i18n/shared"; import { Notice } from "../ui"; +import { isStandaloneRuntime } from "../api-targets"; import "../styles-remote-link.css"; type RemoteLinkRole = "home" | "child"; -type RemoteLinkUiState = "off" | "role-select" | "adding-child" | "confirming-host" | "applying" | "connected" | "reconnecting" | "failed"; +type RemoteLinkUiState = "off" | "role-select" | "adding-child" | "confirming-host" | "applying" | "joining" | "restart-waiting" | "connected" | "reconnecting" | "failed"; export interface RemoteLinkProps { apiBase: string; @@ -27,6 +28,7 @@ const STATUS_LABEL: Record = { idle: "remoteLink.status.idle", }; const ERROR_TKEY: Record = { + admission_failed: "remoteLink.error.admission_failed", admission_timeout: "remoteLink.error.admission_timeout", compensation_failed: "remoteLink.error.compensation_failed", fingerprint_failed: "remoteLink.error.fingerprint_failed", @@ -37,6 +39,11 @@ const ERROR_TKEY: Record = { invalid_alias: "remoteLink.error.invalid_alias", invalid_body: "remoteLink.error.invalid_body", invalid_link_id: "remoteLink.error.invalid_link_id", + join_connect_failed: "remoteLink.error.join_connect_failed", + join_in_progress: "remoteLink.error.join_in_progress", + join_issue_failed: "remoteLink.error.join_issue_failed", + join_port_failed: "remoteLink.error.join_port_failed", + join_tunnel_failed: "remoteLink.error.join_tunnel_failed", key_issue_failed: "remoteLink.error.key_issue_failed", key_revoke_failed: "remoteLink.error.key_revoke_failed", link_apply_failed: "remoteLink.error.link_apply_failed", @@ -49,6 +56,7 @@ const ERROR_TKEY: Record = { remote_connect_failed: "remoteLink.error.remote_connect_failed", remote_disconnect_failed: "remoteLink.error.remote_disconnect_failed", remote_port_failed: "remoteLink.error.remote_port_failed", + standalone_required: "remoteLink.error.standalone_required", tailscale_session_refused: "remoteLink.error.tailscale_session_refused", version_probe_failed: "remoteLink.error.version_probe_failed", }; @@ -63,7 +71,7 @@ const REASON_TKEY: Record = { "stale tunnel may hold the port": "remoteLink.reason.staleTunnel", }; -type FailedAction = { phase: "probe" | "apply"; alias: string }; +type FailedAction = { phase: "probe" | "apply" | "join"; 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; } @@ -103,7 +111,7 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = 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 [busy, setBusy] = useState<"candidates" | "probe" | "confirm" | "apply" | "join" | "remove" | null>(null); const [actionError, setActionError] = useState(null); const [failedAction, setFailedAction] = useState(null); const [confirming, setConfirming] = useState<{ row: LinkRowWire; force: boolean } | null>(null); @@ -136,7 +144,9 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = if (failedAction) { setUiState("failed"); } else if (value.links.length === 0) { - setUiState(current => ["role-select", "adding-child", "confirming-host", "applying"].includes(current) ? current : "off"); + setUiState(current => value.role === "child" && current === "restart-waiting" + ? "connected" + : ["role-select", "adding-child", "confirming-host", "applying", "joining", "restart-waiting"].includes(current) ? current : "off"); } else if (value.links.some(link => link.state === "failed")) setUiState("failed"); else if (value.links.some(link => link.state === "reconnecting")) setUiState("reconnecting"); else if (value.links.some(link => link.state === "connected")) setUiState("connected"); @@ -179,12 +189,29 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = // 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 standaloneRuntime = isStandaloneRuntime(); 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 fixtureMode = typeof document === "undefined" ? null : document.querySelector('meta[name="opencodex-remote-link-fixture"]')?.getAttribute("content"); + const fixtureInitializedRef = useRef(false); + const initializeFixture = useEffectEvent(() => { + if (fixtureInitializedRef.current || !standaloneRuntime) return; + fixtureInitializedRef.current = true; + if (fixtureMode === "standalone-find-home") { setRole("child"); void openSheet(); return; } + if (fixtureMode === "standalone-joining" || fixtureMode === "standalone-join-failure") { + setRole("child"); setAlias("child-workstation"); setProbe({ alias: "child-workstation", fingerprint: "SHA256:fixture-host-key", keyType: "ED25519" }); + setConfirmation({ alias: "child-workstation", fingerprint: "SHA256:fixture-host-key", ocxVersion: "0.0.0-fixture" }); setCheckedFingerprint(true); setSheetOpen(true); + if (fixtureMode === "standalone-joining") { setBusy("join"); setUiState("joining"); } + else { setActionError("remoteLink.error.join_tunnel_failed"); setFailedAction({ phase: "join", alias: "child-workstation" }); setUiState("failed"); } + return; + } + if (fixtureMode === "standalone-restart-waiting") { setRole("child"); setUiState("restart-waiting"); } + }); + useEffect(() => { window.setTimeout(initializeFixture, 0); }, [standaloneRuntime]); const runProbe = async (requestedAlias = alias.trim()) => { const value = requestedAlias.trim(); if (!value) return; @@ -208,9 +235,22 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = finally { setBusy(null); } }; + const joinLink = async (requestedAlias = confirmation?.alias) => { + const value = requestedAlias?.trim(); + if (!value) return; + setBusy("join"); setActionError(null); setFailedAction(null); setUiState("joining"); + try { + await requestLinkJson<{ linkId: string; alias: string; restarting: true }>(apiBase, "/api/link/join", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ alias: value }) }); + setSheetOpen(false); setUiState("restart-waiting"); + } catch (error) { + setActionError(errorKey(error)); setFailedAction({ phase: "join", alias: value }); setUiState("failed"); + } finally { setBusy(null); } + }; + const retryFailedAction = () => { if (!failedAction) { void refreshStatus(); return; } if (failedAction.phase === "probe") { setAlias(failedAction.alias); void runProbe(failedAction.alias); return; } + if (failedAction.phase === "join") { void joinLink(failedAction.alias); return; } void applyLink(); }; @@ -222,6 +262,7 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = const moveRole = (index: number, key: string) => { const next = key === "Home" ? 0 : key === "End" ? 1 : key === "ArrowRight" || key === "ArrowDown" ? (index + 1) % 2 : key === "ArrowLeft" || key === "ArrowUp" ? (index + 1) % 2 : index; + if (next === 1 && !isStandaloneRuntime()) return; if (next === index) return; roleRefs.current[next]?.focus(); setRole(next === 0 ? "home" : "child"); @@ -243,7 +284,7 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = // operator's choice so the heading does not contradict the action in front of them. const choseHome = role === "home" && (uiState === "adding-child" || uiState === "confirming-host" || uiState === "applying"); const roleLabel: TKey = status?.role === "home" || choseHome ? "remoteLink.role.home" : status?.role === "child" ? "remoteLink.role.child" : "remoteLink.role.standalone"; - const primaryActionDisabled = role === "child"; + const primaryActionDisabled = role === "child" && !standaloneRuntime; if (!sessionReady) return

{t("link.title")}

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

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

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

{t("link.switchOffHint")}

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

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

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

{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)}}
} + {uiState === "role-select" &&

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

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

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

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

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

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

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

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

{t("link.noChildren")}

}{(uiState === "reconnecting" || uiState === "failed") &&
{t(STATUS_LABEL[uiState === "failed" ? "failed" : "reconnecting"])}
}{uiState === "joining" &&

{t("remoteLink.joining")}

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

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

{busy === "candidates" ?

{t("link.loading")}

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

{t("link.noCandidates")}

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

{probe.fingerprint}

{probe.keyType}
}{confirmation &&

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

}{actionError && {t(actionError)}}
{ event.preventDefault(); closeConfirmation(); }}> diff --git a/gui/src/remote-link-api.ts b/gui/src/remote-link-api.ts index 38fc5dff276..c9aa70a771d 100644 --- a/gui/src/remote-link-api.ts +++ b/gui/src/remote-link-api.ts @@ -1,5 +1,6 @@ export const LINK_ERROR_CODES = [ "admission_timeout", + "admission_failed", "compensation_failed", "fingerprint_failed", "forbidden", @@ -9,6 +10,11 @@ export const LINK_ERROR_CODES = [ "invalid_alias", "invalid_body", "invalid_link_id", + "join_connect_failed", + "join_in_progress", + "join_issue_failed", + "join_port_failed", + "join_tunnel_failed", "key_issue_failed", "key_revoke_failed", "link_apply_failed", @@ -21,6 +27,7 @@ export const LINK_ERROR_CODES = [ "remote_connect_failed", "remote_disconnect_failed", "remote_port_failed", + "standalone_required", "tailscale_session_refused", "version_probe_failed", ] as const; diff --git a/gui/src/styles-remote-link.css b/gui/src/styles-remote-link.css index e0bef5bc3be..0e542dbed01 100644 --- a/gui/src/styles-remote-link.css +++ b/gui/src/styles-remote-link.css @@ -17,6 +17,8 @@ .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-disabled="true"] { color: var(--muted); cursor: not-allowed; opacity: .72; } +.remote-link-role-card[aria-disabled="true"]:hover { background: var(--raised); } .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); } @@ -34,6 +36,8 @@ .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-restart { display: grid; gap: var(--space-2); padding: var(--space-4); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); background: var(--raised); } +.remote-link-restart p { margin: 0; color: var(--muted); } .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); } diff --git a/gui/tests/remote-link.test.tsx b/gui/tests/remote-link.test.tsx index 2c03610813b..65f83e2ba03 100644 --- a/gui/tests/remote-link.test.tsx +++ b/gui/tests/remote-link.test.tsx @@ -29,6 +29,12 @@ afterEach(async () => { function response(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); } async function flush(): Promise { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); } +function declareRuntimeRole(role: "standalone" | "hub" | "client"): void { + const meta = win.document.createElement("meta"); + meta.name = "opencodex-runtime-role"; + meta.content = role; + win.document.head.append(meta); +} async function mount(props: Partial> = {}): Promise { const host = win.document.createElement("div"); win.document.body.append(host); @@ -70,10 +76,116 @@ test("off state and role choice issue no mutation request", async () => { 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(host.textContent).toContain("Child links can only be started from a standalone runtime."); expect(calls.every(call => call.method === "GET")).toBe(true); }); +test("Child role is disabled unless the served runtime is standalone", async () => { + globalThis.fetch = (async () => response({ ...baseStatus, role: "home" })) as typeof fetch; + const host = await mount(); + await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); + const child = [...host.querySelectorAll('[role="radio"]')][1] as HTMLButtonElement; + expect(child.getAttribute("aria-disabled")).toBe("true"); + await act(async () => { child.click(); }); + expect((host.querySelector(".remote-link-sheet") as HTMLDialogElement | null)?.open ?? false).toBe(false); + await act(async () => { root?.unmount(); }); + root = null; + + declareRuntimeRole("standalone"); + const standaloneHost = await mount(); + await act(async () => { (standaloneHost.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); + const standaloneChild = [...standaloneHost.querySelectorAll('[role="radio"]')][1] as HTMLButtonElement; + expect(standaloneChild.getAttribute("aria-disabled")).toBe("false"); +}); + +test("standalone Child flow joins with exactly the confirmed alias and shows restart waiting", async () => { + declareRuntimeRole("standalone"); + const calls: Array<{ path: string; method: string; body?: string }> = []; + globalThis.fetch = (async (input, init) => { + const path = new URL(String(input)).pathname; + calls.push({ path, method: init?.method ?? "GET", body: typeof init?.body === "string" ? init.body : undefined }); + if (path === "/api/link/candidates") return response({ candidates: [{ alias: "home-one", source: "ssh_config" }] }); + if (path === "/api/link/probe") return response({ alias: "home-one", fingerprint: "SHA256:test", keyType: "ed25519" }); + if (path === "/api/link/confirm-host") return response({ alias: "home-one", fingerprint: "SHA256:test", ocxVersion: "2.0.0" }); + if (path === "/api/link/join") return response({ linkId: "lnk_1234567890abcdef", alias: "home-one", restarting: true }, 202); + return response({ ...baseStatus, role: "standalone" }); + }) as typeof fetch; + const host = await mount(); + await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); + await act(async () => { ([...host.querySelectorAll('[role="radio"]')][1] as HTMLButtonElement).click(); }); + await flush(); + await act(async () => { (host.querySelector(".remote-link-candidate") as HTMLButtonElement).click(); }); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Test connection"))?.click(); }); + await flush(); + await act(async () => { (host.querySelector('input[type="checkbox"]') as HTMLInputElement).click(); }); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Confirm host"))?.click(); }); + await flush(); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Connect as Child"))?.click(); }); + await flush(); + expect(calls.find(call => call.path === "/api/link/join")?.body).toBe(JSON.stringify({ alias: "home-one" })); + expect(calls.some(call => call.path === "/api/link/apply")).toBe(false); + expect(host.textContent).toContain("This computer will restart to connect as a Child."); + expect(host.textContent).toContain("Waiting for this computer to reconnect as Child"); +}); + +test("join failure maps actionable errors and Retry re-joins the confirmed alias", async () => { + declareRuntimeRole("standalone"); + let joins = 0; + globalThis.fetch = (async input => { + const path = new URL(String(input)).pathname; + if (path === "/api/link/candidates") return response({ candidates: [{ alias: "home-one", source: "ssh_config" }] }); + if (path === "/api/link/probe") return response({ alias: "home-one", fingerprint: "SHA256:test", keyType: "ed25519" }); + if (path === "/api/link/confirm-host") return response({ alias: "home-one", fingerprint: "SHA256:test", ocxVersion: "2.0.0" }); + if (path === "/api/link/join") { + joins += 1; + return joins === 1 ? response({ error: { code: "join_tunnel_failed" } }, 502) : response({ linkId: "lnk_1234567890abcdef", alias: "home-one", restarting: true }, 202); + } + return response({ ...baseStatus, role: "standalone" }); + }) as typeof fetch; + const host = await mount(); + await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); + await act(async () => { ([...host.querySelectorAll('[role="radio"]')][1] as HTMLButtonElement).click(); }); + await flush(); + await act(async () => { (host.querySelector(".remote-link-candidate") as HTMLButtonElement).click(); }); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Test connection"))?.click(); }); + await flush(); + await act(async () => { (host.querySelector('input[type="checkbox"]') as HTMLInputElement).click(); }); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Confirm host"))?.click(); }); + await flush(); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Connect as Child"))?.click(); }); + await flush(); + expect(host.textContent).toContain("The tunnel to Home could not be started."); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Retry"))?.click(); }); + await flush(); + expect(joins).toBe(2); + expect(host.textContent).toContain("This computer will restart to connect as a Child."); +}); + +test("join maps standalone_required to an actionable message", async () => { + declareRuntimeRole("standalone"); + globalThis.fetch = (async input => { + const path = new URL(String(input)).pathname; + if (path === "/api/link/candidates") return response({ candidates: [{ alias: "home-one", source: "ssh_config" }] }); + if (path === "/api/link/probe") return response({ alias: "home-one", fingerprint: "SHA256:test", keyType: "ed25519" }); + if (path === "/api/link/confirm-host") return response({ alias: "home-one", fingerprint: "SHA256:test", ocxVersion: "2.0.0" }); + if (path === "/api/link/join") return response({ error: { code: "standalone_required" } }, 409); + return response({ ...baseStatus, role: "standalone" }); + }) as typeof fetch; + const host = await mount(); + await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); + await act(async () => { ([...host.querySelectorAll('[role="radio"]')][1] as HTMLButtonElement).click(); }); + await flush(); + await act(async () => { (host.querySelector(".remote-link-candidate") as HTMLButtonElement).click(); }); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Test connection"))?.click(); }); + await flush(); + await act(async () => { (host.querySelector('input[type="checkbox"]') as HTMLInputElement).click(); }); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Confirm host"))?.click(); }); + await flush(); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Connect as Child"))?.click(); }); + await flush(); + expect(host.textContent).toContain("Child links can only be started from a standalone runtime."); +}); + 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 }); @@ -175,6 +287,7 @@ test("apply failure stays retryable and Retry reapplies the confirmed alias", as }); test("role radios use roving tabIndex and arrow, Home, and End keys", async () => { + declareRuntimeRole("standalone"); globalThis.fetch = (async () => response(baseStatus)) as typeof fetch; const host = await mount(); await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); From 4062ef6fa2fda488ff989f700590746fa063f7fe Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:44:40 +0900 Subject: [PATCH 08/13] docs(link): document the Child-initiated link The eight Remote Link guides describe the Child-initiated flow, its SSH requirement, restart, and disconnect behavior. structure/remote-link.md records the join gate, sidecar, tunnel ownership, orphan rule, teardown, and port contract. --- docs-site/src/content/docs/fr/guides/remote-link.md | 5 ++++- docs-site/src/content/docs/guides/remote-link.md | 5 ++++- docs-site/src/content/docs/ja/guides/remote-link.md | 5 ++++- docs-site/src/content/docs/ko/guides/remote-link.md | 5 ++++- docs-site/src/content/docs/ru/guides/remote-link.md | 5 ++++- docs-site/src/content/docs/tr/guides/remote-link.md | 5 ++++- .../src/content/docs/zh-cn/guides/remote-link.md | 5 ++++- .../src/content/docs/zh-tw/guides/remote-link.md | 5 ++++- structure/remote-link.md | 12 ++++++++++++ structure/runtime.md | 2 +- 10 files changed, 45 insertions(+), 9 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/remote-link.md b/docs-site/src/content/docs/fr/guides/remote-link.md index db7c497abff..6c99cde0d98 100644 --- a/docs-site/src/content/docs/fr/guides/remote-link.md +++ b/docs-site/src/content/docs/fr/guides/remote-link.md @@ -8,11 +8,12 @@ Une liaison entre machines connecte un ordinateur OpenCodex **Home** à un ordin ## Conditions requises - Home peut se connecter à Child avec une clé OpenSSH. +- Pour une liaison initiée par Child, Child peut se connecter à Home avec une clé OpenSSH (la connexion par mot de passe n’est pas prise en charge). - OpenCodex est installé sur Child. - 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**. +SSH par mot de passe et Windows restent hors du flux actuel. Pour démarrer une liaison depuis Child, ouvrez le tableau de bord du Child autonome, choisissez **Enfant** → **Trouver le Home**, sélectionnez l’hôte SSH de Home, vérifiez puis confirmez l’empreinte de la clé hôte, et choisissez **Connecter comme Enfant**. Child doit pouvoir se connecter à Home avec une clé SSH (les mots de passe ne sont pas pris en charge), et `ocx` doit être en cours d’exécution sur Home. Le port du tunnel client est `1024` ou supérieur. Après la jonction, Child redémarre et se connecte via Home. Cette option est disponible uniquement en mode autonome. ## Ajouter un Child depuis `#remote` @@ -43,6 +44,8 @@ Si Home ne peut pas joindre Child pour exécuter la déconnexion, choisissez **R ocx disconnect ``` +Pour déconnecter une liaison initiée par Child, exécutez `ocx disconnect` sur Child. La commande déconnecte le tunnel client et révoque la liaison sur Home via SSH. Si cette révocation échoue, elle affiche : `Home revoke failed; run ocx link revoke --link-id on the home.` + ## 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. diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index d60e39eda4f..7219f1210ed 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -8,11 +8,12 @@ A machine link connects an OpenCodex **Home** computer to a **Child** computer o ## Requirements - The Home computer can log in to the Child with an OpenSSH key. +- For a Child-initiated link, the Child can log in to Home with an OpenSSH key (password login is not supported). - OpenCodex is installed on the Child computer. - 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**. +Password SSH and Windows are outside the current flow. For a Child-initiated link, open the standalone Child dashboard, choose **Child** → **Find Home**, select the SSH host for Home, check and confirm the host-key fingerprint, then choose **Connect as Child**. The Child must be able to log in to Home with an SSH key (password login is not supported), and `ocx` must be running on Home. The client tunnel port is `1024` or higher. After joining, the Child restarts and connects through Home. This option is available only on a standalone runtime. ## Add a Child from `#remote` @@ -43,6 +44,8 @@ If the Home cannot reach the Child to run its disconnect command, choose **Remov ocx disconnect ``` +To disconnect a Child-initiated link, run `ocx disconnect` on the Child. It disconnects the client tunnel and revokes the link on Home over SSH. If Home revocation fails, it prints: `Home revoke failed; run ocx link revoke --link-id on the home.` + ## Security The Child uses the Home computer's providers and provider credentials through the link. The Home creates a separate link key for each Child; removing the link revokes that key. Compare the host fingerprint before confirmation so a wrong machine or changed host key is not accepted by mistake. Dashboard sessions issued from a Tailscale identity cannot manage machine links. diff --git a/docs-site/src/content/docs/ja/guides/remote-link.md b/docs-site/src/content/docs/ja/guides/remote-link.md index 1d9fbd230c4..0a959bb31b1 100644 --- a/docs-site/src/content/docs/ja/guides/remote-link.md +++ b/docs-site/src/content/docs/ja/guides/remote-link.md @@ -8,11 +8,12 @@ description: SSH で OpenCodex の Home コンピューターと Child コンピ ## 要件 - Home から Child に OpenSSH キーでログインできること。 +- Child から開始するリンクでは、Child から Home に OpenSSH キーでログインできる必要があります(パスワードログインには対応していません)。 - Child に OpenCodex がインストールされていること。 - 両方のコンピューターが macOS または Linux であること。 - Home のダッシュボードに完全なペアリング済みセッションがあること。 -パスワード SSH、Windows、Child から開始するリンクは現在のフローに含まれません。Child 開始フローは**近日対応予定**です。 +パスワード SSH と Windows は現在のフローに含まれません。Child からリンクを開始するには、スタンドアロンの Child ダッシュボードで **子** → **Home を探す** を選び、Home の SSH ホストを選択し、ホストキーのフィンガープリントを確認してから **子として接続** を選びます。Child から Home へ SSH キーでログインできる必要があり(パスワードログインには対応していません)、Home では `ocx` が実行中である必要があります。クライアントトンネルのポートは `1024` 以上です。参加後、Child は再起動して Home に接続します。この項目はスタンドアロンランタイムでのみ使用できます。 ## `#remote` から Child を追加する @@ -43,6 +44,8 @@ Home から Child に接続解除コマンドを実行できない場合は **Re ocx disconnect ``` +Child から開始したリンクを切断するには、Child で `ocx disconnect` を実行します。このコマンドはクライアントトンネルを切断し、SSH 経由で Home のリンクを失効させます。Home での失効に失敗すると、次のメッセージが表示されます: `Home revoke failed; run ocx link revoke --link-id on the home.` + ## セキュリティ Child はリンクを通じて Home コンピューターのプロバイダーとプロバイダー認証情報を使います。Home は Child ごとに別のリンクキーを作り、リンクを削除するとそのキーを失効させます。確認前にホストフィンガープリントを比較し、別のコンピューターや変更されたホストキーを誤って受け入れないようにしてください。Tailscale の ID から発行されたダッシュボードセッションはマシンリンクを管理できません。 diff --git a/docs-site/src/content/docs/ko/guides/remote-link.md b/docs-site/src/content/docs/ko/guides/remote-link.md index ab20112f678..8ef2559b0e7 100644 --- a/docs-site/src/content/docs/ko/guides/remote-link.md +++ b/docs-site/src/content/docs/ko/guides/remote-link.md @@ -8,11 +8,12 @@ description: SSH로 OpenCodex Home 컴퓨터와 Child 컴퓨터를 연결합니 ## 요구 사항 - Home 컴퓨터에서 OpenSSH 키 로그인으로 Child 컴퓨터에 접속할 수 있어야 합니다. +- 자식이 시작하는 링크에서는 자식에서 OpenSSH 키 로그인으로 홈에 접속할 수 있어야 합니다(비밀번호 로그인은 지원하지 않음). - Child 컴퓨터에 OpenCodex가 설치되어 있어야 합니다. - 두 컴퓨터 모두 macOS 또는 Linux여야 합니다. - Home 대시보드에 완전한 페어링 세션이 있어야 합니다. -비밀번호 SSH, Windows, Child가 시작하는 링크 흐름은 현재 지원 범위가 아닙니다. Child가 시작하는 흐름은 **준비 중**입니다. +비밀번호 SSH와 Windows는 현재 흐름에서 지원하지 않습니다. 자식이 연결을 시작하려면 독립형 런타임으로 실행 중인 자식의 대시보드에서 **자식** → **홈 찾기**를 선택하고, 홈(Home)으로 사용할 SSH 호스트를 고른 다음 호스트 키 지문을 확인하고 **자식으로 연결**을 누릅니다. 자식에서 홈으로 SSH 키 로그인을 할 수 있어야 하며(비밀번호 로그인은 지원하지 않음), 홈에서 `ocx`가 실행 중이어야 합니다. 클라이언트 터널 포트는 `1024` 이상이어야 합니다. 연결이 완료되면 자식이 재시작되고 홈에 연결됩니다. 이 메뉴는 독립형 런타임에서만 사용할 수 있습니다. ## `#remote`에서 Child 추가하기 @@ -43,6 +44,8 @@ Home에서 Child의 연결 해제 명령을 실행할 수 없으면 **Remove her ocx disconnect ``` +자식 연결을 끊으려면 자식에서 `ocx disconnect`를 실행합니다. 이 명령은 클라이언트 터널을 끊고 SSH를 통해 홈에서 링크를 폐기합니다. 홈에서 폐기하지 못하면 다음 문구를 출력합니다: `Home revoke failed; run ocx link revoke --link-id on the home.` + ## 보안 Child는 링크를 통해 Home 컴퓨터의 프로바이더와 프로바이더 인증 정보를 사용합니다. Home은 Child마다 별도의 링크 키를 만들며, 링크를 제거하면 그 키를 폐기합니다. 확인 전에 호스트 지문을 비교하여 잘못된 컴퓨터나 변경된 호스트 키를 실수로 허용하지 않도록 하세요. Tailscale identity로 발급된 대시보드 세션은 머신 링크를 관리할 수 없습니다. diff --git a/docs-site/src/content/docs/ru/guides/remote-link.md b/docs-site/src/content/docs/ru/guides/remote-link.md index fa082805a56..1db5c571256 100644 --- a/docs-site/src/content/docs/ru/guides/remote-link.md +++ b/docs-site/src/content/docs/ru/guides/remote-link.md @@ -8,11 +8,12 @@ description: Подключите компьютер OpenCodex Home к комп ## Требования - Home может войти на Child по ключу OpenSSH. +- Для связи, инициированной со стороны Child, Child должен входить на Home по ключу OpenSSH (вход по паролю не поддерживается). - На Child установлен OpenCodex. - Оба компьютера работают под macOS или Linux. - В панели Home есть полноценная сопряжённая сессия. -SSH с паролем, Windows и запуск связи со стороны Child сейчас не входят в поток. Поток, инициируемый Child, **появится позже**. +SSH с паролем и Windows сейчас не поддерживаются. Чтобы начать связь со стороны Child, откройте панель автономного Child, выберите **Дочерний** → **Найти Home**, укажите SSH-хост Home, проверьте и подтвердите отпечаток ключа хоста, затем выберите **Подключить как Child**. Child должен входить на Home по ключу SSH (вход по паролю не поддерживается), а на Home должен работать `ocx`. Порт клиентского туннеля должен быть `1024` или выше. После подключения Child перезапускается и подключается через Home. Этот пункт доступен только в автономном режиме. ## Добавление Child из `#remote` @@ -43,6 +44,8 @@ SSH с паролем, Windows и запуск связи со стороны Ch ocx disconnect ``` +Чтобы отключить связь, инициированную со стороны Child, выполните `ocx disconnect` на Child. Команда отключает клиентский туннель и по SSH отзывает связь на Home. Если отзыв на Home не удался, команда выводит: `Home revoke failed; run ocx link revoke --link-id on the home.` + ## Безопасность Child использует через связь провайдеров и учётные данные провайдеров компьютера Home. Home создаёт отдельный ключ связи для каждого Child; удаление связи отзывает этот ключ. Сравнивайте отпечаток хоста перед подтверждением, чтобы случайно не принять другой компьютер или изменённый ключ. Сессии панели, выданные удостоверением Tailscale, не могут управлять связями машин. diff --git a/docs-site/src/content/docs/tr/guides/remote-link.md b/docs-site/src/content/docs/tr/guides/remote-link.md index aed6d18aab4..a4b85cb2277 100644 --- a/docs-site/src/content/docs/tr/guides/remote-link.md +++ b/docs-site/src/content/docs/tr/guides/remote-link.md @@ -8,11 +8,12 @@ Makine bağlantısı, bir OpenCodex **Home** bilgisayarını bir **Child** bilgi ## Gereksinimler - Home bilgisayarı, Child bilgisayarına OpenSSH anahtarıyla giriş yapabilir. +- Child tarafından başlatılan bağlantı için Child, Home bilgisayarına OpenSSH anahtarıyla giriş yapabilmelidir (parola girişi desteklenmez). - Child bilgisayarında OpenCodex kuruludur. - 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**. +Parolalı SSH ve Windows mevcut akışın dışındadır. Child üzerinden bağlantı başlatmak için bağımsız çalışan Child kontrol panelinde **Çocuk** → **Home'u bul** seçeneklerini izleyin, Home için SSH ana bilgisayarını seçin, ana bilgisayar anahtarı parmak izini kontrol edip onaylayın ve ardından **Çocuk olarak bağlan** seçeneğini seçin. Child, Home bilgisayarına SSH anahtarıyla giriş yapabilmelidir (parola girişi desteklenmez) ve Home üzerinde `ocx` çalışıyor olmalıdır. İstemci tüneli portu `1024` veya daha yüksek olmalıdır. Katılma işleminden sonra Child yeniden başlar ve Home bilgisayarına bağlanır. Bu seçenek yalnızca standalone çalışma zamanında kullanılabilir. ## `#remote` üzerinden Child ekleme @@ -43,6 +44,8 @@ Home, bağlantıyı kesme komutunu çalıştırmak için Child'a ulaşamıyorsa ocx disconnect ``` +Child tarafından başlatılan bağlantıyı kesmek için Child üzerinde `ocx disconnect` komutunu çalıştırın. Komut istemci tünelinin bağlantısını keser ve SSH üzerinden Home üzerindeki bağlantıyı iptal eder. Home üzerindeki iptal başarısız olursa şu mesajı yazdırır: `Home revoke failed; run ocx link revoke --link-id on the home.` + ## 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. diff --git a/docs-site/src/content/docs/zh-cn/guides/remote-link.md b/docs-site/src/content/docs/zh-cn/guides/remote-link.md index 6d11b9289de..be53fe62bcd 100644 --- a/docs-site/src/content/docs/zh-cn/guides/remote-link.md +++ b/docs-site/src/content/docs/zh-cn/guides/remote-link.md @@ -8,11 +8,12 @@ description: 通过 SSH 将 OpenCodex 主机与子机连接起来。 ## 要求 - 主机可以使用 OpenSSH 密钥登录子机。 +- 对于由子机发起的链接,子机必须能使用 OpenSSH 密钥登录主机(不支持密码登录)。 - 子机已安装 OpenCodex。 - 两台电脑运行 macOS 或 Linux。 - 主机控制台拥有完整的已配对会话。 -密码 SSH、Windows 以及由子机发起的链接不在当前流程中。子机发起的流程**即将推出**。 +密码 SSH 和 Windows 不在当前流程中。要从子机发起连接,请在独立运行的子机控制台中选择 **子设备** → **查找 Home**,选择 Home 的 SSH 主机,检查并确认主机密钥指纹,然后选择 **以子设备身份连接**。子机必须能使用 SSH 密钥登录 Home(不支持密码登录),并且 Home 上正在运行 `ocx`。客户端隧道端口必须为 `1024` 或更高。加入后,子机会重启并连接到 Home。此入口仅在 standalone 运行时提供。 ## 从 `#remote` 添加子机 @@ -43,6 +44,8 @@ description: 通过 SSH 将 OpenCodex 主机与子机连接起来。 ocx disconnect ``` +要断开由子机发起的链接,请在子机上运行 `ocx disconnect`。该命令会断开客户端隧道,并通过 SSH 在 Home 上撤销链接。如果 Home 撤销失败,命令会输出:`Home revoke failed; run ocx link revoke --link-id on the home.` + ## 安全 子机会通过链接使用主机电脑上的提供商和提供商凭据。主机会为每台子机创建单独的链接密钥;移除链接会吊销该密钥。确认前比较主机指纹,避免误接受错误电脑或已更换的主机密钥。由 Tailscale 身份签发的控制台会话不能管理机器链接。 diff --git a/docs-site/src/content/docs/zh-tw/guides/remote-link.md b/docs-site/src/content/docs/zh-tw/guides/remote-link.md index d0e3f774bbb..b8b6b5c51f7 100644 --- a/docs-site/src/content/docs/zh-tw/guides/remote-link.md +++ b/docs-site/src/content/docs/zh-tw/guides/remote-link.md @@ -8,11 +8,12 @@ description: 透過 SSH 連接 OpenCodex Home 電腦與 Child 電腦。 ## 需求 - Home 可以使用 OpenSSH 金鑰登入 Child。 +- 對於由 Child 發起的連結,Child 必須能使用 OpenSSH 金鑰登入 Home(不支援密碼登入)。 - Child 已安裝 OpenCodex。 - 兩台電腦執行 macOS 或 Linux。 - Home 儀表板擁有完整的已配對工作階段。 -密碼 SSH、Windows,以及由 Child 發起的連結不在目前流程中。Child 發起的流程**即將推出**。 +密碼 SSH 和 Windows 不在目前流程中。若要從 Child 發起連線,請在獨立執行的 Child 儀表板中選擇 **子裝置** → **尋找 Home**,選取 Home 的 SSH 主機,檢查並確認主機金鑰指紋,然後選擇 **以子裝置身分連線**。Child 必須能使用 SSH 金鑰登入 Home(不支援密碼登入),而且 Home 上正在執行 `ocx`。用戶端通道連接埠必須是 `1024` 或更高。加入後,Child 會重新啟動並連線到 Home。這個入口只在 standalone 執行個體中提供。 ## 從 `#remote` 新增 Child @@ -43,6 +44,8 @@ description: 透過 SSH 連接 OpenCodex Home 電腦與 Child 電腦。 ocx disconnect ``` +若要中斷由 Child 發起的連結,請在 Child 上執行 `ocx disconnect`。這個命令會中斷用戶端通道,並透過 SSH 在 Home 上撤銷連結。如果 Home 撤銷失敗,命令會輸出:`Home revoke failed; run ocx link revoke --link-id on the home.` + ## 安全性 Child 會透過連結使用 Home 電腦上的供應商與供應商憑證。Home 會為每個 Child 建立獨立的連結金鑰;移除連結會撤銷該金鑰。確認前請比較主機指紋,避免誤接受錯誤電腦或已變更的主機金鑰。由 Tailscale 身分簽發的儀表板工作階段無法管理機器連結。 diff --git a/structure/remote-link.md b/structure/remote-link.md index 406ab8cc6bf..adb40e880b7 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -12,6 +12,18 @@ `src/link/store.ts` persists link records in `/link/links.json` with private permissions. Records hold aliases, ports, confirmed host-key fingerprints and data-key ids, never keys. The file is a trust boundary: unknown fields, malformed values and duplicate ids are errors, and `hasLinks` reports false for a damaged file. A null host-key fingerprint is accepted only for a client-initiated link, because the hub never opens SSH to that client. +## Client-initiated links + +`src/server/management/link-routes.ts` accepts `POST /api/link/join` with exactly `{ "alias": string }`. The route admits only a paired GUI session on a standalone runtime; a Tailscale identity session receives `403 tailscale_session_refused`, another runtime role receives `409 standalone_required`, and these gates run before link state is read. The alias must have a confirmed, unexpired host entry in the same route state. A successful join issues the Home link through SSH, records the client sidecar, starts the client tunnel and connects the client, then returns `202 { "linkId": string, "alias": string, "restarting": true }`. + +`src/client/link-state.ts` stores `/link/client-link.json` with mode 0600. The sidecar contains exactly `alias`, `hubHostKeyFingerprint`, `tunnelPort`, `peerListenerPort` and `linkId`; it contains no key. The client tunnel port uses `MIN_LINK_PORT = 1024` through `MAX_LINK_PORT = 65535` and `isLinkPort`; the Home listener port keeps its existing 1–65535 contract. + +`src/client/link-tunnel.ts` owns the client `ssh -L 127.0.0.1::127.0.0.1:` process. A client runtime starts that supervisor when link transport and a matching sidecar are present. Its periodic state check stops the tunnel and schedules the existing standalone recycle when the connection is no longer connected with link transport, the link id no longer matches, or the sidecar disappears. Normal shutdown, including recycle, stops the client supervisor before the client listener; it sends TERM, waits at most five seconds, then sends KILL. + +The client tunnel pidfile is `/link/client-tunnel.pid` with `{ version: 1, linkId, pid, argv, ownerPid }` and private permissions. `reapOrphanTunnel` leaves a tunnel alone while `ownerPid` is alive and reports `tunnel: "owned"`. After the owner exits, Linux reaps only a process whose `/proc//cmdline` argv exactly matches the pidfile: TERM is followed by at most five seconds and then KILL, the pidfile is removed, and the result is `reaped`. A missing process or argv mismatch removes only the pidfile and reports `absent`; macOS and Windows leave the process and pidfile untouched and report `unresolved`. + +`ocx disconnect` on the client tears down a matching sidecar link by attempting one SSH `ocx link revoke --link-id ` on Home, then disconnecting the client state and deleting the sidecar after rechecking ownership. A revoke failure still completes local disconnect and prints `Home revoke failed; run ocx link revoke --link-id on the home.`; orphan cleanup runs through the same `reapOrphanTunnel` rule. + ## Tunnels, management and CLI `src/link/ssh-runner.ts` runs every OpenSSH and `ssh-keygen` argv without a shell and caps captured output. `src/link/supervisor.ts` keeps one `ssh -R` child per hub-initiated link, drives it with the tunnel reducer, coalesces reloads without dropping a later request, reconciles unowned `link:` API keys at startup, and stops children before the hub-link listener on shutdown. It reaps a leftover tunnel only when Linux `/proc//cmdline` matches the recorded argv exactly; on other platforms a leftover is reported, never killed. `src/link/status-projection.ts` builds the status document the dashboard and `ocx link status` read, including persisted compensation failures, and `src/link/admission-wait.ts` waits for the first key-authenticated `/v1/catalog` read that proves a new link works. diff --git a/structure/runtime.md b/structure/runtime.md index ae713991215..33961a80682 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -359,7 +359,7 @@ config snapshot and the bounded service-token observation needed for its `_remot it does not import the connect command, inspect catalog readiness, acquire lifecycle locks, or run config/secret ACL hardening. -`src/remote/protocol.ts` owns pure interval/feature negotiation. `src/remote/hub-state.ts` owns the `GET|HEAD /v1/hub-state` contract, its caps, and the parser both sides share. `src/client/hub-client.ts` owns bounded, schema-validated remote catalog consumption, hub-state reads, and key-id probes; `src/client/hub-state.ts` owns the resolution and the owner-stamped 0600 cache, and a failed read reports "unavailable" rather than degrading to the client's own local provider and login state. `src/client/hub-relay.ts` is a fixed-authority management relay with URL, header, body, redirect, and stream bounds. The public data listener remains the direct client→hub path; the loopback management ingress never serves data-plane routes. A client with `transport: "link"` reaches its hub through an SSH tunnel instead; see [Remote Link](remote-link.md). +`src/remote/protocol.ts` owns pure interval/feature negotiation. `src/remote/hub-state.ts` owns the `GET|HEAD /v1/hub-state` contract, its caps, and the parser both sides share. `src/client/hub-client.ts` owns bounded, schema-validated remote catalog consumption, hub-state reads, and key-id probes; `src/client/hub-state.ts` owns the resolution and the owner-stamped 0600 cache, and a failed read reports "unavailable" rather than degrading to the client's own local provider and login state. `src/client/hub-relay.ts` is a fixed-authority management relay with URL, header, body, redirect, and stream bounds. The public data listener remains the direct client→hub path; the loopback management ingress never serves data-plane routes. A client with `transport: "link"` reaches its hub through an SSH tunnel instead; see [Remote Link](remote-link.md). A client with a link sidecar owns its SSH tunnel; see [Remote Link](remote-link.md). ### Remote Hub status credential binding From 9190963b9a12a94a36135da8a6922d7949ff2a24 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:05:23 +0900 Subject: [PATCH 09/13] fix(link): keep a failed join rollback recoverable and make revoke idempotent A join rollback clears the sidecar only after the Home revoke succeeds; otherwise it keeps the sidecar and returns join_rollback_failed with the link id, and the next join retries that revoke first. A restart that cannot be scheduled after the committed connect returns join_restart_failed and keeps the connection. ocx link revoke exits 0 for a link the Home no longer has, and disconnect reaps an orphaned tunnel even when the sidecar is corrupt. --- src/cli/link.ts | 23 ++++- src/client/link-join.ts | 56 ++++++++++-- src/client/link-teardown.ts | 17 ++-- src/server/management/link-routes.ts | 5 + structure/remote-link.md | 6 +- tests/cli/cli-link.test.ts | 28 ++++++ tests/clients/client-link-teardown.test.ts | 12 ++- tests/server/link-join-route.test.ts | 101 ++++++++++++++++++++- 8 files changed, 218 insertions(+), 30 deletions(-) diff --git a/src/cli/link.ts b/src/cli/link.ts index 243514b3f6d..9123bf234d2 100644 --- a/src/cli/link.ts +++ b/src/cli/link.ts @@ -199,13 +199,20 @@ async function linkRequest(path: string, init: RequestInit, deps: LinkCliDeps return await runtimeRequest(path, requestInit, deps); } catch (error) { if (error instanceof RuntimeApiError) { - // Never echo an API error body: issue responses contain a one-time data key. - throw new RuntimeApiError(`Link management request failed (${error.status})`, error.status, null); + // Never echo an API error body: issue responses contain a one-time data key. Only the error + // code survives, because callers branch on it and a code is never secret. + throw new RuntimeApiError(`Link management request failed (${error.status})`, error.status, errorCodeBody(error.body)); } throw error; } } +function errorCodeBody(body: unknown): { error: { code: string } } | null { + if (!isRecord(body) || !isRecord(body.error)) return null; + const code = body.error.code; + return typeof code === "string" && /^[a-z_]{1,64}$/.test(code) ? { error: { code } } : null; +} + async function runPort(args: string[], deps: LinkCliDeps): Promise { takeJsonFlag(args); rejectArgs(args, LINK_USAGE); @@ -255,8 +262,16 @@ async function runRevoke(args: string[], deps: LinkCliDeps): Promise { const linkId = takeOption(args, "--link-id"); if (!linkId || !LINK_ID.test(linkId)) throw new CliUsageError("revoke requires a valid --link-id", LINK_USAGE); rejectArgs(args, LINK_USAGE); - const response = await linkRequest(`/api/link/${encodeURIComponent(linkId)}`, { method: "DELETE" }, deps); - validateRevoke(response, linkId); + try { + const response = await linkRequest(`/api/link/${encodeURIComponent(linkId)}`, { method: "DELETE" }, deps); + validateRevoke(response, linkId); + } catch (error) { + // Revoke is idempotent: a link the Home no longer has is already revoked, and a Child retrying + // a join rollback depends on that answer being success. A 404 without this code comes from a + // listener that does not serve the management API and stays a failure. + const code = error instanceof RuntimeApiError && error.status === 404 ? errorCodeBody(error.body)?.error.code : undefined; + if (code !== "link_not_found") throw error; + } console.log(JSON.stringify({ linkId })); } diff --git a/src/client/link-join.ts b/src/client/link-join.ts index 8c75801059e..b782ef42259 100644 --- a/src/client/link-join.ts +++ b/src/client/link-join.ts @@ -8,9 +8,11 @@ import { connectClient, type ClientConnectDeps } from "./connect"; import { clearClientLinkState, clientLinkStatePath, + readClientLinkState, writeClientLinkState, type ClientLinkState, } from "./link-state"; +import { isLinkConnection, readClientConnectionState, type ClientConnectionState } from "./state"; import { spawnClientLinkTunnel, type ClientLinkTunnelDeps, @@ -40,11 +42,13 @@ export type JoinFailureCode = | "join_issue_failed" | "join_tunnel_failed" | "admission_failed" - | "join_connect_failed"; + | "join_connect_failed" + | "join_rollback_failed" + | "join_restart_failed"; export class ClientLinkJoinError extends Error { - constructor(readonly code: JoinFailureCode) { - super(code); + constructor(readonly code: JoinFailureCode, readonly linkId?: string) { + super(linkId ? `${code}: ${linkId}` : code); this.name = "ClientLinkJoinError"; } } @@ -75,6 +79,8 @@ export interface ClientLinkJoinDeps { }, deps?: ClientLinkTunnelDeps) => ClientLinkTunnelHandle; writeState?: (state: ClientLinkState) => void; clearState?: (linkId: string) => void; + readSidecar?: () => ClientLinkState | null; + readConnectionState?: () => ClientConnectionState; connect?: typeof connectClient; connectDeps?: ClientConnectDeps; selectedClients?: OcxConnectedClientId[]; @@ -137,18 +143,20 @@ async function stopTunnel(tunnel: ClientLinkTunnelHandle | null): Promise } } -async function revokeIssuedLink(deps: ClientLinkJoinDeps, linkId: string): Promise { +async function revokeIssuedLink(deps: ClientLinkJoinDeps, linkId: string, alias = deps.confirmedHost?.alias ?? ""): Promise { try { - await deps.runner.run( + const result = await deps.runner.run( buildExecArgv({ - alias: deps.confirmedHost?.alias ?? "", + alias, argv: ["ocx", "link", "revoke", "--link-id", linkId], knownHostsFile: deps.knownHostsFile, }), { timeoutMs: JOIN_REVOKE_TIMEOUT_MS }, ); + return result.code === 0; } catch (error) { void error; + return false; } } @@ -158,7 +166,7 @@ async function rollback( tunnel: ClientLinkTunnelHandle | null, ): Promise { await stopTunnel(tunnel); - await revokeIssuedLink(deps, linkId); + if (!await revokeIssuedLink(deps, linkId)) throw new ClientLinkJoinError("join_rollback_failed", linkId); try { (deps.clearState ?? (id => defaultClearState(deps.configDir, id)))(linkId); } catch (error) { @@ -166,6 +174,33 @@ async function rollback( } } +async function compensateStaleSidecar(deps: ClientLinkJoinDeps): Promise { + let sidecar: ClientLinkState | null; + try { + sidecar = (deps.readSidecar ?? (() => readClientLinkState(clientLinkStatePath(deps.configDir))))(); + } catch { + // A corrupt sidecar is overwritten by the next successful join. + return; + } + if (!sidecar) return; + let connection: ClientConnectionState; + try { + connection = (deps.readConnectionState ?? readClientConnectionState)(); + } catch { + connection = { kind: "invalid", reason: "client connection state could not be read" }; + } + if (connection.kind === "connected" && isLinkConnection(connection.value) + && connection.value.link?.linkId === sidecar.linkId) return; + if (!await revokeIssuedLink(deps, sidecar.linkId, sidecar.alias)) { + throw new ClientLinkJoinError("join_rollback_failed", sidecar.linkId); + } + try { + (deps.clearState ?? (linkId => defaultClearState(deps.configDir, linkId)))(sidecar.linkId); + } catch { + throw new ClientLinkJoinError("join_rollback_failed", sidecar.linkId); + } +} + async function waitForReady( deps: ClientLinkJoinDeps, port: number, @@ -202,6 +237,7 @@ function requireConfirmedHost(deps: ClientLinkJoinDeps, alias: string): JoinConf export async function joinHome(deps: ClientLinkJoinDeps, input: { alias: string }): Promise<{ linkId: string; apiKeyId: string }> { const confirmed = requireConfirmedHost(deps, input.alias); + await compensateStaleSidecar(deps); let tunnelPort: number; try { tunnelPort = await (deps.choosePort ?? (() => findAvailablePort(0, "127.0.0.1")))(); @@ -278,6 +314,10 @@ export async function joinHome(deps: ClientLinkJoinDeps, input: { alias: string } await stopTunnel(tunnel); - deps.scheduleRestart(); + try { + deps.scheduleRestart(); + } catch { + throw new ClientLinkJoinError("join_restart_failed", issued.linkId); + } return { linkId: issued.linkId, apiKeyId: issued.apiKeyId }; } diff --git a/src/client/link-teardown.ts b/src/client/link-teardown.ts index b3a1b3b0e22..71909fae32e 100644 --- a/src/client/link-teardown.ts +++ b/src/client/link-teardown.ts @@ -24,6 +24,12 @@ export interface ClientLinkTeardownResult { export async function teardownClientLink( deps: ClientLinkTeardownDeps, ): Promise { + let tunnel: OrphanTunnelResult | null = null; + try { + tunnel = await deps.reapOrphanTunnel(); + } catch { + // A reap failure must not prevent the one allowed Home revoke attempt. + } let sidecar: ClientLinkState | null; try { sidecar = deps.readSidecar(); @@ -31,17 +37,10 @@ export async function teardownClientLink( // An unreadable sidecar no longer names the Home alias, so the revoke cannot run here. The // disconnect still proceeds, and a link connection gets the manual revoke instruction. const linkId = deps.connectedLinkId(); - return { linkId, homeRevoke: linkId ? "failed" : "not_applicable", tunnel: null }; + return { linkId, homeRevoke: linkId ? "failed" : "not_applicable", tunnel }; } if (!sidecar || sidecar.linkId !== deps.connectedLinkId()) { - return { linkId: null, homeRevoke: "not_applicable", tunnel: null }; - } - - let tunnel: OrphanTunnelResult | null = null; - try { - tunnel = await deps.reapOrphanTunnel(); - } catch { - // A reap failure must not prevent the one allowed Home revoke attempt. + return { linkId: null, homeRevoke: "not_applicable", tunnel }; } try { diff --git a/src/server/management/link-routes.ts b/src/server/management/link-routes.ts index a81ba2046b8..12c6eb9bed2 100644 --- a/src/server/management/link-routes.ts +++ b/src/server/management/link-routes.ts @@ -390,6 +390,11 @@ function joinFailure(error: unknown): Response { case "join_issue_failed": return fail("join_issue_failed", "The home could not issue a link.", 502); case "join_tunnel_failed": return fail("join_tunnel_failed", "The SSH tunnel to the home did not become ready.", 502); case "admission_failed": return fail("admission_failed", "The home refused the issued link key.", 502); + case "join_rollback_failed": { + const linkId = error && typeof error === "object" && "linkId" in error && typeof error.linkId === "string" ? error.linkId : "unknown"; + return fail("join_rollback_failed", `The join failed and the home link could not be revoked; run ocx link revoke --link-id ${linkId} on the home.`, 502); + } + case "join_restart_failed": return fail("join_restart_failed", "The link is ready; restart OpenCodex to finish connecting as a Child.", 500); default: return fail("join_connect_failed", "The client link join could not be completed.", 502); } } diff --git a/structure/remote-link.md b/structure/remote-link.md index adb40e880b7..70e6022dcd5 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -14,7 +14,7 @@ ## Client-initiated links -`src/server/management/link-routes.ts` accepts `POST /api/link/join` with exactly `{ "alias": string }`. The route admits only a paired GUI session on a standalone runtime; a Tailscale identity session receives `403 tailscale_session_refused`, another runtime role receives `409 standalone_required`, and these gates run before link state is read. The alias must have a confirmed, unexpired host entry in the same route state. A successful join issues the Home link through SSH, records the client sidecar, starts the client tunnel and connects the client, then returns `202 { "linkId": string, "alias": string, "restarting": true }`. +`src/server/management/link-routes.ts` accepts `POST /api/link/join` with exactly `{ "alias": string }`. The route admits only a paired GUI session on a standalone runtime; a Tailscale identity session receives `403 tailscale_session_refused`, another runtime role receives `409 standalone_required`, and these gates run before link state is read. The alias must have a confirmed, unexpired host entry in the same route state. Before choosing a port or issuing a new link, a valid stale client sidecar is compensated over SSH unless the machine is already connected to that link; a successful revoke clears the sidecar, while a failed revoke preserves it and returns `join_rollback_failed` with the link id. A corrupt sidecar is left for the next successful write. A successful join issues the Home link through SSH, records the client sidecar, starts the client tunnel and connects the client, then returns `202 { "linkId": string, "alias": string, "restarting": true }`. `src/client/link-state.ts` stores `/link/client-link.json` with mode 0600. The sidecar contains exactly `alias`, `hubHostKeyFingerprint`, `tunnelPort`, `peerListenerPort` and `linkId`; it contains no key. The client tunnel port uses `MIN_LINK_PORT = 1024` through `MAX_LINK_PORT = 65535` and `isLinkPort`; the Home listener port keeps its existing 1–65535 contract. @@ -22,13 +22,13 @@ The client tunnel pidfile is `/link/client-tunnel.pid` with `{ version: 1, linkId, pid, argv, ownerPid }` and private permissions. `reapOrphanTunnel` leaves a tunnel alone while `ownerPid` is alive and reports `tunnel: "owned"`. After the owner exits, Linux reaps only a process whose `/proc//cmdline` argv exactly matches the pidfile: TERM is followed by at most five seconds and then KILL, the pidfile is removed, and the result is `reaped`. A missing process or argv mismatch removes only the pidfile and reports `absent`; macOS and Windows leave the process and pidfile untouched and report `unresolved`. -`ocx disconnect` on the client tears down a matching sidecar link by attempting one SSH `ocx link revoke --link-id ` on Home, then disconnecting the client state and deleting the sidecar after rechecking ownership. A revoke failure still completes local disconnect and prints `Home revoke failed; run ocx link revoke --link-id on the home.`; orphan cleanup runs through the same `reapOrphanTunnel` rule. +`ocx disconnect` on the client tears down a matching sidecar link by attempting one SSH `ocx link revoke --link-id ` on Home, then disconnecting the client state and deleting the sidecar after rechecking ownership. A revoke failure still completes local disconnect and prints `Home revoke failed; run ocx link revoke --link-id on the home.`; orphan cleanup runs through the same `reapOrphanTunnel` rule before sidecar parsing, including when the sidecar is corrupt or mismatched. After `connectClient` commits during a join, a restart scheduling failure leaves the connection and sidecar intact and returns `join_restart_failed`; the operator restarts OpenCodex to finish connecting as a Child. ## Tunnels, management and CLI `src/link/ssh-runner.ts` runs every OpenSSH and `ssh-keygen` argv without a shell and caps captured output. `src/link/supervisor.ts` keeps one `ssh -R` child per hub-initiated link, drives it with the tunnel reducer, coalesces reloads without dropping a later request, reconciles unowned `link:` API keys at startup, and stops children before the hub-link listener on shutdown. It reaps a leftover tunnel only when Linux `/proc//cmdline` matches the recorded argv exactly; on other platforms a leftover is reported, never killed. `src/link/status-projection.ts` builds the status document the dashboard and `ocx link status` read, including persisted compensation failures, and `src/link/admission-wait.ts` waits for the first key-authenticated `/v1/catalog` read that proves a new link works. -Applying a link probes the host key into a temporary file, waits for the operator to confirm the fingerprint, issues a data key, records the link, starts the tunnel and runs the client's `ocx connect --link --key-stdin` over SSH with the key on standard input. A failed step revokes the new key first and removes the record only after revocation succeeds; if revocation fails the `src/link/` state persists a `compensation_failed` marker, and status reports the failed compensation after restart. A listener that is not listening fails the request instead of handing out a key. Removing a link stops its tunnel, disconnects the client, revokes the key and deletes the record; a failed client disconnect restarts the tunnel and keeps the record and key unless removal is forced. `src/cli/link.ts` provides `ocx link port|issue|revoke|status`. +Applying a link probes the host key into a temporary file, waits for the operator to confirm the fingerprint, issues a data key, records the link, starts the tunnel and runs the client's `ocx connect --link --key-stdin` over SSH with the key on standard input. A failed step revokes the new key first and removes the record only after revocation succeeds; if revocation fails the `src/link/` state persists a `compensation_failed` marker, and status reports the failed compensation after restart. A listener that is not listening fails the request instead of handing out a key. Removing a link stops its tunnel, disconnects the client, revokes the key and deletes the record; a failed client disconnect restarts the tunnel and keeps the record and key unless removal is forced. `src/cli/link.ts` provides `ocx link port|issue|revoke|status`. `ocx link revoke` is idempotent: a `404 link_not_found` answer exits 0, because removal revokes the key before it deletes the record, so a missing record means the key is already gone. A 404 without that code still fails. ## Client link transport diff --git a/tests/cli/cli-link.test.ts b/tests/cli/cli-link.test.ts index 43cdb2ef67c..da86a4da416 100644 --- a/tests/cli/cli-link.test.ts +++ b/tests/cli/cli-link.test.ts @@ -184,6 +184,34 @@ describe("ocx link", () => { } }); + test("revoke of a link the Home no longer has succeeds, and any other 404 still fails", async () => { + const output = captureOutput(); + try { + const gone = await runLinkCommand(["revoke", "--link-id", issueResponse.linkId, "--json"], { + baseUrl: "http://127.0.0.1:19101", + fetchImpl: fakeFetch({ error: { code: "link_not_found", message: "The link was not found." } }, [], 404), + readAdminToken: () => "ocx_admin_test-token", + }); + expect(gone).toBe(0); + expect(JSON.parse(output.stdout[0]!)).toEqual({ linkId: issueResponse.linkId }); + const unrouted = await runLinkCommand(["revoke", "--link-id", issueResponse.linkId, "--json"], { + baseUrl: "http://127.0.0.1:19101", + fetchImpl: fakeFetch({ error: { message: "not found" } }, [], 404), + readAdminToken: () => "ocx_admin_test-token", + }); + expect(unrouted).not.toBe(0); + const refused = await runLinkCommand(["revoke", "--link-id", issueResponse.linkId, "--json"], { + baseUrl: "http://127.0.0.1:19101", + fetchImpl: fakeFetch({ error: { code: "key_revoke_failed" } }, [], 502), + readAdminToken: () => "ocx_admin_test-token", + }); + expect(refused).not.toBe(0); + expect(output.stdout).toHaveLength(1); + } finally { + output.restore(); + } + }); + test("rejects malformed status responses", async () => { const output = captureOutput(); try { diff --git a/tests/clients/client-link-teardown.test.ts b/tests/clients/client-link-teardown.test.ts index 1e0f33d4f9c..c232992011e 100644 --- a/tests/clients/client-link-teardown.test.ts +++ b/tests/clients/client-link-teardown.test.ts @@ -66,7 +66,7 @@ test("teardown reports revoke failure and leaves a mismatched sidecar alone", as runner: { run: async () => { calls += 1; return { code: 0, stdout: "", stderr: "" }; } }, knownHostsFile, }); - expect(mismatch).toEqual({ linkId: null, homeRevoke: "not_applicable", tunnel: null }); + expect(mismatch).toEqual({ linkId: null, homeRevoke: "not_applicable", tunnel: { tunnel: "reaped" } }); expect(calls).toBe(1); }); @@ -128,16 +128,18 @@ test("disconnect output reports an unresolved tunnel", async () => { test("an unreadable sidecar skips the revoke but still reports the manual revoke for a link client", async () => { let calls = 0; + const order: string[] = []; const runner = { run: async () => { calls += 1; return { code: 0, stdout: "", stderr: "" }; } }; - const unreadable = () => { throw new Error("client-link.json is not valid JSON"); }; + const unreadable = () => { order.push("sidecar"); throw new Error("client-link.json is not valid JSON"); }; const linked = await teardownClientLink({ readSidecar: unreadable, connectedLinkId: () => linkId, - reapOrphanTunnel: async () => ({ tunnel: "absent" }), + reapOrphanTunnel: async () => { order.push("reap"); return { tunnel: "absent" }; }, runner, knownHostsFile, }); - expect(linked).toEqual({ linkId, homeRevoke: "failed", tunnel: null }); + expect(linked).toEqual({ linkId, homeRevoke: "failed", tunnel: { tunnel: "absent" } }); + expect(order).toEqual(["reap", "sidecar"]); const standalone = await teardownClientLink({ readSidecar: unreadable, connectedLinkId: () => null, @@ -145,6 +147,6 @@ test("an unreadable sidecar skips the revoke but still reports the manual revoke runner, knownHostsFile, }); - expect(standalone).toEqual({ linkId: null, homeRevoke: "not_applicable", tunnel: null }); + expect(standalone).toEqual({ linkId: null, homeRevoke: "not_applicable", tunnel: { tunnel: "absent" } }); expect(calls).toBe(0); }); diff --git a/tests/server/link-join-route.test.ts b/tests/server/link-join-route.test.ts index b768e0ed877..ca57e13df0c 100644 --- a/tests/server/link-join-route.test.ts +++ b/tests/server/link-join-route.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, spyOn } from "bun:test"; -import { joinHome, type ClientLinkJoinDeps } from "../../src/client/link-join"; +import { ClientLinkJoinError, joinHome, type ClientLinkJoinDeps } from "../../src/client/link-join"; import { handleLinkRoutes, type LinkRouteState } from "../../src/server/management/link-routes"; import type { ManagementContext } from "../../src/server/management/context"; import type { SshRunner } from "../../src/link/ssh-runner"; @@ -47,6 +47,8 @@ function joinDeps(overrides: Partial = {}): ClientLinkJoinDe now: () => 1, sleep: async () => {}, hostname: () => "client-host", + readSidecar: () => null, + readConnectionState: () => ({ kind: "disconnected" }), ...overrides, }; } @@ -229,4 +231,101 @@ describe("client initiated link join", () => { expect(calls.filter(argv => argv.some(value => value.includes("revoke")))).toHaveLength(1); expect(logs.mock.calls.flat().join(" ")).not.toContain(KEY); }); + + test("keeps the sidecar and reports the link id when rollback revoke fails, then compensates before the next join", async () => { + const sidecar = { + linkId: LINK_ID, + alias: "home", + hubHostKeyFingerprint: FINGERPRINT, + peerListenerPort: 45678, + tunnelPort: 23456, + }; + let sidecarPresent = false; + let revokeCount = 0; + const order: string[] = []; + const runner: SshRunner = { + run: async argv => { + if (argv.some(value => value.includes("issue"))) { + order.push("issue"); + return { code: 0, stdout: JSON.stringify({ linkId: LINK_ID, apiKeyId: API_KEY_ID, key: KEY, listenerPort: 45678 }), stderr: "" }; + } + if (argv.some(value => value.includes("revoke"))) { + revokeCount += 1; + order.push(`revoke-${revokeCount}`); + return { code: revokeCount === 1 ? 1 : 0, stdout: "", stderr: "failed" }; + } + return { code: 0, stdout: "", stderr: "" }; + }, + spawnTunnel: () => ({ pid: 1, argv: [], exited: Promise.resolve(0), kill: () => {} }), + }; + const base = joinDeps({ + runner, + readSidecar: () => sidecarPresent ? sidecar : null, + writeState: value => { sidecarPresent = true; Object.assign(sidecar, value); }, + clearState: () => { sidecarPresent = false; }, + spawnTunnel: () => ({ pid: 1, exited: Promise.resolve(0), stop: async () => {} }), + fetchImpl: async () => new Response(null, { status: 200 }), + connect: (async () => { throw new Error("connect failed"); }) as typeof import("../../src/client/connect").connectClient, + }); + await expect(joinHome(base, { alias: "home" })).rejects.toMatchObject({ code: "join_rollback_failed", linkId: LINK_ID }); + expect(sidecarPresent).toBe(true); + + const next = joinDeps({ + ...base, + connect: (async () => {}) as typeof import("../../src/client/connect").connectClient, + scheduleRestart: () => {}, + }); + await expect(joinHome(next, { alias: "home" })).resolves.toEqual({ linkId: LINK_ID, apiKeyId: API_KEY_ID }); + expect(order).toEqual(["issue", "revoke-1", "revoke-2", "issue"]); + expect(sidecarPresent).toBe(true); + }); + + test("maps a restart scheduling failure to 500 while retaining the committed connection and sidecar", async () => { + let connected = false; + let sidecar: Record = {}; + let cleared = false; + const response = await handleLinkRoutes(context({ + principal: "gui-session", + paired: true, + deps: { + sshRunner: runnerFor([]), + joinHome: (async (deps, input) => joinHome({ + ...deps, + readSidecar: () => null, + readConnectionState: () => ({ kind: "disconnected" }), + now: () => 1, + writeState: state => { sidecar = { ...state }; }, + clearState: () => { cleared = true; }, + spawnTunnel: () => tunnelFor([]), + fetchImpl: async () => new Response(null, { status: 200 }), + connect: (async () => { connected = true; }) as typeof import("../../src/client/connect").connectClient, + scheduleRestart: () => { throw new Error("restart unavailable"); }, + }, input)) as typeof import("../../src/client/link-join").joinHome, + }, + }), routeState()); + expect(response?.status).toBe(500); + expect(await response?.json()).toEqual({ + error: { code: "join_restart_failed", message: "The link is ready; restart OpenCodex to finish connecting as a Child." }, + }); + expect(connected).toBe(true); + expect(sidecar).toMatchObject({ linkId: LINK_ID }); + expect(cleared).toBe(false); + }); + + test("maps rollback failure with its link id and preserves the remote compensation receipt", async () => { + const response = await handleLinkRoutes(context({ + principal: "gui-session", + paired: true, + deps: { + joinHome: async () => { throw new ClientLinkJoinError("join_rollback_failed", LINK_ID); }, + }, + }), routeState()); + expect(response?.status).toBe(502); + expect(await response?.json()).toEqual({ + error: { + code: "join_rollback_failed", + message: `The join failed and the home link could not be revoked; run ocx link revoke --link-id ${LINK_ID} on the home.`, + }, + }); + }); }); From 0e5d6da252fb56f8581656872d011c4198ca63bd Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:05:24 +0900 Subject: [PATCH 10/13] fix(link): drop stale Remote Link responses and refresh the role wording Each candidates, probe, confirm, apply, or join attempt has an identity and an abort signal, so a response that arrives after the sheet closed or a newer attempt started changes nothing. The role hint describes the Child flow as it now works, the obsolete childPending notice is gone, the screenshot fixture no longer reaches into the page component, and the two new join errors have their own messages. --- gui/scripts/remote-link-fixture.ts | 2 +- gui/src/i18n/de.ts | 5 +- gui/src/i18n/en.ts | 5 +- gui/src/i18n/fr.ts | 5 +- gui/src/i18n/ja.ts | 5 +- gui/src/i18n/ko.ts | 5 +- gui/src/i18n/ru.ts | 5 +- gui/src/i18n/tr.ts | 5 +- gui/src/i18n/vi.ts | 5 +- gui/src/i18n/zh-TW.ts | 5 +- gui/src/i18n/zh.ts | 5 +- gui/src/pages/RemoteLink.tsx | 129 +++++++++++++++++++---------- gui/src/remote-link-api.ts | 2 + gui/tests/remote-link.test.tsx | 81 ++++++++++++++++++ 14 files changed, 199 insertions(+), 65 deletions(-) diff --git a/gui/scripts/remote-link-fixture.ts b/gui/scripts/remote-link-fixture.ts index 9103eae0725..1340753af58 100644 --- a/gui/scripts/remote-link-fixture.ts +++ b/gui/scripts/remote-link-fixture.ts @@ -42,7 +42,7 @@ async function appDocument(request: Request): Promise { const origin = new URL(request.url).origin; const fixture = fixtureFor(request, new URL(request.url)); const runtimeRole = standaloneFixtures.has(fixture) ? "standalone" : "hub"; - const tags = ``; + const tags = ``; return new Response(body.replace("", `${tags}`), { headers: { "content-type": "text/html" } }); } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 6054a3cf957..ac5dca49a0d 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -3138,7 +3138,7 @@ export const de: Record = { "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.hint": "Home teilt die Provider dieses Computers mit Child-Computern. Als Child verbindet sich dieser Computer mit einem Home, wenn er eigenständig läuft.", "link.role.home": "Zuhause", "link.role.homeHint": "Verwalten Sie Kindercomputer über dieses Dashboard.", "link.role.child": "Kind", @@ -3170,7 +3170,6 @@ export const de: Record = { "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.", "remoteLink.childDisabled": "Kind-Verbindungen können nur von einer eigenständigen Laufzeit gestartet werden.", "remoteLink.findHome.title": "Home suchen", "remoteLink.findHome.body": "Wählen Sie den Home-Computer für dieses Kind aus.", @@ -3186,6 +3185,8 @@ export const de: Record = { "remoteLink.error.join_issue_failed": "Home konnte keinen Link ausstellen. Prüfen Sie, ob OpenCodex auf Home läuft, und versuchen Sie es erneut.", "remoteLink.error.join_in_progress": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", "remoteLink.error.join_port_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.join_rollback_failed": "Die Verbindung ist fehlgeschlagen und der Link auf Home konnte nicht entfernt werden. Wiederholen Sie die Bereinigung oder führen Sie auf Home ocx link revoke aus.", + "remoteLink.error.join_restart_failed": "Der Link ist bereit. Starten Sie OpenCodex auf diesem Computer neu, um die Verbindung als Child abzuschließen.", "remoteLink.error.join_connect_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", "link.noChildren": "Keine Kindercomputer verbunden.", "remoteLink.status.connecting": "Verbindung wird hergestellt", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 60bbb20a8c8..ff455b5616d 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3172,7 +3172,7 @@ export const en = { "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.hint": "Home shares this computer's providers with Child computers. Child connects this computer to a Home when this computer runs standalone.", "link.role.home": "Home", "link.role.homeHint": "Manage child computers from this dashboard.", "link.role.child": "Child", @@ -3204,7 +3204,6 @@ export const en = { "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.", "remoteLink.childDisabled": "Child links can only be started from a standalone runtime.", "remoteLink.findHome.title": "Find Home", "remoteLink.findHome.body": "Choose the Home computer to connect this Child to.", @@ -3220,6 +3219,8 @@ export const en = { "remoteLink.error.join_issue_failed": "Home could not issue a link. Check that OpenCodex is running on Home and retry.", "remoteLink.error.join_in_progress": "Remote link request could not be completed.", "remoteLink.error.join_port_failed": "Remote link request could not be completed.", + "remoteLink.error.join_rollback_failed": "Joining failed and the link on Home could not be removed. Retry the cleanup, or run ocx link revoke on Home.", + "remoteLink.error.join_restart_failed": "The link is ready. Restart OpenCodex on this computer to finish connecting as a Child.", "remoteLink.error.join_connect_failed": "Remote link request could not be completed.", "link.noChildren": "No child computers are connected.", "remoteLink.status.connecting": "Connecting", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 562194322f2..a6e3c6c1524 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -3127,7 +3127,7 @@ export const fr: Record = { "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.hint": "Home partage les fournisseurs de cet ordinateur avec les ordinateurs Enfant. En mode Enfant, cet ordinateur se connecte à un Home lorsqu’il fonctionne en mode autonome.", "link.role.home": "Accueil", "link.role.homeHint": "Gérez les ordinateurs enfants depuis ce tableau de bord.", "link.role.child": "Enfant", @@ -3159,7 +3159,6 @@ export const fr: Record = { "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.", "remoteLink.childDisabled": "Les liens Enfant ne peuvent être lancés que depuis un runtime autonome.", "remoteLink.findHome.title": "Trouver le Home", "remoteLink.findHome.body": "Choisissez l’ordinateur Home auquel connecter cet Enfant.", @@ -3174,6 +3173,8 @@ export const fr: Record = { "remoteLink.error.admission_failed": "Le Home n’a pas accepté le nouveau lien. Vérifiez qu’il fonctionne puis réessayez.", "remoteLink.error.join_issue_failed": "Le Home n’a pas pu émettre le lien. Vérifiez qu’OpenCodex y fonctionne puis réessayez.", "remoteLink.error.join_in_progress": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.join_rollback_failed": "La connexion a échoué et le lien sur le Home n’a pas pu être supprimé. Réessayez le nettoyage ou exécutez ocx link revoke sur le Home.", + "remoteLink.error.join_restart_failed": "Le lien est prêt. Redémarrez OpenCodex sur cet ordinateur pour terminer la connexion en tant qu’Enfant.", "remoteLink.error.join_port_failed": "La demande de lien distant n’a pas pu aboutir.", "remoteLink.error.join_connect_failed": "La demande de lien distant n’a pas pu aboutir.", "link.noChildren": "Aucun ordinateur enfant connecté.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 813792fdac6..4176ebec85a 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -3160,7 +3160,7 @@ export const ja: Record = { "link.switch": "リモート接続", "link.switchOffHint": "接続をオンにして、このコンピューターの役割を選びます。", "link.role.title": "このコンピューターの役割を選択", - "link.role.hint": "ホームが接続を管理します。子の設定は後日対応します。", + "link.role.hint": "ホームはこのコンピューターのプロバイダーを子コンピューターと共有します。子を選ぶと、このコンピューターがスタンドアロンで動作している場合にホームへ接続します。", "link.role.home": "ホーム", "link.role.homeHint": "このダッシュボードから子コンピューターを管理します。", "link.role.child": "子", @@ -3192,7 +3192,6 @@ export const ja: Record = { "link.disconnectConfirm": "{alias} を切断しますか?リンクキーは失効します。", "link.close": "閉じる", "link.cancel": "キャンセル", - "link.childPending": "子の設定はクライアント開始フローで提供されます。", "remoteLink.childDisabled": "子リンクを開始できるのはスタンドアロンランタイムだけです。", "remoteLink.findHome.title": "Home を探す", "remoteLink.findHome.body": "この子コンピューターを接続する Home を選択してください。", @@ -3207,6 +3206,8 @@ export const ja: Record = { "remoteLink.error.admission_failed": "Home が新しいリンクを受け付けませんでした。Home が起動しているか確認して再試行してください。", "remoteLink.error.join_issue_failed": "Home がリンクを発行できませんでした。Home で OpenCodex が起動しているか確認して再試行してください。", "remoteLink.error.join_in_progress": "リモートリンク要求を完了できませんでした。", + "remoteLink.error.join_rollback_failed": "接続に失敗し、ホーム上のリンクを削除できませんでした。クリーンアップを再試行するか、ホームで ocx link revoke を実行してください。", + "remoteLink.error.join_restart_failed": "リンクの準備ができました。このコンピューターで OpenCodex を再起動して、子としての接続を完了してください。", "remoteLink.error.join_port_failed": "リモートリンク要求を完了できませんでした。", "remoteLink.error.join_connect_failed": "リモートリンク要求を完了できませんでした。", "link.noChildren": "接続された子コンピューターはありません。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index b90cd20a010..69eb1cb3a2e 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3160,7 +3160,7 @@ export const ko: Record = { "link.switch": "원격 연결", "link.switchOffHint": "연결을 켜고 이 컴퓨터의 역할을 선택하세요.", "link.role.title": "이 컴퓨터의 역할 선택", - "link.role.hint": "홈이 연결을 관리합니다. 자식 설정은 준비 중입니다.", + "link.role.hint": "홈은 이 컴퓨터의 프로바이더를 자식 컴퓨터와 나눠 씁니다. 자식은 이 컴퓨터를 다른 홈에 연결하며, 이 컴퓨터가 독립형으로 실행 중일 때만 고를 수 있습니다.", "link.role.home": "홈", "link.role.homeHint": "이 대시보드에서 자식 컴퓨터를 관리합니다.", "link.role.child": "자식", @@ -3192,7 +3192,6 @@ export const ko: Record = { "link.disconnectConfirm": "{alias}의 연결을 해제할까요? 연결 키가 폐기됩니다.", "link.close": "닫기", "link.cancel": "취소", - "link.childPending": "자식 설정은 클라이언트 시작 흐름에서 제공될 예정입니다.", "remoteLink.childDisabled": "자식 링크는 독립형 런타임에서만 시작할 수 있습니다.", "remoteLink.findHome.title": "홈 찾기", "remoteLink.findHome.body": "이 자식 컴퓨터를 연결할 홈 컴퓨터를 선택하세요.", @@ -3207,6 +3206,8 @@ export const ko: Record = { "remoteLink.error.admission_failed": "홈이 새 링크를 수락하지 않았습니다. 홈이 실행 중인지 확인한 뒤 다시 시도하세요.", "remoteLink.error.join_issue_failed": "홈에서 링크를 발급하지 못했습니다. 홈에서 OpenCodex가 실행 중인지 확인한 뒤 다시 시도하세요.", "remoteLink.error.join_in_progress": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.join_rollback_failed": "연결에 실패했고 홈의 링크를 삭제하지 못했습니다. 정리를 다시 시도하거나 홈에서 ocx link revoke를 실행하세요.", + "remoteLink.error.join_restart_failed": "링크가 준비되었습니다. 이 컴퓨터에서 OpenCodex를 다시 시작해 자식 연결을 완료하세요.", "remoteLink.error.join_port_failed": "원격 연결 요청을 완료하지 못했습니다.", "remoteLink.error.join_connect_failed": "원격 연결 요청을 완료하지 못했습니다.", "link.noChildren": "연결된 자식 컴퓨터가 없습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 8081ef95768..474ef6c20f2 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -3161,7 +3161,7 @@ export const ru: Record = { "link.switch": "Удалённое подключение", "link.switchOffHint": "Включите связь, чтобы выбрать роль этого компьютера.", "link.role.title": "Выберите роль этого компьютера", - "link.role.hint": "Главный управляет связями. Настройка дочернего режима появится позже.", + "link.role.hint": "Главный компьютер предоставляет свои провайдеры дочерним компьютерам. В роли дочернего этот компьютер подключается к главному, если работает в автономном режиме.", "link.role.home": "Главный", "link.role.homeHint": "Управляйте дочерними компьютерами из этой панели.", "link.role.child": "Дочерний", @@ -3193,7 +3193,6 @@ export const ru: Record = { "link.disconnectConfirm": "Отключить {alias}? Ключ связи будет отозван.", "link.close": "Закрыть", "link.cancel": "Отмена", - "link.childPending": "Настройка дочернего режима появится в потоке, запущенном клиентом.", "remoteLink.childDisabled": "Связь с дочерним компьютером можно начать только из автономного режима.", "remoteLink.findHome.title": "Найти Home", "remoteLink.findHome.body": "Выберите компьютер Home, к которому подключить этот Child.", @@ -3208,6 +3207,8 @@ export const ru: Record = { "remoteLink.error.admission_failed": "Home не принял новую связь. Убедитесь, что он запущен, и повторите попытку.", "remoteLink.error.join_issue_failed": "Home не смог выдать связь. Убедитесь, что на Home запущен OpenCodex, и повторите попытку.", "remoteLink.error.join_in_progress": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.join_rollback_failed": "Подключение не удалось, а связь на Home удалить не удалось. Повторите очистку или выполните на Home команду ocx link revoke.", + "remoteLink.error.join_restart_failed": "Связь готова. Перезапустите OpenCodex на этом компьютере, чтобы завершить подключение в роли дочернего компьютера.", "remoteLink.error.join_port_failed": "Не удалось завершить запрос удалённой связи.", "remoteLink.error.join_connect_failed": "Не удалось завершить запрос удалённой связи.", "link.noChildren": "Дочерние компьютеры не подключены.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 3277869107d..88244f0caae 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -3161,7 +3161,7 @@ export const tr: Record = { "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.hint": "Ana bilgisayar, bu bilgisayarın sağlayıcılarını çocuk bilgisayarlarla paylaşır. Çocuk rolü, bu bilgisayar bağımsız çalışırken bir ana bilgisayara bağlanmasını sağlar.", "link.role.home": "Ana", "link.role.homeHint": "Çocuk bilgisayarlarını bu panodan yönetin.", "link.role.child": "Çocuk", @@ -3193,7 +3193,6 @@ export const tr: Record = { "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.", "remoteLink.childDisabled": "Çocuk bağlantıları yalnızca bağımsız çalışma zamanından başlatılabilir.", "remoteLink.findHome.title": "Home\u0027u bul", "remoteLink.findHome.body": "Bu Çocuk bilgisayarının bağlanacağı Home bilgisayarını seçin.", @@ -3208,6 +3207,8 @@ export const tr: Record = { "remoteLink.error.admission_failed": "Home yeni bağlantıyı kabul etmedi. Çalıştığını kontrol edip yeniden deneyin.", "remoteLink.error.join_issue_failed": "Home bağlantı veremedi. Home üzerinde OpenCodex'in çalıştığını kontrol edip yeniden deneyin.", "remoteLink.error.join_in_progress": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.join_rollback_failed": "Katılma başarısız oldu ve Home üzerindeki bağlantı kaldırılamadı. Temizlemeyi yeniden deneyin veya Home üzerinde ocx link revoke komutunu çalıştırın.", + "remoteLink.error.join_restart_failed": "Bağlantı hazır. Çocuk olarak bağlanmayı tamamlamak için bu bilgisayarda OpenCodex'i yeniden başlatın.", "remoteLink.error.join_port_failed": "Uzak bağlantı isteği tamamlanamadı.", "remoteLink.error.join_connect_failed": "Uzak bağlantı isteği tamamlanamadı.", "link.noChildren": "Bağlı çocuk bilgisayarı yok.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 19e652a87c4..10d9adac45d 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -3096,7 +3096,7 @@ export const vi: Record = { "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.hint": "Máy chủ chia sẻ các nhà cung cấp của máy tính này với các máy con. Máy con kết nối máy tính này với một máy chủ khi máy tính này chạy độc lập.", "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", @@ -3128,7 +3128,6 @@ export const vi: Record = { "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.", "remoteLink.childDisabled": "Chỉ có thể bắt đầu liên kết máy con từ runtime độc lập.", "remoteLink.findHome.title": "Tìm Home", "remoteLink.findHome.body": "Chọn máy Home để kết nối máy con này.", @@ -3143,6 +3142,8 @@ export const vi: Record = { "remoteLink.error.admission_failed": "Home không chấp nhận liên kết mới. Hãy kiểm tra Home đang chạy rồi thử lại.", "remoteLink.error.join_issue_failed": "Home không thể cấp liên kết. Hãy kiểm tra OpenCodex đang chạy trên Home rồi thử lại.", "remoteLink.error.join_in_progress": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.join_rollback_failed": "Không thể kết nối và cũng không thể xóa liên kết trên máy chủ. Hãy thử dọn dẹp lại hoặc chạy ocx link revoke trên máy chủ.", + "remoteLink.error.join_restart_failed": "Liên kết đã sẵn sàng. Hãy khởi động lại OpenCodex trên máy tính này để hoàn tất kết nối với vai trò máy con.", "remoteLink.error.join_port_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", "remoteLink.error.join_connect_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", "link.noChildren": "Chưa có máy con nào được kết nối.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 557f0147f31..040d59bae6a 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3124,7 +3124,7 @@ export const zhTW: Record = { "link.switch": "遠端連線", "link.switchOffHint": "開啟連線以選擇此電腦的角色。", "link.role.title": "選擇此電腦的角色", - "link.role.hint": "主機管理連線。子裝置設定即將推出。", + "link.role.hint": "主機會與子裝置分享此電腦的提供者。選擇子裝置後,此電腦可在獨立執行時連線到主機。", "link.role.home": "主機", "link.role.homeHint": "從此儀表板管理子裝置。", "link.role.child": "子裝置", @@ -3156,7 +3156,6 @@ export const zhTW: Record = { "link.disconnectConfirm": "要中斷 {alias} 的連線嗎?其連線金鑰將被撤銷。", "link.close": "關閉", "link.cancel": "取消", - "link.childPending": "子裝置設定將在用戶端啟動流程中提供。", "remoteLink.childDisabled": "只有獨立執行環境才能發起子裝置連線。", "remoteLink.findHome.title": "尋找 Home", "remoteLink.findHome.body": "選擇要連線此子裝置的 Home 電腦。", @@ -3171,6 +3170,8 @@ export const zhTW: Record = { "remoteLink.error.admission_failed": "Home 未接受新連線。請確認 Home 正在執行後重試。", "remoteLink.error.join_issue_failed": "Home 無法簽發連線。請確認 Home 上正在執行 OpenCodex 後重試。", "remoteLink.error.join_in_progress": "無法完成遠端連線要求。", + "remoteLink.error.join_rollback_failed": "加入失敗,且無法刪除主機上的連線。請重試清理,或在主機上執行 ocx link revoke。", + "remoteLink.error.join_restart_failed": "連線已準備就緒。請在此電腦上重新啟動 OpenCodex,以完成作為子裝置的連線。", "remoteLink.error.join_port_failed": "無法完成遠端連線要求。", "remoteLink.error.join_connect_failed": "無法完成遠端連線要求。", "link.noChildren": "沒有已連線的子裝置。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 2db3d5ef4a7..59cfebcb336 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -3159,7 +3159,7 @@ export const zh: Record = { "link.switch": "远程连接", "link.switchOffHint": "打开连接以选择此电脑的角色。", "link.role.title": "选择此电脑的角色", - "link.role.hint": "主机管理连接。子设备设置即将推出。", + "link.role.hint": "主机与子设备共享此电脑的提供商。选择子设备后,此电脑可在独立运行时连接到一台主机。", "link.role.home": "主机", "link.role.homeHint": "从此仪表板管理子设备。", "link.role.child": "子设备", @@ -3191,7 +3191,6 @@ export const zh: Record = { "link.disconnectConfirm": "断开 {alias}?其连接密钥将被撤销。", "link.close": "关闭", "link.cancel": "取消", - "link.childPending": "子设备设置将在客户端发起流程中提供。", "remoteLink.childDisabled": "只有独立运行时才能发起子设备连接。", "remoteLink.findHome.title": "查找 Home", "remoteLink.findHome.body": "选择要连接此子设备的 Home 电脑。", @@ -3206,6 +3205,8 @@ export const zh: Record = { "remoteLink.error.admission_failed": "Home 未接受新连接。请确认 Home 正在运行后重试。", "remoteLink.error.join_issue_failed": "Home 无法签发连接。请确认 Home 上正在运行 OpenCodex 后重试。", "remoteLink.error.join_in_progress": "无法完成远程连接请求。", + "remoteLink.error.join_rollback_failed": "加入失败,且无法删除主机上的连接。请重试清理,或在主机上运行 ocx link revoke。", + "remoteLink.error.join_restart_failed": "连接已准备就绪。请在此电脑上重启 OpenCodex,以完成作为子设备的连接。", "remoteLink.error.join_port_failed": "无法完成远程连接请求。", "remoteLink.error.join_connect_failed": "无法完成远程连接请求。", "link.noChildren": "没有已连接的子设备。", diff --git a/gui/src/pages/RemoteLink.tsx b/gui/src/pages/RemoteLink.tsx index 18cbf20fe61..46dfb7af1a3 100644 --- a/gui/src/pages/RemoteLink.tsx +++ b/gui/src/pages/RemoteLink.tsx @@ -43,6 +43,8 @@ const ERROR_TKEY: Record = { join_in_progress: "remoteLink.error.join_in_progress", join_issue_failed: "remoteLink.error.join_issue_failed", join_port_failed: "remoteLink.error.join_port_failed", + join_restart_failed: "remoteLink.error.join_restart_failed", + join_rollback_failed: "remoteLink.error.join_rollback_failed", join_tunnel_failed: "remoteLink.error.join_tunnel_failed", key_issue_failed: "remoteLink.error.key_issue_failed", key_revoke_failed: "remoteLink.error.key_revoke_failed", @@ -72,6 +74,7 @@ const REASON_TKEY: Record = { }; type FailedAction = { phase: "probe" | "apply" | "join"; alias: string }; +type LinkAttempt = { controller: AbortController; sequence: number }; 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; } @@ -123,6 +126,23 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = const roleRefs = useRef>([]); const statusRequestRef = useRef<{ controller: AbortController; sequence: number } | null>(null); const statusSequenceRef = useRef(0); + const linkAttemptRef = useRef(null); + const linkAttemptSequenceRef = useRef(0); + + const startLinkAttempt = useCallback((): LinkAttempt => { + linkAttemptRef.current?.controller.abort(); + const attempt = { controller: new AbortController(), sequence: ++linkAttemptSequenceRef.current }; + linkAttemptRef.current = attempt; + return attempt; + }, []); + + const cancelLinkAttempt = useCallback(() => { + linkAttemptSequenceRef.current += 1; + linkAttemptRef.current?.controller.abort(); + linkAttemptRef.current = null; + }, []); + + const isCurrentLinkAttempt = (attempt: LinkAttempt): boolean => linkAttemptRef.current?.sequence === attempt.sequence && !attempt.controller.signal.aborted; const abortStatusRequest = useCallback(() => { statusSequenceRef.current += 1; @@ -186,72 +206,93 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = } }, [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(); }; + // Cancelling the sheet abandons the attempt, so late responses cannot recreate its state. + const closeSheet = () => { cancelLinkAttempt(); setSheetOpen(false); setCandidates([]); setProbe(null); setConfirmation(null); setCheckedFingerprint(false); setActionError(null); setFailedAction(null); setBusy(null); setUiState(current => ["failed", "adding-child", "confirming-host", "applying", "joining"].includes(current) ? "adding-child" : current); addButtonRef.current?.focus(); }; const standaloneRuntime = isStandaloneRuntime(); 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 fixtureMode = typeof document === "undefined" ? null : document.querySelector('meta[name="opencodex-remote-link-fixture"]')?.getAttribute("content"); - const fixtureInitializedRef = useRef(false); - const initializeFixture = useEffectEvent(() => { - if (fixtureInitializedRef.current || !standaloneRuntime) return; - fixtureInitializedRef.current = true; - if (fixtureMode === "standalone-find-home") { setRole("child"); void openSheet(); return; } - if (fixtureMode === "standalone-joining" || fixtureMode === "standalone-join-failure") { - setRole("child"); setAlias("child-workstation"); setProbe({ alias: "child-workstation", fingerprint: "SHA256:fixture-host-key", keyType: "ED25519" }); - setConfirmation({ alias: "child-workstation", fingerprint: "SHA256:fixture-host-key", ocxVersion: "0.0.0-fixture" }); setCheckedFingerprint(true); setSheetOpen(true); - if (fixtureMode === "standalone-joining") { setBusy("join"); setUiState("joining"); } - else { setActionError("remoteLink.error.join_tunnel_failed"); setFailedAction({ phase: "join", alias: "child-workstation" }); setUiState("failed"); } - return; + const attempt = startLinkAttempt(); + setSheetOpen(true); setUiState("adding-child"); setCandidates([]); setProbe(null); setConfirmation(null); setCheckedFingerprint(false); setActionError(null); setFailedAction(null); setBusy("candidates"); + try { + const result = await requestLinkJson(apiBase, "/api/link/candidates", { signal: attempt.controller.signal }); + if (!isCurrentLinkAttempt(attempt)) return; + setCandidates(parseCandidates(result)); + } catch (error) { + if (!isCurrentLinkAttempt(attempt)) return; + setActionError(errorKey(error)); + } finally { + if (isCurrentLinkAttempt(attempt)) setBusy(null); } - if (fixtureMode === "standalone-restart-waiting") { setRole("child"); setUiState("restart-waiting"); } - }); - useEffect(() => { window.setTimeout(initializeFixture, 0); }, [standaloneRuntime]); - const runProbe = async (requestedAlias = alias.trim()) => { + }; + const runProbe = async (requestedAlias = alias.trim(), attempt = linkAttemptRef.current ?? startLinkAttempt()) => { 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); } + try { + const result = await requestLinkJson(apiBase, "/api/link/probe", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ alias: value }), signal: attempt.controller.signal }); + if (!isCurrentLinkAttempt(attempt)) return; + setProbe(parseProbe(result)); + } catch (error) { + if (!isCurrentLinkAttempt(attempt)) return; + setActionError(errorKey(error)); setFailedAction({ phase: "probe", alias: value }); setUiState("failed"); + } finally { + if (isCurrentLinkAttempt(attempt)) setBusy(null); + } }; const confirmHost = async () => { if (!probe || !checkedFingerprint) return; + const attempt = linkAttemptRef.current; + if (!attempt) 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); } + try { + const result = await requestLinkJson(apiBase, "/api/link/confirm-host", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ alias: probe.alias, fingerprint: probe.fingerprint }), signal: attempt.controller.signal }); + if (!isCurrentLinkAttempt(attempt)) return; + setConfirmation(parseConfirmation(result)); + } catch (error) { + if (!isCurrentLinkAttempt(attempt)) return; + setActionError(errorKey(error)); + } finally { + if (isCurrentLinkAttempt(attempt)) setBusy(null); + } }; - const applyLink = async () => { - if (!confirmation) return; + const applyLink = async (attempt = linkAttemptRef.current) => { + const confirmed = confirmation; + if (!confirmed || !attempt) 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); } + try { + await requestLinkJson<{ linkId: string }>(apiBase, "/api/link/apply", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ alias: confirmed.alias }), signal: attempt.controller.signal }); + if (!isCurrentLinkAttempt(attempt)) return; + closeSheet(); + void refreshStatus(); + } catch (error) { + if (!isCurrentLinkAttempt(attempt)) return; + setActionError(errorKey(error)); setFailedAction({ phase: "apply", alias: confirmed.alias }); setUiState("failed"); + } finally { + if (isCurrentLinkAttempt(attempt)) setBusy(null); + } }; - const joinLink = async (requestedAlias = confirmation?.alias) => { + const joinLink = async (requestedAlias = confirmation?.alias, attempt = linkAttemptRef.current ?? startLinkAttempt()) => { const value = requestedAlias?.trim(); if (!value) return; setBusy("join"); setActionError(null); setFailedAction(null); setUiState("joining"); try { - await requestLinkJson<{ linkId: string; alias: string; restarting: true }>(apiBase, "/api/link/join", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ alias: value }) }); - setSheetOpen(false); setUiState("restart-waiting"); + await requestLinkJson<{ linkId: string; alias: string; restarting: true }>(apiBase, "/api/link/join", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ alias: value }), signal: attempt.controller.signal }); + if (!isCurrentLinkAttempt(attempt)) return; + closeSheet(); setUiState("restart-waiting"); } catch (error) { + if (!isCurrentLinkAttempt(attempt)) return; setActionError(errorKey(error)); setFailedAction({ phase: "join", alias: value }); setUiState("failed"); - } finally { setBusy(null); } + } finally { + if (isCurrentLinkAttempt(attempt)) setBusy(null); + } }; const retryFailedAction = () => { if (!failedAction) { void refreshStatus(); return; } - if (failedAction.phase === "probe") { setAlias(failedAction.alias); void runProbe(failedAction.alias); return; } - if (failedAction.phase === "join") { void joinLink(failedAction.alias); return; } - void applyLink(); + const attempt = startLinkAttempt(); + if (failedAction.phase === "probe") { setAlias(failedAction.alias); void runProbe(failedAction.alias, attempt); return; } + if (failedAction.phase === "join") { void joinLink(failedAction.alias, attempt); return; } + void applyLink(attempt); }; const closeConfirmation = () => { @@ -294,8 +335,8 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = {workspaceAvailable &&
{t("remoteLink.workspaceMoved.title")}

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

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

{t("link.switchOffHint")}

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

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

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

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

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

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

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

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

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

{t("link.noChildren")}

}{(uiState === "reconnecting" || uiState === "failed") &&
{t(STATUS_LABEL[uiState === "failed" ? "failed" : "reconnecting"])}
}{uiState === "joining" &&

{t("remoteLink.joining")}

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

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

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

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

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

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

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

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

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

{t("link.noChildren")}

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

{t("remoteLink.joining")}

}{actionError && {t(actionError)}}
} { event.preventDefault(); closeSheet(); }}>
diff --git a/gui/src/remote-link-api.ts b/gui/src/remote-link-api.ts index c9aa70a771d..935a19032e7 100644 --- a/gui/src/remote-link-api.ts +++ b/gui/src/remote-link-api.ts @@ -14,6 +14,8 @@ export const LINK_ERROR_CODES = [ "join_in_progress", "join_issue_failed", "join_port_failed", + "join_restart_failed", + "join_rollback_failed", "join_tunnel_failed", "key_issue_failed", "key_revoke_failed", diff --git a/gui/tests/remote-link.test.tsx b/gui/tests/remote-link.test.tsx index 65f83e2ba03..14893a40416 100644 --- a/gui/tests/remote-link.test.tsx +++ b/gui/tests/remote-link.test.tsx @@ -186,6 +186,32 @@ test("join maps standalone_required to an actionable message", async () => { expect(host.textContent).toContain("Child links can only be started from a standalone runtime."); }); +test("join_restart_failed shows restart guidance without Retry", async () => { + declareRuntimeRole("standalone"); + globalThis.fetch = (async input => { + const path = new URL(String(input)).pathname; + if (path === "/api/link/candidates") return response({ candidates: [{ alias: "home-one", source: "ssh_config" }] }); + if (path === "/api/link/probe") return response({ alias: "home-one", fingerprint: "SHA256:test", keyType: "ed25519" }); + if (path === "/api/link/confirm-host") return response({ alias: "home-one", fingerprint: "SHA256:test", ocxVersion: "2.0.0" }); + if (path === "/api/link/join") return response({ error: { code: "join_restart_failed" } }, 500); + return response({ ...baseStatus, role: "standalone" }); + }) as typeof fetch; + const host = await mount(); + await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); + await act(async () => { ([...host.querySelectorAll('[role="radio"]')][1] as HTMLButtonElement).click(); }); + await flush(); + await act(async () => { (host.querySelector(".remote-link-candidate") as HTMLButtonElement).click(); }); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Test connection"))?.click(); }); + await flush(); + await act(async () => { (host.querySelector('input[type="checkbox"]') as HTMLInputElement).click(); }); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Confirm host"))?.click(); }); + await flush(); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Connect as Child"))?.click(); }); + await flush(); + expect(host.textContent).toContain("The link is ready. Restart OpenCodex on this computer to finish connecting as a Child."); + expect(host.textContent).not.toContain("Retry"); +}); + 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 }); @@ -387,3 +413,58 @@ test("cancelling a failed apply leaves no dead Retry behind", async () => { expect([...host.querySelectorAll("button")].some(button => button.textContent === "Retry")).toBe(false); expect([...host.querySelectorAll("button")].some(button => button.textContent?.includes("Add child"))).toBe(true); }); + +test("cancelling a join ignores a late failure", async () => { + declareRuntimeRole("standalone"); + let releaseJoin!: (value: Response) => void; + const joinResponse = new Promise(resolve => { releaseJoin = resolve; }); + globalThis.fetch = (async input => { + const path = new URL(String(input)).pathname; + if (path === "/api/link/candidates") return response({ candidates: [{ alias: "home-one", source: "ssh_config" }] }); + if (path === "/api/link/probe") return response({ alias: "home-one", fingerprint: "SHA256:test", keyType: "ed25519" }); + if (path === "/api/link/confirm-host") return response({ alias: "home-one", fingerprint: "SHA256:test", ocxVersion: "2.0.0" }); + if (path === "/api/link/join") return joinResponse; + return response({ ...baseStatus, role: "standalone" }); + }) as typeof fetch; + const host = await mount(); + await act(async () => { (host.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); + await act(async () => { ([...host.querySelectorAll('[role="radio"]')][1] as HTMLButtonElement).click(); }); + await flush(); + await act(async () => { (host.querySelector(".remote-link-candidate") as HTMLButtonElement).click(); }); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Test connection"))?.click(); }); + await flush(); + await act(async () => { (host.querySelector('input[type="checkbox"]') as HTMLInputElement).click(); }); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Confirm host"))?.click(); }); + await flush(); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Connect as Child"))?.click(); }); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent === "Cancel")?.click(); }); + releaseJoin(response({ error: { code: "join_tunnel_failed" } }, 502)); + await flush(); + expect(host.textContent).not.toContain("Retry"); + expect(host.textContent).not.toContain("The tunnel to Home could not be started."); +}); + +test("cancelling candidates prevents a late response from appearing in a new attempt", async () => { + let releaseFirst!: (value: Response) => void; + let candidateCalls = 0; + const firstCandidates = new Promise(resolve => { releaseFirst = resolve; }); + globalThis.fetch = (async input => { + const path = new URL(String(input)).pathname; + if (path === "/api/link/candidates") { + candidateCalls += 1; + return candidateCalls === 1 ? firstCandidates : response({ candidates: [{ alias: "new-home", 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 act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent === "Cancel")?.click(); }); + await act(async () => { [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Add child"))?.click(); }); + await flush(); + releaseFirst(response({ candidates: [{ alias: "stale-home", source: "ssh_config" }] })); + await flush(); + expect(host.textContent).toContain("new-home"); + expect(host.textContent).not.toContain("stale-home"); +}); From 428498f7408583462d4f1b938e63ab33e94a575e Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:15:40 +0900 Subject: [PATCH 11/13] fix(link): say a Child has no Home yet instead of listing no children The Find Home panel reused the Home empty text, so a standalone Child read that it had no child computers. --- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/vi.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/RemoteLink.tsx | 2 +- 11 files changed, 11 insertions(+), 1 deletion(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index ac5dca49a0d..37308e18859 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -3175,6 +3175,7 @@ export const de: Record = { "remoteLink.findHome.body": "Wählen Sie den Home-Computer für dieses Kind aus.", "remoteLink.findHome.action": "Home suchen", "remoteLink.findHome.connect": "Als Kind verbinden", + "remoteLink.findHome.empty": "Noch mit keinem Home verbunden.", "remoteLink.joining": "Home wird verbunden …", "remoteLink.restart.title": "Dieser Computer wird neu gestartet, um sich als Kind zu verbinden.", "remoteLink.restart.body": "Das Dashboard verbindet sich automatisch wieder, sobald das Kind bereit ist.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index ff455b5616d..86f303e0d73 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3209,6 +3209,7 @@ export const en = { "remoteLink.findHome.body": "Choose the Home computer to connect this Child to.", "remoteLink.findHome.action": "Find Home", "remoteLink.findHome.connect": "Connect as Child", + "remoteLink.findHome.empty": "Not connected to a Home yet.", "remoteLink.joining": "Joining Home…", "remoteLink.restart.title": "This computer will restart to connect as a Child.", "remoteLink.restart.body": "The dashboard will reconnect automatically when the Child is ready.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index a6e3c6c1524..77269ffce75 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -3164,6 +3164,7 @@ export const fr: Record = { "remoteLink.findHome.body": "Choisissez l’ordinateur Home auquel connecter cet Enfant.", "remoteLink.findHome.action": "Trouver le Home", "remoteLink.findHome.connect": "Connecter comme Enfant", + "remoteLink.findHome.empty": "Pas encore connecté à un Home.", "remoteLink.joining": "Connexion au Home…", "remoteLink.restart.title": "Cet ordinateur va redémarrer pour se connecter comme Enfant.", "remoteLink.restart.body": "Le tableau de bord se reconnectera automatiquement lorsque l’Enfant sera prêt.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 4176ebec85a..3e433860df0 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -3197,6 +3197,7 @@ export const ja: Record = { "remoteLink.findHome.body": "この子コンピューターを接続する Home を選択してください。", "remoteLink.findHome.action": "Home を探す", "remoteLink.findHome.connect": "子として接続", + "remoteLink.findHome.empty": "まだ Home に接続されていません。", "remoteLink.joining": "Home に接続しています…", "remoteLink.restart.title": "このコンピューターは子として接続するため再起動します。", "remoteLink.restart.body": "子の準備ができるとダッシュボードは自動的に再接続します。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 69eb1cb3a2e..7073e04b491 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3197,6 +3197,7 @@ export const ko: Record = { "remoteLink.findHome.body": "이 자식 컴퓨터를 연결할 홈 컴퓨터를 선택하세요.", "remoteLink.findHome.action": "홈 찾기", "remoteLink.findHome.connect": "자식으로 연결", + "remoteLink.findHome.empty": "아직 연결된 홈이 없습니다.", "remoteLink.joining": "홈에 연결하는 중…", "remoteLink.restart.title": "이 컴퓨터는 자식으로 연결하기 위해 재시작합니다.", "remoteLink.restart.body": "자식이 준비되면 대시보드가 자동으로 다시 연결됩니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 474ef6c20f2..1776e8f4936 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -3198,6 +3198,7 @@ export const ru: Record = { "remoteLink.findHome.body": "Выберите компьютер Home, к которому подключить этот Child.", "remoteLink.findHome.action": "Найти Home", "remoteLink.findHome.connect": "Подключить как Child", + "remoteLink.findHome.empty": "Пока не подключено к Home.", "remoteLink.joining": "Подключение к Home…", "remoteLink.restart.title": "Компьютер перезапустится для подключения как Child.", "remoteLink.restart.body": "Панель управления подключится автоматически, когда Child будет готов.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 88244f0caae..1747fff72a7 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -3198,6 +3198,7 @@ export const tr: Record = { "remoteLink.findHome.body": "Bu Çocuk bilgisayarının bağlanacağı Home bilgisayarını seçin.", "remoteLink.findHome.action": "Home'u bul", "remoteLink.findHome.connect": "Çocuk olarak bağlan", + "remoteLink.findHome.empty": "Henüz bir Home'a bağlı değil.", "remoteLink.joining": "Home'a bağlanılıyor…", "remoteLink.restart.title": "Bu bilgisayar Çocuk olarak bağlanmak için yeniden başlatılacak.", "remoteLink.restart.body": "Çocuk hazır olduğunda pano otomatik olarak yeniden bağlanır.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 10d9adac45d..90111c0154f 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -3133,6 +3133,7 @@ export const vi: Record = { "remoteLink.findHome.body": "Chọn máy Home để kết nối máy con này.", "remoteLink.findHome.action": "Tìm Home", "remoteLink.findHome.connect": "Kết nối với vai trò máy con", + "remoteLink.findHome.empty": "Chưa kết nối với Home nào.", "remoteLink.joining": "Đang kết nối với Home…", "remoteLink.restart.title": "Máy tính này sẽ khởi động lại để kết nối với vai trò máy con.", "remoteLink.restart.body": "Bảng điều khiển sẽ tự động kết nối lại khi máy con sẵn sàng.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 040d59bae6a..56182f2aa19 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3161,6 +3161,7 @@ export const zhTW: Record = { "remoteLink.findHome.body": "選擇要連線此子裝置的 Home 電腦。", "remoteLink.findHome.action": "尋找 Home", "remoteLink.findHome.connect": "以子裝置身分連線", + "remoteLink.findHome.empty": "尚未連線到 Home。", "remoteLink.joining": "正在連線 Home…", "remoteLink.restart.title": "此電腦將重新啟動,以子裝置身分連線。", "remoteLink.restart.body": "子裝置準備好後,控制面板會自動重新連線。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 59cfebcb336..3883ef907b6 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -3196,6 +3196,7 @@ export const zh: Record = { "remoteLink.findHome.body": "选择要连接此子设备的 Home 电脑。", "remoteLink.findHome.action": "查找 Home", "remoteLink.findHome.connect": "以子设备身份连接", + "remoteLink.findHome.empty": "尚未连接到 Home。", "remoteLink.joining": "正在连接 Home…", "remoteLink.restart.title": "此电脑将重启,以子设备身份连接。", "remoteLink.restart.body": "子设备准备就绪后,控制面板会自动重新连接。", diff --git a/gui/src/pages/RemoteLink.tsx b/gui/src/pages/RemoteLink.tsx index 46dfb7af1a3..2c6af8b0d8f 100644 --- a/gui/src/pages/RemoteLink.tsx +++ b/gui/src/pages/RemoteLink.tsx @@ -336,7 +336,7 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = {statusError && {t(statusError)}} {statusRows.length === 0 && uiState === "off" &&
{t("link.switch")}

{t("link.switchOffHint")}

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

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

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

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

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

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

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

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

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

{t("link.noChildren")}

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

{t("remoteLink.joining")}

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

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

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

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

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

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

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

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

{t("remoteLink.joining")}

}{actionError && {t(actionError)}}
} { event.preventDefault(); closeSheet(); }}>
From 70c0dac38c99d470472fae094ba871e47fd6983a Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:57:20 +0900 Subject: [PATCH 12/13] fix(link): center the Remote Link sheet on desktop The add-child and Find Home sheet was pinned to the right edge with only its left corners rounded, so on a wide screen it floated at the side, detached from the page. It now opens centered like the disconnect confirmation; narrow screens keep the bottom sheet. --- gui/src/styles-remote-link.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/styles-remote-link.css b/gui/src/styles-remote-link.css index 0e542dbed01..c61326ef05f 100644 --- a/gui/src/styles-remote-link.css +++ b/gui/src/styles-remote-link.css @@ -41,7 +41,7 @@ .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 { width: min(620px, calc(100vw - var(--space-4))); max-height: min(760px, calc(100dvh - var(--space-4))); margin: auto; padding: var(--space-6); overflow: auto; border: 1px solid var(--border); border-radius: 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); } From 9aa953eaa380d01aaf810aa66f98fdca08cb5e64 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 14:06:32 +0900 Subject: [PATCH 13/13] fix(link): use the dashboard input and field label in the Remote Link sheet The SSH host alias field rendered as a bare browser input with a bold label, unlike every other form in the dashboard. It now uses the shared .input and .field-label styles, matching the candidate cards and the fingerprint box beside it. --- gui/src/pages/RemoteLink.tsx | 2 +- gui/src/styles-remote-link.css | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/gui/src/pages/RemoteLink.tsx b/gui/src/pages/RemoteLink.tsx index 2c6af8b0d8f..ece988c680c 100644 --- a/gui/src/pages/RemoteLink.tsx +++ b/gui/src/pages/RemoteLink.tsx @@ -340,7 +340,7 @@ export default function RemoteLink({ apiBase, sessionReady, workspaceAvailable = { event.preventDefault(); closeSheet(); }}>
-

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

{busy === "candidates" ?

{t("link.loading")}

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

{t("link.noCandidates")}

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

{probe.fingerprint}

{probe.keyType}
}{confirmation &&

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

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

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

{busy === "candidates" ?

{t("link.loading")}

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

{t("link.noCandidates")}

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

{probe.fingerprint}

{probe.keyType}
}{confirmation &&

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

}{actionError && {t(actionError)}}
{ event.preventDefault(); closeConfirmation(); }}> diff --git a/gui/src/styles-remote-link.css b/gui/src/styles-remote-link.css index c61326ef05f..14c53706fa4 100644 --- a/gui/src/styles-remote-link.css +++ b/gui/src/styles-remote-link.css @@ -50,8 +50,7 @@ .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-form .field-label { margin-bottom: 0; } .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); }