diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07b91e6059..adcf1fc62d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,7 @@ on: - "bin/**" - "tests/**" - "scripts/**" + - "native/**" - "gui/**" - "assets/**" - ".gitattributes" @@ -178,6 +179,7 @@ jobs: - 'bin/**' - 'tests/**' - 'scripts/**' + - 'native/**' - 'gui/**' - 'assets/**' - '.gitattributes' @@ -213,6 +215,7 @@ jobs: - 'README.md' - 'LICENSE' - 'scripts/prepare-package.ts' + - 'native/**' - name: Assert the scope output is usable id: scope @@ -669,6 +672,79 @@ jobs: - name: CLI help smoke run: bun run src/cli/index.ts help + # Remote Workspace advertises native command execution only after the helper itself proves the + # OS boundary. Keep this small exact-binary lane on disposable hosted runners even while the full + # Windows Bun suite remains dispatch-only: cross-compilation proves types, not Seatbelt or + # AppContainer behavior. + native-workspace-helper: + name: native workspace helper (${{ matrix.name }}) + needs: changes + if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' + runs-on: ${{ matrix.runner }} + timeout-minutes: 8 + strategy: + fail-fast: false + matrix: + include: + - name: macos + runner: macos-latest + - name: windows + runner: windows-latest + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Build and run native confinement probe + run: cargo test --locked --release --manifest-path native/remote-workspace-helper/Cargo.toml + + # Unlike the Rust helpers, Linux confinement lives in the production TypeScript runner. Make + # bubblewrap absence fatal in this dedicated hosted lane and exercise the real write/read/network + # boundary instead of treating an argv-shape unit test as sandbox evidence. + linux-workspace-confinement: + name: Linux workspace confinement + needs: changes + if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' + # Noble's AppArmor user-namespace policy can make bwrap fail closed before the test reaches the + # product boundary. Use a fixed hosted Jammy image for the semantic confinement proof instead + # of weakening the disposable runner's kernel policy with a privileged sysctl. + runs-on: ubuntu-22.04 + timeout-minutes: 8 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + fetch-tags: true + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Install bubblewrap + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --yes --no-install-recommends bubblewrap + + - name: Prove Linux confinement boundary + shell: bash + env: + OCX_REQUIRE_LINUX_REMOTE_WORKSPACE_CONFINEMENT: "1" + run: | + set -euo pipefail + isolated_root="$(mktemp -d)" + mkdir -p "$isolated_root/home" "$isolated_root/opencodex" "$isolated_root/codex" "$isolated_root/paseo" + HOME="$isolated_root/home" \ + OPENCODEX_HOME="$isolated_root/opencodex" \ + CODEX_HOME="$isolated_root/codex" \ + PASEO_HOME="$isolated_root/paseo" \ + bun test --isolate tests/remote-workspace-linux-confinement.test.ts + # Keep every OS credential-store check on a disposable GitHub-hosted machine. # A force-cancelled process cannot run its in-process finally cleanup, so no # keyring matrix leg may use the persistent self-hosted Windows runner. @@ -811,7 +887,7 @@ jobs: # direct dependencies only, so a failing `select-windows-runner` would # otherwise reach this gate as nothing at all while its dependents report # `skipped` — which the gate is required to read as a deliberate skip. - needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, platform-windows, keyring-smoke, npm-global-smoke] + needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, platform-windows, native-workspace-helper, linux-workspace-confinement, keyring-smoke, npm-global-smoke] runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/.gitignore b/.gitignore index 19ab5348b8..f38f066486 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,6 @@ tests/.tmp-* # `git add` three separate times and reached `dev` once — see # tests/repo-hygiene.test.ts, which fails if any path here becomes tracked again. go/ + +# Rust native helpers keep their reproducible sources and lockfile in git, never local artifacts. +native/**/target/ diff --git a/.npmignore b/.npmignore index acf3a0c4d0..cfbe1d3750 100644 --- a/.npmignore +++ b/.npmignore @@ -19,6 +19,7 @@ gui/eslint.config.* gui/bun.lock # misc +native/remote-workspace-helper/target/ *.test.ts *.map .DS_Store diff --git a/devlog/_plan/260903_improved_remote_control/000_product_plan.md b/devlog/_plan/260903_improved_remote_control/000_product_plan.md new file mode 100644 index 0000000000..95468470d5 --- /dev/null +++ b/devlog/_plan/260903_improved_remote_control/000_product_plan.md @@ -0,0 +1,532 @@ +# OpenCodex Remote Workspace 기획서 + +- 상태: private dogfood 구현 완료; runtime/CLI/GUI 연결 및 Hub-Executor 실제 통합 검증, 출시 전 검토 대기 +- 대상 브랜치: `improved-remote-control` +- 기준 `dev`: `04879bc88` +- 범위: 사용자 소유 메인 OCX를 통한 다중 기기 제어와 원격 Codex 실행 +- 제외: Super Sync, 유료 VPS, 원격 데스크톱, production 배포 + +## 1. 한 줄 정의 + +컴1의 OpenCodex가 웹 GUI, Codex 로그인, Codex App Server와 세션 기록을 소유하고, +컴2의 최소 OCX Executor가 파일 및 명령을 실제로 실행하게 한다. 컴3의 브라우저는 컴1 +GUI에 접속해 컴2를 선택하고 그 세션을 조작한다. + +```text +컴3 Browser + | HTTPS/WSS: GUI, prompt, approvals, streamed session events + v +컴1 Main OCX + |-- Web GUI + account/device control plane + |-- Codex credentials + isolated Codex App Server pool + |-- bounded relay + | + | E2EE remote execution channel + v +컴2 OCX Executor + |-- allowed workspace roots + |-- capability-filtered remote tool endpoint + `-- shell, file operations, patch application, process lifecycle +``` + +사용자 관점에서는 Codex가 컴2에서 실행되는 것처럼 보인다. 실제 프로세스 경계는 다음처럼 +나뉜다. + +- 대화, 모델 호출, 계정 인증, 세션 기록: 컴1 +- 파일 읽기/수정, 명령, 테스트, 로컬 프로세스: 컴2 +- 화면, 입력, 승인: 컴3 +- 암호문과 세션 라우팅: 컴1 relay + +컴2에는 ChatGPT 로그인, OpenAI API key, Codex binary, Codex 세션 저장소가 없어야 한다. 컴1의 +실제 Codex App Server는 격리된 Code Mode에서 Remote Workspace MCP만 호출하고, App Server client +역할의 OCX가 그 요청을 컴2의 OCX-native Executor로 전달한다. + +## 2. 해결하려는 사용 사례 + +### 2.1 컴3에서 컴2를 조작 + +1. 컴1이 `ocx gui`를 안전한 HTTPS 주소로 제공한다. +2. 실행 대상인 컴2는 OCX 기기로 컴1에 등록한다. 컴3이 브라우저 controller 역할만 한다면 OCX를 + 따로 실행할 필요가 없다. +3. 컴3 사용자가 브라우저에서 컴1 GUI에 로그인한다. +4. Devices에서 컴2를 선택한다. +5. 컴2의 terminal 또는 Remote Codex Workspace를 연다. +6. 컴1은 세션 권한을 검사하고 컴3과 컴2, 또는 컴1 Codex와 컴2 Executor를 연결한다. + +컴3이 Executor로도 등록돼 있더라도 현재 선택한 실행 대상은 컴2다. `browser/controller`와 +`executor device` 역할은 컴퓨터 자체가 아니라 세션마다 분리한다. + +### 2.2 컴2에 Codex가 없어도 컴2에서 작업 + +1. 컴3이 컴1 GUI에서 컴2와 workspace root를 선택한다. +2. 컴1의 Runtime Supervisor가 격리된 Codex App Server process를 시작하거나 pool을 재사용한다. +3. App Server는 컴1의 Codex 자격증명을 사용한다. +4. thread는 `executorDeviceId=컴2`와 `rootId`에 고정되고 capability별 remote tools만 등록한다. +5. App Server의 격리된 `functions.exec`가 허용된 Remote Workspace MCP를 호출하면 컴1 OCX가 이를 + 컴2 Executor로 전달한다. +6. 컴2가 파일과 명령 도구를 실행하고 결과만 컴1 Codex에 돌려준다. +7. 결과와 승인은 컴1 GUI를 통해 컴3에 streaming된다. + +컴2가 offline이면 컴1 로컬 실행으로 fallback하지 않는다. 세션을 `waiting_for_executor`로 +멈춰야 한다. 조용한 local fallback은 컴1 파일을 잘못 수정할 수 있으므로 금지한다. + +### 2.3 컴2 터미널만 직접 사용 + +Codex 없이 shell만 필요하면 Browser와 컴2 Executor가 기존 프로토타입의 E2EE terminal +channel을 사용한다. 컴1 relay는 terminal plaintext를 해독하지 않는다. + +## 3. 제품 역할 + +| 역할 | 기본 위치 | 책임 | +| --- | --- | --- | +| Main Coordinator | 컴1 | GUI, 로그인, 기기/세션 권한, Codex credentials, session storage, runtime supervision | +| Controller | 컴3 browser | 기기 선택, prompt 입력, terminal input, 승인, 진행 확인 | +| Executor | 컴2 OCX | 허용된 workspace의 file/shell/process/PTY 실행 | +| Opaque relay | 컴1 | device/session routing, bounds, backpressure, presence | +| Codex App Server | 컴1 | 실제 Codex thread/turn/model lifecycle | +| Dynamic tool dispatcher | 컴1과 컴2 사이 | App Server의 client-executed tool call을 선택된 Executor에 고정 | + +한 컴퓨터가 여러 역할을 가질 수 있다. 예를 들어 컴3은 browser controller인 동시에 나중에 +다른 세션의 executor가 될 수 있다. 그러나 한 세션의 `controllerDeviceId`, `coordinatorId`, +`executorDeviceId`는 명시적으로 분리해 기록한다. + +## 4. 기존 OpenCodex 기능과의 관계 + +### Remote Hub + +기존 Remote Hub는 서브컴의 Codex/Claude 요청을 컴1 provider gateway로 라우팅한다. 자격증명을 +복사하지 않고 컴1에서 provider 요청을 실행한다. Remote Workspace는 이를 대체하지 않는다. + +### Remote Control prototype + +현재 브랜치의 `src/remote-control/`은 다음을 이미 증명한다. + +- account/device 상호 서명 +- session별 ECDH 및 방향별 AES-GCM key +- replay/order 검증 +- opaque relay frame +- terminal session과 buffer limit +- 첫 authenticated application frame 이후 local terminal 생성 + +이 토대는 device enrollment 이후 Browser-to-Executor terminal channel과 +Coordinator-to-Executor dynamic tool channel에 공통으로 사용한다. Phase 0에는 추가로 +`workspace-tools`, `workspace-coordinator`, `workspace-executor`가 들어갔다. + +### Super Sync + +API key, OAuth token, provider 계정을 다른 컴퓨터로 복제하는 Super Sync는 이 계획에 포함하지 +않는다. Remote Workspace에서는 provider credentials가 컴1을 떠나지 않는다. + +## 5. 실제 Codex 통합 전략 + +공식 OpenAI의 [Codex App Server 문서](https://learn.chatgpt.com/docs/app-server)에 따르면 +현재 Codex는 App Server thread별 permission profile과 MCP 설정을 받고, MCP tool은 격리된 +`functions.exec` Code Mode를 통해 호출한다. 이 경로가 컴2에 Codex를 두지 않는 Remote Workspace의 +기준이다. + +- `codex app-server --listen ...`: 컴1 OCX가 thread와 turn을 제어하는 JSON-RPC transport +- `thread/start` / `thread/resume`: remote workspace 전용 permission profile과 MCP 등록 +- `functions.exec`: 다른 Hub 도구 없이 `mcp__ocx_remote_workspace__*`만 호출 +- MCP response: 컴2 결과를 Codex의 다음 model request에 연결 + +thread ID를 기준으로 컴2/컴3 binding을 찾을 수 있으므로 여러 executor thread가 작은 App Server +pool을 공유할 수 있다. 다른 executor로 조용히 fallback하는 것은 허용하지 않는다. + +```text +컴1 Runtime Supervisor + |-- isolated app-server A -> thread 1 -> 컴2 binding + | `-> thread 2 -> 컴3 binding + `-- isolated app-server B -> capacity/failure isolation +``` + +각 remote thread는 생성 시 `executorDeviceId`, `rootId`, App Server writer identity를 함께 저장한다. +다른 기기로 thread를 resume하는 동작은 기본적으로 거부하고, 명시적인 `fork to another device`만 +새 thread로 허용한다. + +### 5.1 선택 경로: App Server와 제한된 Remote Workspace MCP + +컴1이 자체 Codex login을 사용하면 App Server가 컴1의 Codex credential을 읽고, OCX provider +route를 사용하면 provider credential은 컴1 OCX Hub가 소유한다. 두 경우 모두 credential은 컴1을 +떠나지 않는다. remote thread에는 `ocx_remote_workspace` MCP만 등록한다. Codex가 격리된 Code +Mode에서 이 namespace를 호출하면 컴1 OCX가 E2EE transport로 컴2 Executor에 전달한다. + +현재 local compatibility spike가 다음을 확인했다. + +1. Codex `0.152.1` App Server가 thread permission profile과 required HTTP MCP를 적용한다. +2. 실제 Codex App Server의 `functions.exec`가 `mcp__ocx_remote_workspace__exec`를 호출한다. +3. 컴1 OCX coordinator가 그 request를 session별 E2EE RPC로 선택된 컴2 fixture에 전달한다. +4. command output이 function-call output으로 Codex의 다음 model request에 들어간다. +5. 컴2 canary만 변경되고 같은 이름의 컴1 canary는 바뀌지 않는다. +6. offline, unbound thread, wrong namespace는 local fallback 없이 실패한다. + +App Server client protocol은 계속 버전 호환 검사가 필요하다. 또한 developer instruction만으로 +built-in local tool을 막았다고 간주하지 않으므로, 컴1 App Server thread에는 명시적인 deny-local +permission profile, 비어 있는 coordinator root, 제한된 shell environment와 단 하나의 required MCP를 +동시에 적용한다. 현재 Linux 통합 검증은 같은 이름의 컴1 canary를 읽거나 수정하지 못하고 컴2 +canary만 변경함을 증명한다. 이 permission-profile 호환성을 확인할 수 없으면 runtime을 활성화하지 +않는다. + +### 5.2 제외한 경로: remote Code Mode host + +`--code-mode-host`도 실제로 조사했지만 이는 Code Mode JavaScript runtime을 원격화한다. Code Mode +안에서 호출하는 nested shell/file tool은 App Server로 다시 delegate될 수 있어, 이 옵션만으로 +컴2 filesystem과 process 실행을 보장할 수 없다. Remote Workspace의 실행 경계로 사용하지 않는다. + +prompt 지시만으로 remote tool 사용을 유도하는 방식도 같은 이유로 사용하지 않는다. App Server +permission profile, required MCP allowlist와 OS-level coordinator isolation을 함께 사용한다. + +## 6. 연결 구조 + +### 6.1 컴1 공개 경로 + +컴1은 public port를 열지 않고 Cloudflare Tunnel 또는 동등한 outbound tunnel로 GUI와 WebSocket +upgrade를 제공한다. TLS edge와 E2EE endpoint를 구분한다. + +- Cloudflare: public TLS, hostname, rate limit, WebSocket 전달 +- 컴1 control plane: 사용자/기기/session authorization +- Browser와 컴2 또는 컴1과 컴2: application E2EE + +Cloudflare나 relay compromise만으로 terminal command를 만들거나 plaintext를 읽을 수 없어야 한다. + +### 6.2 컴2 연결 + +컴2 OCX Executor는 컴1에 outbound WSS 한 개를 유지한다. inbound listener, 공유기 port forwarding, +root 권한은 요구하지 않는다. 한 socket에서 control, dynamic tool, terminal channel을 session ID로 +multiplex하되 channel별 key와 counter를 분리한다. + +### 6.3 direct transport + +LAN/Tailscale direct 연결을 선택해도 같은 endpoint protocol과 identity key를 사용한다. transport가 +relay를 우회하더라도 인증이나 capability 검사가 약해지면 안 된다. + +## 7. 인증과 암호화 + +### 7.1 사용자 인증 + +- GitHub login은 계정 소유권만 증명한다. +- 별도 E2EE password는 account vault를 browser에서 unlock한다. +- GitHub OAuth token은 장기 provider credential처럼 저장하지 않는다. +- browser session과 device authority는 별도 revoke 대상이다. + +### 7.2 기기 등록 + +1. 컴1 GUI가 짧은 수명의 one-time pairing grant를 만든다. +2. 컴2가 local OCX GUI/CLI에서 grant를 입력한다. +3. 컴2가 device signing key를 local owner-only storage에 생성한다. +4. 컴1은 device public key, 이름, OS, capabilities만 저장한다. +5. pairing grant는 한 번 사용하면 폐기한다. + +### 7.3 세션 암호화 + +- account signing key가 Browser/Coordinator hello를 서명 +- device signing key가 Executor hello를 서명 +- session별 ephemeral P-256 ECDH +- HKDF domain separation으로 방향 및 channel key 분리 +- AES-256-GCM과 monotonically increasing counter +- session/device/profile/capability binding + +Direct terminal mode에서 컴1은 ciphertext만 본다. Remote Codex Workspace mode에서는 컴1이 Codex +endpoint이므로 prompt, model output과 tool request를 볼 수 있다. 이 차이를 UI와 문서에서 숨기지 +않는다. 향후 제3자 hosted 컴1 서비스를 만들 때는 별도의 trust model이 필요하다. + +## 8. 권한과 workspace 경계 + +컴2 등록만으로 전체 filesystem 접근을 주지 않는다. 사용자가 컴2에서 명시적으로 승인한 +workspace root만 노출한다. + +- canonical path 기준 root allowlist +- symlink/junction/reparse point escape 거부 +- 각 session은 한 executor와 root set에 고정 +- read/write/exec/network 권한 분리 +- 승인 요청에 실제 컴2 hostname, cwd, command, target path 표시 +- write와 destructive command는 기본 prompt +- output, file, request body, concurrent process에 상한 적용 +- secret-looking file과 OS credential store는 기본 제외 + +`thread/shellCommand`, App Server `fs/*`, `command/exec`, `process/spawn`처럼 컴1에서 실행될 수 있는 +API는 remote thread GUI에서 그대로 노출하지 않는다. compatibility spike로 컴2 실행이 증명된 +method만 allowlist한다. + +## 9. 세션 모델 + +```text +RemoteWorkspaceSession + id + ownerAccountId + controllerDeviceId? # browser-only이면 null 가능 + coordinatorInstallationId # 컴1 + executorDeviceId # 컴2 + appServerInstanceId # 컴1 isolated process/pool writer + codexThreadId # 컴1 저장 + rootId # 컴2가 승인한 workspace root + workspaceRoots[] # 컴2 native paths + state # starting/ready/running/waiting/offline/closed + createdAt / lastActiveAt +``` + +한 Codex thread에 동시에 두 App Server writer를 붙이지 않는다. Browser disconnect는 현재 turn을 +자동 kill하지 않지만 approval은 timeout된다. 컴2 disconnect는 tool execution을 중단하고 session을 +`waiting_for_executor`로 둔다. 같은 device key가 다시 연결되면 명시적인 resume 확인 후 계속한다. + +## 10. GUI 기획 + +컴1 `ocx gui`에 Remote Workspace를 추가한다. + +### Devices + +- 기기명, OS, online/offline, last seen +- 역할/capabilities +- 승인된 workspace roots +- active sessions +- key rotate/revoke +- 새 기기 pairing + +### Sessions + +- `Codex runs on: Main OCX (컴1)` +- `Tools run on: 컴2` +- 현재 cwd와 workspace roots +- model/account는 secret 없이 label만 표시 +- session state와 reconnect 상태 +- open terminal, new Codex, resume, fork, stop + +기기를 선택하지 않은 상태에서는 remote session을 만들 수 없다. 컴2가 offline이거나 remote tool +protocol compatibility가 다르면 실행 버튼을 비활성화하고 정확한 이유를 표시한다. + +## 11. 서버 부하 원칙 + +- 빌드, 테스트, shell, file I/O는 선택된 Executor에서만 실행한다. 컴3이 browser controller로만 + 참여하면 컴3의 CPU는 작업 실행에 쓰이지 않는다. +- account당 작은 isolated App Server pool을 사용하고 thread ID로 executor를 dispatch한다. +- 한 thread의 active writer는 정확히 한 process만 소유한다. +- idle process는 grace period 후 종료하고 persisted thread는 유지한다. +- relay는 frame마다 DB query나 plaintext logging을 하지 않는다. +- presence와 session table은 연결/해제 경계에서만 갱신한다. +- per-device session/process/frame/buffer limit을 둔다. +- output burst는 짧게 coalesce하되 interactive echo를 지연시키지 않는다. + +향후 컴1만 우리 서버로 대체하면 `hosted coordinator` 상품이다. 빌드 컴퓨트까지 판매하려면 +별도의 `hosted executor` VM/workspace를 함께 제공해야 한다. 우리 서버가 coordinator/Codex를 +실행하는 모드에서는 실행 중 prompt가 tool result를 보지 못하는 `server-blind E2EE`라고 표현하지 +않는다. 이 모드의 보안 목표는 전송 구간 암호화, 보관 시 암호화, 분리된 runtime과 명시적 +unlock이다. + +## 12. 장애 동작 + +| 장애 | 동작 | +| --- | --- | +| 컴3 browser disconnect | turn은 계속될 수 있고 재접속 시 event snapshot/resume | +| 컴2 disconnect | remote tool 중단, session 대기, local fallback 금지 | +| 컴1 restart | persisted thread 복구 후 동일 device 재연결 대기 | +| device key revoke | 해당 device socket과 session 즉시 종료 | +| relay overload | bounded close와 jittered reconnect, 무제한 queue 금지 | +| protocol mismatch | 실행 전 차단하고 필요한 OCX/Codex version 표시 | +| approval timeout | 해당 tool 거부, turn 상태 명시 | +| selected root removed | fail closed, 상위 directory로 자동 확대 금지 | + +## 13. 구현 단계 + +### Phase 0 — 기획 및 Codex compatibility spike + +- 이 문서 리뷰 +- [x] current Codex-generated experimental schema 확인 +- [x] actual App Server + local mock model + 컴2 Executor harness +- [x] dynamic `exec` request/response round trip +- [x] 컴1/컴2 file negative canary +- [x] offline/unbound/namespace mismatch fail-closed unit test +- [x] App Server OS sandbox proof +- [x] E2EE transport binding +- [x] WebSocket relay adapter and reconnect +- [x] session-scoped read/write consent and protocol mismatch integration test + +종료 조건: 실제 upstream Codex가 제한된 Remote Workspace MCP를 통해 컴2에서 도구를 실행하고, built-in local +tools가 컴1 사용자 filesystem을 볼 수 없는 OS sandbox가 증명된다. + +### Phase 1 — 공통 protocol 정리 + +- [x] 기존 prototype frame/crypto/relay review +- [x] transport-neutral connection interface +- [x] channel multiplexing과 capability manifest +- [x] replay, ownership, backpressure, reconnect tests +- [x] core runtime import graph와 완전 분리 + +### Phase 2 — 컴2 Executor + +- [x] owner-user process +- [x] workspace root admission +- [x] OCX-native dynamic tool executor +- [x] command/process cancellation +- [x] Linux/macOS/Windows Hub CLI 실행/종료 lifecycle abstraction (`.cmd` 및 Windows process tree 포함) +- [x] Executor command sandbox 구현 (Linux bubblewrap, Windows AppContainer+Job; macOS file-only) +- [x] hosted Linux/Windows confinement과 macOS direct-run fail-closed CI +- [ ] exact signed Windows release binary에서 native confinement probe 통과 +- [ ] signed background-agent artifact/update policy (현재 foreground agent는 자동 재연결) + +### Phase 3 — 컴1 Coordinator + +- [x] device registry와 one-time pairing +- [x] outbound relay admission +- [x] isolated App Server와 단일 writer ownership +- [x] thread/device binding persistence +- [x] session-scoped access grant와 session recovery + +### Phase 4 — 컴1 GUI와 컴3 browser flow + +- [x] Devices/Sessions screens +- [ ] GitHub login과 E2EE vault unlock (현재는 기존 OCX GUI session/HTTPS identity 사용) +- [x] 컴2 선택 후 Codex/Claude Code/Pi start +- [x] reconnect, access mode, revoke UX +- [ ] 실제 브라우저 screenshot/accessibility 검증 (Happy DOM interaction, i18n, Vite build는 완료) + +### Phase 5 — private dogfood + +- 컴1, 컴2, 컴3 실제 분리 +- 컴2에 ChatGPT/API credential 없음 확인 +- 컴3에서 컴1 GUI로 컴2 canary project 수정 +- disconnect/restart/rotation/revocation +- CPU/RAM/relay bandwidth 측정 +- production feature flag는 계속 off + +## 14. 필수 인수 테스트 + +1. 컴2에는 ChatGPT session, OpenAI API key, Codex thread history가 없다. +2. 컴3에서 컴1 GUI를 열어 컴2를 선택한다. +3. 새 Codex thread는 컴1에 생성된다. +4. `pwd`, shell, read, patch 결과는 컴2 경로와 프로세스를 증명한다. +5. 컴2 canary file은 변경되고 같은 이름의 컴1 canary는 변경되지 않는다. +6. 모델 요청은 컴1의 OpenCodex/provider credential 경로를 사용한다. +7. 컴2 연결을 끊으면 다음 tool이 fail closed하고 컴1에서 실행되지 않는다. +8. 컴2 reconnect 후 같은 device identity를 확인해야 resume할 수 있다. +9. 컴3 browser를 닫았다 열어도 session ownership과 event ordering이 유지된다. +10. 컴3 자체도 executor로 등록 가능하지만 컴2 session의 실행 대상은 바뀌지 않는다. +11. relay log와 DB에는 terminal plaintext, provider token, E2EE private key가 없다. +12. unsupported Codex/Executor protocol 조합은 작업 시작 전에 거부된다. +13. capability presence acknowledgement 전에는 새 WSS가 online으로 표시되지 않는다. +14. 시작 중 Stop/revoke와 긴 turn의 Stop은 뒤늦은 `ready` 상태나 남은 command process를 만들지 않는다. + +## 15. 출시 차단 조건 + +- 검증되지 않은 App Server/MCP protocol 조합을 stable 기능으로 노출 +- 컴1 App Server OS sandbox가 없거나 실제 컴1 filesystem을 읽을 수 있음 +- remote thread가 컴1 local tools로 fallback 가능 +- 컴2 root escape 또는 device/session identity confusion +- browser/Executor E2EE interop 미검증 +- 지원되는 Windows/Linux command lifecycle 또는 PTY cleanup 미검증 +- config 또는 credentials가 컴2로 복제됨 +- real three-computer acceptance 미완료 + +## 16. 현재 판단 + +기술적으로 가능하고 실제 local integration에서 핵심 round trip을 확인했다. 실제 Codex App Server는 +컴1에 있고 컴2에는 Codex가 없지만, namespaced MCP 요청이 OCX coordinator와 E2EE RPC를 +거쳐 컴2 Executor에서 실행됐으며 결과가 다음 model request로 돌아갔다. + +Linux private dogfood의 양쪽 실행 경계는 구현됐다. 컴1 Codex에는 deny-local permission profile과 +비어 있는 coordinator root를 적용하고, 컴2 `exec`는 bubblewrap이 실제로 실행 가능할 때만 광고한다. +현재 OCX Bun runtime은 실행 파일 하나만 read-only로 자동 노출되고 추가 Node/Rust/Go 경로는 컴2 +소유자가 pairing 때 명시해야 한다. command runner가 없거나 capability acknowledgement가 끝나지 +않으면 실행은 fail closed한다. + +Windows command sandbox는 좁은 Rust helper로 구현했다. capability 없는 AppContainer를 suspended +상태로 만들고 첫 instruction 전에 Job Object에 붙인 뒤 resume한다. 실제 workspace 쓰기는 성공하고 +인접 파일 read/write와 live loopback 연결은 실패해야만 exec capability를 광고한다. helper binary는 +pairing 때 SHA-256을 고정하고 command마다 다시 확인한다. macOS는 모든 descendant를 회수할 좁은 +native owner가 아직 없고 넓은 Seatbelt system profile import는 host service 권한을 늘리므로, 현재 +helper가 probe와 direct run을 모두 거부해 file-only로 fail closed한다. + +남은 blocker는 핵심 데이터 흐름의 미구현이 아니라 출시 공정이다. signed Windows helper 설치와 +업데이트, exact release binary native CI, macOS의 revocable descendant containment, 선택한 public HTTPS +identity, 독립 maintainer review, current `dev` rebase와 실제 세 대의 컴퓨터 acceptance를 끝내기 전에는 +stable 기능으로 표시하지 않는다. + +## 17. 최종 Windows / macOS / Linux 감사 + +- Windows Hub: npm `codex.cmd`, `claude.cmd`, `pi.cmd`를 PATHEXT로 찾고 안전하게 이스케이프된 + `ComSpec`으로 실행한다. Stop은 신뢰된 System32 `taskkill /T /F`로 OCX가 띄운 wrapper tree만 + 종료하며, 임시 폴더 삭제는 AV/indexer의 짧은 lock을 bounded retry한다. +- macOS Hub: 실행 argv를 shell 없이 그대로 유지하고 deny-local profile에는 macOS 최소 PATH를 쓴다. +- macOS Executor: file tools만 광고한다. unprivileged Job Object/cgroup 동등물이 없고 fork된 자식은 + `setsid()`로 process group을 벗어날 수 있다. 넓은 Apple system profile을 import해 command를 억지로 + 시작하지 않고 helper의 probe/direct run 양쪽을 거부하므로, deadline/cancel 뒤 남는 writer도 없다. +- Windows Executor: 고유 AppContainer SID에 workspace modify와 toolchain read/execute ACL만 임시로 + 부여한다. non-breakaway kill-on-close Job을 primary thread가 suspended인 동안 붙이고 + stdin/stdout/stderr 세 handle만 명시적으로 상속하며, command가 끝나면 Job, SID profile, ACL을 + 정리한다. writable profile environment는 workspace-owned temporary directory만 가리킨다. +- Linux Hub/Executor: Hub argv는 shell 없이 유지한다. Executor는 `bwrap` 파일 존재가 아니라 실제 + PID/IPC/UTS/network namespace probe가 성공할 때만 `workspace.exec`를 광고한다. +- 모든 Executor: 한 기기의 file/exec 작업을 직렬화하고, 열린 파일 descriptor와 현재 path identity를 + I/O 전후 비교하며, write hash를 atomic replace 직전에 다시 확인한다. 승인된 root filesystem + identity를 고정하고 toolchain root는 command마다 symlink 여부를 다시 확인한다. hardlink는 다른 + path의 동일 inode를 path sandbox가 구분할 수 없으므로 file read/write에서 거부하고, command 전에는 + 최대 250,000개 entry를 검사해 multi-link 항목이 있으면 exec를 fail closed한다. +- 재연결 capability는 페어링 당시 grant와 현재 OS sandbox 지원의 교집합만 광고한다. sandbox가 + 사라지면 권한을 줄일 수 있지만, 재연결만으로 사용자가 승인하지 않은 exec 권한을 늘리지 않는다. +- Windows 파일 경계: `NUL`, `CON`, `COM1` 같은 device 이름, NTFS ADS, trailing dot/space alias를 + 거부한다. junction/symlink와 parent escape는 기존 canonical boundary에서 계속 차단한다. +- GUI: 파일 전용 기기는 `Edit files only`로 표시하고, pairing 명령은 Linux/macOS와 Windows + PowerShell을 따로 제공한다. +- 연결 종료: 아직 열리지 않은 WebSocket의 `close()`가 OS/runtime별로 throw해도 agent stop이 + 자체적으로 settle되어 reconnect loop나 종료를 붙잡지 않는다. Linux/macOS CLI는 SIGTERM grace + 뒤에도 남아 있으면 해당 자식에만 SIGKILL을 보내 Hub session 밖으로 고아가 되지 않게 한다. + +일부 Linux VM/컨테이너는 외부 실행 환경의 network namespace 제한 때문에 bubblewrap 실제 probe가 +실패한다. 그런 장비에서는 `workspace.exec`가 의도대로 광고되지 않고 파일 도구만 제공된다. 이는 기능 +실패를 숨기는 fallback이 아니라 capability fail-closed 동작이다. PR CI의 별도 Ubuntu hosted lane은 +bubblewrap을 명시적으로 설치하고 production runner/Bun mount, workspace write, adjacent read/write와 +hardlink denial, live loopback denial, cancel 뒤 detached child 미생존을 실행 증명한다. 실제 Windows +confinement probe, macOS file-only fail-closed probe와 3-computer acceptance까지 통과하기 전에는 세 OS +production-ready라고 주장하지 않는다. + +[Decision Log] +- 목적과 의도: 세 OS에서 지원되는 기능만 정확히 광고하면서 Hub CLI와 Executor 파일 경계가 OS별 + launcher, path, lock, shutdown 차이 때문에 깨지지 않게 한다. +- 기존 구현 및 제약 조건: Windows npm shim은 직접 spawn할 수 없고 child kill만으로 Node가 남는다. + Windows 파일 이름은 POSIX와 다른 alias/device 규칙이 있으며, bwrap 설치 여부는 namespace 권한을 + 증명하지 않는다. +- 검토한 주요 대안: `shell: true`, OS 차이를 문서로만 경고, bwrap 존재 검사 유지, Job Object만 사용, + Docker/Podman을 필수화, 병렬 file/exec, 모든 OS에서 command 기능을 강제로 노출. +- 선택한 방식: Hub에는 검증된 Windows launcher/taskkill/atomic-replace primitives를 재사용한다. + Executor에는 Linux isolation probe와 좁은 Rust Seatbelt/AppContainer helper, 직렬화/descriptor + identity 확인을 적용한다. +- 다른 대안 대신 이 방식을 선택한 이유: 지원되지 않는 실행을 성공처럼 보이지 않게 하면서 shell + injection과 orphan process, path-swap 경합을 동시에 줄인다. +- 장점, 단점 및 영향: 세 OS 모두 동일한 capability 계약을 사용하며 native sandbox/probe가 실패한 + 호스트만 파일 전용으로 남는다. 실제 native OS 검증과 signed artifact 배포는 branch-level + cross-target 검증과 별도로 남는다. + +[Decision Log] +- 목적과 의도: 컴2에 Codex 설치나 로그인이 없어도 컴1의 실제 Codex 세션이 컴2의 파일과 명령을 + 안전하게 다루고, 컴3에서 컴1 GUI를 통해 그 세션을 조작하게 한다. +- 기존 구현 및 제약 조건: 기존 Remote Hub는 provider 요청만 중앙화하고 terminal/filesystem을 + 원격화하지 않는다. 기존 remote-control prototype은 E2EE terminal/relay 경계만 증명했으며, 이번 + 작업에서 실제 Codex App Server, runtime/GUI와 E2EE device transport를 하나의 흐름으로 연결했다. +- 검토한 주요 대안: 컴2에 전체 Codex 설치와 로그인, 컴1 filesystem에 컴2를 mount, prompt 지시만으로 + remote tools 사용 강제, 제한된 App Server MCP, official remote Code Mode host. +- 선택한 방식: 컴1의 isolated actual Codex App Server에 제한된 Remote Workspace MCP를 등록하고, OCX + coordinator가 thread-device-root binding에 따라 컴2 OCX-native Executor로 dispatch한다. +- 다른 대안 대신 이 방식을 선택한 이유: 사용자 자격증명을 컴2로 복사하지 않으면서 실제 upstream + Codex thread/turn/history를 유지하면서 컴2에 Codex를 두지 않고, tool call마다 device binding을 + 검증할 수 있다. prompt-only MCP, mount, remote Code Mode host는 local execution 혼동을 막지 못한다. +- 장점, 단점 및 영향: 정확히 요청한 컴1-Codex/컴2-execution/컴3-control UX를 제공할 수 있다. + 대신 App Server/MCP protocol 추적, 컴1/컴2 양쪽 OS sandbox, E2EE transport, 3개 OS 검증이 필요하고 + 컴1이 offline이면 Remote Workspace 전체가 멈춘다. + +## 18. 타입과 메모리 수명 최적화 + +- [x] Remote Workspace production source의 explicit `any` 0개와 strict TypeScript 확인 +- [x] untrusted RPC result를 success/error discriminated union과 runtime guard로 검증 +- [x] chunked pairing response를 64 KiB까지만 streaming read하고 overflow 즉시 cancel +- [x] loopback bridge body 512 KiB, 동시 8개 제한 및 async idempotent shutdown +- [x] session acceptance 전송 실패 시 cipher endpoint 즉시 삭제/파기 +- [x] directory entry를 incremental read하고 4,097번째에서 중단 +- [x] UTF-8 truncation의 반복 rescanning을 logarithmic boundary search로 교체 +- [x] GUI의 stopped-session tombstone을 무제한 Set 대신 단일 in-flight identity로 제한 +- [x] 64 KiB relay 상한을 늘리지 않고 최대 2 MiB logical RPC를 bounded fragmentation하며, + 불완전 reassembly는 동시 8개/30초로 제한 +- [x] process/bridge/transport/temp-dir 정리를 전 단계 시도하고 강제 종료 결과까지 확인 +- [x] Rust native Executor helper에 64 KiB request, 256 KiB combined output, 60초 timeout, + Windows 256-process Job 상한과 RAII native handle/ACL/profile cleanup 적용 +- [x] macOS/Windows helper source의 cross-target compile과 common Rust unit tests +- [ ] signed Windows helper artifact pipeline 및 exact-binary live probe +- [ ] macOS command execution용 revocable descendant containment 설계와 native proof +- [ ] PTY streaming은 command RPC와 별도 protocol/backpressure 설계 후 도입 diff --git a/devlog/_plan/260903_improved_remote_control/remote-workspace-dashboard.png b/devlog/_plan/260903_improved_remote_control/remote-workspace-dashboard.png new file mode 100644 index 0000000000..38e1de71be Binary files /dev/null and b/devlog/_plan/260903_improved_remote_control/remote-workspace-dashboard.png differ diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index b25586f7a6..b76a01cdd5 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -86,6 +86,7 @@ export default defineConfig({ translations: { fr: "Guides", ko: "가이드", "zh-CN": "指南", "zh-TW": "指南", ru: "Руководства", ja: "ガイド", tr: "Kılavuzlar" }, items: [ { label: "Remote Hub Deployment", translations: { fr: "Déploiement Remote Hub", ko: "Remote Hub 배포", "zh-CN": "Remote Hub 部署", "zh-TW": "Remote Hub 部署", ru: "Развёртывание Remote Hub", ja: "Remote Hub のデプロイ", tr: "Remote Hub Dağıtımı" }, slug: "guides/remote-hub" }, + { label: "Remote Workspace", translations: { fr: "Espace de travail distant", ko: "원격 워크스페이스", "zh-CN": "远程工作区", "zh-TW": "遠端工作區", ru: "Удалённая рабочая область", ja: "リモートワークスペース", tr: "Uzak Çalışma Alanı" }, slug: "guides/remote-workspace" }, { label: "Providers", translations: { fr: "Fournisseurs", ko: "프로바이더", "zh-CN": "提供商", "zh-TW": "供應商", ru: "Провайдеры", ja: "プロバイダー", tr: "Sağlayıcılar" }, slug: "guides/providers" }, { label: "Factory Droid Bridge", translations: { fr: "Pont Factory Droid", ko: "Factory Droid 브리지" }, slug: "guides/factory-droid" }, { label: "Cursor Private Inference", translations: { ko: "Cursor Private Inference" }, slug: "guides/cursor-private-inference" }, diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 16ef574ab1..82b942eed5 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -8,9 +8,10 @@ use its data plane remotely. The browser-facing management plane is separate: an binds only `127.0.0.1`, serves the dashboard and `/api/*`, and is intended to sit behind Tailscale Serve or another operator-owned HTTPS frontend. -The management ingress never serves `/v1/*`, `/healthz`, `/readyz`, or WebSockets. Do not publish its -port directly, do not add a cloud-firewall rule for it, and do not use Tailscale Funnel. Funnel is a -public-internet surface and is outside this deployment model. +The management ingress never serves `/v1/*`, `/healthz`, or `/readyz`. Its only WebSocket exception +is the bearer-authenticated `/remote-workspace/agent` connection opened outbound by a paired OCX +Executor. Do not publish the ingress port directly, do not add a cloud-firewall rule for it, and do +not use Tailscale Funnel. Funnel is a public-internet surface and is outside this deployment model. ## Trust and consent boundaries diff --git a/docs-site/src/content/docs/guides/remote-workspace.md b/docs-site/src/content/docs/guides/remote-workspace.md new file mode 100644 index 0000000000..0811b6f147 --- /dev/null +++ b/docs-site/src/content/docs/guides/remote-workspace.md @@ -0,0 +1,203 @@ +--- +title: Remote Workspace +description: Keep Codex, Claude Code, Pi, and their logins on one OCX Hub while OCX-only computers provide the workspace and build environment. +--- + +Remote Workspace lets one OpenCodex Hub run your coding agents while another computer supplies the +project files, commands, tests, and build compute. A phone or third computer can control the session +through the Hub dashboard. + +```text +Phone browser -> Computer 1 OCX Hub -> encrypted channel -> Computer 2 OCX Executor + Codex / Claude / Pi project and commands + logins and sessions no coding CLI login +``` + +The Executor needs OpenCodex only. It does not need Codex, Claude Code, Pi, a ChatGPT login, or a +provider API key. It opens an outbound WebSocket to the Hub, so the Executor needs no public port or +router port-forward. + +:::caution[Private dogfood] +Remote Workspace is currently implemented on its feature branch and is not yet part of a stable +release. Linux supports file tools and conditionally supports sandboxed commands. macOS and Windows +have file tools, while Windows additionally uses the separately built Rust Executor helper described +below for sandboxed commands. macOS command execution is deliberately disabled until OCX can revoke +every descendant without importing a broad Seatbelt system profile. On supported command platforms, +commands are advertised only after a real confinement probe succeeds. Missing helpers, replaced +helper bytes, unsupported OS policy, and restricted containers degrade to file tools only. +::: + +## Set up the Hub + +Computer 1 owns every coding-agent login and model session. Install and log in to whichever agents +you want to use there, then run OpenCodex as a Hub: + +```bash +ocx config set runtimeRole hub +ocx gui +``` + +Use an authenticated HTTPS deployment when opening the dashboard from a phone or another computer. +See [Remote Hub Deployment](/guides/remote-hub/) for the supported management-ingress and Tailscale +pattern. Do not publish an unauthenticated local dashboard port. + +Codex Remote Workspace uses current App Server permission profiles. If the Hub's selected Codex +configuration still sets legacy `sandbox_mode` or `sandbox_workspace_write`, the dashboard reports +Codex as unavailable instead of starting with a weaker boundary. Migrate that Codex profile before +using the feature; do not configure both the legacy sandbox and a permission profile. + +## Pair an Executor + +1. Open **Remote Workspace** in the Hub dashboard. +2. Select **Create pairing code**. +3. On Computer 2, change into the project directory you want to expose. +4. Copy the generated **Linux / macOS terminal** or **Windows PowerShell** command for that computer. + It pairs the current directory and keeps + `ocx remote-workspace agent` connected in that terminal. + +The equivalent manual flow is: + +```bash +cd /path/to/project +printf '%s\n' 'ONE-TIME-CODE' | ocx remote-workspace pair 'https://your-hub.example' \ + --pairing-code-stdin --root "$PWD" +ocx remote-workspace agent +``` + +On Windows PowerShell, use the command shown in the dashboard. The equivalent manual form is: + +```powershell +$pairingCode = 'ONE-TIME-CODE' +$pairingCode | ocx remote-workspace pair 'https://your-hub.example' ` + --pairing-code-stdin --root (Get-Location).Path +if ($LASTEXITCODE -eq 0) { ocx remote-workspace agent } +``` + +The current OCX Bun executable is added as one read-only file to the Linux sandbox automatically. If +the project needs a user-installed toolchain outside the system paths, pair it explicitly without +exposing the rest of the home directory: + +```bash +printf '%s\n' 'ONE-TIME-CODE' | ocx remote-workspace pair 'https://your-hub.example' \ + --pairing-code-stdin --root "$PWD" \ + --toolchain-root "$HOME/.nvm/versions/node/v24/bin" +``` + +For private dogfood on Windows, build the memory-safe native Executor helper once from the +checked-out OpenCodex source: + +```bash +bun run build:remote-workspace-helper +``` + +The ordinary pair command discovers that release build automatically. An explicitly reviewed helper +at another location can be selected with `--executor-helper `. OCX stores its SHA-256 +digest in the local device record and verifies the bytes again before every probe and command; after +rebuilding or updating the helper, pair the Executor again. The helper is not uploaded to the Hub. +Official packages must use the signed Windows release helper rather than asking end users to build +Rust source; that distribution step remains a release gate for this feature branch. + +The one-time code is read from standard input, not command-line arguments. Pairing creates a local +device signing key and a device-scoped bearer. The Hub stores only its hash and never receives the +real Executor path. Stop the foreground agent with Ctrl+C; running it again reconnects the same +device. + +Check local enrollment without printing secrets: + +```bash +ocx remote-workspace status +``` + +## Start a remote coding session + +In the dashboard choose: + +1. the online computer; +2. one locally approved workspace folder; +3. Codex, Claude Code, or Pi from the Hub; and +4. an access mode. + +**Read only** is the default and exposes directory listing and file reading. The write option is +shown as **Edit files and run commands** only when that Executor passed a command-sandbox probe; +otherwise it is shown as **Edit files only**. The dashboard shows two separate locations so it is +clear that the model and login remain on the Hub while workspace operations run on the selected +computer. + +Send prompts from the Hub dashboard on Computer 1, Computer 3, or a phone. The session cannot switch +to another computer or folder silently. If the Executor disconnects, the session enters +**Executor offline** and never falls back to the Hub's filesystem. + +**Stop** remains available while a prompt is running. It interrupts the Hub coding-agent turn, +cancels an active Executor command, and prevents a late response from reopening the stopped +session. + +## Restart and reconnect behavior + +The Hub persists bounded session metadata and a small recent event snapshot. After a Hub restart, +an unfinished session waits for its original Executor. Once that device reconnects, the next prompt +resumes the original Codex thread, Claude Code session, or Pi session ID. + +Claude Code creates its durable history on the first completed prompt. If the Hub stops before a +new Claude session has completed any prompt, there is no conversation to resume; start a new +session instead. + +A changed capability manifest does not silently weaken an existing session. Start a new session if +the Executor loses command containment or its available tools change. Revoking a computer closes its +socket and stops sessions bound to it. + +## Security boundaries + +- Provider credentials and coding-agent history remain on the Hub. +- Executor private keys, device bearer, and real root paths remain in its owner-only OCX state. +- Pairing-code failures are limited per kernel-observed peer on every listener. Ten failed codes in + ten minutes return a generic `429` with `Retry-After`; the Hub retains only bounded, expiring + hashes of those source identities. Tailscale Serve users share the management listener's loopback + bucket because a direct local caller could forge its identity header. +- Each work session uses an Ed25519-signed ephemeral P-256 ECDH handshake and ordered + AES-256-GCM messages. +- A socket is not shown as online until both sides agree on its current capability manifest. +- Reconnection may remove a capability when its local sandbox is unavailable, but never adds a + capability outside the grant recorded at pairing. +- Every request is bound to one model thread, device, root, access mode, and capability set. +- Paths are relative, canonicalized, bounded, and rejected on symlink, junction, or parent-directory + escape. Windows device names, alternate data streams, and trailing-dot/space aliases are denied. +- Executor operations are serialized, opened file identities are rechecked, and write hashes are + checked again immediately before atomic replacement. Replacing an approved root requires pairing + it again, and toolchain roots are revalidated before each command. +- File reads/writes reject hard-linked files. Before command execution, OCX scans at most 250,000 + workspace entries and disables the command path if any non-directory entry has multiple links; + path sandboxes cannot prove whether the other name for that inode is outside the approved root. +- Linux commands run through bubblewrap with one writable workspace, cleared environment, private + process namespaces, the current OCX Bun executable as one read-only file, bounded output + and timeout, and network disabled by default. Hosted CI also cancels a detached-background-process + attempt and verifies that it cannot write after cancellation. +- macOS advertises file tools only. A process group cannot contain a descendant after it calls + `setsid()`, and importing a broad Apple Seatbelt system profile merely to start a command would + expose unrelated host-service authority. The native helper therefore rejects both its probe and + direct command requests until OCX has a narrow, revocable descendant-containment owner. +- Windows commands start suspended in a capability-free AppContainer and are attached to a + kill-on-close Job Object before the first instruction is resumed. A unique AppContainer SID + receives temporary modify access only to the workspace and read/execute access to approved + toolchains; the sanitized environment maps writable profile paths into the workspace, and OCX + removes those grants after the job. +- The Windows native probe must write inside a disposable workspace, read and modify a pre-existing nested + workspace file, fail to read or write an adjacent sentinel, and fail to reach a live loopback + listener. The macOS hosted probe instead proves that command execution remains explicitly + unavailable. Binary existence alone never enables `workspace.exec`. +- The pinned native helper must be outside every approved writable workspace. OCX checks this both + before advertising command support and immediately before each command, so workspace code cannot + replace the binary that enforces its next sandbox. +- Stopping a session cancels an active Executor command and cleans up the Hub model process and + loopback tool bridge. Windows stops the owned npm-wrapper process tree rather than leaving its + Node child behind; Linux and macOS force-stop a CLI only if it ignores the graceful stop window. + +The Hub intentionally sees prompts and model output because it runs the coding agent. End-to-end +encryption protects the Executor RPC transport from an intermediate relay; it does not make the Hub +blind to its own model conversation. + +## Current scope + +Remote Workspace does not copy or synchronize credentials to other computers. It is separate from +Remote Hub provider routing and from any future hosted compute or Super Sync product. A production +release still requires signed Windows helper packaging, native CI proof on the exact binaries, +independent maintainer review, and a real three-computer acceptance run. diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index e39d0cc018..d91e6c1865 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -18,6 +18,19 @@ opencodex state. `ocx alias list [--json]` shows effective user and built-in aliases. Use `ocx alias set [/] ` and `ocx alias rm [/]` to edit them. Native model ids may contain additional slashes because the selector splits only at the first slash. Enable shipped defaults with `ocx alias defaults on|off [--provider ]`. +### `ocx remote-workspace` + +`ocx remote-workspace pair --pairing-code-stdin --root ` enrolls the local +computer as an OCX-only Executor. Repeat `--root` to approve more folders and use `--name` to +override the hostname. Repeat `--toolchain-root ` to expose a user-installed +Node, Rust, Go, or other toolchain directory read-only inside the command sandbox. On macOS and +Windows private-dogfood builds, `bun run build:remote-workspace-helper` creates the Rust helper that +the pair command discovers automatically; `--executor-helper ` selects another +explicitly reviewed build and pins its digest in local Executor state. +`ocx remote-workspace agent` maintains the outbound encrypted connection; +`ocx remote-workspace status [--json]` reports the Hub, device, roots, and advertised capabilities +without printing its bearer or private key. See [Remote Workspace](/guides/remote-workspace/). + - [Lifecycle](/reference/cli/lifecycle/) — setup, proxy and service lifecycle, health, diagnostics, catalog sync, the dashboard, and updates. - [Providers, accounts, and models](/reference/cli/providers-accounts/) — provider configuration, diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 716636bfdc..b45bdd0764 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -86,6 +86,31 @@ route-specific results rather than repeating this table. For the concepts behind the model roster and encrypted worker-task behavior, see [Sub-agent Surface](/guides/sub-agent-surface/). +### Remote Workspace + +| Method and path | Purpose | Notable errors | +| --- | --- | --- | +| `GET /api/remote-workspace` | Read paired computers, current capabilities, Hub runtimes, and session snapshots | 409 when the instance is not a Hub | +| `POST /api/remote-workspace/pairing` | Create a ten-minute one-use Executor enrollment code | GUI session only; 429 pairing capacity | +| `GET /api/remote-workspace/runtimes` | Read Codex, Claude Code, and Pi availability on the Hub | — | +| `GET, POST /api/remote-workspace/sessions` | List sessions or start one bound to a device, root, runtime, and access mode | POST is GUI session only; 409 offline/unavailable/invalid target | +| `POST /api/remote-workspace/sessions/{id}/prompt` | Continue the bound model session | GUI session only; 409 active turn, offline Executor, or resume failure | +| `DELETE /api/remote-workspace/sessions/{id}` | Stop the model runtime and encrypted Executor session | GUI session only; 404 unknown session | +| `DELETE /api/remote-workspace/devices/{id}` | Revoke one computer and stop its sessions | GUI session only; 404 unknown device | + +Executor enrollment exchanges a one-use code at `POST /remote-workspace/pair` and then opens +`/remote-workspace/agent` as a bearer-authenticated outbound WebSocket. Those two machine endpoints +are not general management API authority. The bearer is device-scoped, and each work session adds a +signed E2EE handshake. Ten failed pairing codes from one kernel-observed peer return `429` with +`Retry-After` for the remainder of the fixed ten-minute window. Tailscale Serve clients share the +management listener's loopback peer bucket; the identity header is not used for throttling because +a direct local process could forge it. See [Remote Workspace](/guides/remote-workspace/) for the +end-user flow and trust boundaries. + +Session snapshots include `resumable`. It becomes true only after the selected coding-agent runtime +has durable history; notably, a new Claude Code session remains false until its first prompt +completes. + ### Combos | Method and path | Purpose | Notable errors | diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 432e550ec2..38bcf68577 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -10,6 +10,7 @@ import Storage from "./pages/Storage"; import CodexSet from "./pages/CodexSet"; import Integrations from "./pages/Integrations"; import Startup from "./pages/Startup"; +import RemoteWorkspace from "./pages/RemoteWorkspace"; import ErrorBoundary from "./components/ErrorBoundary"; import { SidebarGithubRow } from "./components/sidebar-github-row"; import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconCodex, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconX, IconRefresh} from "./icons"; @@ -35,6 +36,7 @@ const PAGE_TKEY: Record = { logs: "nav.logs", usage: "nav.usage", storage: "nav.storage", + remote: "nav.remote", "codex-set": "nav.codexSet", integrations: "nav.integrations", }; @@ -68,6 +70,7 @@ const NAV: NavEntry[] = [ { id: "logs", tkey: "nav.logs", Icon: IconList }, { id: "usage", tkey: "nav.usage", Icon: IconActivity }, { id: "storage", tkey: "nav.storage", Icon: IconHardDrive }, + { id: "remote", tkey: "nav.remote", Icon: IconMonitor }, { id: "integrations", tkey: "nav.integrations", Icon: IconGlobe }, ]; @@ -414,6 +417,7 @@ export default function App() { {page === "logs" && } {page === "usage" && } {page === "storage" && } + {page === "remote" && } {page === "codex-set" && } {page === "integrations" && } diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index bab41b1eee..e283585da2 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -11,6 +11,7 @@ export type Page = | "logs" | "usage" | "storage" + | "remote" | "codex-set" | "integrations"; @@ -23,6 +24,7 @@ export const VALID_PAGES = new Set([ "logs", "usage", "storage", + "remote", "codex-set", "integrations", ]); diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 7a26642061..f13df319a3 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2416,4 +2416,64 @@ export const de: Record = { "integrations.cursor.colReasoning": "Reasoning-Aufwand", "integrations.cursor.colContext": "Kontext", "integrations.cursor.guide": "Anleitung zu Cursor Private Inference öffnen", + "nav.remote": "Remote-Arbeitsbereich", + "remote.title": "Remote-Arbeitsbereich", + "remote.subtitle": "Codex, Claude Code oder Pi laufen auf diesem Hub; Dateien, Befehle, Tests und Builds bleiben auf dem ausgewählten Computer.", + "remote.loading": "Remote-Arbeitsbereich wird geladen…", + "remote.loadFailed": "Remote-Arbeitsbereich konnte nicht geladen werden.", + "remote.hubRequired": "Stelle diese OpenCodex-Instanz auf den Hub-Modus, bevor du Remote Workspace verwendest.", + "remote.refresh": "Aktualisieren", + "remote.addComputer": "Computer hinzufügen", + "remote.addComputerHint": "Gib lokal Ordner frei und halte den reinen OCX-Executor mit diesem Hub verbunden.", + "remote.createPairing": "Kopplungscode erstellen", + "remote.pairingCode": "Einmaliger Kopplungscode", + "remote.pairingExpires": "Läuft um {time} ab", + "remote.pairingCommand": "Auf dem hinzuzufügenden Computer ausführen", + "remote.pairingCommandPosix": "Linux- / macOS-Terminal", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "Befehl kopieren", + "remote.copied": "Kopiert", + "remote.devices": "Computer", + "remote.noDevices": "Noch keine Computer gekoppelt.", + "remote.online": "Online", + "remote.offline": "Offline", + "remote.revoke": "Computer widerrufen", + "remote.revokeConfirm": "{name} widerrufen? Aktive Sitzungen auf diesem Computer werden beendet.", + "remote.newSession": "Neue Remote-Sitzung", + "remote.device": "Computer", + "remote.folder": "Arbeitsordner", + "remote.runtime": "Coding-Agent", + "remote.access": "Workspace-Zugriff", + "remote.access.readOnly": "Nur lesen", + "remote.access.workspace": "Dateien bearbeiten und Befehle ausführen", + "remote.access.workspaceFilesOnly": "Nur Dateien bearbeiten", + "remote.unavailable": "Nicht verfügbar", + "remote.capability.full": "Dateien + isolierte Befehle", + "remote.capability.files": "Nur Dateiwerkzeuge", + "remote.runsOnHub": "Modell und Anmeldung bleiben auf diesem Hub", + "remote.runsReadOnly": "Dateien können auf diesem Computer nur gelesen werden", + "remote.runsFilesCommands": "Dateien, Builds und Befehle laufen hier", + "remote.runsFilesOnly": "Dateiwerkzeuge laufen hier; Befehls-Sandbox nicht verfügbar", + "remote.execUnavailable": "Dieser Computer kann Dateien bearbeiten, aber Builds und Terminalbefehle sind ohne unterstützte Betriebssystem-Sandbox deaktiviert.", + "remote.notResumable": "Diese Sitzung wurde beendet, bevor der Coding-Agent einen dauerhaften Verlauf erstellt hat. Starten Sie eine neue Remote-Sitzung.", + "remote.startSession": "Remote-Sitzung starten", + "remote.sessionStarted": "Remote-Sitzung ist bereit.", + "remote.sessions": "Sitzungen", + "remote.noSessions": "Wähle einen Online-Computer, Ordner und Coding-Agenten.", + "remote.events": "Aktivität der Remote-Sitzung", + "remote.noEvents": "Noch keine Aktivität.", + "remote.prompt": "Nachricht", + "remote.promptPlaceholder": "Bitte den Hub-Agenten, im ausgewählten Remote-Ordner zu arbeiten…", + "remote.send": "Senden", + "remote.stop": "Sitzung stoppen", + "remote.requestFailed": "Remote-Workspace-Anfrage fehlgeschlagen.", + "remote.status.starting": "Startet", + "remote.status.ready": "Bereit", + "remote.status.running": "Läuft", + "remote.status.waiting": "Executor offline", + "remote.status.failed": "Fehlgeschlagen", + "remote.status.stopped": "Gestoppt", + "remote.event.status": "Status", + "remote.event.tool": "Remote-Werkzeug", + "remote.event.error": "Fehler", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 549bd2b885..f8bbbb8a8a 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2450,6 +2450,66 @@ export const en = { "usage.scope.machine": "This machine", "usage.scope.hub": "Hub-wide", "usage.hubOffline": "Hub usage is unavailable. Local usage was not substituted.", + "nav.remote": "Remote Workspace", + "remote.title": "Remote Workspace", + "remote.subtitle": "Run Codex, Claude Code, or Pi from this Hub while files, commands, tests, and builds stay on the computer you select.", + "remote.loading": "Loading Remote Workspace…", + "remote.loadFailed": "Could not load Remote Workspace.", + "remote.hubRequired": "Set this OpenCodex instance to Hub mode before using Remote Workspace.", + "remote.refresh": "Refresh", + "remote.addComputer": "Add a computer", + "remote.addComputerHint": "Approve one or more folders locally, then keep the OCX-only executor connected to this Hub.", + "remote.createPairing": "Create pairing code", + "remote.pairingCode": "One-time pairing code", + "remote.pairingExpires": "Expires at {time}", + "remote.pairingCommand": "Run on the computer you are adding", + "remote.pairingCommandPosix": "Linux / macOS terminal", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "Copy command", + "remote.copied": "Copied", + "remote.devices": "Computers", + "remote.noDevices": "No computers are paired yet.", + "remote.online": "Online", + "remote.offline": "Offline", + "remote.revoke": "Revoke computer", + "remote.revokeConfirm": "Revoke {name}? Active sessions on this computer will stop.", + "remote.newSession": "New remote session", + "remote.device": "Computer", + "remote.folder": "Workspace folder", + "remote.runtime": "Coding agent", + "remote.access": "Workspace access", + "remote.access.readOnly": "Read only", + "remote.access.workspace": "Edit files and run commands", + "remote.access.workspaceFilesOnly": "Edit files only", + "remote.unavailable": "Unavailable", + "remote.capability.full": "Files + sandboxed commands", + "remote.capability.files": "File tools only", + "remote.runsOnHub": "Model and login stay on this Hub", + "remote.runsReadOnly": "Files can only be read on this computer", + "remote.runsFilesCommands": "Files, builds, and commands run here", + "remote.runsFilesOnly": "File tools run here; command sandbox unavailable", + "remote.execUnavailable": "This computer can edit files, but builds and terminal commands are disabled because a supported OS sandbox is not available.", + "remote.notResumable": "This session stopped before the coding agent created durable history. Start a new remote session.", + "remote.startSession": "Start remote session", + "remote.sessionStarted": "Remote session is ready.", + "remote.sessions": "Sessions", + "remote.noSessions": "Choose an online computer, folder, and coding agent to start.", + "remote.events": "Remote session activity", + "remote.noEvents": "No activity yet.", + "remote.prompt": "Message", + "remote.promptPlaceholder": "Ask the Hub agent to work inside the selected remote folder…", + "remote.send": "Send", + "remote.stop": "Stop session", + "remote.requestFailed": "Remote Workspace request failed.", + "remote.status.starting": "Starting", + "remote.status.ready": "Ready", + "remote.status.running": "Running", + "remote.status.waiting": "Executor offline", + "remote.status.failed": "Failed", + "remote.status.stopped": "Stopped", + "remote.event.status": "Status", + "remote.event.tool": "Remote tool", + "remote.event.error": "Error", } as const; export type TKey = keyof typeof en; diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 050b40fa67..ddbf5d5de5 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2403,4 +2403,64 @@ export const fr: Record = { "integrations.cursor.colReasoning": "Raisonnement", "integrations.cursor.colContext": "Contexte", "integrations.cursor.guide": "Ouvrir le guide de Cursor Private Inference", + "nav.remote": "Espace distant", + "remote.title": "Espace de travail distant", + "remote.subtitle": "Codex, Claude Code ou Pi s'exécutent sur ce Hub tandis que fichiers, commandes, tests et builds restent sur l'ordinateur choisi.", + "remote.loading": "Chargement de l'espace distant…", + "remote.loadFailed": "Impossible de charger l'espace distant.", + "remote.hubRequired": "Passez cette instance OpenCodex en mode Hub avant d'utiliser l'espace distant.", + "remote.refresh": "Actualiser", + "remote.addComputer": "Ajouter un ordinateur", + "remote.addComputerHint": "Autorisez localement un ou plusieurs dossiers, puis gardez l'exécuteur OCX connecté à ce Hub.", + "remote.createPairing": "Créer un code d'association", + "remote.pairingCode": "Code d'association à usage unique", + "remote.pairingExpires": "Expire à {time}", + "remote.pairingCommand": "À exécuter sur l'ordinateur à ajouter", + "remote.pairingCommandPosix": "Terminal Linux / macOS", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "Copier la commande", + "remote.copied": "Copié", + "remote.devices": "Ordinateurs", + "remote.noDevices": "Aucun ordinateur associé.", + "remote.online": "En ligne", + "remote.offline": "Hors ligne", + "remote.revoke": "Révoquer l'ordinateur", + "remote.revokeConfirm": "Révoquer {name} ? Ses sessions actives seront arrêtées.", + "remote.newSession": "Nouvelle session distante", + "remote.device": "Ordinateur", + "remote.folder": "Dossier de travail", + "remote.runtime": "Agent de code", + "remote.access": "Accès à l’espace de travail", + "remote.access.readOnly": "Lecture seule", + "remote.access.workspace": "Modifier les fichiers et exécuter des commandes", + "remote.access.workspaceFilesOnly": "Modifier uniquement les fichiers", + "remote.unavailable": "Indisponible", + "remote.capability.full": "Fichiers + commandes isolées", + "remote.capability.files": "Outils de fichiers uniquement", + "remote.runsOnHub": "Le modèle et la connexion restent sur ce Hub", + "remote.runsReadOnly": "Les fichiers de cet ordinateur sont accessibles en lecture seule", + "remote.runsFilesCommands": "Les fichiers, builds et commandes s’exécutent ici", + "remote.runsFilesOnly": "Les outils de fichiers s’exécutent ici ; bac à sable indisponible", + "remote.execUnavailable": "Cet ordinateur peut modifier les fichiers, mais les builds et commandes de terminal sont désactivés faute de bac à sable système pris en charge.", + "remote.notResumable": "Cette session s’est arrêtée avant que l’agent de code ne crée un historique durable. Démarrez une nouvelle session distante.", + "remote.startSession": "Démarrer la session distante", + "remote.sessionStarted": "La session distante est prête.", + "remote.sessions": "Sessions", + "remote.noSessions": "Choisissez un ordinateur en ligne, un dossier et un agent de code.", + "remote.events": "Activité de la session distante", + "remote.noEvents": "Aucune activité pour le moment.", + "remote.prompt": "Message", + "remote.promptPlaceholder": "Demandez à l'agent du Hub de travailler dans le dossier distant choisi…", + "remote.send": "Envoyer", + "remote.stop": "Arrêter la session", + "remote.requestFailed": "La requête d'espace distant a échoué.", + "remote.status.starting": "Démarrage", + "remote.status.ready": "Prêt", + "remote.status.running": "En cours", + "remote.status.waiting": "Exécuteur hors ligne", + "remote.status.failed": "Échec", + "remote.status.stopped": "Arrêté", + "remote.event.status": "État", + "remote.event.tool": "Outil distant", + "remote.event.error": "Erreur", }; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index e9860ff537..7dd529cc93 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2437,4 +2437,64 @@ export const ja: Record = { "integrations.cursor.colReasoning": "推論", "integrations.cursor.colContext": "コンテキスト", "integrations.cursor.guide": "Cursor Private Inference のガイドを開く", + "nav.remote": "リモートワークスペース", + "remote.title": "リモートワークスペース", + "remote.subtitle": "Codex、Claude Code、Pi はこの Hub で実行し、ファイル、コマンド、テスト、ビルドは選択したコンピューターで処理します。", + "remote.loading": "リモートワークスペースを読み込み中…", + "remote.loadFailed": "リモートワークスペースを読み込めませんでした。", + "remote.hubRequired": "使用前にこの OpenCodex インスタンスを Hub モードに設定してください。", + "remote.refresh": "更新", + "remote.addComputer": "コンピューターを追加", + "remote.addComputerHint": "ローカルでフォルダーを承認し、OCX 専用エグゼキューターをこの Hub に接続したままにします。", + "remote.createPairing": "ペアリングコードを作成", + "remote.pairingCode": "ワンタイムペアリングコード", + "remote.pairingExpires": "{time} に期限切れ", + "remote.pairingCommand": "追加するコンピューターで実行", + "remote.pairingCommandPosix": "Linux / macOS ターミナル", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "コマンドをコピー", + "remote.copied": "コピー済み", + "remote.devices": "コンピューター", + "remote.noDevices": "ペアリング済みのコンピューターはありません。", + "remote.online": "オンライン", + "remote.offline": "オフライン", + "remote.revoke": "コンピューターを解除", + "remote.revokeConfirm": "{name} を解除しますか?このコンピューターの実行中セッションは停止します。", + "remote.newSession": "新しいリモートセッション", + "remote.device": "コンピューター", + "remote.folder": "ワークスペースフォルダー", + "remote.runtime": "コーディングエージェント", + "remote.access": "ワークスペース権限", + "remote.access.readOnly": "読み取り専用", + "remote.access.workspace": "ファイル編集とコマンド実行", + "remote.access.workspaceFilesOnly": "ファイル編集のみ", + "remote.unavailable": "利用不可", + "remote.capability.full": "ファイル + 分離されたコマンド", + "remote.capability.files": "ファイルツールのみ", + "remote.runsOnHub": "モデルとログインはこの Hub に保持", + "remote.runsReadOnly": "このコンピューターのファイルは読み取りのみ", + "remote.runsFilesCommands": "ファイル、ビルド、コマンドはここで実行", + "remote.runsFilesOnly": "ファイルツールのみここで実行、コマンド分離は未対応", + "remote.execUnavailable": "このコンピューターではファイル編集はできますが、対応する OS サンドボックスがないためビルドとターミナルコマンドは無効です。", + "remote.notResumable": "コーディングエージェントが永続的な履歴を作成する前にセッションが停止しました。新しいリモートセッションを開始してください。", + "remote.startSession": "リモートセッションを開始", + "remote.sessionStarted": "リモートセッションの準備ができました。", + "remote.sessions": "セッション", + "remote.noSessions": "オンラインのコンピューター、フォルダー、エージェントを選択してください。", + "remote.events": "リモートセッションのアクティビティ", + "remote.noEvents": "まだアクティビティはありません。", + "remote.prompt": "メッセージ", + "remote.promptPlaceholder": "選択したリモートフォルダーでの作業を Hub エージェントに依頼…", + "remote.send": "送信", + "remote.stop": "セッションを停止", + "remote.requestFailed": "リモートワークスペースの要求に失敗しました。", + "remote.status.starting": "開始中", + "remote.status.ready": "準備完了", + "remote.status.running": "実行中", + "remote.status.waiting": "エグゼキューターがオフライン", + "remote.status.failed": "失敗", + "remote.status.stopped": "停止済み", + "remote.event.status": "状態", + "remote.event.tool": "リモートツール", + "remote.event.error": "エラー", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 8d1e3bc8ea..ae3786dbbc 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2438,4 +2438,64 @@ export const ko: Record = { "integrations.cursor.colReasoning": "추론", "integrations.cursor.colContext": "컨텍스트", "integrations.cursor.guide": "Cursor Private Inference 가이드 열기", + "nav.remote": "원격 워크스페이스", + "remote.title": "원격 워크스페이스", + "remote.subtitle": "Codex, Claude Code, Pi는 이 Hub에서 실행하고 파일·명령·테스트·빌드는 선택한 컴퓨터에서 처리합니다.", + "remote.loading": "원격 워크스페이스 불러오는 중…", + "remote.loadFailed": "원격 워크스페이스를 불러오지 못했습니다.", + "remote.hubRequired": "원격 워크스페이스를 사용하려면 이 OpenCodex를 Hub 모드로 설정하세요.", + "remote.refresh": "새로고침", + "remote.addComputer": "컴퓨터 추가", + "remote.addComputerHint": "추가할 컴퓨터에서 폴더를 승인하고 OCX 전용 실행기를 이 Hub에 계속 연결하세요.", + "remote.createPairing": "페어링 코드 만들기", + "remote.pairingCode": "일회용 페어링 코드", + "remote.pairingExpires": "{time}에 만료", + "remote.pairingCommand": "추가할 컴퓨터에서 실행", + "remote.pairingCommandPosix": "Linux / macOS 터미널", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "명령어 복사", + "remote.copied": "복사됨", + "remote.devices": "컴퓨터", + "remote.noDevices": "아직 페어링된 컴퓨터가 없습니다.", + "remote.online": "온라인", + "remote.offline": "오프라인", + "remote.revoke": "컴퓨터 연결 해제", + "remote.revokeConfirm": "{name} 연결을 해제할까요? 이 컴퓨터의 활성 세션이 중지됩니다.", + "remote.newSession": "새 원격 세션", + "remote.device": "컴퓨터", + "remote.folder": "워크스페이스 폴더", + "remote.runtime": "코딩 에이전트", + "remote.access": "워크스페이스 권한", + "remote.access.readOnly": "읽기 전용", + "remote.access.workspace": "파일 편집 및 명령 실행", + "remote.access.workspaceFilesOnly": "파일 편집만", + "remote.unavailable": "사용 불가", + "remote.capability.full": "파일 + 격리된 명령 실행", + "remote.capability.files": "파일 도구만 지원", + "remote.runsOnHub": "모델과 로그인은 이 Hub에서 유지", + "remote.runsReadOnly": "이 컴퓨터의 파일은 읽기만 가능", + "remote.runsFilesCommands": "파일, 빌드, 명령은 이 컴퓨터에서 실행", + "remote.runsFilesOnly": "파일 도구만 이 컴퓨터에서 실행, 명령 격리 미지원", + "remote.execUnavailable": "이 컴퓨터의 파일은 편집할 수 있지만, 지원되는 OS 격리 기능이 없어 빌드와 터미널 명령은 비활성화됩니다.", + "remote.notResumable": "코딩 에이전트가 세션 기록을 만들기 전에 중단되었습니다. 새 원격 세션을 시작하세요.", + "remote.startSession": "원격 세션 시작", + "remote.sessionStarted": "원격 세션이 준비되었습니다.", + "remote.sessions": "세션", + "remote.noSessions": "온라인 컴퓨터, 폴더, 코딩 에이전트를 선택해 시작하세요.", + "remote.events": "원격 세션 활동", + "remote.noEvents": "아직 활동이 없습니다.", + "remote.prompt": "메시지", + "remote.promptPlaceholder": "Hub 에이전트에게 선택한 원격 폴더에서 작업을 요청하세요…", + "remote.send": "보내기", + "remote.stop": "세션 중지", + "remote.requestFailed": "원격 워크스페이스 요청에 실패했습니다.", + "remote.status.starting": "시작 중", + "remote.status.ready": "준비됨", + "remote.status.running": "실행 중", + "remote.status.waiting": "실행기 오프라인", + "remote.status.failed": "실패", + "remote.status.stopped": "중지됨", + "remote.event.status": "상태", + "remote.event.tool": "원격 도구", + "remote.event.error": "오류", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index a00d5dcbe4..8cfc42e2c4 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2439,4 +2439,64 @@ export const ru: Record = { "integrations.cursor.colReasoning": "Рассуждения", "integrations.cursor.colContext": "Контекст", "integrations.cursor.guide": "Открыть руководство по Cursor Private Inference", + "nav.remote": "Удалённое рабочее пространство", + "remote.title": "Удалённое рабочее пространство", + "remote.subtitle": "Codex, Claude Code или Pi работают на этом Hub, а файлы, команды, тесты и сборки остаются на выбранном компьютере.", + "remote.loading": "Загрузка удалённого рабочего пространства…", + "remote.loadFailed": "Не удалось загрузить удалённое рабочее пространство.", + "remote.hubRequired": "Перед использованием включите режим Hub для этого экземпляра OpenCodex.", + "remote.refresh": "Обновить", + "remote.addComputer": "Добавить компьютер", + "remote.addComputerHint": "Разрешите локальные папки и держите исполнитель только с OCX подключённым к этому Hub.", + "remote.createPairing": "Создать код сопряжения", + "remote.pairingCode": "Одноразовый код сопряжения", + "remote.pairingExpires": "Истекает в {time}", + "remote.pairingCommand": "Запустите на добавляемом компьютере", + "remote.pairingCommandPosix": "Терминал Linux / macOS", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "Копировать команду", + "remote.copied": "Скопировано", + "remote.devices": "Компьютеры", + "remote.noDevices": "Сопряжённых компьютеров пока нет.", + "remote.online": "В сети", + "remote.offline": "Не в сети", + "remote.revoke": "Отозвать компьютер", + "remote.revokeConfirm": "Отозвать {name}? Активные сеансы на этом компьютере будут остановлены.", + "remote.newSession": "Новый удалённый сеанс", + "remote.device": "Компьютер", + "remote.folder": "Папка рабочего пространства", + "remote.runtime": "Агент программирования", + "remote.access": "Доступ к рабочей области", + "remote.access.readOnly": "Только чтение", + "remote.access.workspace": "Изменять файлы и выполнять команды", + "remote.access.workspaceFilesOnly": "Только изменять файлы", + "remote.unavailable": "Недоступно", + "remote.capability.full": "Файлы + изолированные команды", + "remote.capability.files": "Только файловые инструменты", + "remote.runsOnHub": "Модель и вход остаются на этом Hub", + "remote.runsReadOnly": "Файлы на этом компьютере доступны только для чтения", + "remote.runsFilesCommands": "Файлы, сборки и команды выполняются здесь", + "remote.runsFilesOnly": "Здесь работают только файловые инструменты; песочница команд недоступна", + "remote.execUnavailable": "На этом компьютере можно редактировать файлы, но сборки и команды терминала отключены без поддерживаемой системной песочницы.", + "remote.notResumable": "Сеанс остановился до создания постоянной истории агентом. Запустите новый удалённый сеанс.", + "remote.startSession": "Запустить удалённый сеанс", + "remote.sessionStarted": "Удалённый сеанс готов.", + "remote.sessions": "Сеансы", + "remote.noSessions": "Выберите компьютер в сети, папку и агента программирования.", + "remote.events": "Активность удалённого сеанса", + "remote.noEvents": "Активности пока нет.", + "remote.prompt": "Сообщение", + "remote.promptPlaceholder": "Попросите агент Hub работать в выбранной удалённой папке…", + "remote.send": "Отправить", + "remote.stop": "Остановить сеанс", + "remote.requestFailed": "Запрос удалённого рабочего пространства завершился ошибкой.", + "remote.status.starting": "Запуск", + "remote.status.ready": "Готово", + "remote.status.running": "Выполняется", + "remote.status.waiting": "Исполнитель не в сети", + "remote.status.failed": "Ошибка", + "remote.status.stopped": "Остановлено", + "remote.event.status": "Состояние", + "remote.event.tool": "Удалённый инструмент", + "remote.event.error": "Ошибка", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 981bacb56b..c32edd91d0 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2439,4 +2439,64 @@ export const tr: Record = { "integrations.cursor.colReasoning": "Akıl yürütme", "integrations.cursor.colContext": "Bağlam", "integrations.cursor.guide": "Cursor Private Inference kılavuzunu aç", + "nav.remote": "Uzak Çalışma Alanı", + "remote.title": "Uzak Çalışma Alanı", + "remote.subtitle": "Codex, Claude Code veya Pi bu Hub üzerinde çalışır; dosyalar, komutlar, testler ve derlemeler seçtiğiniz bilgisayarda kalır.", + "remote.loading": "Uzak çalışma alanı yükleniyor…", + "remote.loadFailed": "Uzak çalışma alanı yüklenemedi.", + "remote.hubRequired": "Uzak çalışma alanını kullanmadan önce bu OpenCodex örneğini Hub moduna alın.", + "remote.refresh": "Yenile", + "remote.addComputer": "Bilgisayar ekle", + "remote.addComputerHint": "Klasörleri yerel olarak onaylayın ve yalnızca OCX kurulu yürütücüyü bu Hub'a bağlı tutun.", + "remote.createPairing": "Eşleştirme kodu oluştur", + "remote.pairingCode": "Tek kullanımlık eşleştirme kodu", + "remote.pairingExpires": "{time} saatinde sona erer", + "remote.pairingCommand": "Eklenecek bilgisayarda çalıştırın", + "remote.pairingCommandPosix": "Linux / macOS terminali", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "Komutu kopyala", + "remote.copied": "Kopyalandı", + "remote.devices": "Bilgisayarlar", + "remote.noDevices": "Henüz eşleştirilmiş bilgisayar yok.", + "remote.online": "Çevrimiçi", + "remote.offline": "Çevrimdışı", + "remote.revoke": "Bilgisayarı iptal et", + "remote.revokeConfirm": "{name} iptal edilsin mi? Bu bilgisayardaki etkin oturumlar durur.", + "remote.newSession": "Yeni uzak oturum", + "remote.device": "Bilgisayar", + "remote.folder": "Çalışma alanı klasörü", + "remote.runtime": "Kodlama aracısı", + "remote.access": "Çalışma alanı erişimi", + "remote.access.readOnly": "Salt okunur", + "remote.access.workspace": "Dosyaları düzenle ve komut çalıştır", + "remote.access.workspaceFilesOnly": "Yalnızca dosyaları düzenle", + "remote.unavailable": "Kullanılamıyor", + "remote.capability.full": "Dosyalar + yalıtılmış komutlar", + "remote.capability.files": "Yalnızca dosya araçları", + "remote.runsOnHub": "Model ve oturum bu Hub üzerinde kalır", + "remote.runsReadOnly": "Bu bilgisayardaki dosyalar yalnızca okunabilir", + "remote.runsFilesCommands": "Dosyalar, derlemeler ve komutlar burada çalışır", + "remote.runsFilesOnly": "Burada yalnızca dosya araçları çalışır; komut yalıtımı yok", + "remote.execUnavailable": "Bu bilgisayar dosyaları düzenleyebilir; ancak desteklenen bir işletim sistemi yalıtımı olmadığı için derlemeler ve terminal komutları devre dışıdır.", + "remote.notResumable": "Kodlama aracısı kalıcı geçmiş oluşturmadan önce oturum durdu. Yeni bir uzak oturum başlatın.", + "remote.startSession": "Uzak oturumu başlat", + "remote.sessionStarted": "Uzak oturum hazır.", + "remote.sessions": "Oturumlar", + "remote.noSessions": "Çevrimiçi bir bilgisayar, klasör ve kodlama aracısı seçin.", + "remote.events": "Uzak oturum etkinliği", + "remote.noEvents": "Henüz etkinlik yok.", + "remote.prompt": "Mesaj", + "remote.promptPlaceholder": "Hub aracısından seçili uzak klasörde çalışmasını isteyin…", + "remote.send": "Gönder", + "remote.stop": "Oturumu durdur", + "remote.requestFailed": "Uzak çalışma alanı isteği başarısız oldu.", + "remote.status.starting": "Başlatılıyor", + "remote.status.ready": "Hazır", + "remote.status.running": "Çalışıyor", + "remote.status.waiting": "Yürütücü çevrimdışı", + "remote.status.failed": "Başarısız", + "remote.status.stopped": "Durduruldu", + "remote.event.status": "Durum", + "remote.event.tool": "Uzak araç", + "remote.event.error": "Hata", }; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 5eb6557302..7c7f5aeaae 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2401,4 +2401,64 @@ export const zhTW: Record = { "integrations.cursor.colReasoning": "推理", "integrations.cursor.colContext": "上下文", "integrations.cursor.guide": "開啟 Cursor Private Inference 指南", + "nav.remote": "遠端工作區", + "remote.title": "遠端工作區", + "remote.subtitle": "Codex、Claude Code 或 Pi 在此 Hub 執行,檔案、命令、測試與建置則留在所選電腦上處理。", + "remote.loading": "正在載入遠端工作區…", + "remote.loadFailed": "無法載入遠端工作區。", + "remote.hubRequired": "請先將此 OpenCodex 執行個體設為 Hub 模式。", + "remote.refresh": "重新整理", + "remote.addComputer": "新增電腦", + "remote.addComputerHint": "在本機核准一個或多個資料夾,並讓僅安裝 OCX 的執行端持續連線此 Hub。", + "remote.createPairing": "建立配對碼", + "remote.pairingCode": "一次性配對碼", + "remote.pairingExpires": "{time} 到期", + "remote.pairingCommand": "在要新增的電腦上執行", + "remote.pairingCommandPosix": "Linux / macOS 終端機", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "複製命令", + "remote.copied": "已複製", + "remote.devices": "電腦", + "remote.noDevices": "尚未配對電腦。", + "remote.online": "上線", + "remote.offline": "離線", + "remote.revoke": "撤銷電腦", + "remote.revokeConfirm": "撤銷 {name}?此電腦上的作用中工作階段將停止。", + "remote.newSession": "新增遠端工作階段", + "remote.device": "電腦", + "remote.folder": "工作區資料夾", + "remote.runtime": "程式設計代理", + "remote.access": "工作區權限", + "remote.access.readOnly": "唯讀", + "remote.access.workspace": "編輯檔案並執行命令", + "remote.access.workspaceFilesOnly": "僅編輯檔案", + "remote.unavailable": "無法使用", + "remote.capability.full": "檔案 + 沙箱命令", + "remote.capability.files": "僅檔案工具", + "remote.runsOnHub": "模型與登入保留在此 Hub", + "remote.runsReadOnly": "此電腦上的檔案僅可讀取", + "remote.runsFilesCommands": "檔案、建置與命令在此電腦執行", + "remote.runsFilesOnly": "僅檔案工具在此執行;命令沙箱無法使用", + "remote.execUnavailable": "此電腦可以編輯檔案,但因沒有支援的作業系統沙箱,建置與終端命令已停用。", + "remote.notResumable": "程式設計代理尚未建立持久歷史記錄時工作階段就已停止。請啟動新的遠端工作階段。", + "remote.startSession": "啟動遠端工作階段", + "remote.sessionStarted": "遠端工作階段已就緒。", + "remote.sessions": "工作階段", + "remote.noSessions": "請選擇上線電腦、資料夾與程式設計代理。", + "remote.events": "遠端工作階段活動", + "remote.noEvents": "尚無活動。", + "remote.prompt": "訊息", + "remote.promptPlaceholder": "請 Hub 代理在所選遠端資料夾中工作…", + "remote.send": "傳送", + "remote.stop": "停止工作階段", + "remote.requestFailed": "遠端工作區要求失敗。", + "remote.status.starting": "正在啟動", + "remote.status.ready": "就緒", + "remote.status.running": "執行中", + "remote.status.waiting": "執行端離線", + "remote.status.failed": "失敗", + "remote.status.stopped": "已停止", + "remote.event.status": "狀態", + "remote.event.tool": "遠端工具", + "remote.event.error": "錯誤", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index f53e53ce39..b7a0ce2c9c 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2437,4 +2437,64 @@ export const zh: Record = { "integrations.cursor.colReasoning": "推理", "integrations.cursor.colContext": "上下文", "integrations.cursor.guide": "打开 Cursor Private Inference 指南", + "nav.remote": "远程工作区", + "remote.title": "远程工作区", + "remote.subtitle": "Codex、Claude Code 或 Pi 在此 Hub 上运行,文件、命令、测试和构建则留在所选电脑上执行。", + "remote.loading": "正在加载远程工作区…", + "remote.loadFailed": "无法加载远程工作区。", + "remote.hubRequired": "请先将此 OpenCodex 实例设为 Hub 模式。", + "remote.refresh": "刷新", + "remote.addComputer": "添加电脑", + "remote.addComputerHint": "在本机批准一个或多个文件夹,并让仅安装 OCX 的执行端持续连接此 Hub。", + "remote.createPairing": "创建配对码", + "remote.pairingCode": "一次性配对码", + "remote.pairingExpires": "{time} 过期", + "remote.pairingCommand": "在要添加的电脑上运行", + "remote.pairingCommandPosix": "Linux / macOS 终端", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "复制命令", + "remote.copied": "已复制", + "remote.devices": "电脑", + "remote.noDevices": "尚未配对电脑。", + "remote.online": "在线", + "remote.offline": "离线", + "remote.revoke": "撤销电脑", + "remote.revokeConfirm": "撤销 {name}?该电脑上的活动会话将停止。", + "remote.newSession": "新建远程会话", + "remote.device": "电脑", + "remote.folder": "工作区文件夹", + "remote.runtime": "编程代理", + "remote.access": "工作区权限", + "remote.access.readOnly": "只读", + "remote.access.workspace": "编辑文件并运行命令", + "remote.access.workspaceFilesOnly": "仅编辑文件", + "remote.unavailable": "不可用", + "remote.capability.full": "文件 + 沙箱命令", + "remote.capability.files": "仅文件工具", + "remote.runsOnHub": "模型和登录保留在此 Hub", + "remote.runsReadOnly": "此电脑上的文件仅可读取", + "remote.runsFilesCommands": "文件、构建和命令在此电脑运行", + "remote.runsFilesOnly": "仅文件工具在此运行;命令沙箱不可用", + "remote.execUnavailable": "此电脑可以编辑文件,但由于没有受支持的操作系统沙箱,构建和终端命令已禁用。", + "remote.notResumable": "编码代理尚未创建持久历史记录时会话就已停止。请启动新的远程会话。", + "remote.startSession": "启动远程会话", + "remote.sessionStarted": "远程会话已就绪。", + "remote.sessions": "会话", + "remote.noSessions": "请选择在线电脑、文件夹和编程代理。", + "remote.events": "远程会话活动", + "remote.noEvents": "暂无活动。", + "remote.prompt": "消息", + "remote.promptPlaceholder": "让 Hub 代理在所选远程文件夹中工作…", + "remote.send": "发送", + "remote.stop": "停止会话", + "remote.requestFailed": "远程工作区请求失败。", + "remote.status.starting": "正在启动", + "remote.status.ready": "就绪", + "remote.status.running": "运行中", + "remote.status.waiting": "执行端离线", + "remote.status.failed": "失败", + "remote.status.stopped": "已停止", + "remote.event.status": "状态", + "remote.event.tool": "远程工具", + "remote.event.error": "错误", }; diff --git a/gui/src/pages/RemoteWorkspace.tsx b/gui/src/pages/RemoteWorkspace.tsx new file mode 100644 index 0000000000..85d0e243f3 --- /dev/null +++ b/gui/src/pages/RemoteWorkspace.tsx @@ -0,0 +1,381 @@ +import { useMemo, useRef, useState } from "react"; +import { useKeyedClientResource } from "../client-resource"; +import { readJsonOrThrow } from "../fetch-json"; +import { IconLink, IconMonitor, IconPlus, IconRefresh, IconTerminal, IconTrash } from "../icons"; +import { type TKey, useT } from "../i18n/shared"; +import { Notice, Select } from "../ui"; +import { remoteWorkspacePairingCommands } from "../remote-workspace-command"; + +type RuntimeProfile = "codex" | "claude" | "pi"; +type RemoteCapability = "workspace.read" | "workspace.write" | "workspace.exec"; +type RemoteAccessMode = "read-only" | "workspace"; +type SessionStatus = "starting" | "ready" | "running" | "waiting_for_executor" | "failed" | "stopped"; + +interface RemoteRoot { id: string; label: string } +interface RemoteDevice { + id: string; + name: string; + platform: string; + capabilities: RemoteCapability[]; + roots: RemoteRoot[]; + online: boolean; + createdAt: string; + lastSeenAt: string | null; +} +interface RuntimeAvailability { available: boolean; version?: string; reason?: string } +interface SessionEvent { sequence: number; at: string; type: "status" | "assistant" | "tool" | "error"; text: string } +interface RemoteSession { + id: string; + profile: RuntimeProfile; + accessMode: RemoteAccessMode; + deviceId: string; + deviceName: string; + rootId: string; + rootLabel: string; + capabilities: RemoteCapability[]; + tools: string[]; + threadId: string | null; + resumable: boolean; + status: SessionStatus; + createdAt: string; + updatedAt: string; + events: SessionEvent[]; +} +interface RemoteWorkspaceState { + available: boolean; + reason?: string; + devices: RemoteDevice[]; + runtimes: Record; + sessions: RemoteSession[]; +} +interface PairingGrant { code: string; expiresAt: string } + +const PROFILES: RuntimeProfile[] = ["codex", "claude", "pi"]; +const PROFILE_LABEL: Record = { codex: "Codex", claude: "Claude Code", pi: "Pi" }; +const STATUS_TKEY: Record = { + starting: "remote.status.starting", + ready: "remote.status.ready", + running: "remote.status.running", + waiting_for_executor: "remote.status.waiting", + failed: "remote.status.failed", + stopped: "remote.status.stopped", +}; +const EVENT_TKEY: Record, TKey> = { + status: "remote.event.status", + tool: "remote.event.tool", + error: "remote.event.error", +}; + +function isRuntimeProfile(value: string): value is RuntimeProfile { + return value === "codex" || value === "claude" || value === "pi"; +} + +function isRemoteAccessMode(value: string): value is RemoteAccessMode { + return value === "read-only" || value === "workspace"; +} + +async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +} + +export default function RemoteWorkspace({ apiBase }: { apiBase: string }) { + const t = useT(); + const resource = useKeyedClientResource( + `remote-workspace:${apiBase}`, + [apiBase], + async signal => { + const response = await fetch(`${apiBase}/api/remote-workspace`, { signal, cache: "no-store" }); + return await readJsonOrThrow(response, t("remote.loadFailed")); + }, + { pollMs: 3_000, deadlineMs: 10_000 }, + ); + const state = resource.data; + const [selectedDeviceId, setSelectedDeviceId] = useState(""); + const [selectedRootId, setSelectedRootId] = useState(""); + const [selectedProfile, setSelectedProfile] = useState("codex"); + const [selectedAccessMode, setSelectedAccessMode] = useState("read-only"); + const [selectedSessionId, setSelectedSessionId] = useState(""); + const [localSession, setLocalSession] = useState(null); + const [pairing, setPairing] = useState(null); + const [prompt, setPrompt] = useState(""); + const [busy, setBusy] = useState<"pair" | "session" | "revoke" | null>(null); + const [promptPending, setPromptPending] = useState(false); + const [stopPending, setStopPending] = useState(false); + const stoppedSessionId = useRef(null); + const [notice, setNotice] = useState<{ tone: "ok" | "err"; text: string } | null>(null); + const [copiedCommand, setCopiedCommand] = useState<"posix" | "powershell" | null>(null); + + const devices = state?.devices ?? []; + const effectiveDevice = devices.find(device => device.id === selectedDeviceId) + ?? devices.find(device => device.online) + ?? devices[0] + ?? null; + const effectiveRoot = effectiveDevice?.roots.find(root => root.id === selectedRootId) + ?? effectiveDevice?.roots[0] + ?? null; + const selectedCanExecute = selectedAccessMode === "workspace" + && (effectiveDevice?.capabilities.includes("workspace.exec") ?? false); + const workspaceAccessLabel = effectiveDevice && !effectiveDevice.capabilities.includes("workspace.exec") + ? t("remote.access.workspaceFilesOnly") + : t("remote.access.workspace"); + const availableProfiles = PROFILES.filter(profile => state?.runtimes?.[profile]?.available); + const effectiveProfile = availableProfiles.includes(selectedProfile) + ? selectedProfile + : availableProfiles[0] ?? selectedProfile; + const remoteSessions = state?.sessions ?? []; + const effectiveSession = remoteSessions.find(session => session.id === selectedSessionId) + ?? (localSession && localSession.id === selectedSessionId ? localSession : null) + ?? [...remoteSessions].reverse().find(session => session.status !== "stopped") + ?? localSession; + + const pairingCommands = useMemo(() => { + if (!pairing) return { posix: "", powershell: "" }; + const hub = typeof window === "undefined" ? "https://hub.example" : window.location.origin; + return remoteWorkspacePairingCommands(pairing.code, hub); + }, [pairing]); + + const mutate = async (path: string, init: RequestInit, fallback: string): Promise => { + const response = await fetch(`${apiBase}${path}`, init); + const body = await readJsonOrThrow(response, fallback); + if (body === undefined) throw new Error(fallback); + return body; + }; + + const createPairing = async () => { + setBusy("pair"); + setNotice(null); + try { + const grant = await mutate("/api/remote-workspace/pairing", { method: "POST" }, t("remote.requestFailed")); + setPairing(grant); + setCopiedCommand(null); + } catch (error) { + setNotice({ tone: "err", text: error instanceof Error ? error.message : t("remote.requestFailed") }); + } finally { setBusy(null); } + }; + + const createSession = async () => { + if (!effectiveDevice || !effectiveRoot) return; + setBusy("session"); + setNotice(null); + try { + const session = await mutate("/api/remote-workspace/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + profile: effectiveProfile, + deviceId: effectiveDevice.id, + rootId: effectiveRoot.id, + accessMode: selectedAccessMode, + }), + }, t("remote.requestFailed")); + setLocalSession(session); + setSelectedSessionId(session.id); + setNotice({ tone: "ok", text: t("remote.sessionStarted") }); + void resource.refresh(); + } catch (error) { + setNotice({ tone: "err", text: error instanceof Error ? error.message : t("remote.requestFailed") }); + } finally { setBusy(null); } + }; + + const sendPrompt = async () => { + if (!effectiveSession || !prompt.trim() || promptPending || stopPending || busy !== null) return; + const target = effectiveSession; + const submitted = prompt; + setPrompt(""); + setPromptPending(true); + setNotice(null); + try { + const session = await mutate(`/api/remote-workspace/sessions/${target.id}/prompt`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: submitted }), + }, t("remote.requestFailed")); + if (stoppedSessionId.current !== target.id) setLocalSession(session); + void resource.refresh(); + } catch (error) { + if (stoppedSessionId.current !== target.id) { + setPrompt(submitted); + setNotice({ tone: "err", text: error instanceof Error ? error.message : t("remote.requestFailed") }); + } + } finally { setPromptPending(false); } + }; + + const stopSession = async () => { + if (!effectiveSession || stopPending || busy !== null) return; + const target = effectiveSession; + setStopPending(true); + try { + await mutate(`/api/remote-workspace/sessions/${target.id}`, { method: "DELETE" }, t("remote.requestFailed")); + stoppedSessionId.current = target.id; + setLocalSession({ ...target, status: "stopped" }); + void resource.refresh(); + } catch (error) { + setNotice({ tone: "err", text: error instanceof Error ? error.message : t("remote.requestFailed") }); + } finally { setStopPending(false); } + }; + + const revokeDevice = async (device: RemoteDevice) => { + if (!confirm(t("remote.revokeConfirm", { name: device.name }))) return; + setBusy("revoke"); + try { + await mutate(`/api/remote-workspace/devices/${device.id}`, { method: "DELETE" }, t("remote.requestFailed")); + if (selectedDeviceId === device.id) setSelectedDeviceId(""); + void resource.refresh(); + } catch (error) { + setNotice({ tone: "err", text: error instanceof Error ? error.message : t("remote.requestFailed") }); + } finally { setBusy(null); } + }; + + const copyPairingCommand = async (kind: "posix" | "powershell", command: string) => { + setCopiedCommand(await copyText(command) ? kind : null); + }; + + if (resource.loading && !state) return
{t("remote.loading")}
; + if (resource.error && !state) { + return <>{t("remote.loadFailed")}; + } + if (state?.available === false) return {state.reason ?? t("remote.hubRequired")}; + + return ( +
+
+
+

{t("remote.title")}

+

{t("remote.subtitle")}

+
+ +
+ + {notice ? {notice.text} : null} + +
+
+
+
+
+

{t("remote.addComputer")}

{t("remote.addComputerHint")}

+
+ + {pairing ? ( +
+ {t("remote.pairingCode")} +
{pairing.code}
+
{t("remote.pairingExpires", { time: new Date(pairing.expiresAt).toLocaleTimeString() })}
+ {t("remote.pairingCommandPosix")} +
{pairingCommands.posix}
+ + {t("remote.pairingCommandWindows")} +
{pairingCommands.powershell}
+ +
+ ) : null} +
+ +
+

{t("remote.devices")}

{devices.length}
+ {devices.length === 0 ?

{t("remote.noDevices")}

: ( +
+ {devices.map(device => ( +
+ + +
+ ))} +
+ )} +
+
+ +
+
+

{t("remote.newSession")}

+
+ + +
+ {effectiveDevice ? ( +
+ {PROFILE_LABEL[effectiveProfile]}{t("remote.runsOnHub")} + {effectiveDevice.name}{selectedAccessMode === "read-only" ? t("remote.runsReadOnly") : selectedCanExecute ? t("remote.runsFilesCommands") : t("remote.runsFilesOnly")} +
+ ) : null} + {selectedAccessMode === "workspace" && !selectedCanExecute && effectiveDevice ? {t("remote.execUnavailable")} : null} + {!state?.runtimes?.[effectiveProfile]?.available && state?.runtimes?.[effectiveProfile]?.reason + ?

{state.runtimes[effectiveProfile].reason}

+ : null} + +
+ +
+
+

{t("remote.sessions")}

{effectiveSession ? {PROFILE_LABEL[effectiveSession.profile]} · {effectiveSession.deviceName}/{effectiveSession.rootLabel} · {effectiveSession.accessMode === "read-only" ? t("remote.access.readOnly") : t("remote.access.workspace")} : null}
+ {effectiveSession ? {t(STATUS_TKEY[effectiveSession.status])} : null} +
+ {remoteSessions.length > 1 ? ( +