diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39930f894..83b9f8401 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,8 @@ on: - "tests/**" - "scripts/**" - "gui/**" + - "platform/**" + - "remote-agent/**" - ".gitattributes" - ".npmignore" - "package.json" @@ -26,6 +28,8 @@ on: - "tests/**" - "scripts/**" - "gui/**" + - "platform/**" + - "remote-agent/**" - ".gitattributes" - ".npmignore" - "package.json" @@ -45,6 +49,70 @@ concurrency: cancel-in-progress: true jobs: + remote-agent: + name: Remote Agent ${{ matrix.package }} + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + include: + - package: linux-x64 + os: ubuntu-24.04 + target: x86_64-unknown-linux-musl + executable: opencodex-remote-agent + - package: linux-arm64 + os: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + executable: opencodex-remote-agent + - package: macos-x64 + os: macos-15-intel + target: x86_64-apple-darwin + executable: opencodex-remote-agent + - package: macos-arm64 + os: macos-15 + target: aarch64-apple-darwin + executable: opencodex-remote-agent + - package: windows-x64 + os: windows-latest + target: x86_64-pc-windows-msvc + executable: opencodex-remote-agent.exe + - package: windows-arm64 + os: windows-11-arm + target: aarch64-pc-windows-msvc + executable: opencodex-remote-agent.exe + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Install musl build tools + if: ${{ startsWith(matrix.package, 'linux-') }} + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends musl-tools + + - name: Install pinned Rust target + working-directory: remote-agent + run: rustup target add "${{ matrix.target }}" + + - name: Format + working-directory: remote-agent + run: cargo fmt --check + + - name: Clippy + working-directory: remote-agent + run: cargo clippy --locked --target "${{ matrix.target }}" --all-targets -- -D warnings + + - name: Test + working-directory: remote-agent + run: cargo test --locked --target "${{ matrix.target }}" + + - name: Build release binary + working-directory: remote-agent + run: cargo build --locked --release --target "${{ matrix.target }}" + + - name: Verify release binary exists + shell: bash + run: test -s "remote-agent/target/${{ matrix.target }}/release/${{ matrix.executable }}" + test: name: ${{ matrix.os }} runs-on: ${{ matrix.os }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d9767d931..966004ef5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ on: default: true expected-sha: description: "Immutable release commit this dispatch must publish (fail if the branch moved)" - required: false + required: true type: string permissions: @@ -40,9 +40,78 @@ concurrency: cancel-in-progress: false jobs: + remote-agent-build: + name: Remote Agent ${{ matrix.package }} + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - package: linux-x64 + os: ubuntu-24.04 + target: x86_64-unknown-linux-musl + executable: opencodex-remote-agent + - package: linux-arm64 + os: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + executable: opencodex-remote-agent + - package: macos-x64 + os: macos-15-intel + target: x86_64-apple-darwin + executable: opencodex-remote-agent + - package: macos-arm64 + os: macos-15 + target: aarch64-apple-darwin + executable: opencodex-remote-agent + - package: windows-x64 + os: windows-latest + target: x86_64-pc-windows-msvc + executable: opencodex-remote-agent.exe + - package: windows-arm64 + os: windows-11-arm + target: aarch64-pc-windows-msvc + executable: opencodex-remote-agent.exe + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Install musl build tools + if: ${{ startsWith(matrix.package, 'linux-') }} + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends musl-tools + + - name: Install pinned Rust target + working-directory: remote-agent + run: rustup target add "${{ matrix.target }}" + + - name: Build release binary + working-directory: remote-agent + run: cargo build --locked --release --target "${{ matrix.target }}" + + - name: Stage release binary + shell: bash + run: | + mkdir -p remote-agent-artifact + cp "remote-agent/target/${{ matrix.target }}/release/${{ matrix.executable }}" \ + "remote-agent-artifact/${{ matrix.executable }}" + test -s "remote-agent-artifact/${{ matrix.executable }}" + + - name: Upload unsigned build artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: remote-agent-${{ matrix.package }} + path: remote-agent-artifact/${{ matrix.executable }} + if-no-files-found: error + retention-days: 1 + publish: + needs: remote-agent-build runs-on: ubuntu-latest timeout-minutes: 15 + environment: + name: ${{ inputs.dry-run && 'remote-agent-dry-run' || 'remote-agent-release' }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -54,7 +123,8 @@ jobs: EXPECTED_SHA: ${{ inputs.expected-sha }} run: | if [ -z "$EXPECTED_SHA" ]; then - echo "::warning::no expected-sha supplied; publishing whatever the branch currently points at" + echo "::error::expected-sha is required; refusing to release an unaudited branch tip" + exit 1 elif [ "$GITHUB_SHA" != "$EXPECTED_SHA" ]; then echo "::error::branch moved after the release audit (expected ${EXPECTED_SHA}, got ${GITHUB_SHA}) — refusing to publish an unaudited commit" exit 1 @@ -92,6 +162,12 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Download platform Agent artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: remote-agent-* + path: remote-agent-artifacts + - name: Verify version matches package.json env: RELEASE_VERSION: ${{ inputs.version }} @@ -243,10 +319,51 @@ jobs: fi fi + - name: Assemble and sign Remote Agent bundle + shell: bash + env: + REMOTE_AGENT_SIGNING_KEY: ${{ secrets.REMOTE_AGENT_SIGNING_KEY }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + set -euo pipefail + signing_key="$RUNNER_TEMP/opencodex-remote-agent-signing.pem" + public_key="$RUNNER_TEMP/opencodex-remote-agent-signing.pub.pem" + cleanup() { + if [ -f "$signing_key" ] && command -v shred >/dev/null 2>&1; then + shred -u "$signing_key" + else + rm -f "$signing_key" + fi + } + trap cleanup EXIT + umask 077 + expected_public_key="" + if [ "$DRY_RUN" = "true" ]; then + openssl genpkey -algorithm ED25519 -out "$signing_key" + openssl pkey -in "$signing_key" -pubout -out "$public_key" + expected_public_key="$public_key" + echo "OPENCODEX_REMOTE_AGENT_DRY_RUN_PUBLIC_KEY_FILE=$public_key" >> "$GITHUB_ENV" + else + if [ -z "$REMOTE_AGENT_SIGNING_KEY" ]; then + echo "::error::REMOTE_AGENT_SIGNING_KEY is not configured in the protected release environment" + exit 1 + fi + printf '%s' "$REMOTE_AGENT_SIGNING_KEY" > "$signing_key" + fi + REMOTE_AGENT_SIGNING_KEY_FILE="$signing_key" \ + REMOTE_AGENT_EXPECTED_PUBLIC_KEY_FILE="$expected_public_key" \ + GITHUB_SHA="$GITHUB_SHA" \ + bun scripts/remote-agent-bundle.ts assemble \ + remote-agent-artifacts \ + bin/remote-agent \ + .tmp/remote-agent-release + - name: Publish (or dry-run) env: DRY_RUN: ${{ inputs.dry-run }} NPM_DIST_TAG: ${{ inputs.tag }} + OPENCODEX_REQUIRE_REMOTE_AGENT_BUNDLE: "1" + OPENCODEX_RELEASE_DRY_RUN: ${{ inputs.dry-run }} run: | if [ "$DRY_RUN" = "true" ]; then echo "::notice::DRY RUN — building + packing, not publishing" @@ -452,5 +569,6 @@ jobs: git push origin "refs/tags/${release_tag}" fi - gh release create "$release_tag" --target "$GITHUB_SHA" --title "$release_tag" \ + gh release create "$release_tag" .tmp/remote-agent-release/* \ + --target "$GITHUB_SHA" --title "$release_tag" \ --notes-file "$notes_file" ${prerelease_flag:+$prerelease_flag} diff --git a/.gitignore b/.gitignore index dd44e2c67..bbfd480fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +/remote-agent/target/ .env *.log .DS_Store diff --git a/README.md b/README.md index 86b30f082..c3de09875 100644 --- a/README.md +++ b/README.md @@ -466,6 +466,14 @@ WebSocket transport is off by default. Set `"websockets": true` only if you want ### Remote access +The dashboard now has an invite-only **Remote** page for the central `opencodexpages.me` MVP. It starts +in local `ocx gui`: approve this PC with GitHub, set a separate Remote password, reserve a hostname, +and follow Tunnel/Agent provisioning. GitHub OAuth tokens are not stored or sent to the PC. The signed +privileged Linux helper installer and Windows/macOS helpers are not shipped yet, so this is not a +general release and **does not include Super Sync**. + +The LAN bind below is a separate, self-managed option and does not use the central Remote service. + By default opencodex binds to `127.0.0.1` (loopback) and requires no extra authentication. If you set `"hostname": "0.0.0.0"` to expose the proxy on the LAN, opencodex requires a bearer token to protect both the management API (`/api/*`) and the data-plane (`/v1/responses`, diff --git a/bun.lock b/bun.lock index 7da1b7b91..3fead0905 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@bufbuild/protobuf": "^2.12.0", "@modelcontextprotocol/sdk": "^1", + "@noble/hashes": "2.2.0", "bun": "1.3.14", "zod": "4.4.3", }, @@ -29,6 +30,8 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A=="], "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-FFj3QdU/OhlDyZOJ8CWfN5eWLpRlT4qjZg7lMQi7jA6GuoY5ajlO1zWLP/MuHYRSbXQUvV52RejNi8DVnAp13w=="], diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 000000000..df3c1c227 --- /dev/null +++ b/design-qa.md @@ -0,0 +1,32 @@ +# Design QA — OpenCodex Remote + +result: blocked + +## Sources + +- `platform/docs/assets/instances-reference.png` — approved Instances screen, 1488×1058. +- `platform/docs/assets/agent-onboarding-reference.png` — approved Agent onboarding, 1488×1058. +- Implementation: `platform/web/src/App.tsx`, `platform/web/src/styles.css`. + +## Completed checks + +- Both approved source images were opened at original resolution before implementation. +- Existing OpenCodex logo and dark design tokens were reused. +- Visible UI icons come from `@tabler/icons-react`; no custom SVG or placeholder illustration was added. +- Desktop and responsive CSS states, loading/error/empty/login/invite states, primary actions, form flow, copy actions, and destructive confirmations are implemented. +- TypeScript and production build passed after the final auth/invite handoff edits. + +## Blocker + +The environment had no browser tool and no previously selected browser. The Product Design workflow requires permission before using Playwright directly. Permission was requested, but the task changed to a branch handoff before a response was received. Therefore no same-viewport implementation screenshot exists and the mandatory side-by-side source/implementation comparison cannot honestly be marked passed. + +## Required next pass + +1. Start `VITE_REMOTE_DEMO=true bun run dev:web` in `platform/`. +2. With the user's selected browser or approved Playwright Chromium, capture 1488×1058 Instances. +3. Click New instance, reserve the demo instance, and capture 1488×1058 Install Agent. +4. Put each reference and matching implementation capture into one side-by-side image. +5. Review typography, grid, spacing, colors, icon alignment, borders, states, keyboard/focus, tablet, and mobile. +6. Fix all P0/P1/P2 findings and change only the final line below when the comparison genuinely passes. + +Final result: blocked diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index adc970bc8..188455bf0 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -29,6 +29,21 @@ they expire or the proxy restarts. Only a dashboard bound to a non-loopback host the admin token (`OPENCODEX_ADMIN_AUTH_TOKEN`, or the auto-generated `~/.opencodex/admin-api-token` file). +## Remote private beta + +The **Remote** page makes the local `ocx gui` the start of remote setup: + +1. Continue with GitHub. `opencodexpages.me` handles OAuth and asks you to approve the device code. +2. Set a separate Remote password. GitHub OAuth tokens are not stored or sent to the local PC. +3. Choose a unique one-level `.opencodexpages.me` address and a unique name for this computer. +4. Start the signed, unprivileged Agent from the page. It opens one outbound encrypted WSS connection; + no root access, port forwarding, or inbound listener is required. Visiting the assigned hostname + later requires both the owning GitHub account and the Remote password. + +This remains an invite-only beta. Release packages carry signature-verified Agent binaries for Linux, +macOS, and Windows on x64 and arm64. Unsupported OS/architecture combinations fail honestly instead of +compiling code on the user's computer. **Super Sync is not part of this flow.** + ## What you can do | Area | What it does | @@ -43,6 +58,7 @@ the admin token (`OPENCODEX_ADMIN_AUTH_TOKEN`, or the auto-generated | **Providers** | Add, edit, enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. For Claude (Anthropic) OAuth pools, each logged-in account shows its own 5-hour and weekly rate-limit bars (usage is per credential); a failed probe keeps the last-known bars and marks them unavailable until the next successful refresh. | | **Add provider** | Search registry-backed presets for account login, API-key services, local servers, or a custom endpoint. | | **Codex Auth** | Add ChatGPT/Codex pool accounts, select the next-session account, refresh 5h / weekly / 30d quotas, enable or disable quota auto-switch, set its 1–100% threshold, and configure transient-failure failover. | +| **Remote** | Connect this PC to the central GitHub identity service, set a separate E2EE password, reserve its hostname, and start its signed outbound Agent. | | **Subagents** | Feature up to five bare native or namespaced routed models in the `spawn_agent` override list. | | **Models** | Toggle native GPT and routed models, set provider allowlists and context caps, choose v1/base/v2, and configure the v2 thread limit. Configured providers stay visible as zero-model groups when discovery is off or returns no rows. | | **Logs** | Auto-refresh recent requests with tokens, requested effort and (when available) effective outbound effort, resolved model, provider, status, request id, duration, and error details. The detail view includes the exact reasoning wire field when the adapter emits one. Filter by opaque conversation/session id (when the client sends one) to total tokens and estimated list-price cost for the currently loaded Logs ring. | @@ -121,6 +137,8 @@ The GUI is a thin client over the proxy's JSON management API. Useful endpoints | Endpoint | Purpose | | --- | --- | | `GET` / `PUT /api/settings` | Read settings or toggle Codex autostart. | +| `GET /api/remote/status` · `POST /api/remote/link` · `PUT /api/remote/password` | Read local Remote state, start device approval, and set the separate access password without exposing device or GitHub tokens to the browser. | +| `POST /api/remote/activate` · `POST /api/remote/agent/start` · `POST /api/remote/pairing-code` · `DELETE /api/remote/device` | Reserve the workspace/computer names, start the packaged Agent, use the legacy pairing path, or revoke this PC. | | `GET /api/startup-health` | Read secret-free routing, service, shim, and restart-safety diagnostics. | | `POST /api/startup-action` | Install the background service or Codex launcher shim through fixed, allowlisted actions. | | `GET` / `POST /api/windows-tray` | Read or change the Windows tray installation and visible-process state. POST accepts `install`, `start`, `stop`, or `uninstall`. | diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index 38bd2086b..2a57cf50f 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -21,6 +21,17 @@ ocx start bun run dev:gui ``` +## Remote プライベートベータ + +**Remote** ページでは、ローカルの `ocx gui` からリモート設定を始めます。 + +1. GitHub で続行します。`opencodexpages.me` が OAuth を処理し、デバイスコードの承認を求めます。 +2. GitHub パスワードとは別の Remote パスワードを設定します。GitHub OAuth トークンは保存せず、ローカル PC にも送りません。 +3. 重複しない 1 階層の `<名前>.opencodexpages.me` と、この PC 固有の名前を選びます。 +4. ページから署名済み非特権 Agent を起動します。Agent は暗号化 outbound WSS 接続を 1 本だけ開くため、root、ポート転送、inbound listener は不要です。割り当て済みアドレスでは所有者の GitHub アカウントと Remote パスワードを確認します。 + +現在は招待制プライベートベータです。release package には Linux、macOS、Windows の x64・arm64 用署名検証済み Agent が含まれます。未対応 OS/architecture ではユーザー PC 上でコンパイルせず明確に停止します。**Super Sync はこのフローに含まれません。** + ## できること | 領域 | 機能 | @@ -35,6 +46,7 @@ bun run dev:gui | **プロバイダー** | プロバイダーを追加、編集、有効化/無効化、削除し、対応する OAuth アカウントプールと API キープールを管理します。Claude(Anthropic)OAuth プールでは、ログイン済みの各アカウントに独自の 5 時間・週間レート制限バーが表示され(利用量は資格情報単位)、取得失敗時は直近の値を保持して一時利用不可と表示します。 | | **プロバイダー追加** | レジストリベースのプリセットからアカウントログイン、API キーサービス、ローカルサーバー、custom エンドポイントを検索します。 | | **Codex 認証** | ChatGPT/Codex プールアカウントを追加し、次回セッションアカウントを選び、5 時間 / 週間 / 30 日クォータを更新し、クォータ自動切り替えのオン/オフと 1~100% のしきい値、一時的失敗フェイルオーバーを設定します。 | +| **Remote** | この PC を中央 GitHub ID サービスへ接続し、別の E2EE パスワードと hostname を設定して署名済み outbound Agent を起動します。 | | **サブエージェント** | `spawn_agent` オーバーライド一覧にネイティブまたはルーティングモデルを最大 5 つまで優先公開します。 | | **モデル** | ネイティブ GPT とルーティングモデルをオン/オフし、プロバイダー許可リストとコンテキスト上限、v1/base/v2、v2 スレッド数を設定します。 | | **ログ** | トークン、要求された強度と(利用可能な場合は)実際に送信された強度、実際のモデル、プロバイダー、状態、リクエスト ID、所要時間、エラー詳細を含む最近のリクエストを自動更新します。アダプターが reasoning パラメーターを送信した場合、詳細表示に正確な wire field も表示されます。 | @@ -98,6 +110,8 @@ GUI はプロキシの JSON 管理 API を使うシンクライアントです | エンドポイント | 用途 | --- | --- | | `GET` / `PUT /api/settings` | 設定を読むか Codex 自動起動をオン/オフします。 | +| `GET /api/remote/status` · `POST /api/remote/link` · `PUT /api/remote/password` | デバイス/GitHub トークンをブラウザへ公開せず、Remote 状態、デバイス承認、アクセスパスワードを管理します。 | +| `POST /api/remote/activate` · `POST /api/remote/agent/start` · `POST /api/remote/pairing-code` · `DELETE /api/remote/device` | workspace/PC 名の予約、同梱 Agent の起動、従来のペアリング経路、またはこの PC の失効を行います。 | | `GET /api/startup-health` | 秘密情報を含まないルーティング、サービス、shim、再起動安全性診断を読み取ります。 | | `GET` / `POST /api/windows-tray` | Windows トレイの導入・表示状態を読み取り、`install`、`start`、`stop`、`uninstall` を実行します。 | | `POST /api/sync` | 共有モデルカタログを再構築し Codex モデルキャッシュを古い状態としてマークします。 | diff --git a/docs-site/src/content/docs/ja/reference/cli.md b/docs-site/src/content/docs/ja/reference/cli.md index 8476d4cd1..3d6a3d7a4 100644 --- a/docs-site/src/content/docs/ja/reference/cli.md +++ b/docs-site/src/content/docs/ja/reference/cli.md @@ -339,6 +339,17 @@ ocx login xai ## ダッシュボード +### `ocx remote <サブコマンド>` + +ダッシュボードの **Remote** ページに対応する headless コマンドです。`status`、`link`、 +`activate --name <名前> --slug ` で hostname を予約して署名済み同梱 Agent を起動し、 +`pairing-code` は従来の 10 分ペアリング経路、`disconnect --yes` は解除を同じ local management API で実行します。別の Remote パスワードは process argument や shell history +に残らないよう stdin から渡します。 + +```bash +printf '%s\n' 'long-remote-password' | ocx remote password +``` + ### `ocx gui` `http://localhost:` で [ウェブダッシュボード](/ja/guides/web-dashboard/) を開きます。 diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index 63ef1b0da..43ef6e422 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -21,6 +21,17 @@ ocx start bun run dev:gui ``` +## Remote 비공개 베타 + +**Remote** 페이지는 로컬 `ocx gui`에서 원격 설정을 시작합니다. + +1. GitHub로 계속합니다. `opencodexpages.me`가 OAuth를 처리하고 장치 코드를 승인받습니다. +2. GitHub 비밀번호와 별개의 Remote 비밀번호를 만듭니다. GitHub OAuth 토큰은 저장하거나 로컬 PC로 보내지 않습니다. +3. 중복되지 않는 한 단계 `<이름>.opencodexpages.me` 주소와 이 컴퓨터의 고유 이름을 고릅니다. +4. 페이지에서 서명된 비특권 Agent를 시작합니다. Agent는 암호화된 outbound WSS 연결 하나만 열기 때문에 root, 포트포워딩, inbound listener가 필요 없습니다. 이후 주소에 접속할 때는 소유 GitHub 계정과 Remote 비밀번호를 모두 확인합니다. + +현재는 초대형 비공개 베타입니다. 릴리스 패키지는 Linux, macOS, Windows의 x64·arm64용 서명 검증 Agent를 동봉합니다. 지원하지 않는 OS/아키텍처에서는 사용자 PC에서 임의 컴파일하지 않고 명확히 중단합니다. **Super Sync는 이 흐름의 범위가 아닙니다.** + ## 할 수 있는 일 | 영역 | 기능 | @@ -35,6 +46,7 @@ bun run dev:gui | **Providers** | 프로바이더를 추가, 편집, 활성화/비활성화, 제거하고, 지원되는 OAuth 계정 풀과 API key 풀을 관리합니다. Claude(Anthropic) OAuth 풀에서는 로그인한 계정마다 자체 5시간·주간 한도 막대가 표시되며(사용량은 자격 증명 단위), 조회 실패 시 마지막 값을 유지하고 일시 불가 상태로 표시합니다. | | **Add provider** | 레지스트리 기반 프리셋에서 계정 로그인, API key 서비스, 로컬 서버, custom endpoint를 검색합니다. | | **Codex Auth** | ChatGPT/Codex 풀 계정을 추가하고, 다음 세션 계정을 선택하고, 5시간 / 주간 / 30일 할당량을 갱신하며, 할당량 자동 전환을 켜거나 끄고 1~100% 임계값과 일시적 실패 failover를 설정합니다. | +| **Remote** | 이 PC를 중앙 GitHub 신원 서비스에 연결하고, 별도 E2EE 비밀번호와 hostname을 설정한 뒤 서명된 outbound Agent를 시작합니다. | | **Subagents** | `spawn_agent` override 목록에 네이티브 또는 라우팅 모델을 최대 5개까지 우선 노출합니다. | | **Models** | 네이티브 GPT와 라우팅 모델을 켜고 끄고, 프로바이더 allowlist와 컨텍스트 상한, v1/base/v2, v2 thread 수를 설정합니다. | | **Logs** | 토큰, 요청한 강도와 (사용 가능한 경우) 실제 전송 강도, 실제 모델, 프로바이더, 상태, 요청 id, 소요 시간, 오류 상세가 포함된 최근 요청을 자동 갱신합니다. 어댑터가 reasoning 매개변수를 전송한 경우 상세 보기에 정확한 wire field도 표시됩니다. 클라이언트가 보낸 불투명 대화/세션 id로 필터하면 현재 로드된 Logs 링의 토큰·추정 정가 합계를 볼 수 있습니다. | @@ -100,6 +112,8 @@ GUI는 프록시의 JSON 관리 API를 사용하는 얇은 클라이언트입니 | 엔드포인트 | 용도 | | --- | --- | | `GET` / `PUT /api/settings` | 설정을 읽거나 Codex 자동 시작을 켜고 끕니다. | +| `GET /api/remote/status` · `POST /api/remote/link` · `PUT /api/remote/password` | 브라우저에 장치/GitHub 토큰을 노출하지 않고 로컬 Remote 상태를 읽고 장치 승인과 접속 비밀번호를 설정합니다. | +| `POST /api/remote/activate` · `POST /api/remote/agent/start` · `POST /api/remote/pairing-code` · `DELETE /api/remote/device` | workspace/컴퓨터 이름을 예약하고 동봉 Agent를 시작하며, 기존 페어링 경로를 사용하거나 이 PC 연결을 해제합니다. | | `GET /api/startup-health` | 비밀값 없이 라우팅, 서비스, shim, 재부팅 안전성 진단을 읽습니다. | | `GET` / `POST /api/windows-tray` | Windows 트레이 설치 및 표시 상태를 읽거나 `install`, `start`, `stop`, `uninstall` 작업을 수행합니다. | | `POST /api/sync` | 공유 모델 카탈로그를 다시 만들고 Codex 모델 캐시를 오래된 상태로 표시합니다. | diff --git a/docs-site/src/content/docs/ko/reference/cli.md b/docs-site/src/content/docs/ko/reference/cli.md index 2acd60981..9e0de7826 100644 --- a/docs-site/src/content/docs/ko/reference/cli.md +++ b/docs-site/src/content/docs/ko/reference/cli.md @@ -387,6 +387,17 @@ ocx login xai ## 대시보드 +### `ocx remote <하위 명령>` + +대시보드 **Remote** 페이지의 headless 대응 명령입니다. `status`는 보호된 로컬 상태를 읽고, +`link`는 GitHub 장치 승인을 시작하고, `activate --name <이름> --slug `는 hostname을 +예약한 뒤 서명된 동봉 Agent를 시작하고, `pairing-code`는 기존 10분 페어링 경로를 유지하며, `disconnect --yes`는 이 PC를 해제합니다. +별도 비밀번호는 process argument나 shell history에 남지 않도록 stdin으로 전달합니다. + +```bash +printf '%s\n' '긴-리모트-비밀번호' | ocx remote password +``` + ### `ocx gui` `http://localhost:`에서 [웹 대시보드](/ko/guides/web-dashboard/)를 엽니다. diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 4e90900fc..4b2df36f3 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -450,6 +450,17 @@ Remove the stored OAuth credential for a provider. ## Dashboard +### `ocx remote ` + +Headless parity for the dashboard **Remote** page. `status` reads protected local state, `link` starts +GitHub device approval, `activate --name --slug ` reserves the hostname and starts the +signed packaged Agent, `pairing-code` keeps the legacy ten-minute pairing path, and `disconnect --yes` revokes this PC. Set the +separate password through stdin so it never appears in process arguments or shell history: + +```bash +printf '%s\n' 'a-long-remote-password' | ocx remote password +``` + ### `ocx gui` Open the [web dashboard](/guides/web-dashboard/) at `http://localhost:`, auto-starting diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index d7a4c97d2..cda901463 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -21,6 +21,17 @@ ocx start bun run dev:gui ``` +## Закрытая бета Remote + +Настройка на странице **Remote** начинается в локальном `ocx gui`: + +1. Продолжите через GitHub. `opencodexpages.me` обрабатывает OAuth и просит подтвердить код устройства. +2. Задайте отдельный пароль Remote. OAuth-токены GitHub не сохраняются и не передаются на локальный ПК. +3. Выберите уникальный одноуровневый адрес `<имя>.opencodexpages.me` и уникальное имя компьютера. +4. Запустите со страницы подписанный непривилегированный Agent. Он открывает одно исходящее зашифрованное WSS-соединение без root, перенаправления портов и входящего listener. При входе по адресу проверяются аккаунт-владелец GitHub и пароль Remote. + +Это всё ещё закрытая бета по приглашениям. Release-пакет содержит проверяемые по подписи Agent для Linux, macOS и Windows на x64 и arm64. На неподдерживаемой ОС/архитектуре OCX останавливается явно и не компилирует код на ПК пользователя. **Super Sync в этот поток не входит.** + ## Возможности | Раздел | Что делает | @@ -35,6 +46,7 @@ bun run dev:gui | **Providers** | Добавление, редактирование, включение/отключение и удаление провайдеров; управление пулами OAuth-аккаунтов и пулами API-ключей там, где они поддерживаются. Для пулов Claude (Anthropic) OAuth у каждого вошедшего аккаунта свои полосы 5-часового и недельного лимита (использование по учётным данным); при сбое опроса сохраняются последние известные значения с пометкой недоступности. | | **Add provider** | Поиск по пресетам из реестра: вход по аккаунту, сервисы с API-ключом, локальные серверы или пользовательская конечная точка. | | **Codex Auth** | Добавление аккаунтов пула ChatGPT/Codex, выбор аккаунта для следующей сессии, обновление квот 5 ч / недельных / 30-дневных, включение или отключение автопереключения, настройка его порога 1–100% и failover при временных сбоях. | +| **Remote** | Подключение ПК к центральной идентификации GitHub, отдельный E2EE-пароль и hostname, затем запуск подписанного outbound Agent. | | **Subagents** | Выделение до пяти «голых» нативных или маршрутизируемых моделей с пространством имён в списке переопределений `spawn_agent`. | | **Models** | Включение и отключение нативных GPT и маршрутизируемых моделей, настройка списков разрешённых провайдеров и лимитов контекста, выбор v1/base/v2 и настройка лимита потоков v2. | | **Logs** | Автообновляемый список недавних запросов: токены, запрошенный и, когда доступен, фактически отправленный уровень рассуждений, фактическая модель, провайдер, статус, id запроса, длительность и подробности ошибок. Если адаптер отправляет параметр рассуждений, в подробностях также отображается точное wire-поле. Можно фильтровать по непрозрачному id диалога/сессии (если клиент его передаёт) и суммировать токены и оценочную стоимость по прайс-листу в пределах загруженного кольца Logs. | @@ -104,6 +116,8 @@ GUI — это тонкий клиент поверх JSON-API управлен | Эндпоинт | Назначение | | --- | --- | | `GET` / `PUT /api/settings` | Чтение настроек или переключение автозапуска Codex. | +| `GET /api/remote/status` · `POST /api/remote/link` · `PUT /api/remote/password` | Чтение состояния Remote, подтверждение устройства и настройка пароля без выдачи браузеру токенов устройства/GitHub. | +| `POST /api/remote/activate` · `POST /api/remote/agent/start` · `POST /api/remote/pairing-code` · `DELETE /api/remote/device` | Резервирование workspace/имени ПК, запуск встроенного Agent, прежний путь сопряжения или отзыв этого ПК. | | `GET /api/startup-health` | Чтение безопасной диагностики маршрутизации, службы, shim и устойчивости к перезагрузке. | | `GET` / `POST /api/windows-tray` | Чтение или изменение установки и видимости трея Windows; POST поддерживает `install`, `start`, `stop`, `uninstall`. | | `POST /api/sync` | Пересборка общего каталога моделей и инвалидация кэша моделей Codex. | diff --git a/docs-site/src/content/docs/ru/reference/cli.md b/docs-site/src/content/docs/ru/reference/cli.md index 80493b7d5..d3f53d3c6 100644 --- a/docs-site/src/content/docs/ru/reference/cli.md +++ b/docs-site/src/content/docs/ru/reference/cli.md @@ -381,6 +381,17 @@ ocx login xai ## Дашборд +### `ocx remote <подкоманда>` + +Headless-эквивалент страницы **Remote**: `status` читает защищённое локальное состояние, `link` +запускает подтверждение GitHub, `activate --name <имя> --slug ` резервирует hostname и запускает +встроенный подписанный Agent, `pairing-code` сохраняет прежний 10-минутный путь, а `disconnect --yes` отзывает этот ПК. Отдельный +пароль передаётся через stdin, чтобы не попадать в аргументы процесса и историю shell. + +```bash +printf '%s\n' 'long-remote-password' | ocx remote password +``` + ### `ocx gui` Открывает [веб-дашборд](/ru/guides/web-dashboard/) по адресу `http://localhost:`, diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index 85dc44d9b..d9f20effa 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -20,6 +20,17 @@ ocx start bun run dev:gui ``` +## Remote 私测 + +**Remote** 页面从本地 `ocx gui` 开始远程设置: + +1. 使用 GitHub 继续。`opencodexpages.me` 处理 OAuth 并要求确认设备码。 +2. 设置独立的 Remote 密码。GitHub OAuth token 不会保存,也不会发送到本地电脑。 +3. 选择不重复的一级 `<名称>.opencodexpages.me` 地址和此电脑的唯一名称。 +4. 从页面启动已签名的非特权 Agent。它只建立一条加密出站 WSS 连接,无需 root、端口转发或入站 listener。以后访问该地址时,必须同时验证所属 GitHub 账号和 Remote 密码。 + +目前仍是仅限邀请的私测。Release 包包含 Linux、macOS、Windows 的 x64 和 arm64 签名验证 Agent。对不支持的操作系统/架构,OCX 会明确停止,不在用户电脑上临时编译。**Super Sync 不属于此流程。** + ## 可以完成哪些操作 | 区域 | 作用 | @@ -34,6 +45,7 @@ bun run dev:gui | **Providers** | 添加、编辑、启用/禁用、删除 provider,并在支持时管理 OAuth 账号池和 API key 池。Claude(Anthropic)OAuth 池中,每个已登录账号显示各自的 5 小时与周限额条(用量按凭证计);探测失败时保留上次已知数值并标记为暂时不可用。 | | **Add provider** | 搜索 registry preset,选择账号登录、API key 服务、本地服务器或自定义 endpoint。 | | **Codex Auth** | 添加 ChatGPT/Codex 池账号,选择下一 session 的账号,刷新 5h / 每周 / 30d 配额,启用或停用配额自动切换,设置其 1–100% 阈值和临时故障 failover。 | +| **Remote** | 将此电脑连接到中央 GitHub 身份服务,设置独立 E2EE 密码和 hostname,然后启动已签名的 outbound Agent。 | | **Subagents** | 在 `spawn_agent` override 列表中置顶最多五个原生或路由模型。 | | **Models** | 开关原生 GPT 与路由模型,配置 provider allowlist、上下文上限、v1/base/v2 以及 v2 thread 数量。 | | **Logs** | 自动刷新近期请求,显示 token、请求强度以及(可用时)实际发送强度、实际模型、provider、状态、request id、耗时和错误详情。适配器发送 reasoning 参数时,详情中还会显示准确的 wire field。可按不透明会话/对话 ID(客户端提供时)筛选,并对当前已加载的 Logs 环形缓冲合计 token 与估算标价成本。 | @@ -92,6 +104,8 @@ GUI 是代理 JSON 管理 API 之上的轻量客户端。常用 endpoint 包括 | Endpoint | 用途 | | --- | --- | | `GET` / `PUT /api/settings` | 读取设置或切换 Codex 自动启动。 | +| `GET /api/remote/status` · `POST /api/remote/link` · `PUT /api/remote/password` | 在不向浏览器暴露设备/GitHub token 的情况下读取 Remote 状态、开始设备批准并设置访问密码。 | +| `POST /api/remote/activate` · `POST /api/remote/agent/start` · `POST /api/remote/pairing-code` · `DELETE /api/remote/device` | 保留 workspace/电脑名称、启动随包 Agent、使用旧配对路径或撤销此电脑。 | | `GET /api/startup-health` | 读取不含秘密信息的路由、服务、shim 和重启安全诊断。 | | `GET` / `POST /api/windows-tray` | 读取或更改 Windows 托盘安装和显示状态;POST 支持 `install`、`start`、`stop`、`uninstall`。 | | `POST /api/sync` | 重建共享模型目录,并把 Codex 模型缓存标记为过期。 | diff --git a/docs-site/src/content/docs/zh-cn/reference/cli.md b/docs-site/src/content/docs/zh-cn/reference/cli.md index 00f58c054..210e6fdbd 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli.md @@ -333,6 +333,17 @@ ocx login xai ## 仪表盘 +### `ocx remote <子命令>` + +这是仪表盘 **Remote** 页面的 headless 对应命令。`status` 读取受保护的本地状态,`link` 开始 +GitHub 设备批准,`activate --name <名称> --slug ` 保留 hostname 并启动随包签名 Agent, +`pairing-code` 保留旧的 10 分钟配对路径,`disconnect --yes` 撤销此电脑。独立 Remote 密码从 stdin 读取,避免 +进入进程参数或 shell 历史。 + +```bash +printf '%s\n' 'long-remote-password' | ocx remote password +``` + ### `ocx gui` 在 `http://localhost:` 打开 [Web 仪表盘](/zh-cn/guides/web-dashboard/)。如果代理 diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 2b249f752..9f13f46c7 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -13,6 +13,7 @@ import ApiKeys from "./pages/ApiKeys"; import Claude from "./pages/Claude"; import Grok from "./pages/Grok"; import Startup from "./pages/Startup"; +import Remote from "./pages/Remote"; import ErrorBoundary from "./components/ErrorBoundary"; import { SidebarGithubRow } from "./components/sidebar-github-row"; import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconKey, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconSparkle, IconX } from "./icons"; @@ -31,6 +32,7 @@ type Theme = "light" | "dark" | "system"; const PAGE_TKEY: Record = { dashboard: "nav.dashboard", startup: "nav.startup", + remote: "nav.remote", providers: "nav.providers", models: "nav.models", combos: "nav.combos", @@ -50,6 +52,7 @@ const THEME_KEY = "ocx-theme"; const NAV: { id: Page; tkey: TKey; Icon: typeof IconGrid }[] = [ { id: "dashboard", tkey: "nav.dashboard", Icon: IconGrid }, { id: "codex-auth", tkey: "nav.codexAuth", Icon: IconKey }, + { id: "remote", tkey: "nav.remote", Icon: IconGlobe }, { id: "providers", tkey: "nav.providers", Icon: IconServer }, { id: "models", tkey: "nav.models", Icon: IconBoxes }, { id: "subagents", tkey: "nav.subagents", Icon: IconBot }, @@ -305,6 +308,7 @@ export default function App() { reloadLabel={t("errorBoundary.reload")} > {page === "dashboard" && } + {page === "remote" && } {page === "startup" && } {page === "providers" && } {page === "models" && } diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index 901983211..81a8081cf 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -5,6 +5,7 @@ import { normalizeHashPath } from "./hash-routing"; export type Page = | "dashboard" | "startup" + | "remote" | "providers" | "models" | "combos" @@ -20,6 +21,7 @@ export type Page = export const VALID_PAGES = new Set([ "dashboard", "startup", + "remote", "providers", "models", "combos", diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index aeb8f7a39..60ed7edec 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -4,6 +4,7 @@ import type { TKey } from "./en"; export const de: Record = { "nav.dashboard": "Übersicht", "nav.startup": "Startsicherheit", + "nav.remote": "Remote", "nav.providers": "Anbieter", "nav.models": "Modelle", "nav.combos": "Combos", @@ -24,6 +25,83 @@ export const de: Record = { "common.remove": "Entfernen", "common.loading": "Lädt…", "common.retry": "Wiederholen", + "remote.title": "Remote", + "remote.subtitle": "Dieses OpenCodex mit deinem Konto verbinden und seine Terminals über eine verschlüsselte ausgehende Relay-Verbindung nutzen.", + "remote.refresh": "Remote-Status aktualisieren", + "remote.progressLabel": "Remote-Einrichtung", + "remote.stepAccount": "GitHub-Konto", + "remote.stepPassword": "Zugriffspasswort", + "remote.stepTunnel": "Computerverbindung", + "remote.loading": "Remote-Status wird geladen…", + "remote.loadFailed": "Remote-Status konnte nicht geladen werden.", + "remote.signedOutTitle": "Diesen Computer verbinden", + "remote.signedOutBody": "Die Anmeldung läuft über den OpenCodex-Dienst. GitHub bestätigt nur deine Identität; dieses lokale OCX erhält nur einen OpenCodex-Gerätenachweis.", + "remote.signIn": "Mit GitHub fortfahren", + "remote.starting": "Wird gestartet…", + "remote.identityTitle": "Identität bleibt zentral", + "remote.identityBody": "Der Dienst verarbeitet GitHub OAuth und sendet dein GitHub-Token nie an diesen Computer.", + "remote.deviceTitle": "Eigener Nachweis pro Gerät", + "remote.deviceBody": "Dieses OCX erstellt einen eigenen Geräteschlüssel und kann einzeln widerrufen werden.", + "remote.tunnelTitle": "Kein eingehender Port", + "remote.tunnelBody": "Der Agent öffnet nur eine ausgehende verschlüsselte Verbindung; dein Heim- oder Büronetz bleibt verborgen.", + "remote.startFailed": "GitHub-Anmeldung konnte nicht gestartet werden.", + "remote.cancelFailed": "Geräteanmeldung konnte nicht abgebrochen werden.", + "remote.pendingTitle": "Diesen Computer im Browser bestätigen", + "remote.pendingBody": "Schließe die GitHub-Anmeldung auf opencodexpages.me ab und vergleiche den angezeigten Code.", + "remote.codeLabel": "Gerätecode", + "remote.openBrowser": "Anmeldeseite öffnen", + "remote.waiting": "Warten auf Browser-Bestätigung…", + "remote.connectedAs": "Verbunden als", + "remote.passwordTitle": "Remote-Zugriffspasswort erstellen", + "remote.passwordBody": "Dieses Passwort schützt den Browserzugriff und ist von deinem GitHub-Passwort getrennt.", + "remote.passwordLabel": "Remote-Passwort", + "remote.passwordConfirm": "Passwort bestätigen", + "remote.passwordLength": "Mindestens 10 Zeichen verwenden.", + "remote.passwordMismatch": "Die Passwörter stimmen nicht überein.", + "remote.passwordFailed": "Remote-Passwort konnte nicht gespeichert werden.", + "remote.savePassword": "Remote-Passwort speichern", + "remote.accountReadyTitle": "Konto und Gerät sind bereit", + "remote.accountReadyBody": "GitHub-Identität, dieser Computer und das Remote-Passwort sind verbunden.", + "remote.connectorNext": "Als Nächstes: den signierten OCX-Connector starten und diesem Computer eine opencodexpages.me-Adresse zuweisen.", + "remote.privateBetaTitle": "Privater Beta-Zugang erforderlich", + "remote.privateBetaBody": "Das Konto ist verbunden, aber Remote kann auf diesem Server nur mit Einladung aktiviert werden.", + "remote.domainTitle": "Adresse dieses Computers wählen", + "remote.domainBody": "OpenCodex reserviert eine eindeutige opencodexpages.me-Adresse für dein Konto und diesen Computer.", + "remote.deviceNameLabel": "Computername", + "remote.deviceNamePlaceholder": "Heim-PC", + "remote.domainLabel": "Remote-Adresse", + "remote.domainSuffix": ".opencodexpages.me", + "remote.domainInvalid": "Nur Kleinbuchstaben, Zahlen und Bindestriche verwenden.", + "remote.activateFailed": "Remote konnte für diesen Computer nicht aktiviert werden.", + "remote.activating": "Adresse wird reserviert…", + "remote.activate": "Remote aktivieren", + "remote.provisioningTitle": "Sichere Adresse wird vorbereitet", + "remote.provisioningBody": "Der zentrale Dienst reserviert die Adresse und bereitet den verschlüsselten Relay-Zugang vor.", + "remote.connectorTitle": "Lokalen Helfer verbinden", + "remote.connectorBody": "Die Adresse ist bereit. Starte den signierten, unprivilegierten Agent mit einer ausgehenden verschlüsselten Relay-Verbindung.", + "remote.pairingCode": "Einmaliger Kopplungscode", + "remote.pairingFailed": "Connector-Kopplung konnte nicht vorbereitet werden.", + "remote.preparingConnector": "Wird vorbereitet…", + "remote.prepareConnector": "Connector vorbereiten", + "remote.connectorRequirement": "Der Connector läuft als aktueller Benutzer ohne root oder Portweiterleitung. OCX muss die signierte Binärdatei für diese Plattform enthalten.", + "remote.connectingTitle": "Computer wird verbunden", + "remote.connectingBody": "Der Agent ist gestartet. OpenCodex-, Agent-, Relay- und Gateway-Prüfungen laufen.", + "remote.readyTitle": "Remote ist online", + "remote.readyBody": "Die lokale OpenCodex-GUI ist über die authentifizierte Adresse erreichbar.", + "remote.openRemote": "Remote-GUI öffnen", + "remote.unavailableTitle": "Remote benötigt Aufmerksamkeit", + "remote.unavailableBody": "Die Instanz ist angehalten oder konnte nicht bereitgestellt werden.", + "remote.disconnect": "Trennen", + "remote.disconnectConfirm": "Diesen Computer von OpenCodex Remote trennen?", + "remote.disconnectFailed": "Dieses Gerät konnte nicht widerrufen werden.", + "remote.devicesTitle": "Gekoppelte Computer", + "remote.devicesBody": "Jeder Computer hält eine ausgehende verschlüsselte Relay-Verbindung. Eingehende Ports oder Routereinstellungen sind nicht erforderlich.", + "remote.changePasswordTitle": "Verschlüsselungspasswort ändern", + "remote.changePasswordBody": "Das alte Passwort ist erforderlich. Der Tresor wird lokal neu verschlüsselt, bevor der Server die neue Hülle erhält.", + "remote.currentPassword": "Aktuelles Passwort", + "remote.newPassword": "Neues Passwort", + "remote.confirmNewPassword": "Neues Passwort bestätigen", + "remote.changePassword": "Passwort ändern", "theme.label": "Design", "theme.light": "Hell", "theme.dark": "Dunkel", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index a710368fe..7e18c6f20 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -4,6 +4,7 @@ export const en = { // sidebar / nav / common "nav.dashboard": "Dashboard", "nav.startup": "Startup", + "nav.remote": "Remote", "nav.providers": "Providers", "nav.models": "Models", "nav.combos": "Combos", @@ -26,6 +27,83 @@ export const en = { "common.remove": "Remove", "common.loading": "Loading…", "common.retry": "Retry", + "remote.title": "Remote", + "remote.subtitle": "Connect this OpenCodex installation to your account, then use its encrypted terminals from your Remote address.", + "remote.refresh": "Refresh Remote status", + "remote.progressLabel": "Remote setup progress", + "remote.stepAccount": "OpenCodex account", + "remote.stepPassword": "Access password", + "remote.stepTunnel": "Computer link", + "remote.loading": "Loading Remote status…", + "remote.loadFailed": "Could not load Remote status.", + "remote.signedOutTitle": "Connect this computer", + "remote.signedOutBody": "Connect this computer through OpenCodex Remote. This local OCX receives only a revocable OpenCodex device credential.", + "remote.signIn": "Connect Remote", + "remote.starting": "Starting…", + "remote.identityTitle": "Identity stays central", + "remote.identityBody": "The service handles GitHub OAuth and never sends your GitHub token to this computer.", + "remote.deviceTitle": "One credential per device", + "remote.deviceBody": "This OCX creates its own device key and can be revoked without signing out your other computers.", + "remote.tunnelTitle": "No inbound port", + "remote.tunnelBody": "This computer opens one outbound WSS connection. No router port or inbound firewall rule is required.", + "remote.startFailed": "Could not start GitHub sign-in.", + "remote.cancelFailed": "Could not cancel device sign-in.", + "remote.pendingTitle": "Approve this computer in your browser", + "remote.pendingBody": "Approve this computer on opencodexpages.me and confirm that the code shown there matches this local code.", + "remote.codeLabel": "Device code", + "remote.openBrowser": "Open sign-in page", + "remote.waiting": "Waiting for browser approval…", + "remote.connectedAs": "Connected as", + "remote.passwordTitle": "Create a Remote access password", + "remote.passwordBody": "This separate password encrypts your Remote vault locally with Argon2id and AES-GCM.", + "remote.passwordLabel": "Remote password", + "remote.passwordConfirm": "Confirm password", + "remote.passwordLength": "Use at least 10 characters.", + "remote.passwordMismatch": "The passwords do not match.", + "remote.passwordFailed": "Could not save the Remote password.", + "remote.savePassword": "Save Remote password", + "remote.accountReadyTitle": "Account and device are ready", + "remote.accountReadyBody": "GitHub identity, this computer, and the Remote access password are connected.", + "remote.connectorNext": "Next: install and start the signed OCX connector, then assign this computer its opencodexpages.me address.", + "remote.privateBetaTitle": "Private beta access required", + "remote.privateBetaBody": "This account is connected, but Remote activation is still invite-only on this server.", + "remote.domainTitle": "Choose this computer's address", + "remote.domainBody": "Reserve one account Remote address, then register this computer under a unique name.", + "remote.deviceNameLabel": "Computer name", + "remote.deviceNamePlaceholder": "Home PC", + "remote.domainLabel": "Remote address", + "remote.domainSuffix": ".opencodexpages.me", + "remote.domainInvalid": "Use lowercase letters, numbers, and hyphens for the address.", + "remote.activateFailed": "Could not activate Remote for this computer.", + "remote.activating": "Reserving address…", + "remote.activate": "Enable Remote", + "remote.provisioningTitle": "Creating the Remote workspace", + "remote.provisioningBody": "The central service is reserving the account address and registering this computer.", + "remote.connectorTitle": "Start the local connector", + "remote.connectorBody": "The address is ready. Start the signed, unprivileged Agent that maintains one outbound encrypted relay connection.", + "remote.pairingCode": "One-time pairing code", + "remote.pairingFailed": "Could not prepare connector pairing.", + "remote.preparingConnector": "Preparing…", + "remote.prepareConnector": "Start connector", + "remote.connectorRequirement": "The connector runs as your current user and needs no root access or port forwarding. A signed binary for this platform must be installed with OCX.", + "remote.connectingTitle": "Connecting this computer", + "remote.connectingBody": "The helper is paired. Waiting for the local OCX, Agent, Tunnel, and Gateway checks to pass.", + "remote.readyTitle": "Remote is online", + "remote.readyBody": "This computer's shell, Codex CLI, and Claude Code are available through the end-to-end encrypted Remote workspace.", + "remote.openRemote": "Open Remote workspace", + "remote.unavailableTitle": "Remote needs attention", + "remote.unavailableBody": "This instance is suspended or could not be provisioned. Refresh the status or contact the operator.", + "remote.disconnect": "Disconnect", + "remote.disconnectConfirm": "Disconnect this computer from OpenCodex Remote?", + "remote.disconnectFailed": "Could not revoke this device.", + "remote.devicesTitle": "Paired computers", + "remote.devicesBody": "Each computer keeps one outbound encrypted relay connection. No inbound port or router setting is required.", + "remote.changePasswordTitle": "Change encryption password", + "remote.changePasswordBody": "The old password is required. The vault is re-encrypted locally before the server receives the new envelope.", + "remote.currentPassword": "Current password", + "remote.newPassword": "New password", + "remote.confirmNewPassword": "Confirm new password", + "remote.changePassword": "Change password", "app.logoAria": "opencodex logo", "app.claudeOn": "Claude ON", "app.claudeOff": "Claude OFF", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 86cb31317..7022f0758 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -4,6 +4,7 @@ export const ja: Record = { // sidebar / nav / common "nav.dashboard": "ダッシュボード", "nav.startup": "起動安全性", + "nav.remote": "リモート", "nav.providers": "プロバイダー", "nav.models": "モデル", "nav.combos": "コンボ", @@ -26,6 +27,83 @@ export const ja: Record = { "common.remove": "削除", "common.loading": "読み込み中…", "common.retry": "再試行", + "remote.title": "リモート", + "remote.subtitle": "この OpenCodex をアカウントに接続し、暗号化された outbound relay 経由で端末を利用します。", + "remote.refresh": "リモート状態を更新", + "remote.progressLabel": "リモート設定の進行状況", + "remote.stepAccount": "GitHub アカウント", + "remote.stepPassword": "アクセスパスワード", + "remote.stepTunnel": "コンピューター接続", + "remote.loading": "リモート状態を読み込み中…", + "remote.loadFailed": "リモート状態を読み込めませんでした。", + "remote.signedOutTitle": "このコンピューターを接続", + "remote.signedOutBody": "OpenCodex サービス経由でログインします。GitHub は本人確認のみを行い、ローカル OCX には OpenCodex のデバイス資格情報だけが渡されます。", + "remote.signIn": "GitHub で続行", + "remote.starting": "開始中…", + "remote.identityTitle": "本人確認は中央で処理", + "remote.identityBody": "サービスが GitHub OAuth を処理し、GitHub トークンをこの PC へ送りません。", + "remote.deviceTitle": "デバイスごとの資格情報", + "remote.deviceBody": "この OCX は固有のデバイスキーを作成し、他の PC に影響せず個別に解除できます。", + "remote.tunnelTitle": "受信ポート不要", + "remote.tunnelBody": "Agent は暗号化された outbound 接続を 1 本だけ開き、自宅や社内ネットワークを公開しません。", + "remote.startFailed": "GitHub ログインを開始できませんでした。", + "remote.cancelFailed": "デバイスログインをキャンセルできませんでした。", + "remote.pendingTitle": "ブラウザーでこの PC を承認してください", + "remote.pendingBody": "opencodexpages.me で GitHub ログインを完了し、表示コードが下のコードと一致することを確認してください。", + "remote.codeLabel": "デバイスコード", + "remote.openBrowser": "ログインページを開く", + "remote.waiting": "ブラウザーの承認を待っています…", + "remote.connectedAs": "接続中のアカウント", + "remote.passwordTitle": "リモートアクセス用パスワードを作成", + "remote.passwordBody": "このパスワードはリモートブラウザーアクセス用で、GitHub パスワードとは別です。", + "remote.passwordLabel": "リモートパスワード", + "remote.passwordConfirm": "パスワードの確認", + "remote.passwordLength": "10 文字以上で入力してください。", + "remote.passwordMismatch": "パスワードが一致しません。", + "remote.passwordFailed": "リモートパスワードを保存できませんでした。", + "remote.savePassword": "リモートパスワードを保存", + "remote.accountReadyTitle": "アカウントとデバイスの準備ができました", + "remote.accountReadyBody": "GitHub の本人確認、この PC、リモートアクセス用パスワードが接続されました。", + "remote.connectorNext": "次の手順: 署名済み OCX コネクターを起動し、この PC に opencodexpages.me アドレスを割り当てます。", + "remote.privateBetaTitle": "プライベートベータの承認が必要です", + "remote.privateBetaBody": "アカウントは接続済みですが、このサーバーでは招待されたアカウントのみ Remote を有効化できます。", + "remote.domainTitle": "このコンピューターのアドレスを選択", + "remote.domainBody": "OpenCodex がアカウントとこの PC 用の一意な opencodexpages.me アドレスを予約します。", + "remote.deviceNameLabel": "コンピューター名", + "remote.deviceNamePlaceholder": "自宅 PC", + "remote.domainLabel": "Remote アドレス", + "remote.domainSuffix": ".opencodexpages.me", + "remote.domainInvalid": "小文字、数字、ハイフンのみ使用できます。", + "remote.activateFailed": "このコンピューターの Remote を有効化できませんでした。", + "remote.activating": "アドレスを予約中…", + "remote.activate": "Remote を有効化", + "remote.provisioningTitle": "安全なアドレスを準備中", + "remote.provisioningBody": "中央サービスがアドレスを予約し、暗号化 relay アクセスを準備しています。", + "remote.connectorTitle": "ローカルヘルパーを接続", + "remote.connectorBody": "アドレスの準備ができました。暗号化 outbound relay を維持する署名済み非特権 Agent を起動します。", + "remote.pairingCode": "一回限りのペアリングコード", + "remote.pairingFailed": "コネクターのペアリングを準備できませんでした。", + "remote.preparingConnector": "準備中…", + "remote.prepareConnector": "コネクターを準備", + "remote.connectorRequirement": "コネクターは現在のユーザー権限で動作し、root やポート転送は不要です。このプラットフォーム用の署名済み binary が OCX に同梱されている必要があります。", + "remote.connectingTitle": "このコンピューターを接続中", + "remote.connectingBody": "Agent を起動しました。OCX、Agent、Relay、Gateway の確認を待っています。", + "remote.readyTitle": "Remote はオンラインです", + "remote.readyBody": "認証済みアドレスからローカル OpenCodex GUI にアクセスできます。", + "remote.openRemote": "Remote GUI を開く", + "remote.unavailableTitle": "Remote の確認が必要です", + "remote.unavailableBody": "インスタンスが停止中か、プロビジョニングに失敗しました。", + "remote.disconnect": "接続解除", + "remote.disconnectConfirm": "この PC を OpenCodex Remote から切断しますか?", + "remote.disconnectFailed": "このデバイスを解除できませんでした。", + "remote.devicesTitle": "ペアリング済みコンピューター", + "remote.devicesBody": "各コンピューターは暗号化されたアウトバウンド Relay 接続を1本だけ維持します。受信ポートやルーター設定は不要です。", + "remote.changePasswordTitle": "暗号化パスワードを変更", + "remote.changePasswordBody": "以前のパスワードが必要です。新しい封筒をサーバーへ送る前に、保管庫をローカルで再暗号化します。", + "remote.currentPassword": "現在のパスワード", + "remote.newPassword": "新しいパスワード", + "remote.confirmNewPassword": "新しいパスワードを確認", + "remote.changePassword": "パスワードを変更", "app.logoAria": "opencodex ロゴ", "app.claudeOn": "Claude オン", "app.claudeOff": "Claude オフ", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index f880055b0..2630c427e 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -4,6 +4,7 @@ export const ko: Record = { // sidebar / nav / common "nav.dashboard": "대시보드", "nav.startup": "시작 안전성", + "nav.remote": "리모트", "nav.providers": "프로바이더", "nav.models": "모델", "nav.combos": "콤보", @@ -24,6 +25,83 @@ export const ko: Record = { "common.remove": "삭제", "common.loading": "불러오는 중…", "common.retry": "재시도", + "remote.title": "리모트", + "remote.subtitle": "이 컴퓨터의 OpenCodex를 계정에 연결하고 리모트 주소에서 종단간 암호화 터미널을 사용합니다.", + "remote.refresh": "리모트 상태 새로고침", + "remote.progressLabel": "리모트 설정 진행 상태", + "remote.stepAccount": "OpenCodex 계정", + "remote.stepPassword": "접속 비밀번호", + "remote.stepTunnel": "컴퓨터 연결", + "remote.loading": "리모트 상태를 불러오는 중…", + "remote.loadFailed": "리모트 상태를 불러오지 못했습니다.", + "remote.signedOutTitle": "이 컴퓨터 연결", + "remote.signedOutBody": "OpenCodex Remote에 이 컴퓨터를 연결합니다. 이 로컬 OCX에는 폐기 가능한 OpenCodex 장치 자격증명만 전달됩니다.", + "remote.signIn": "Remote 연결", + "remote.starting": "시작 중…", + "remote.identityTitle": "신원 처리는 중앙에서", + "remote.identityBody": "서버가 GitHub OAuth를 처리하며 GitHub 토큰은 이 컴퓨터로 전달하지 않습니다.", + "remote.deviceTitle": "컴퓨터별 자격증명", + "remote.deviceBody": "각 OCX가 자체 장치 키를 만들기 때문에 다른 컴퓨터의 로그인을 유지한 채 이 장치만 해제할 수 있습니다.", + "remote.tunnelTitle": "인바운드 포트 불필요", + "remote.tunnelBody": "이 컴퓨터가 중앙으로 WSS 연결 하나만 엽니다. 공유기 포트포워딩이나 인바운드 방화벽 설정은 필요 없습니다.", + "remote.startFailed": "GitHub 로그인을 시작하지 못했습니다.", + "remote.cancelFailed": "장치 로그인을 취소하지 못했습니다.", + "remote.pendingTitle": "브라우저에서 이 컴퓨터를 승인하세요", + "remote.pendingBody": "opencodexpages.me에서 이 컴퓨터를 승인하고 사이트의 코드가 아래 로컬 코드와 같은지 확인하세요.", + "remote.codeLabel": "장치 코드", + "remote.openBrowser": "로그인 페이지 열기", + "remote.waiting": "브라우저 승인을 기다리는 중…", + "remote.connectedAs": "연결된 계정", + "remote.passwordTitle": "리모트 접속 비밀번호 만들기", + "remote.passwordBody": "GitHub 비밀번호와 별개이며 Argon2id와 AES-GCM으로 리모트 금고를 로컬에서 암호화합니다.", + "remote.passwordLabel": "리모트 비밀번호", + "remote.passwordConfirm": "비밀번호 확인", + "remote.passwordLength": "10자 이상 입력하세요.", + "remote.passwordMismatch": "비밀번호가 일치하지 않습니다.", + "remote.passwordFailed": "리모트 비밀번호를 저장하지 못했습니다.", + "remote.savePassword": "리모트 비밀번호 저장", + "remote.accountReadyTitle": "계정과 이 컴퓨터가 준비됐습니다", + "remote.accountReadyBody": "GitHub 신원, 이 컴퓨터, 리모트 접속 비밀번호 연결이 완료됐습니다.", + "remote.connectorNext": "다음 단계: 서명된 OCX 커넥터를 설치·실행하고 이 컴퓨터에 opencodexpages.me 주소를 할당합니다.", + "remote.privateBetaTitle": "비공개 베타 승인이 필요합니다", + "remote.privateBetaBody": "계정 연결은 끝났지만 현재 서버는 초대받은 계정만 리모트를 활성화할 수 있습니다.", + "remote.domainTitle": "이 컴퓨터의 주소 선택", + "remote.domainBody": "계정용 리모트 주소 하나를 예약하고 이 컴퓨터를 중복되지 않는 이름으로 등록합니다.", + "remote.deviceNameLabel": "컴퓨터 이름", + "remote.deviceNamePlaceholder": "집 PC", + "remote.domainLabel": "리모트 주소", + "remote.domainSuffix": ".opencodexpages.me", + "remote.domainInvalid": "주소에는 영문 소문자, 숫자, 하이픈만 사용할 수 있습니다.", + "remote.activateFailed": "이 컴퓨터의 리모트를 활성화하지 못했습니다.", + "remote.activating": "주소 예약 중…", + "remote.activate": "리모트 활성화", + "remote.provisioningTitle": "리모트 작업공간 생성 중", + "remote.provisioningBody": "중앙 서버가 계정 주소를 예약하고 이 컴퓨터를 등록하고 있습니다.", + "remote.connectorTitle": "로컬 커넥터 시작", + "remote.connectorBody": "주소 준비가 끝났습니다. 중앙으로 암호화 Relay 연결 하나를 유지하는 비특권 서명 Agent를 시작합니다.", + "remote.pairingCode": "일회용 페어링 코드", + "remote.pairingFailed": "커넥터 페어링을 준비하지 못했습니다.", + "remote.preparingConnector": "준비 중…", + "remote.prepareConnector": "커넥터 시작", + "remote.connectorRequirement": "커넥터는 현재 사용자 권한으로 실행되며 root나 포트포워딩이 필요 없습니다. 이 플랫폼용 서명 바이너리는 OCX에 함께 설치되어야 합니다.", + "remote.connectingTitle": "이 컴퓨터 연결 중", + "remote.connectingBody": "헬퍼 페어링이 끝났습니다. 로컬 OCX, Agent, Tunnel, Gateway 검사를 기다리고 있습니다.", + "remote.readyTitle": "리모트 온라인", + "remote.readyBody": "종단간 암호화 리모트 작업공간에서 이 컴퓨터의 Shell, Codex CLI, Claude Code를 사용할 수 있습니다.", + "remote.openRemote": "리모트 작업공간 열기", + "remote.unavailableTitle": "리모트 확인 필요", + "remote.unavailableBody": "인스턴스가 정지됐거나 프로비저닝에 실패했습니다. 상태를 새로고침하거나 운영자에게 문의하세요.", + "remote.disconnect": "연결 해제", + "remote.disconnectConfirm": "이 컴퓨터의 OpenCodex Remote 연결을 해제할까요?", + "remote.disconnectFailed": "이 장치를 해제하지 못했습니다.", + "remote.devicesTitle": "연결된 컴퓨터", + "remote.devicesBody": "각 컴퓨터는 중앙으로 암호화 Relay 연결 하나만 유지합니다. 인바운드 포트나 공유기 설정은 필요 없습니다.", + "remote.changePasswordTitle": "암호화 비밀번호 변경", + "remote.changePasswordBody": "이전 비밀번호가 필요합니다. 서버에 새 봉투를 보내기 전에 로컬에서 금고를 다시 암호화합니다.", + "remote.currentPassword": "현재 비밀번호", + "remote.newPassword": "새 비밀번호", + "remote.confirmNewPassword": "새 비밀번호 확인", + "remote.changePassword": "비밀번호 변경", "theme.label": "테마", "theme.light": "라이트", "theme.dark": "다크", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index cf49ced34..50bbb7b9a 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -4,6 +4,7 @@ export const ru: Record = { // sidebar / nav / common "nav.dashboard": "Дашборд", "nav.startup": "Безопасность запуска", + "nav.remote": "Удалённый доступ", "nav.providers": "Провайдеры", "nav.models": "Модели", "nav.combos": "Комбо", @@ -26,6 +27,83 @@ export const ru: Record = { "common.remove": "Удалить", "common.loading": "Загрузка…", "common.retry": "Повторить", + "remote.title": "Удалённый доступ", + "remote.subtitle": "Подключите этот OpenCodex к аккаунту и работайте с терминалами через зашифрованный исходящий relay.", + "remote.refresh": "Обновить состояние Remote", + "remote.progressLabel": "Настройка удалённого доступа", + "remote.stepAccount": "Аккаунт GitHub", + "remote.stepPassword": "Пароль доступа", + "remote.stepTunnel": "Подключение ПК", + "remote.loading": "Загрузка состояния Remote…", + "remote.loadFailed": "Не удалось загрузить состояние Remote.", + "remote.signedOutTitle": "Подключить этот компьютер", + "remote.signedOutBody": "Вход выполняется через сервис OpenCodex. GitHub подтверждает личность, а локальный OCX получает только учётные данные устройства OpenCodex.", + "remote.signIn": "Продолжить через GitHub", + "remote.starting": "Запуск…", + "remote.identityTitle": "Идентификация на центральном сервере", + "remote.identityBody": "Сервис обрабатывает GitHub OAuth и не отправляет токен GitHub на этот компьютер.", + "remote.deviceTitle": "Отдельные данные для устройства", + "remote.deviceBody": "Этот OCX создаёт собственный ключ устройства, который можно отозвать отдельно.", + "remote.tunnelTitle": "Без входящего порта", + "remote.tunnelBody": "Agent открывает только одно исходящее зашифрованное соединение, не публикуя домашнюю или офисную сеть.", + "remote.startFailed": "Не удалось начать вход через GitHub.", + "remote.cancelFailed": "Не удалось отменить вход устройства.", + "remote.pendingTitle": "Подтвердите компьютер в браузере", + "remote.pendingBody": "Завершите вход GitHub на opencodexpages.me и сравните показанный там код с локальным.", + "remote.codeLabel": "Код устройства", + "remote.openBrowser": "Открыть страницу входа", + "remote.waiting": "Ожидание подтверждения в браузере…", + "remote.connectedAs": "Подключено как", + "remote.passwordTitle": "Создайте пароль удалённого доступа", + "remote.passwordBody": "Этот пароль защищает доступ из браузера и отделён от пароля GitHub.", + "remote.passwordLabel": "Пароль Remote", + "remote.passwordConfirm": "Подтвердите пароль", + "remote.passwordLength": "Используйте не менее 10 символов.", + "remote.passwordMismatch": "Пароли не совпадают.", + "remote.passwordFailed": "Не удалось сохранить пароль Remote.", + "remote.savePassword": "Сохранить пароль Remote", + "remote.accountReadyTitle": "Аккаунт и устройство готовы", + "remote.accountReadyBody": "GitHub, этот компьютер и пароль удалённого доступа подключены.", + "remote.connectorNext": "Далее: запустить подписанный коннектор OCX и назначить компьютеру адрес opencodexpages.me.", + "remote.privateBetaTitle": "Нужен доступ к закрытой бете", + "remote.privateBetaBody": "Аккаунт подключен, но активация Remote на этом сервере доступна только по приглашению.", + "remote.domainTitle": "Выберите адрес компьютера", + "remote.domainBody": "OpenCodex зарезервирует уникальный адрес opencodexpages.me для аккаунта и этого ПК.", + "remote.deviceNameLabel": "Имя компьютера", + "remote.deviceNamePlaceholder": "Домашний ПК", + "remote.domainLabel": "Remote-адрес", + "remote.domainSuffix": ".opencodexpages.me", + "remote.domainInvalid": "Используйте строчные буквы, цифры и дефисы.", + "remote.activateFailed": "Не удалось активировать Remote для этого компьютера.", + "remote.activating": "Резервирование адреса…", + "remote.activate": "Включить Remote", + "remote.provisioningTitle": "Подготовка защищённого адреса", + "remote.provisioningBody": "Центральный сервис резервирует адрес и готовит доступ к зашифрованному relay.", + "remote.connectorTitle": "Подключите локальный помощник", + "remote.connectorBody": "Адрес готов. Запустите подписанный непривилегированный Agent с исходящим зашифрованным relay-соединением.", + "remote.pairingCode": "Одноразовый код сопряжения", + "remote.pairingFailed": "Не удалось подготовить сопряжение коннектора.", + "remote.preparingConnector": "Подготовка…", + "remote.prepareConnector": "Подготовить коннектор", + "remote.connectorRequirement": "Коннектор работает от текущего пользователя без root и перенаправления портов. OCX должен содержать подписанный бинарник для этой платформы.", + "remote.connectingTitle": "Подключение компьютера", + "remote.connectingBody": "Agent запущен. Ожидаются проверки OCX, Agent, Relay и Gateway.", + "remote.readyTitle": "Remote в сети", + "remote.readyBody": "Локальный OpenCodex GUI доступен через защищённый адрес.", + "remote.openRemote": "Открыть Remote GUI", + "remote.unavailableTitle": "Remote требует внимания", + "remote.unavailableBody": "Инстанс остановлен или не удалось завершить подготовку.", + "remote.disconnect": "Отключить", + "remote.disconnectConfirm": "Отключить этот компьютер от OpenCodex Remote?", + "remote.disconnectFailed": "Не удалось отозвать это устройство.", + "remote.devicesTitle": "Подключённые компьютеры", + "remote.devicesBody": "Каждый компьютер поддерживает одно исходящее зашифрованное Relay-соединение. Входящие порты и настройка роутера не нужны.", + "remote.changePasswordTitle": "Изменить пароль шифрования", + "remote.changePasswordBody": "Нужен старый пароль. Хранилище повторно шифруется локально до отправки нового конверта серверу.", + "remote.currentPassword": "Текущий пароль", + "remote.newPassword": "Новый пароль", + "remote.confirmNewPassword": "Подтвердите новый пароль", + "remote.changePassword": "Изменить пароль", "app.logoAria": "Логотип opencodex", "app.claudeOn": "Claude ВКЛ", "app.claudeOff": "Claude ВЫКЛ", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index aa416bcb8..2c3d54d3a 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -4,6 +4,7 @@ export const zh: Record = { // sidebar / nav / common "nav.dashboard": "仪表盘", "nav.startup": "启动安全", + "nav.remote": "远程", "nav.providers": "提供方", "nav.models": "模型", "nav.combos": "组合", @@ -24,6 +25,83 @@ export const zh: Record = { "common.remove": "移除", "common.loading": "加载中…", "common.retry": "重试", + "remote.title": "远程", + "remote.subtitle": "将此 OpenCodex 连接到你的账户,并通过加密的出站 relay 使用终端。", + "remote.refresh": "刷新远程状态", + "remote.progressLabel": "远程设置进度", + "remote.stepAccount": "GitHub 账户", + "remote.stepPassword": "访问密码", + "remote.stepTunnel": "电脑连接", + "remote.loading": "正在加载远程状态…", + "remote.loadFailed": "无法加载远程状态。", + "remote.signedOutTitle": "连接此电脑", + "remote.signedOutBody": "通过 OpenCodex 服务登录。GitHub 仅验证身份,本地 OCX 只会收到 OpenCodex 设备凭据。", + "remote.signIn": "使用 GitHub 继续", + "remote.starting": "正在启动…", + "remote.identityTitle": "身份由中央服务处理", + "remote.identityBody": "服务处理 GitHub OAuth,不会把 GitHub 令牌发送到此电脑。", + "remote.deviceTitle": "每台设备独立凭据", + "remote.deviceBody": "此 OCX 创建独立设备密钥,可单独撤销而不影响其他电脑。", + "remote.tunnelTitle": "无需开放入站端口", + "remote.tunnelBody": "Agent 只建立一条加密出站连接,不暴露家庭或办公网络。", + "remote.startFailed": "无法启动 GitHub 登录。", + "remote.cancelFailed": "无法取消设备登录。", + "remote.pendingTitle": "请在浏览器中批准此电脑", + "remote.pendingBody": "在 opencodexpages.me 完成 GitHub 登录,并确认网站代码与下方代码一致。", + "remote.codeLabel": "设备代码", + "remote.openBrowser": "打开登录页面", + "remote.waiting": "正在等待浏览器批准…", + "remote.connectedAs": "已连接账户", + "remote.passwordTitle": "创建远程访问密码", + "remote.passwordBody": "此密码用于保护远程浏览器访问,与 GitHub 密码分开。", + "remote.passwordLabel": "远程密码", + "remote.passwordConfirm": "确认密码", + "remote.passwordLength": "请至少使用 10 个字符。", + "remote.passwordMismatch": "两次输入的密码不一致。", + "remote.passwordFailed": "无法保存远程密码。", + "remote.savePassword": "保存远程密码", + "remote.accountReadyTitle": "账户和设备已准备就绪", + "remote.accountReadyBody": "GitHub 身份、此电脑和远程访问密码已连接。", + "remote.connectorNext": "下一步:安装并启动签名 OCX 连接器,然后为此电脑分配 opencodexpages.me 地址。", + "remote.privateBetaTitle": "需要私测资格", + "remote.privateBetaBody": "账号已连接,但当前服务器仅允许受邀账号启用远程功能。", + "remote.domainTitle": "选择这台电脑的地址", + "remote.domainBody": "OpenCodex 会为账户和此电脑保留唯一的 opencodexpages.me 地址。", + "remote.deviceNameLabel": "电脑名称", + "remote.deviceNamePlaceholder": "家用电脑", + "remote.domainLabel": "远程地址", + "remote.domainSuffix": ".opencodexpages.me", + "remote.domainInvalid": "地址只能使用小写字母、数字和连字符。", + "remote.activateFailed": "无法为这台电脑启用远程功能。", + "remote.activating": "正在保留地址…", + "remote.activate": "启用远程", + "remote.provisioningTitle": "正在准备安全地址", + "remote.provisioningBody": "中央服务正在保留地址并准备加密 relay 访问。", + "remote.connectorTitle": "连接本地助手", + "remote.connectorBody": "地址已就绪。启动保持加密出站 relay 连接的已签名非特权 Agent。", + "remote.pairingCode": "一次性配对码", + "remote.pairingFailed": "无法准备连接器配对。", + "remote.preparingConnector": "准备中…", + "remote.prepareConnector": "准备连接器", + "remote.connectorRequirement": "连接器以当前用户权限运行,无需 root 或端口转发。OCX 必须包含此平台的已签名二进制文件。", + "remote.connectingTitle": "正在连接这台电脑", + "remote.connectingBody": "Agent 已启动,正在等待 OCX、Agent、Relay 和 Gateway 检查通过。", + "remote.readyTitle": "远程已上线", + "remote.readyBody": "现在可通过认证地址访问这台电脑的本地 OpenCodex GUI。", + "remote.openRemote": "打开远程 GUI", + "remote.unavailableTitle": "远程功能需要处理", + "remote.unavailableBody": "实例已暂停或配置失败,请刷新状态或联系运营者。", + "remote.disconnect": "断开连接", + "remote.disconnectConfirm": "要断开此电脑与 OpenCodex Remote 的连接吗?", + "remote.disconnectFailed": "无法撤销此设备。", + "remote.devicesTitle": "已配对的计算机", + "remote.devicesBody": "每台计算机只维护一条出站加密中继连接,无需入站端口或路由器设置。", + "remote.changePasswordTitle": "更改加密密码", + "remote.changePasswordBody": "必须提供旧密码。保险库会先在本地重新加密,再把新信封发送到服务器。", + "remote.currentPassword": "当前密码", + "remote.newPassword": "新密码", + "remote.confirmNewPassword": "确认新密码", + "remote.changePassword": "更改密码", "theme.label": "主题", "theme.light": "浅色", "theme.dark": "深色", diff --git a/gui/src/pages/Remote.tsx b/gui/src/pages/Remote.tsx new file mode 100644 index 000000000..2d98004f3 --- /dev/null +++ b/gui/src/pages/Remote.tsx @@ -0,0 +1,291 @@ +import { useCallback, useEffect, useState } from "react"; +import { readJsonOrThrow } from "../fetch-json"; +import { IconCheck, IconExternal, IconGithub, IconGlobe, IconLock, IconRefresh, IconTerminal } from "../icons"; +import { useT } from "../i18n/shared"; +import { Notice } from "../ui"; + +type RemoteStatus = + | { state: "signed_out"; controlPlaneUrl: string; serviceReachable: boolean; error?: string } + | { + state: "pending"; + controlPlaneUrl: string; + serviceReachable: boolean; + authorizeUrl: string; + userCode: string; + expiresAt: string; + error?: string; + } + | { + state: "connected"; + controlPlaneUrl: string; + serviceReachable: boolean; + deviceId: string; + account: { name: string; email: string; githubNumericId: string }; + passwordSet: boolean; + canActivate: boolean; + relayReady: boolean; + devices: Array<{ + id: string; + name: string; + platform: string; + relayOnline: boolean; + lastSeenAt: string | null; + instanceId: string | null; + }>; + agent?: { supported: boolean; running: boolean; pid?: number; error?: string }; + instance: { + id: string; + name: string; + slug: string; + transportMode: "mesh-tunnel" | "outbound-relay"; + status: "pending" | "provisioning" | "awaiting_agent" | "connecting" | "online" | "degraded" | "offline" | "suspending" | "suspended" | "deleting" | "delete_failed" | "deleted"; + publicUrl: string; + } | null; + error?: string; + }; + +async function requestRemote(apiBase: string, path: string, init?: RequestInit): Promise { + const response = await fetch(`${apiBase}${path}`, init); + const body = await readJsonOrThrow(response); + if (!body) throw new Error("empty remote response"); + return body; +} + +function RemoteSteps({ status }: { status: RemoteStatus }) { + const t = useT(); + const accountDone = status.state === "connected"; + const passwordDone = accountDone && status.passwordSet; + const tunnelDone = accountDone && status.instance !== null && status.agent?.running === true; + return
+ {[ + { label: t("remote.stepAccount"), done: accountDone, current: !accountDone }, + { label: t("remote.stepPassword"), done: passwordDone, current: accountDone && !passwordDone }, + { label: t("remote.stepTunnel"), done: tunnelDone, current: passwordDone && !tunnelDone }, + ].map((step, index) =>
+ {step.done ? : index + 1}{step.label} +
)} +
; +} + +export default function Remote({ apiBase }: { apiBase: string }) { + const t = useT(); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [password, setPassword] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [oldPassword, setOldPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [newConfirmation, setNewConfirmation] = useState(""); + const [instanceName, setInstanceName] = useState(""); + const [slug, setSlug] = useState(""); + + const refresh = useCallback(async (quiet = false) => { + if (!quiet) setLoading(true); + try { + const next = await requestRemote(apiBase, "/api/remote/status"); + setStatus(next); + setError(next.error ?? null); + } catch (reason) { + setError(reason instanceof Error ? reason.message : t("remote.loadFailed")); + } finally { + if (!quiet) setLoading(false); + } + }, [apiBase, t]); + + useEffect(() => { + const controller = new AbortController(); + const timer = window.setTimeout(() => { void refresh(); }, 0); + return () => { controller.abort(); window.clearTimeout(timer); }; + }, [refresh]); + + useEffect(() => { + const waitingForDevice = status?.state === "pending"; + const waitingForInstance = status?.state === "connected" && !!status.instance + && (["pending", "provisioning", "connecting"].includes(status.instance.status) + || status.agent?.running !== true); + if (!waitingForDevice && !waitingForInstance) return; + const timer = window.setInterval(() => { void refresh(true); }, 2_000); + return () => window.clearInterval(timer); + }, [refresh, status]); + + const startLogin = async () => { + const popup = window.open("about:blank", "_blank"); + if (popup) popup.opener = null; + setBusy(true); + setError(null); + try { + const next = await requestRemote(apiBase, "/api/remote/link", { method: "POST" }); + setStatus(next); + if (next.state === "pending") { + if (popup) popup.location.href = next.authorizeUrl; + else window.open(next.authorizeUrl, "_blank", "noopener,noreferrer"); + } else popup?.close(); + } catch (reason) { + popup?.close(); + setError(reason instanceof Error ? reason.message : t("remote.startFailed")); + } finally { + setBusy(false); + } + }; + + const cancelLogin = async () => { + setBusy(true); + try { + setStatus(await requestRemote(apiBase, "/api/remote/link/cancel", { method: "POST" })); + setError(null); + } catch (reason) { + setError(reason instanceof Error ? reason.message : t("remote.cancelFailed")); + } finally { + setBusy(false); + } + }; + + const savePassword = async () => { + if (password.length < 10) { setError(t("remote.passwordLength")); return; } + if (password !== confirmation) { setError(t("remote.passwordMismatch")); return; } + setBusy(true); + setError(null); + try { + const next = await requestRemote(apiBase, "/api/remote/password", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ password }), + }); + setStatus(next); + setPassword(""); + setConfirmation(""); + } catch (reason) { + setError(reason instanceof Error ? reason.message : t("remote.passwordFailed")); + } finally { + setBusy(false); + } + }; + + const disconnect = async () => { + if (!window.confirm(t("remote.disconnectConfirm"))) return; + setBusy(true); + try { + setStatus(await requestRemote(apiBase, "/api/remote/device", { method: "DELETE" })); + setError(null); + } catch (reason) { + setError(reason instanceof Error ? reason.message : t("remote.disconnectFailed")); + } finally { + setBusy(false); + } + }; + + const changePassword = async () => { + if (newPassword.length < 10) { setError(t("remote.passwordLength")); return; } + if (newPassword !== newConfirmation) { setError(t("remote.passwordMismatch")); return; } + setBusy(true); + setError(null); + try { + const next = await requestRemote(apiBase, "/api/remote/password", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ oldPassword, newPassword }), + }); + setStatus(next); + setOldPassword(""); + setNewPassword(""); + setNewConfirmation(""); + } catch (reason) { + setError(reason instanceof Error ? reason.message : t("remote.passwordFailed")); + } finally { + setBusy(false); + } + }; + + const activate = async () => { + const cleanSlug = slug.trim().toLowerCase(); + if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(cleanSlug)) { + setError(t("remote.domainInvalid")); + return; + } + setBusy(true); + setError(null); + try { + setStatus(await requestRemote(apiBase, "/api/remote/activate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: instanceName.trim() || cleanSlug, slug: cleanSlug }), + })); + } catch (reason) { + setError(reason instanceof Error ? reason.message : t("remote.activateFailed")); + } finally { + setBusy(false); + } + }; + + const startAgent = async () => { + setBusy(true); + setError(null); + try { + setStatus(await requestRemote(apiBase, "/api/remote/agent/start", { + method: "POST", + })); + } catch (reason) { + setError(reason instanceof Error ? reason.message : t("remote.pairingFailed")); + } finally { + setBusy(false); + } + }; + + return
+

{t("remote.title")}

{t("remote.subtitle")}

+ +
+ {status && } + {error && {error}} + {loading && !status ?
{t("remote.loading")}
+ : status?.state === "signed_out" ? <> +
+

{t("remote.signedOutTitle")}

{t("remote.signedOutBody")}

+
+
+
+
{t("remote.identityTitle")}{t("remote.identityBody")}
+
{t("remote.deviceTitle")}{t("remote.deviceBody")}
+
{t("remote.tunnelTitle")}{t("remote.tunnelBody")}
+
+ : status?.state === "pending" ?
+

{t("remote.pendingTitle")}

{t("remote.pendingBody")}

+
{t("remote.codeLabel")}{status.userCode.match(/.{1,4}/g)?.join(" ")}
+
{t("remote.openBrowser")}
+ {t("remote.waiting")}
+
: status?.state === "connected" ? <> +
{t("remote.connectedAs")}{status.account.name}{status.account.email}
+ {!status.passwordSet ?

{t("remote.passwordTitle")}

{t("remote.passwordBody")}

+
+
+
+
: !status.canActivate ?

{t("remote.privateBetaTitle")}

{t("remote.privateBetaBody")}

+ : !status.instance ?

{t("remote.domainTitle")}

{t("remote.domainBody")}

+
+
+
+
: ["pending", "provisioning"].includes(status.instance.status) ?

{t("remote.provisioningTitle")}

{t("remote.provisioningBody")}

{status.instance.publicUrl}
+ : ["awaiting_agent", "offline"].includes(status.instance.status) ?

{t("remote.connectorTitle")}

{t("remote.connectorBody")}

+ {status.instance.publicUrl} +
+ {status.agent?.error ? {status.agent.error} : null} + {t("remote.connectorRequirement")} +
: status.instance.status === "connecting" ?

{t("remote.connectingTitle")}

{t("remote.connectingBody")}

{status.instance.publicUrl}
+ : ["online", "degraded"].includes(status.instance.status) ?

{t("remote.readyTitle")}

{t("remote.readyBody")}

{t("remote.openRemote")}
+ :

{t("remote.unavailableTitle")}

{t("remote.unavailableBody")}

} + {status.passwordSet ? <> +

{t("remote.devicesTitle")}

{t("remote.devicesBody")}

+
{status.devices.map(device =>
{device.name}{device.platform}
{device.relayOnline ? "Online" : "Offline"}
)}
+
+

{t("remote.changePasswordTitle")}

{t("remote.changePasswordBody")}

+
+ +
+
+
+ : null} + : null} +
; +} diff --git a/gui/src/styles.css b/gui/src/styles.css index 04fd8f9b6..0c9f5d439 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -1642,6 +1642,73 @@ dialog.modal-overlay::backdrop { .modal-actions { display: flex; gap: 8px; margin-top: 16px; } .modal-actions .btn { flex: 1; } +/* Remote is a local control-plane onboarding surface: calm, medium-density cards use the + dashboard's existing opaque material while the three-step rail keeps account, password, + and Tunnel ownership visibly separate. */ +.remote-page { max-width: 900px; } +.remote-page-sub { margin-bottom: 0; } +.remote-steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 22px 0 16px; } +.remote-step { position: relative; display: flex; align-items: center; gap: 9px; min-width: 0; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--faint); background: var(--surface); } +.remote-step > span { width: 24px; height: 24px; flex: 0 0 auto; display: grid; place-items: center; border: 1px solid var(--border); border-radius: var(--radius-round); font-size: var(--text-caption); font-weight: var(--weight-semibold); } +.remote-step > span svg { width: 13px; height: 13px; } +.remote-step strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-label); } +.remote-step.is-current { color: var(--text); border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); } +.remote-step.is-current > span { color: var(--accent-hover); border-color: var(--accent-ring); background: var(--accent-soft); } +.remote-step.is-done { color: var(--green); border-color: color-mix(in srgb, var(--green) 32%, var(--border)); } +.remote-step.is-done > span { color: var(--green); border-color: transparent; background: var(--green-soft); } +.remote-hero { display: flex; align-items: flex-start; gap: 18px; padding: 24px; margin-bottom: 14px; } +.remote-hero-icon, .remote-account-icon { width: 46px; height: 46px; flex: 0 0 auto; display: grid; place-items: center; border-radius: var(--radius); color: var(--accent-hover); background: var(--accent-soft); } +.remote-hero-icon svg, .remote-account-icon svg { width: 22px; height: 22px; } +.remote-hero > div:last-child { min-width: 0; } +.remote-hero h3, .remote-password-panel h3 { margin: 0 0 6px; font-size: var(--text-subtitle); } +.remote-hero p, .remote-password-panel p { margin: 0 0 18px; max-width: 62ch; color: var(--muted); font-size: var(--text-body); line-height: var(--leading-body); } +.remote-explain-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } +.remote-explain { display: grid; align-content: start; gap: 8px; padding: 18px; } +.remote-explain svg { width: 20px; height: 20px; color: var(--muted); } +.remote-explain strong { font-size: var(--text-control); } +.remote-explain span { color: var(--muted); font-size: var(--text-label); line-height: var(--leading-body); } +.remote-code { display: inline-flex; flex-direction: column; gap: 5px; min-width: 220px; padding: 12px 14px; margin: 0 0 16px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--raised); } +.remote-code span { color: var(--muted); font-size: var(--text-caption); } +.remote-code strong { font-family: var(--font-code); font-size: var(--text-subtitle); letter-spacing: 0.08em; } +.remote-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.remote-waiting, .remote-next-note { display: block; margin-top: 14px; color: var(--muted); font-size: var(--text-label); } +.remote-domain-input { display: grid; grid-template-columns: minmax(120px, 1fr) auto; align-items: center; gap: 10px; } +.remote-domain-input > span { color: var(--muted); font-size: var(--text-label); white-space: nowrap; } +.remote-address { display: block; margin: 12px 0 4px; color: var(--accent); font-size: var(--text-label); overflow-wrap: anywhere; } +.remote-open { width: fit-content; margin-top: 16px; } +.remote-waiting::before { content: ""; display: inline-block; width: 7px; height: 7px; margin-right: 7px; border-radius: var(--radius-round); background: var(--amber); box-shadow: 0 0 0 3px var(--amber-soft); } +.remote-account-card { display: flex; align-items: center; gap: 14px; padding: 16px; margin-bottom: 14px; } +.remote-account-icon { width: 38px; height: 38px; color: var(--green); background: var(--green-soft); } +.remote-account-icon svg { width: 18px; height: 18px; } +.remote-account-card > div:nth-child(2) { display: flex; flex-direction: column; min-width: 0; } +.remote-account-card > div:nth-child(2) > span, .remote-account-card small { color: var(--muted); font-size: var(--text-caption); overflow: hidden; text-overflow: ellipsis; } +.remote-account-card .btn { margin-left: auto; } +.remote-password-panel { padding: 22px; } +.remote-password-panel .panel-head { display: flex; align-items: flex-start; justify-content: flex-start; gap: 12px; } +.remote-password-panel .panel-head > div { min-width: 0; } +.remote-password-panel .panel-head > svg { width: 20px; height: 20px; margin-top: 2px; color: var(--muted); flex: 0 0 auto; } +.remote-password-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 4px 0 16px; } +.remote-password-grid label { display: grid; gap: 6px; } +.remote-password-grid label > span { color: var(--muted); font-size: var(--text-label); } +.remote-device-panel { margin-top: 14px; } +.remote-device-list { display: grid; gap: 8px; margin-top: 15px; } +.remote-device-row { min-height: 58px; border: 1px solid var(--border); border-radius: var(--radius-control); padding: 9px 12px; display: grid; grid-template-columns: 9px 1fr auto; align-items: center; gap: 11px; } +.remote-device-row > span { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); opacity: .5; } +.remote-device-row > span.is-online { background: var(--success); opacity: 1; box-shadow: 0 0 0 4px color-mix(in srgb, var(--success) 14%, transparent); } +.remote-device-row > div { display: grid; gap: 3px; min-width: 0; } +.remote-device-row small { color: var(--muted); overflow: hidden; text-overflow: ellipsis; } +.remote-device-row em { color: var(--muted); font-size: var(--text-label); font-style: normal; } +.remote-ready { border-color: color-mix(in srgb, var(--green) 34%, var(--border)); background: color-mix(in srgb, var(--green-soft) 58%, var(--surface)); } +.remote-ready .remote-hero-icon { color: var(--green); background: var(--green-soft); } +.remote-loading { display: flex; align-items: center; gap: 10px; color: var(--muted); } +.remote-loading svg { width: 18px; height: 18px; animation: spin 1s linear infinite; } +@media (max-width: 700px) { + .remote-steps, .remote-explain-grid, .remote-password-grid { grid-template-columns: 1fr; } + .remote-hero { padding: 18px; } + .remote-account-card { align-items: flex-start; flex-wrap: wrap; } + .remote-account-card .btn { margin-left: 52px; } +} + /* request logs: clamped request id, status + details affordance, detail modal */ .log-reqid { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; diff --git a/gui/tests/remote-page.test.ts b/gui/tests/remote-page.test.ts new file mode 100644 index 000000000..cb3442bfc --- /dev/null +++ b/gui/tests/remote-page.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test"; + +const LOCALES = ["en", "de", "ja", "ko", "ru", "zh"] as const; + +async function read(path: string): Promise { + return Bun.file(new URL(path, import.meta.url)).text(); +} + +test("Remote is a local GUI route directly after the established Codex Auth entry", async () => { + const routing = await read("../src/app-routing.ts"); + const app = await read("../src/App.tsx"); + expect(routing).toContain('| "remote"'); + expect(routing).toContain('"remote",'); + expect(app).toContain('{page === "remote" && }'); + const nav = app.slice(app.indexOf("const NAV"), app.indexOf("];", app.indexOf("const NAV"))); + expect(nav.indexOf('id: "codex-auth"')).toBeLessThan(nav.indexOf('id: "remote"')); +}); + +test("Remote page owns device linking, password setup, hostname activation, and connector pairing without handling GitHub credentials", async () => { + const page = await read("../src/pages/Remote.tsx"); + expect(page).toContain("/api/remote/link"); + expect(page).toContain("/api/remote/status"); + expect(page).toContain("/api/remote/password"); + expect(page).toContain("/api/remote/activate"); + expect(page).toContain("/api/remote/pairing-code"); + expect(page).toContain("/api/remote/device"); + expect(page).not.toContain("access_token"); + expect(page).not.toContain("refresh_token"); + expect(page).not.toContain("clientSecret"); +}); + +test("every locale carries the Remote onboarding copy", async () => { + const keys = [ + "nav.remote", "remote.title", "remote.subtitle", "remote.stepAccount", "remote.stepPassword", + "remote.stepTunnel", "remote.signedOutTitle", "remote.signIn", "remote.pendingTitle", + "remote.codeLabel", "remote.passwordTitle", "remote.passwordLabel", "remote.savePassword", + "remote.domainTitle", "remote.activate", "remote.provisioningTitle", "remote.connectorTitle", + "remote.prepareConnector", "remote.readyTitle", "remote.openRemote", "remote.disconnect", + ]; + const missing: string[] = []; + for (const locale of LOCALES) { + const dictionary = await read(`../src/i18n/${locale}.ts`); + for (const key of keys) if (!dictionary.includes(`"${key}":`)) missing.push(`${locale}:${key}`); + } + expect(missing).toEqual([]); +}); diff --git a/package.json b/package.json index 650db1ed7..22cbe871f 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "dependencies": { "@bufbuild/protobuf": "^2.12.0", "@modelcontextprotocol/sdk": "^1", + "@noble/hashes": "2.2.0", "bun": "1.3.14", "zod": "4.4.3" }, diff --git a/platform/bun.lock b/platform/bun.lock new file mode 100644 index 000000000..f66e8316e --- /dev/null +++ b/platform/bun.lock @@ -0,0 +1,356 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@opencodex/remote-platform", + "dependencies": { + "@noble/hashes": "2.2.0", + "@tabler/icons-react": "^3.34.1", + "@xterm/xterm": "6.0.0", + "better-auth": "^1.6.25", + "hono": "^4.9.8", + "pg": "^8.16.3", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "zod": "^4.1.11", + }, + "devDependencies": { + "@types/bun": "1.3.14", + "@types/pg": "^8.15.5", + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.4", + "typescript": "5.9.3", + "vite": "^7.1.7", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@better-auth/core": ["@better-auth/core@1.6.25", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.7", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-lMTlhtwyK4NpY9kPF+2rQCRKYpg136d3gM2xl8esxT1PjJx5Nh5YwZvxcYCIjDuO759sx6TCloJTuwcZGG6ZBw=="], + + "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.25", "", { "peerDependencies": { "@better-auth/core": "^1.6.25", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-ru/DeKjFPQUVeKkxF/ScazmPqIY7lwfkAV5Yt4j24wmn1Y8vFwoiPRnHgXUeZqBs10+nubaRwEqLF39CP6EhRw=="], + + "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.6.25", "", { "peerDependencies": { "@better-auth/core": "^1.6.25", "@better-auth/utils": "0.4.2", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-zxiePhtN1YClS1irKYPVwWfN6kYp+QoYlz1hdQUOj8hXyo2aE/ny4RNAb6v332b0+U6Vu88EhYITRPdmvCo6uA=="], + + "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.6.25", "", { "peerDependencies": { "@better-auth/core": "^1.6.25", "@better-auth/utils": "0.4.2" } }, "sha512-GhEzTumc8yfTz+OZ6pMg06BA49xob49x1bX+1mEl/FStDJoSF+6mTfI5M2ytFxaiN89336/aUjkW8u+qRyLexw=="], + + "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.6.25", "", { "peerDependencies": { "@better-auth/core": "^1.6.25", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-ZtMmjcOdXR2Ziqx5y8ptTOaNpe0snNfALbBUPXJsgeyeRkDJDYzyLZ8MpuvNBTNllNeIFDbiXWAK5k+pEBZrUQ=="], + + "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.25", "", { "peerDependencies": { "@better-auth/core": "^1.6.25", "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-ym7B6Iqcry+/4aQnYpFwqP/GBIiXvjrm/5B6+0qmx8mkTY/apHFTpHuGzUYYNf4vPTtzF3eYY2+s2GOsomKaRg=="], + + "@better-auth/telemetry": ["@better-auth/telemetry@1.6.25", "", { "peerDependencies": { "@better-auth/core": "^1.6.25", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1" } }, "sha512-2ZfC9lp7tU6Jw/q2Lz/bKfQqGMdMwc/IQDTYdBhvtGi24qInYVnhp2ZCW57hHM9j+fq1ULOtxgg6M3T1LEaihw=="], + + "@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], + + "@better-fetch/fetch": ["@better-fetch/fetch@1.3.1", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], + + "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.3", "", { "os": "android", "cpu": "arm" }, "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.3", "", { "os": "android", "cpu": "arm64" }, "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.3", "", { "os": "linux", "cpu": "arm" }, "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.3", "", { "os": "linux", "cpu": "arm" }, "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.3", "", { "os": "linux", "cpu": "none" }, "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.3", "", { "os": "linux", "cpu": "none" }, "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.3", "", { "os": "linux", "cpu": "none" }, "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.3", "", { "os": "linux", "cpu": "none" }, "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.3", "", { "os": "linux", "cpu": "x64" }, "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.3", "", { "os": "linux", "cpu": "x64" }, "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.3", "", { "os": "none", "cpu": "arm64" }, "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.3", "", { "os": "win32", "cpu": "x64" }, "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.3", "", { "os": "win32", "cpu": "x64" }, "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@tabler/icons": ["@tabler/icons@3.46.0", "", {}, "sha512-f2RYFl3fzPwj5WO82x6en0dmkjefxEfOm16D1ByM6cj/McNiwOkL4VaPUoP9VVIrXAD9WnTSVFr70px703b//A=="], + + "@tabler/icons-react": ["@tabler/icons-react@3.46.0", "", { "dependencies": { "@tabler/icons": "3.46.0" }, "peerDependencies": { "react": ">= 16" } }, "sha512-CCm7xJWhDT2PH4ZIFkP6AgYKtVhq0gpYkjUN+GVh1AzmIQaa77OW0bQPBPQiTE0PsXMR9oSxFqA3qBglzPyrVQ=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="], + + "@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="], + + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], + + "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.6", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-69D/imtToCsIcAl8WBS2YaRwA4jO/j0HhU+hELqMEu9f54MoUtI6+XH5mrKU8rEFNEk/Ui1I2MK4/JkWacclGw=="], + + "better-auth": ["better-auth@1.6.25", "", { "dependencies": { "@better-auth/core": "1.6.25", "@better-auth/drizzle-adapter": "1.6.25", "@better-auth/kysely-adapter": "1.6.25", "@better-auth/memory-adapter": "1.6.25", "@better-auth/mongo-adapter": "1.6.25", "@better-auth/prisma-adapter": "1.6.25", "@better-auth/telemetry": "1.6.25", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.7", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-fvoq+oCO+FF5fpP3XfU7znRyGFpHB77UG2EyxsKNy+Cak7Q5pELu+auvvDveQbWQxcoKugZ7jYQQPFQLpUTGOw=="], + + "better-call": ["better-call@1.3.7", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w=="], + + "browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.398", "", {}, "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ=="], + + "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "hono": ["hono@4.12.32", "", {}, "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg=="], + + "jose": ["jose@6.2.4", "", {}, "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "kysely": ["kysely@0.29.4", "", {}, "sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], + + "nanostores": ["nanostores@1.4.2", "", {}, "sha512-Wxv8Roefr2nqtiRG0bnaFlpYqpIVtOEeJZHaH+4nGgOK1/7n6OHOuHCb/bhqrNQgZM8fyd0s1PqhdrJc9Ib44g=="], + + "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], + + "pg": ["pg@8.22.0", "", { "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.15.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA=="], + + "pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="], + + "pg-connection-string": ["pg-connection-string@2.14.0", "", {}, "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="], + + "pg-protocol": ["pg-protocol@1.15.0", "", {}, "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "postcss": ["postcss@8.5.25", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw=="], + + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + + "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], + + "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], + + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + + "rollup": ["rollup@4.62.3", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.3", "@rollup/rollup-android-arm64": "4.62.3", "@rollup/rollup-darwin-arm64": "4.62.3", "@rollup/rollup-darwin-x64": "4.62.3", "@rollup/rollup-freebsd-arm64": "4.62.3", "@rollup/rollup-freebsd-x64": "4.62.3", "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", "@rollup/rollup-linux-arm-musleabihf": "4.62.3", "@rollup/rollup-linux-arm64-gnu": "4.62.3", "@rollup/rollup-linux-arm64-musl": "4.62.3", "@rollup/rollup-linux-loong64-gnu": "4.62.3", "@rollup/rollup-linux-loong64-musl": "4.62.3", "@rollup/rollup-linux-ppc64-gnu": "4.62.3", "@rollup/rollup-linux-ppc64-musl": "4.62.3", "@rollup/rollup-linux-riscv64-gnu": "4.62.3", "@rollup/rollup-linux-riscv64-musl": "4.62.3", "@rollup/rollup-linux-s390x-gnu": "4.62.3", "@rollup/rollup-linux-x64-gnu": "4.62.3", "@rollup/rollup-linux-x64-musl": "4.62.3", "@rollup/rollup-openbsd-x64": "4.62.3", "@rollup/rollup-openharmony-arm64": "4.62.3", "@rollup/rollup-win32-arm64-msvc": "4.62.3", "@rollup/rollup-win32-ia32-msvc": "4.62.3", "@rollup/rollup-win32-x64-gnu": "4.62.3", "@rollup/rollup-win32-x64-msvc": "4.62.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q=="], + + "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + } +} diff --git a/platform/deploy/mesh/Dockerfile b/platform/deploy/mesh/Dockerfile new file mode 100644 index 000000000..3d5290aad --- /dev/null +++ b/platform/deploy/mesh/Dockerfile @@ -0,0 +1,15 @@ +FROM ubuntu:24.04 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl dbus gnupg iproute2 procps \ + && curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg \ + | gpg --dearmor -o /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ noble main" \ + > /etc/apt/sources.list.d/cloudflare-client.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends cloudflare-warp \ + && rm -rf /var/lib/apt/lists/* + +COPY --chmod=0755 entrypoint.sh /usr/local/bin/ocxr-mesh-entrypoint + +ENTRYPOINT ["/usr/local/bin/ocxr-mesh-entrypoint"] diff --git a/platform/deploy/mesh/entrypoint.sh b/platform/deploy/mesh/entrypoint.sh new file mode 100644 index 000000000..6682085ab --- /dev/null +++ b/platform/deploy/mesh/entrypoint.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -eu + +TOKEN_FILE=${OCXR_MESH_TOKEN_FILE:-/run/secrets/mesh-token} +STATE_MARKER=/var/lib/cloudflare-warp/.ocxr-mesh-enrolled + +/bin/warp-svc >/var/log/warp-svc.log 2>&1 & +WARP_PID=$! + +for _ in $(seq 1 60); do + if warp-cli --accept-tos status >/dev/null 2>&1; then break; fi + sleep 1 +done + +if [ ! -f "$STATE_MARKER" ]; then + test -s "$TOKEN_FILE" + warp-cli --accept-tos connector new "$(tr -d '\r\n' < "$TOKEN_FILE")" + touch "$STATE_MARKER" +fi + +warp-cli --accept-tos connect +wait "$WARP_PID" diff --git a/platform/deploy/systemd/opencodex-remote-control.service b/platform/deploy/systemd/opencodex-remote-control.service new file mode 100644 index 000000000..f0cf56f7b --- /dev/null +++ b/platform/deploy/systemd/opencodex-remote-control.service @@ -0,0 +1,32 @@ +[Unit] +Description=OpenCodex Remote Control Plane +After=network-online.target opencodex-remote-postgres.service +Requires=opencodex-remote-postgres.service +Wants=network-online.target + +[Service] +Type=simple +User=ocxr +Group=ocxr +WorkingDirectory=/opt/opencodex-remote/platform +EnvironmentFile=/etc/opencodex-remote/common.env +LoadCredential=database-url:/etc/opencodex-remote/credentials/database-url-host +LoadCredential=better-auth-secret:/etc/opencodex-remote/credentials/better-auth-secret +LoadCredential=github-client-secret:/etc/opencodex-remote/credentials/github-client-secret +LoadCredential=gateway-ed25519-private-key:/etc/opencodex-remote/credentials/gateway-ed25519-private-key +LoadCredential=platform-encryption-key:/etc/opencodex-remote/credentials/platform-encryption-key +LoadCredential=audit-hmac-key:/etc/opencodex-remote/credentials/audit-hmac-key +LoadCredential=synthetic-health-token:/etc/opencodex-remote/credentials/synthetic-health-token +LoadCredential=cloudflare-api-token:/etc/opencodex-remote/credentials/cloudflare-api-token +LoadCredential=cloudflare-auth-email:/etc/opencodex-remote/credentials/cloudflare-auth-email +ExecStart=/usr/local/bin/bun server/src/control-plane.ts +Restart=always +RestartSec=3 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/opencodex-remote + +[Install] +WantedBy=multi-user.target diff --git a/platform/deploy/systemd/opencodex-remote-gateway.service b/platform/deploy/systemd/opencodex-remote-gateway.service new file mode 100644 index 000000000..be1ce93a4 --- /dev/null +++ b/platform/deploy/systemd/opencodex-remote-gateway.service @@ -0,0 +1,16 @@ +[Unit] +Description=OpenCodex Remote Streaming Gateway +After=docker.service opencodex-remote-postgres.service opencodex-remote-mesh.service +Requires=docker.service opencodex-remote-postgres.service opencodex-remote-mesh.service + +[Service] +Type=simple +ExecStartPre=-/usr/bin/docker rm ocxr-gateway +ExecStart=/usr/bin/docker run --rm --name ocxr-gateway --network container:ocxr-mesh --user 0:0 --read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m --cap-drop ALL --security-opt no-new-privileges --env-file /etc/opencodex-remote/common.env -e CREDENTIALS_DIRECTORY=/run/credentials -v /opt/opencodex-remote/platform:/app:ro -v /etc/opencodex-remote/credentials:/run/credentials:ro -w /app oven/bun:1.3.14 bun server/src/gateway.ts +ExecStop=/usr/bin/docker stop -t 20 ocxr-gateway +Restart=always +RestartSec=5 +TimeoutStopSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/platform/deploy/systemd/opencodex-remote-mesh.service b/platform/deploy/systemd/opencodex-remote-mesh.service new file mode 100644 index 000000000..1271bcd31 --- /dev/null +++ b/platform/deploy/systemd/opencodex-remote-mesh.service @@ -0,0 +1,17 @@ +[Unit] +Description=OpenCodex Remote Cloudflare Mesh Node +After=docker.service network-online.target +Requires=docker.service +Wants=network-online.target + +[Service] +Type=simple +ExecStartPre=-/usr/bin/docker rm ocxr-mesh +ExecStart=/usr/bin/docker run --rm --name ocxr-mesh --network ocxr-net --ip 172.30.0.3 --cap-add NET_ADMIN --device /dev/net/tun --sysctl net.ipv4.ip_forward=1 -p 127.0.0.1:10201:10201 -v ocxr-mesh-state:/var/lib/cloudflare-warp -v /etc/opencodex-remote/credentials/mesh-token:/run/secrets/mesh-token:ro opencodex-remote-mesh:2026.07.30 +ExecStop=/usr/bin/docker stop -t 20 ocxr-mesh +Restart=always +RestartSec=5 +TimeoutStopSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/platform/deploy/systemd/opencodex-remote-postgres.service b/platform/deploy/systemd/opencodex-remote-postgres.service new file mode 100644 index 000000000..5ec68e492 --- /dev/null +++ b/platform/deploy/systemd/opencodex-remote-postgres.service @@ -0,0 +1,17 @@ +[Unit] +Description=OpenCodex Remote PostgreSQL 17 +After=docker.service network-online.target +Requires=docker.service +Wants=network-online.target + +[Service] +Type=simple +ExecStartPre=-/usr/bin/docker rm ocxr-postgres +ExecStart=/usr/bin/docker run --rm --name ocxr-postgres --network ocxr-net --ip 172.30.0.2 -p 127.0.0.1:15444:5432 -e POSTGRES_USER=ocxr -e POSTGRES_DB=ocxr -e POSTGRES_PASSWORD_FILE=/run/secrets/postgres-password -v ocxr-postgres-data:/var/lib/postgresql/data -v /etc/opencodex-remote/credentials/postgres-password:/run/secrets/postgres-password:ro postgres:17-alpine +ExecStop=/usr/bin/docker stop -t 30 ocxr-postgres +Restart=always +RestartSec=5 +TimeoutStopSec=40 + +[Install] +WantedBy=multi-user.target diff --git a/platform/deploy/systemd/opencodex-remote-tunnel.service b/platform/deploy/systemd/opencodex-remote-tunnel.service new file mode 100644 index 000000000..eb0f24d60 --- /dev/null +++ b/platform/deploy/systemd/opencodex-remote-tunnel.service @@ -0,0 +1,21 @@ +[Unit] +Description=OpenCodex Remote Public Cloudflare Tunnel +After=network-online.target opencodex-remote-control.service opencodex-remote-gateway.service +Requires=opencodex-remote-control.service opencodex-remote-gateway.service +Wants=network-online.target + +[Service] +Type=simple +User=ocxr +Group=ocxr +LoadCredential=tunnel-token:/etc/opencodex-remote/credentials/central-tunnel-token +ExecStart=/usr/local/bin/cloudflared tunnel --no-autoupdate run --token-file ${CREDENTIALS_DIRECTORY}/tunnel-token +Restart=always +RestartSec=5 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true + +[Install] +WantedBy=multi-user.target diff --git a/platform/deploy/systemd/opencodex-remote-worker.service b/platform/deploy/systemd/opencodex-remote-worker.service new file mode 100644 index 000000000..7c20dbbb6 --- /dev/null +++ b/platform/deploy/systemd/opencodex-remote-worker.service @@ -0,0 +1,32 @@ +[Unit] +Description=OpenCodex Remote Provisioning Worker +After=network-online.target opencodex-remote-postgres.service +Requires=opencodex-remote-postgres.service +Wants=network-online.target + +[Service] +Type=simple +User=ocxr +Group=ocxr +WorkingDirectory=/opt/opencodex-remote/platform +EnvironmentFile=/etc/opencodex-remote/common.env +LoadCredential=database-url:/etc/opencodex-remote/credentials/database-url-host +LoadCredential=better-auth-secret:/etc/opencodex-remote/credentials/better-auth-secret +LoadCredential=github-client-secret:/etc/opencodex-remote/credentials/github-client-secret +LoadCredential=gateway-ed25519-private-key:/etc/opencodex-remote/credentials/gateway-ed25519-private-key +LoadCredential=platform-encryption-key:/etc/opencodex-remote/credentials/platform-encryption-key +LoadCredential=audit-hmac-key:/etc/opencodex-remote/credentials/audit-hmac-key +LoadCredential=synthetic-health-token:/etc/opencodex-remote/credentials/synthetic-health-token +LoadCredential=cloudflare-api-token:/etc/opencodex-remote/credentials/cloudflare-api-token +LoadCredential=cloudflare-auth-email:/etc/opencodex-remote/credentials/cloudflare-auth-email +ExecStart=/usr/local/bin/bun server/src/worker.ts +Restart=always +RestartSec=3 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/opencodex-remote + +[Install] +WantedBy=multi-user.target diff --git a/platform/docs/CLOUDFLARE_PHASE0_2026-07-30.md b/platform/docs/CLOUDFLARE_PHASE0_2026-07-30.md new file mode 100644 index 000000000..1390fc622 --- /dev/null +++ b/platform/docs/CLOUDFLARE_PHASE0_2026-07-30.md @@ -0,0 +1,64 @@ +# Cloudflare Mesh private-hostname Phase 0 — 2026-07-30 + +## 결론 + +실제 Cloudflare account와 `opencodexpages.me` zone에서 Linux Cloudflare Mesh node → private hostname route → dedicated Cloudflare Tunnel → RFC1918 origin 경로가 통과했다. 일반 WARP client만이 아니라 central Gateway 역할의 headless Mesh node에서도 확인했다. + +Phase 0에서 확정된 private instance domain은 `*.private.remote.opencodexpages.me`다. 2026-07-30 운영 ingress에서는 Cloudflare Universal SSL이 바로 지원하는 `.opencodexpages.me`를 공개 instance domain으로 확정했다. 중첩 wildcard인 `.remote.opencodexpages.me`는 별도 인증서가 필요해 사용하지 않는다. + +## 필수 구성 + +1. Gateway TCP/UDP proxy를 켠다. +2. Mesh와 client profile에서 synthetic `100.80.0.0/16`을 WARP로 보낸다. Exclude mode에서는 기본 `100.64.0.0/10` 제외를 제거한다. +3. private hostname TLD를 Local Domain Fallback에 넣지 않는다. +4. private hostname의 DNS-only A record를 먼저 만들고 고유 RFC1918 `/32`를 반환하게 한다. +5. 같은 `/32` CIDR route와 hostname route를 dedicated Tunnel에 연결한다. +6. privileged `opencodex-remote-agent prepare-network`가 `/32`를 loopback에 할당하고 비특권 Agent runtime이 `:10101`에 bind한다. + +Hostname route만 만들었을 때 DNS 합성은 가능했지만 TCP가 `No route to host`로 실패했다. 좁은 CIDR route가 추가되자 Tunnel remote config의 `warp-routing.enabled`가 `true`로 바뀌고 TCP가 통과했다. `cloudflared --max-active-flows` 수동 override는 필요하지 않았다. + +## 실측 결과 + +| 항목 | 결과 | +|---|---:| +| Mesh client | Linux `warp-cli 2026.6.880.0` | +| cloudflared | `2026.3.0`, QUIC, 4 connections | +| DNS | private hostname → synthetic `100.80.0.0/16` | +| 기본 HTTP | 200, 10회 19.37–23.68 ms | +| 100 MiB download | 104,857,600 bytes / 1.500 s | +| 100 MiB upload | 104,857,600 bytes accepted | +| SSE | 5-event smoke pass | +| WebSocket | bidirectional echo pass | +| Rust Agent assertion E2E | authorized 200, unauthorized concealed as 404 | +| Rust Agent 100 MiB download | 104,857,600 bytes / 1.707 s | +| Rust Agent 100 MiB upload | 104,857,600 bytes accepted | +| Rust Agent SSE / WebSocket | 5-event SSE and bidirectional echo pass | +| Tunnel restart | active SSE closed with curl rc 18; new HTTP recovered in 39.632 s | +| 30-minute transport SSE | pass: rc 0, 1,801 s, 1,800 events | +| 30-minute Rust Agent SSE | pass: rc 0, 1,800 s, 1,800 events | + +Restart 결과 때문에 health computation은 connector connection 수가 돌아온 직후 online으로 바꾸면 안 된다. hostname data plane의 synthetic route가 회복될 시간을 포함해 Gateway synthetic health를 authoritative signal로 유지하고 최소 60초 grace를 둔다. + +## 실패에서 확인한 사항 + +- `.internal`은 기본 Local Domain Fallback에 포함돼 Gateway DNS를 우회했다. +- DNS record보다 먼저 private hostname을 질의하면 Gateway가 zone SOA의 negative TTL 동안 NXDOMAIN을 캐시했다. +- `cloudflared` virtual DNS는 컨테이너 `/etc/hosts`의 `127.0.0.1` 매핑을 origin 주소로 사용하지 않았다. +- public DNS의 `127.0.0.1` 답은 synthetic IP로 바뀌지 않고 그대로 반환됐다. +- RFC1918 origin과 `/32` CIDR activation route를 함께 사용했을 때 Mesh/Tunnel L4가 통과했다. + +## 운영 경계 + +- DNS record는 proxied public application route가 아니며 RFC1918 주소만 반환한다. +- private hostname label은 160-bit random 값이고 사용자 slug에서 유도하지 않는다. +- Cloudflare account credential은 Control Plane만 보유한다. +- 활성 Agent가 재연결 중인 suspend에서도 hostname route, CIDR route, DNS record를 먼저 제거하고 공식 connector-cleanup API를 호출한 뒤 Tunnel을 삭제했다. Gateway는 즉시 404로 닫혔고 Cloudflare 리소스 네 개가 모두 제거됐다. +- Phase 0의 service token, WARP enrollment app/policy/registrations, Mesh node, Tunnel, DNS/route는 측정 종료 후 모두 삭제했다. 테스트를 위해 변경한 Gateway TCP/UDP, virtual IP, split-exclude 기본값도 사전 백업과 동일하게 복원했고 활성 `ocxr-` Cloudflare 리소스가 0개임을 다시 확인했다. + +[Decision Log] +- 목적과 의도: 문서상 가정이 아니라 실제 Cloudflare account에서 transport 적합성을 확정한다. +- 기존 구현 및 제약 조건: 기존 계획은 Agent의 hosts file이 private hostname을 `127.0.0.1`로 해석한다고 가정했다. +- 검토한 주요 대안: hosts loopback, public DNS loopback, custom DNS, RFC1918 loopback alias와 CIDR activation route. +- 선택한 방식: DNS-backed 고유 RFC1918 `/32`, Tunnel CIDR route, Tunnel hostname route, Agent loopback bind. +- 다른 대안 대신 이 방식을 선택한 이유: 현재 공식 API와 Linux Mesh/Tunnel 버전에서 유일하게 DNS 합성 및 TCP 종단이 함께 실측 통과했다. +- 장점, 단점 및 영향: private-only 경계는 유지되지만 네 Cloudflare 리소스의 idempotent lifecycle, connector cleanup, 60초 restart grace가 필요하다. diff --git a/platform/docs/IMPLEMENTATION_PLAN.md b/platform/docs/IMPLEMENTATION_PLAN.md new file mode 100644 index 000000000..d35ddf59c --- /dev/null +++ b/platform/docs/IMPLEMENTATION_PLAN.md @@ -0,0 +1,78 @@ +# OpenCodex Remote 비공개 MVP 확정 구현 계획 + +> 2026-07-30 변경: 신규 Remote workspace의 transport와 암호화 기준은 [ADR 0013](../../structure/adr/0013-remote-outbound-e2ee-relay.md)이다. 아래 Mesh 계획은 이미 배포된 private-MVP rollback 경로와 검증 기록으로만 유지한다. + +이 문서는 원 기획서 이후 확정된 구현 기준을 요약한다. 세부 제품 배경은 [PRODUCT_PLAN_v1.md](./PRODUCT_PLAN_v1.md), 현재 코드 상태는 [IMPLEMENTATION_STATUS.md](./IMPLEMENTATION_STATUS.md)를 따른다. + +## 목표 구조 + +```text +Browser / CLI + → Cloudflare public ingress + → VPS Auth Gateway + → Cloudflare Mesh private hostname route + → per-instance Tunnel + → Rust Agent assigned RFC1918 loopback:10101 + → OpenCodex 127.0.0.1:10100 +``` + +- 사용자 Tunnel에는 공개 hostname을 만들지 않는다. +- `*.instance-domain`은 언제나 중앙 Gateway로 향한다. +- Gateway만 DB의 추측 불가능한 private hostname을 목적지로 사용한다. +- 중앙 Control Plane과 worker는 전용 `ocxr` 사용자로 native systemd 운영한다. 공유 VPS의 host routing을 보호하기 위해 PostgreSQL, Cloudflare Mesh, Gateway는 별도 Docker network namespace에 격리하고 모두 systemd가 수명주기를 관리한다. +- private hostname은 `*.private.remote.opencodexpages.me` DNS-only A record로 인스턴스별 `10.192.0.0/10` `/32`를 가리킨다. +- 같은 `/32` CIDR activation route와 hostname route를 dedicated Tunnel에 연결하며, Agent는 그 주소를 `lo`에만 할당한다. + +[Decision Log] +- 목적과 의도: live Cloudflare Mesh에서 검증된 hostname routing 전제조건을 프로비저닝과 Agent에 고정한다. +- 기존 구현 및 제약 조건: `127.0.0.1` hosts 해석과 hostname route 단독 구성은 실제 account에서 TCP data plane을 열지 못했다. +- 검토한 주요 대안: public Tunnel route, LAN listener, custom resolver, 고유 RFC1918 loopback alias. +- 선택한 방식: DNS record 생성 후 `/32` activation route와 hostname route를 만들고 Agent가 alias를 loopback에 bind한다. +- 다른 대안 대신 이 방식을 선택한 이유: public service exposure 없이 Mesh synthetic IP와 Tunnel origin resolution이 모두 통과했다. +- 장점, 단점 및 영향: provisioning/deletion saga가 네 Cloudflare 리소스를 다룬다. 별도 `prepare-network` 단계만 root 또는 `CAP_NET_ADMIN`이 필요하고 장시간 Agent runtime은 비특권으로 실행한다. + +[Decision Log] +- 목적과 의도: 기존 TeamWicked 서비스가 함께 있는 중앙 VPS에 Mesh를 배포하되 host network namespace의 route와 DNS를 바꾸지 않는다. +- 기존 구현 및 제약 조건: 초기 문서는 중앙 구성요소 전체를 native systemd로 가정했지만 Cloudflare Mesh client는 kernel route와 resolver를 관리한다. +- 검토한 주요 대안: host-native Mesh, 별도 VPS, 모든 구성요소의 containerization, Mesh와 Gateway만 network namespace를 공유하는 hybrid 배포. +- 선택한 방식: Control Plane과 worker는 native로 유지하고 PostgreSQL·Mesh·Gateway를 고정 Docker network에 격리하며 systemd가 여섯 서비스를 관리한다. +- 다른 대안 대신 이 방식을 선택한 이유: 기존 VPS의 Cloudflare Tunnels와 management traffic을 변경하지 않으면서 Gateway만 Mesh 경로를 사용할 수 있다. +- 장점, 단점 및 영향: host 영향 범위가 작고 재시작 경계가 명확하지만 Docker가 운영 의존성에 추가되며 Gateway는 Mesh 컨테이너와 함께 재시작해야 한다. + +## 보안 기준 + +- GitHub OAuth와 24시간 일회용 초대만 허용한다. +- 최초 관리자는 GitHub numeric ID로 부트스트랩한다. +- official domain과 instance domain은 registrable domain을 분리한다. +- 플랫폼과 인스턴스 쿠키는 `__Host-`, HttpOnly, Secure, SameSite=Lax다. +- Agent pairing code는 12자, 10분, 1회용이다. +- Agent는 Ed25519 challenge 후 5분 token을 사용한다. +- CLI token은 `ocxr_` 256-bit, 30일 기본 만료, SHA-256만 저장하며 `/v1/*`만 허용한다. +- Gateway assertion은 Ed25519, 30초이며 instance/user/method/path hash/iat/exp/jti/kid를 포함한다. +- Agent가 assertion과 replay를 검증하고 OpenCodex가 `/api/*`에서 다시 검증한다. +- Prompt, response, repository, provider credential, OpenCodex 설정 본문은 중앙 수집 대상이 아니다. + +## 상태와 lifecycle + +```text +pending → provisioning → awaiting_agent → connecting → online + └────→ degraded/offline +online/degraded/offline → suspending → suspended +any live state → deleting → deleted + └→ delete_failed → retry +``` + +- online은 90초 이내 Agent OpenCodex health, dedicated Tunnel connections, Gateway synthetic health가 모두 정상일 때만 표시한다. +- 정지는 DB 차단과 active connection abort를 먼저 수행한다. +- 삭제는 재시도 가능한 saga이며 slug는 30일 tombstone으로 보존한다. +- job claim은 PostgreSQL `FOR UPDATE SKIP LOCKED`와 idempotency key를 사용한다. + +## 실행 단계 + +1. Phase 0: 실제 Mesh/private hostname protocol, HTTP/SSE/WS/long stream/cancel/100 MiB/restart/격리 PoC. +2. Phase 1: PostgreSQL, Better Auth, invite, instance lifecycle, Gateway, tokens, suspend/delete. +3. Phase 2: Rust Agent, OpenCodex assertion 통합, signed installer와 musl artifacts. +4. Phase 3: Instances, onboarding, Activity, Security, Audit/admin UI. +5. Phase 4: live E2E, backup/restore, key rotation, abuse runbook, CI gates, 최대 50명 private beta. + +Mesh PoC가 하나라도 실패하면 보안 수준을 낮추거나 Workers VPC/자체 reverse tunnel로 자동 전환하지 않고 MVP를 중단해 transport를 다시 설계한다. diff --git a/platform/docs/IMPLEMENTATION_STATUS.md b/platform/docs/IMPLEMENTATION_STATUS.md new file mode 100644 index 000000000..786cbd35f --- /dev/null +++ b/platform/docs/IMPLEMENTATION_STATUS.md @@ -0,0 +1,293 @@ +# OpenCodex Remote MVP 구현 인수인계 + +마지막 갱신: 2026-07-31 + +작업 브랜치: `ingw/remote-outbound-e2ee` + +기준 브랜치/커밋: `dev` / `cd46a34d` + +이 문서는 다른 컴퓨터에서 작업을 바로 이어가기 위한 현재 상태의 기준 문서다. PostgreSQL 17과 실제 Control Plane·worker·Gateway 프로세스를 사용하는 로컬 통합 검증을 완료했고, 2026-07-30 기존 TeamWicked Oracle VPS에 중앙 bootstrap runtime과 `opencodexpages.me` public ingress를 배포했다. 실제 Cloudflare account의 Linux Mesh/private-hostname transport와 Rust Agent 포함 30분 stream은 통과했지만 실제 OpenCodex process를 포함한 public 운영 E2E는 아직 완료되지 않았다. + +## 2026-07-30 outbound E2EE relay 전환 작업 + +신규 제품 경로는 [ADR 0013](../../structure/adr/0013-remote-outbound-e2ee-relay.md)으로 변경했다. 이 변경은 현재 `ingw/remote-private-mvp-handoff` worktree에만 있고 운영 중앙 서버와 `dev`에는 배포하지 않았다. + +- 계정당 주소 하나와 여러 `remote_devices`를 workspace로 사용한다. +- `0003_outbound_e2ee_relay.sql`이 E2EE envelope, device Relay credential/presence, terminal session을 추가한다. +- local OCX는 Argon2id + separated HKDF + AES-GCM vault를 만들고 이전 비밀번호를 확인한 로컬 rewrap을 지원한다. +- Gateway는 `relay.opencodexpages.me/_ocxr/agent` outbound WSS와 wildcard workspace API/WS를 제공하며 DB-per-frame 없이 최대 64 KiB 암호문만 전달한다. +- Rust `relay` command는 현재 사용자 권한의 portable PTY, 상호 서명 ephemeral P-256 handshake, 방향별 AES-GCM counter를 구현한다. +- wildcard web workspace는 xterm.js에서 Shell/Codex/Claude profile을 열고 브라우저-Agent 사이 E2EE를 수행한다. +- 기존 Mesh instance는 새 E2EE password를 설정할 때 slug를 보존해 `outbound-relay`로 전환한다. 기존 Cloudflare resource cleanup은 자동으로 수행하지 않는다. +- 로컬 GUI는 연결 컴퓨터, Relay 상태, 이전 비밀번호가 필요한 암호화 password 변경, 사전 빌드 Agent 시작을 표시한다. + +현재 검증: root/platform typecheck, GUI/platform production build와 lint, Rust check/build와 6 unit tests, root 전체 `6035 pass / 2 skip / 0 fail`을 통과했다. PostgreSQL 17에 `0003`까지 적용한 통합 테스트는 실제 Gateway, Rust Agent, WebCrypto client, `/bin/sh` portable PTY를 연결해 암호화한 `OCXR_RELAY_OK` 왕복과 누락된 immutable asset의 404 경계까지 `1 test / 87 expects`로 3회 연속 통과했다. 로컬 GUI와 wildcard workspace는 Playwright viewport에서 수평 overflow 없이 확인했다. signed multi-platform Agent packaging과 런타임 fail-closed 검증 코드는 구현했으며 정확한 PR commit의 Actions 6종 통과가 필요하다. 아직 남은 release 차단 조건은 실제 Codex/Claude CLI를 포함한 public 운영 E2E와 운영 backup/deploy/rollback이다. 이 코드는 운영 중앙 서버와 `dev`에 배포하지 않았다. + +## 문서와 디자인 자산 + +- 원 기획서: [PRODUCT_PLAN_v1.md](./PRODUCT_PLAN_v1.md) +- 확정 구현 범위: [IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md) +- Instances 승인 이미지: [instances-reference.png](./assets/instances-reference.png) +- Agent 온보딩 승인 이미지: [agent-onboarding-reference.png](./assets/agent-onboarding-reference.png) +- 아키텍처 문서: [../../structure/12_remote-platform.md](../../structure/12_remote-platform.md) +- ADR: [../../structure/adr/0012-remote-private-mesh.md](../../structure/adr/0012-remote-private-mesh.md) + +## 구현 완료된 부분 + +여기서 “완료”는 코드가 작성되고 해당 범위의 로컬 정적 검사 또는 집중 테스트를 통과했다는 뜻이다. 실제 인프라 검증 완료를 뜻하지 않는다. + +### 기존 OpenCodex 통합 + +- `OcxConfig.remoteAccess` 설정 타입과 Zod 검증 추가. +- `X-OpenCodex-Remote-Assertion` 관리 credential class 추가. +- Ed25519 JWT 형식 assertion 검증: + - `kid`, issuer, audience, instance ID, user ID + - HTTP method + - 정규화한 path/query SHA-256 + - 최대 30초 수명, clock skew + - bounded `jti` replay cache +- 기존 admin token과 로컬 GUI session 인증 흐름 유지. +- 정상·재사용·잘못된 method/path/instance·만료·수명 초과 집중 테스트 추가. +- `ocx gui`에 **Remote** 로컬 시작점을 추가하고 GitHub device approval, 별도 비밀번호, hostname 예약, 중앙 provisioning 상태를 보호된 `remote.json`과 management API로 연결. +- polling secret과 `ocxr_device_` token은 browser DTO에 포함하지 않고 기존 config directory permission hardening과 atomic write를 재사용. + +### 중앙 플랫폼 코드 기반 + +- `platform/`을 기존 npm 배포 패키지와 분리된 private package로 추가. +- Bun + Hono Control Plane, streaming Gateway, PostgreSQL worker entrypoint 추가. +- PostgreSQL 초기 migration 추가: + - Better Auth users/sessions/accounts/verifications + - invites, instances, slug tombstones + - agents, pairing codes, access/authorization/session tokens + - provisioning jobs, Cloudflare resources, health observations + - audit logs, abuse reports +- Better Auth + GitHub provider 설정과 GitHub numeric ID 필드 추가. +- GitHub OAuth account create/update hook에서 access/refresh/ID token을 DB 저장 전에 제거해 identity-only 경계로 축소. +- Remote device authorization, 장치별 token 폐기, Argon2id Remote password, 5회 실패/15분 잠금, password 변경 시 instance session 폐기 추가. +- 장치 token으로 hostname/instance를 만들고 provisioning 상태와 10분 Agent pairing code를 로컬 GUI에 반환하는 API 추가. +- 최초 관리자 numeric ID 부트스트랩과 24시간 일회용 초대 소비 로직 추가. +- 사용자당 live instance 1개, 전체 active 사용자 50명 제한 추가. +- 인스턴스 slug 트랜잭션 예약과 provisioning job 생성 추가. +- 12자/10분/1회용 Agent pairing code 추가. +- Ed25519 challenge와 5분 Agent token, heartbeat 기록 추가. +- `ocxr_` 256-bit 데이터 토큰 발급, SHA-256 저장, 30일 만료 추가. +- 브라우저 authorization code → instance session 교환 추가. +- `FOR UPDATE SKIP LOCKED` worker와 provision/suspend/delete job 골격 추가. +- Tunnel secret AES-256-GCM envelope 저장과 audit network HMAC 추가. +- Cloudflare API adapter와 로컬 fake adapter 추가. +- API token과 Global API Key(`X-Auth-Email` + `X-Auth-Key`) 인증을 모두 지원. +- Tunnel 연결 상태는 전용 `/cfd_tunnel/{id}/connections` endpoint만 사용. +- private hostname route는 `/zerotrust/routes/hostname` endpoint 사용. +- Tunnel token 조회는 현재 API의 `GET /cfd_tunnel/{id}/token`을 사용. +- `*.private.remote.opencodexpages.me` DNS-only A record, 고유 RFC1918 `/32`, narrow CIDR activation route를 hostname route보다 먼저 생성. +- suspend/delete 시 hostname route → CIDR route → DNS record → Tunnel 순서로 제거. + +### Gateway + +- hostname → active instance → owner 상태를 요청마다 DB에서 확인. +- 인증 실패·정지·삭제·다른 사용자 접근은 모두 `404`로 은닉. +- instance session과 `ocxr_` 데이터 토큰을 분리. +- ChatGPT Direct용 Authorization을 보존할 수 있도록 `x-opencodex-remote-token` 보조 입력 지원. +- 30초 Ed25519 request assertion 생성. +- 사용자 Cookie, `Set-Cookie`, proxy/auth control header, Cloudflare 내부 header 제거. +- Fetch body/response streaming, request abort propagation, WebSocket relay 코드 추가. +- PostgreSQL `NOTIFY instance_state`를 통해 suspend/delete 시 진행 중 HTTP 연결 abort. +- response body가 끝나거나 취소될 때까지 HTTP 요청을 추적하고, suspend/delete 시 활성 WebSocket도 함께 종료하도록 lifecycle 보강. +- Gateway synthetic `/healthz` 경로와 health observation 기록 추가. + +### Rust Agent + +- `remote-agent/` Rust 2024 package와 lockfile 추가. +- Ed25519 키 생성·pairing·challenge 응답·heartbeat 구현. +- Agent config와 Tunnel token을 `0600`으로 원자 저장. +- Control Plane이 배정한 `10.192.0.0/10` `/32`만 허용. privileged `prepare-network`가 `ip address replace ... dev lo`를 수행하고 비특권 `run`은 `:10101` local ingress를 실행. +- assertion signature/scope/method/path/time/replay 검증 구현. +- 사용자 control header 제거, Host·Origin loopback 정규화. +- streaming HTTP body와 WebSocket 양방향 relay 구현. +- `cloudflared tunnel --no-autoupdate run --token-file` 자식 감독과 backoff 재시작 구현. +- OpenCodex에 넣을 `remoteAccess` JSON 출력 명령 추가. +- WebSocket handshake key/version/upgrade header를 hop마다 새로 생성하도록 중복 전달 차단. + +### Web UI + +- React 19 + Vite 기반 `platform/web` 추가. +- 기존 OpenCodex logo와 dark 운영 콘솔 토큰을 재사용. +- 승인 이미지 기반 Instances 목록·상세, 상태 노드, action UI 구현. +- 3단계 instance 생성·Agent pairing·verify 화면 구현. +- Activity, Security, 로그인, 초대 소비, loading/error/empty/mobile 상태 구현. +- New instance, pairing code, open, token 발급, suspend, delete API 연결. +- 개발 demo data mode: `VITE_REMOTE_DEMO=true`. +- production SPA fallback과 미등록 `/api/*`, `/agent/*` JSON 404 경계 검증. +- apex `/`는 local `ocx gui → Remote` 안내로 교체하고 운영 console은 `/admin`으로 분리. +- `/connect/` GitHub 장치 승인과 `/access/` GitHub 소유권 + Remote password 접속 화면 추가. +- instance Gateway는 무자격 top-level HTML navigation만 central access 화면으로 redirect하고 API/WebSocket은 계속 은닉 `404` 처리. + +### PostgreSQL 17 로컬 통합 검증 + +- migration 17개 테이블 생성과 Better Auth users/sessions/accounts/verifications CRUD 확인. +- 실제 Control Plane·worker·Gateway 자식 프로세스와 fake Cloudflare provider 동시 실행. +- 두 사용자·두 인스턴스 provision, token/session/hostname 격리, Agent assertion scope 오류가 전부 `404`인지 확인. +- HTTP/SSE, WebSocket 양방향 relay, 100 MiB upload/download streaming 확인. +- Gateway 재시작 후 재연결과 suspend `NOTIFY`에 의한 진행 중 stream 취소 확인. +- 중첩 SPA 경로는 production HTML, 미등록 API 경로는 JSON `404`인지 확인. + +### 실제 Cloudflare Phase 0 transport + +- `opencodexpages.me` active zone과 실제 Zero Trust account API shape 검증. +- DNS-only RFC1918 A record → `/32` CIDR activation route → hostname route → dedicated Tunnel 순서 검증. +- Linux Cloudflare Mesh node `warp-cli 2026.6.880.0`에서 private hostname이 `100.80.0.0/16` synthetic IP로 해석되고 Tunnel origin까지 HTTP 200 확인. +- `cloudflared 2026.3.0`, QUIC 4 connections, 수동 `max-active-flows` override 없이 통과. +- 10회 HTTP 19.37–23.68 ms, 100 MiB download 1.500초, 100 MiB upload 정확한 byte count, SSE smoke, WebSocket echo 통과. +- Tunnel restart 시 active SSE는 약 17.05초에 종료됐고 신규 HTTP data plane은 39.632초에 복구. 최소 60초 health grace 필요. +- 실제 Control Plane/worker가 live Cloudflare 리소스 4개를 만들고 Rust Agent pair, 전용 `/32` loopback bind, 4 Tunnel connections, heartbeat를 확인. +- 실제 Mesh Gateway의 `ocxr_` 요청이 Rust Agent assertion 검증을 거쳐 origin 200을 반환했고 무자격 요청은 404로 은닉. +- Rust Agent 포함 100 MiB download 1.707초, upload 정확한 byte count, SSE smoke, WebSocket echo 통과. +- transport-only 30분 SSE는 rc 0, 1,801초, 정확히 1,800 events로 통과. +- 실제 Rust Agent 포함 30분 SSE도 rc 0, 1,800초, 정확히 1,800 events로 통과. +- 재현 절차와 실패 원인은 [CLOUDFLARE_PHASE0_2026-07-30.md](./CLOUDFLARE_PHASE0_2026-07-30.md)에 기록. + +### 중앙 VPS bootstrap 배포 + +- `opencodexpages.me` → dedicated Cloudflare Tunnel → Control Plane/UI `127.0.0.1:10200` 구성. +- `*.opencodexpages.me` → 같은 Tunnel → isolated Mesh namespace의 Gateway `127.0.0.1:10201` 구성 및 Universal SSL 확인. +- `ocxr` 전용 사용자와 systemd 여섯 서비스 추가: PostgreSQL, Control Plane, worker, Mesh, Gateway, public Tunnel. +- PostgreSQL 17, Mesh, Gateway를 `ocxr-net`에 격리해 기존 VPS host routing과 DNS를 보존. +- root-only systemd credentials와 `/etc/opencodex-remote/cloudflare-state.json` 운영 state 구성. +- apex는 exact-email Cloudflare Access OTP로 닫고 내부 numeric GitHub ID bootstrap은 한 명에게만 허용. +- 실제 worker가 테스트 인스턴스의 Tunnel·DNS·CIDR·hostname route 네 리소스를 생성하고 delete saga가 모두 회수하는지 확인. +- public apex health/UI/asset, wildcard TLS와 무자격 `404`, Mesh 연결 상태를 운영 경로에서 확인. +- 실제 리소스 ID와 재시작/점검 절차는 [PRODUCTION_DEPLOYMENT.md](./PRODUCTION_DEPLOYMENT.md)에 기록. +- `0002_remote_devices.sql`과 local-first landing/approval/access runtime을 운영에 적용. 적용 전 PostgreSQL 17 dump와 `/opt` runtime archive를 생성. +- capability 없는 Gateway가 read-only bind mount를 읽을 수 있도록 운영 source tree `0755/0644` permission 불변조건을 확인. + +## 구현 중인 부분 + +- 실제 Codex/Claude CLI 프로세스를 포함한 outbound E2EE terminal E2E. `/bin/sh` PTY 암호화 왕복은 완료했다. +- outbound Gateway 프로세스 재시작 시 Agent/browser 재접속 측정. 기존 Mesh transport/Rust Agent 30분과 Tunnel 재시작은 완료. +- suspend/delete saga의 Cloudflare 부분 실패 재시도와 orphan reconciliation. +- 최초 서명 bundle이 실 npm dry-run/package와 각 OS에서 설치·시작되는지 확인. +- UI의 keyboard/focus/reduced-motion/zoom 접근성 브라우저 QA. 기본 viewport 시각 QA와 overflow 검사는 완료했다. + +## 남은 부분 + +### P0 — 다음 컴퓨터에서 가장 먼저 + +- [x] `platform` typecheck/build, root 전체 테스트, Rust fmt/clippy/test 재실행. +- [x] PostgreSQL 17 migration과 Better Auth schema/CRUD 호환 검증. +- [x] 실제 Control Plane·worker·Gateway를 동시에 띄우는 로컬 통합 테스트. +- [x] 실제 Cloudflare Mesh account Phase 0 core transport. DNS, CIDR activation, hostname route, Tunnel, Linux Mesh HTTP/SSE/WS/100 MiB를 확인했다. +- [x] 실제 Mesh/Rust Agent에서 30분 stream. transport와 Agent 경로 모두 1,800 events로 통과했고 Tunnel 재시작은 신규 요청 39.632초 복구로 확인했다. +- [x] 두 사용자·두 인스턴스 교차 hostname/token/session/assertion 접근 `404` 검증. + +### P1 — 운영 기능 + +- [x] native/isolated hybrid systemd unit 여섯 개와 중앙 `cloudflared`, Linux Mesh node 배포. +- [x] Linux/macOS/Windows x64·arm64 native Agent CI/release matrix와 pinned Rust toolchain. +- [x] Agent binary SHA-256 + Ed25519 detached signature, package version·source commit을 묶는 canonical manifest signature, npm prepare/runtime fail-closed 검증. +- suspend 시 hostname/CIDR/DNS를 먼저 제거하고 Cloudflare connector-cleanup API로 모든 연결을 끊은 뒤 Tunnel을 삭제하는 흐름을 실제 활성 Agent에서 확인했다. +- resume 흐름 추가. 현재 suspend worker는 연결 종료를 확실히 하기 위해 Tunnel과 token을 폐기하며 resume은 미구현이다. +- delete saga 단계별 idempotency와 orphan resource reconciler 추가. +- Gateway rate limit, 동시 연결/본문 크기 정책, abuse report/admin UI 추가. +- audit 조회·90일 retention job, 일일 암호화 backup, 7일/4주 retention, restore drill 추가. +- gateway signing key rotation과 Agent key rotation 구현. +- [x] public instance domain `*.opencodexpages.me`, apex ingress, wildcard TLS 구성. +- dedicated GitHub OAuth app 생성 후 production callback과 multi-user cookie 경계 검증. 현재는 exact-email Access OTP single-user bootstrap이다. + +### P2 — 제품 마감 + +- 승인 이미지와 같은 1488×1058 viewport에서 Instances/Onboarding 시각 비교. +- keyboard, focus, reduced motion, zoom, tablet/mobile QA. +- UI에서 실시간 health signal 세부값과 retry 상태 연결. +- [x] 로컬 `ocx gui`가 별도 privileged installer 대신 npm에 동봉된 서명 Agent를 현재 사용자 권한으로 시작. +- 사용자/인스턴스 관리자 정지 화면과 Audit Log/Activity 실제 API 연결. +- [x] CI에 Rust fmt/clippy/test와 Linux musl, macOS, Windows x64·arm64 native build 추가. platform 전체 CI 확대는 별도 운영 시간 판단이 필요하다. +- Cloudflare 다중 사용자 서비스 허용 범위 서면 확인. + +## 알려진 차이와 위험 + +- 원 기획서의 Workers VPC/fallback보다 이후 확정안인 Cloudflare Mesh private hostname route가 우선한다. Mesh 실패 시 자동 fallback하지 않는다. +- private hostname은 `127.0.0.1` hosts가 아니라 DNS-only RFC1918 A record를 사용한다. Agent `prepare-network`는 구현했지만 installer/systemd의 privileged `ExecStartPre`와 비특권 runtime unit은 아직 미구현이다. +- Cloudflare Tunnel, DNS, CIDR route, hostname route create/delete와 token GET 응답 shape를 live account로 검증했다. +- Gateway synthetic token과 signing key를 포함한 credential은 root-only systemd/container credential mount로 배포했다. +- Better Auth의 GitHub OAuth account token은 DB hook에서 저장 전에 제거한다. callback 성공과 재로그인 후에도 `accounts.access_token`, `refresh_token`, `id_token`이 NULL인지 운영 전환 시 다시 확인한다. +- Gateway와 Rust Agent는 100 MiB 양방향 streaming, SSE/WS, 30분 stream을 통과했다. 실제 OpenCodex process와 public ingress를 포함한 부하·backpressure는 아직 측정하지 않았다. +- private transport DNS는 Phase 0에서 검증했고 public wildcard ingress와 TLS도 운영 배포에서 확인했다. dedicated GitHub OAuth app이 없어 `opencodex.me`와 `opencodexpages.me`의 production OAuth/cookie 분리는 아직 검증하지 않았다. + +## 현재까지 확인된 검증 + +```text +PASS bun run typecheck (root) +PASS bun test tests/server-management-auth.test.ts + 16 tests / 56 expects +PASS bun test --isolate tests + 6035 pass / 2 skip / 0 fail / 30582 expects +PASS bun run privacy:scan +PASS cd gui && bun run lint && bun test tests && bun run build && bun run doctor + 385 pass / 0 fail / 1663 expects; React Doctor 0 issues +PASS cd platform && bun run typecheck +PASS cd platform && bun test server/tests/cloudflare.test.ts + 5 tests / 22 expects +PASS cd platform && CLOUDFLARE_LIVE_TESTS=true ... \ + bun test server/tests/cloudflare-live.test.ts + 1 test / 7 expects, live create/delete cleanup +PASS cd platform && VITE_REMOTE_DEMO=true bun run build:web +PASS cd platform && PLATFORM_TEST_DATABASE_URL=postgres://postgres@127.0.0.1:55432/postgres \ + bun test server/tests/postgres-integration.test.ts + 1 test / 87 expects, PostgreSQL 17; real Gateway + Rust Agent + WebCrypto + PTY relay +PASS cd remote-agent && cargo fmt --check +PASS cd remote-agent && cargo check +PASS cd remote-agent && cargo clippy --all-targets --all-features -- -D warnings +PASS cd remote-agent && cargo test + 6 Rust unit tests +PASS Playwright visual smoke + local Remote GUI and wildcard workspace; no horizontal overflow +PASS cd docs-site && bun run build + 146 pages with localized Remote GUI and CLI documentation +``` + +운영 bootstrap에서 추가로 통과한 항목: + +- systemd 여섯 서비스 enabled/active와 재시작 후 health +- public apex Access redirect, service-token 기반 health/UI/asset 200 검증 후 임시 token/policy 삭제 +- public first-level wildcard TLS와 무자격 404 +- production worker create/delete Cloudflare 리소스 회수 + +아직 통과로 간주하면 안 되는 항목: + +- 실제 Gateway 프로세스 재시작 측정 +- 실제 OpenCodex process와 public ingress를 포함한 HTTP/SSE/WebSocket 운영 E2E +- 시각 QA와 접근성 브라우저 QA +- PostgreSQL backup/restore drill + +## 다른 컴퓨터에서 재개 + +```bash +git fetch origin +git switch ingw/remote-outbound-e2ee + +bun install --frozen-lockfile +bun run typecheck +bun test tests/server-management-auth.test.ts + +cd gui +bun install --frozen-lockfile +cd .. +bun test --isolate tests + +cd platform +bun install --frozen-lockfile +bun run typecheck +VITE_REMOTE_DEMO=true bun run build:web + +docker run --name ocx-remote-pg17 -e POSTGRES_HOST_AUTH_METHOD=trust \ + -p 127.0.0.1:55432:5432 -d postgres:17-alpine +PLATFORM_TEST_DATABASE_URL=postgres://postgres@127.0.0.1:55432/postgres \ + bun test server/tests/postgres-integration.test.ts + +cd ../remote-agent +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test +``` + +UI demo는 `http://127.0.0.1:4173`에서 확인한다. production 실행에는 PostgreSQL URL, GitHub OAuth, Better Auth secret, Gateway Ed25519 key, AES key, audit HMAC key, synthetic health token이 필요하다. 환경 변수와 credential 이름은 `platform/server/src/config.ts`가 현재 기준이다. diff --git a/platform/docs/PRODUCTION_DEPLOYMENT.md b/platform/docs/PRODUCTION_DEPLOYMENT.md new file mode 100644 index 000000000..de2581a0b --- /dev/null +++ b/platform/docs/PRODUCTION_DEPLOYMENT.md @@ -0,0 +1,92 @@ +# OpenCodex Remote central VPS deployment + +The first live deployment uses `opencodexpages.me` on the existing TeamWicked Oracle VPS. + +Deployment date: `2026-07-30` + +## Runtime map + +- `opencodexpages.me` → dedicated Cloudflare Tunnel → Control Plane and built React UI on `127.0.0.1:10200`. +- `*.opencodexpages.me` → the same public Tunnel → Gateway on `127.0.0.1:10201`. +- Control Plane and worker run as the dedicated `ocxr` system user. +- PostgreSQL 17, the isolated Cloudflare Mesh network namespace, and the Gateway run in named Docker containers managed by systemd. +- Per-instance `cloudflared` connectors remain on user servers. The central Gateway is the only component enrolled in Mesh. + +The Gateway shares the Mesh container network namespace. This keeps Cloudflare One routing away from the VPS host namespace, where other TeamWicked Tunnels and management services already run. + +Public instance hostnames use `.opencodexpages.me`. Cloudflare Universal SSL covers this first-level wildcard, while the originally reserved `.remote.opencodexpages.me` would require an additional certificate for a nested wildcard. Private transport names stay under `*.private.remote.opencodexpages.me` because they are DNS-only RFC1918 routing labels and do not terminate public TLS. + +## Bootstrap authentication + +The central service now has a dedicated GitHub OAuth application and the production callback is generated as `https://opencodexpages.me/api/auth/callback/github`. OAuth is identity-only: database hooks clear GitHub access, refresh, and ID tokens before the account row is stored. + +The apex remains protected by a Cloudflare Access application allowing one exact maintainer email through One-Time PIN. Behind that edge boundary, the Control Plane still runs its non-production numeric-ID bootstrap path for the same maintainer while the signed Linux helper and abuse controls are unfinished. + +This is deliberately single-user. Do not add another Access email while numeric-ID bootstrap mode is active because every allowed request would inherit the bootstrap administrator. `PLATFORM_DEV_AUTO_APPROVE_DEVICE_LINKS=true` may be used only for a short, access-gated test: it makes a newly created device link inherit that bootstrap actor and approve immediately. Before a multi-user beta, remove `PLATFORM_DEV_AUTH_GITHUB_ID` and `PLATFORM_DEV_AUTO_APPROVE_DEVICE_LINKS`, set `NODE_ENV=production`, explicitly choose `PLATFORM_SIGNUP_MODE=private|open`, and remove or relax the apex Access application as intended. + +The wildcard instance Gateway is not placed behind the apex Access application. It continues to require an instance session or `ocxr_` token and conceals invalid access as `404`. + +## Live inventory + +- Cloudflare account: `ab4c7f382f138fea000f5374481028af` +- zone: `opencodexpages.me` (`967ca131ed9157323b367e4a57ca0c03`) +- central Tunnel: `opencodex-remote-central` (`201b093f-b7e0-4967-abce-48c728f58e15`) +- Mesh node: `opencodex-remote-central-mesh` (`25adb618-d63a-4317-ad69-0093a5d4a3bc`) +- Access app: `b2c11dab-81da-4789-abf2-9a3112ca7941` +- Access policy: `281c1b8c-f71c-47c5-85c7-6aaff0f02660` +- apex DNS record: `8f6c837918e5904eac2ebc9b192d04d7` +- wildcard DNS record: `1e56251e23703d6f1a164b334490adb2` + +The mutable inventory is also stored at `/etc/opencodex-remote/cloudflare-state.json` with mode `0600`. This file contains IDs, not API credentials. + +## Operations + +The six units are: + +```text +opencodex-remote-postgres.service +opencodex-remote-control.service +opencodex-remote-worker.service +opencodex-remote-mesh.service +opencodex-remote-gateway.service +opencodex-remote-tunnel.service +``` + +Check them together with: + +```bash +systemctl is-active opencodex-remote-{postgres,control,worker,mesh,gateway,tunnel} +curl -fsS http://127.0.0.1:10200/healthz +docker exec ocxr-mesh warp-cli --accept-tos status +``` + +The Gateway container drops every Linux capability. Its read-only `/opt/opencodex-remote/platform` bind mount must therefore remain world-traversable/readable (`0755` directories and `0644` source/assets). A deployment that copies a private `0700/0600` checkout with `rsync -a` makes Bun fail with `CouldntReadCurrentDirectory`. Normalize deployment permissions before restarting the Gateway; no secret is stored in this tree. + +Restart Mesh and Gateway as a pair because the Gateway joins the Mesh container network namespace: + +```bash +sudo systemctl restart opencodex-remote-mesh.service +sudo systemctl restart opencodex-remote-gateway.service +``` + +Only `127.0.0.1:10200`, `127.0.0.1:10201`, and `127.0.0.1:15444` are published on the host. Public traffic must enter through the central Tunnel. + +## Validation and cleanup + +The live worker created a disposable instance Tunnel, DNS-only RFC1918 record, `/32` CIDR activation route, and private hostname route. The delete saga marked the instance deleted and removed all four Cloudflare resources. A temporary Access service token used to validate public health/UI/assets was deleted after the test. No validation token or disposable `ocxr-` Tunnel remains. + +An awaiting-Agent private hostname does not synthesize through Gateway until its dedicated Tunnel connector is online. This is expected; the Agent obtains its Tunnel token through the public Control Plane and connects before private Gateway traffic is sent. + +The 2026-07-30 local-first onboarding deployment added migration `0002_remote_devices.sql`, the central `/connect/`, `/access/`, and landing routes, device-token activation APIs, Argon2id Remote password verification with a five-attempt lock, and top-level unauthenticated browser redirects from instance hostnames. A version-matched PostgreSQL 17 custom-format dump and the prior `/opt` runtime are stored under `/home/ubuntu/backups/opencodex-remote-onboarding-20260730T063728Z`. + +## Secret boundary + +Secrets live under root-owned `/etc/opencodex-remote/credentials`. Native services receive individual files with systemd `LoadCredential=`. Containers receive read-only credential mounts. No credential is committed to the repository or written into the Cloudflare state documentation. + +[Decision Log] +- 목적과 의도: 기존 Oracle VPS를 중앙 서버로 사용하면서 다른 TeamWicked Tunnels와 host routing을 훼손하지 않는다. +- 기존 구현 및 제약 조건: Cloudflare Mesh는 host kernel routing을 바꾸며, 현재 VPS에는 여러 public Tunnel과 관리 서비스가 함께 실행 중이다. 전용 GitHub OAuth app도 아직 없다. +- 검토한 주요 대안: host-native WARP, 별도 VPS, 공개 development auth, isolated Mesh namespace와 apex Access bootstrap. +- 선택한 방식: Mesh와 Gateway만 전용 Docker network namespace에 두고, apex는 exact-email OTP Access로 닫은 single-user bootstrap으로 배포한다. +- 다른 대안 대신 이 방식을 선택한 이유: 현재 VPS의 management traffic을 건드리지 않으며 OAuth app 없이도 무인증 개발 모드를 공개하지 않는다. +- 장점, 단점 및 영향: 즉시 실제 도메인에서 검증할 수 있지만 OAuth 전환 전까지 한 명만 사용할 수 있고, 운영 topology가 완전한 Docker-free 초안과 달라진다. diff --git a/platform/docs/PRODUCT_PLAN_v1.md b/platform/docs/PRODUCT_PLAN_v1.md new file mode 100644 index 000000000..61c7e022e --- /dev/null +++ b/platform/docs/PRODUCT_PLAN_v1.md @@ -0,0 +1,407 @@ +# OpenCodex Remote Access Platform + +> Self-hosted OpenCodex 원격 접속·관리 플랫폼 기획서 + +| 항목 | 내용 | +|---|---| +| 버전 | v1.0 Final | +| 작성일 | 2026-07-29 | +| 상태 | 조건부 승인 | +| 초기 대상 | 초대 기반 비공개 베타 | + +--- + +## 1. 결론 + +이 계획은 **비공개 MVP로 실현 가능**하다. + +다만 사용자 서브도메인을 Cloudflare Tunnel에 직접 연결하지 않고, 모든 요청을 OpenCodex가 관리하는 Gateway에서 인증한 뒤 비공개 Tunnel로 전달해야 한다. + +```text +권장: +사용자 → OpenCodex Auth Gateway → Private Tunnel → 사용자 OpenCodex + +비권장: +사용자 → Public Tunnel → 사용자 OpenCodex +``` + +### 핵심 결정 + +| 항목 | 결정 | +|---|---| +| 도메인 | 공식 서비스와 사용자 인스턴스 분리 | +| 트래픽 진입점 | OpenCodex Auth Gateway | +| Tunnel | MVP에서 `1 Instance = 1 Tunnel` | +| Tunnel 공개 | 공개 hostname을 직접 연결하지 않음 | +| 접근 범위 | Private only | +| Agent 등록 | 짧은 수명의 페어링 코드 | +| Cloudflare Access | 기본안에서 제외, 서면 허가 시에만 검토 | +| Harness·범용 Shell | MVP 제외 | + +### 진행 조건 + +- Cloudflare 계정 권한은 Control Plane만 보유한다. +- 사용자 서버와 Agent가 보내는 정보는 신뢰하지 않는다. +- 공개 출시 전 Cloudflare의 상용·다중 사용자 이용 조건을 서면으로 확인한다. +- Workers VPC의 스트리밍·WebSocket 호환성을 PoC로 검증한다. + +--- + +## 2. 목표와 MVP 범위 + +### 목표 + +사용자가 자신의 서버에서 실행하는 OpenCodex를 포트포워딩, 고정 공인 IP, 직접 TLS 설정 없이 전용 주소로 접속하고 중앙 대시보드에서 관리할 수 있도록 한다. + +```text +https://. +``` + +### 포함 + +- 이메일 또는 OAuth 로그인 +- 인스턴스 생성·삭제 +- 서브도메인 자동 발급 +- Tunnel과 private route 자동 생성 +- Linux Agent 설치·페어링 +- OpenCodex Auth Gateway +- Heartbeat, Tunnel 상태, 외부 Health Check +- 인증 정보 회전·폐기 +- 관리자 정지, Audit Log, Abuse 신고 + +### 제외 + +- Public·Link Access 인스턴스 +- 범용 Tunnel·웹 호스팅 +- SSH·웹 터미널·범용 Shell +- Harness와 작업 큐 +- 팀·조직·결제·SSO +- 사용자 지정 도메인 +- Windows·macOS Agent +- 자체 Reverse Tunnel 프로토콜 + +--- + +## 3. 권장 아키텍처 + +```text +[사용자 브라우저] + │ + ▼ +[Cloudflare Edge] + │ + ▼ +[OpenCodex Auth Gateway] +- 로그인 검증 +- 인스턴스 소유권 확인 +- Rate Limit +- 요청 크기 제한 +- Audit Log + │ + ▼ +[Private Routing] +예: Cloudflare Workers VPC + │ + ▼ +[인스턴스 전용 Cloudflare Tunnel] + │ + ▼ +[사용자 서버의 cloudflared] + │ + ▼ +[127.0.0.1의 OpenCodex Server] +``` + +관리 통신은 별도로 구성한다. + +```text +OpenCodex Agent → Control Plane API +``` + +### 구성요소 + +| 구성요소 | 역할 | +|---|---| +| OpenCodex Web | 로그인, 인스턴스·상태·보안 설정 | +| Auth Gateway | 사용자 인증, 인스턴스 권한 확인, 트래픽 정책 강제 | +| Control Plane | Cloudflare API, Agent 등록, 정지·삭제·감사 로그 | +| OpenCodex Agent | Heartbeat, 로컬 상태 확인, Connector 관리 | +| Cloudflare Tunnel | 사용자 서버에서 Cloudflare로 아웃바운드 연결 | +| Private Routing | Gateway에서 해당 Tunnel로만 비공개 전달 | +| OpenCodex Server | `127.0.0.1`에 바인딩된 실제 애플리케이션 | + +### Gateway 라우팅 원칙 + +```text +.ocx.run +→ Gateway가 hostname 확인 +→ 로그인 사용자와 instance owner 비교 +→ DB에 저장된 내부 목적지 조회 +→ Private Tunnel로 전달 +``` + +사용자 입력 URL이나 IP를 내부 목적지로 직접 사용하지 않는다. Gateway는 Control Plane이 생성한 고정 매핑만 사용해야 한다. + +### Workers VPC 사용안 + +PoC에서는 하나의 Gateway Worker를 Private Network에 연결하고, 인스턴스별 private hostname 또는 VPC Service를 통해 해당 Tunnel로 라우팅한다. + +Workers VPC는 현재 Beta이므로 다음 항목이 실패하면 자체 Gateway 또는 Reverse Tunnel 구조로 전환한다. + +- SSE·HTTP 스트리밍 +- WebSocket Upgrade +- 장시간 연결 +- 요청·응답 크기 +- 다지역 지연시간 +- 장애 후 재연결 + +--- + +## 4. 도메인과 인증 + +### 도메인 분리 + +실제로 소유한 도메인이 확정되기 전까지 특정 도메인을 전제로 하지 않는다. + +```text +공식 서비스: +app. +api. +auth. + +사용자 인스턴스: +. +예: .ocx.run +``` + +사용자 콘텐츠를 `.opencodex.com`처럼 공식 서비스와 같은 상위 도메인에서 제공하지 않는다. 사용자 서버가 임의 응답을 반환할 수 있어 피싱, 쿠키, OAuth, 도메인 평판 문제가 공식 서비스까지 번질 수 있다. + +### 세션 정책 + +- 공식 대시보드 쿠키는 Host-only로 설정한다. +- 인스턴스 접속 세션은 사용자 콘텐츠 도메인용으로 분리한다. +- Gateway는 세션 사용자와 요청 hostname의 소유자를 매번 확인한다. +- 사용자 서버에는 공식 세션과 Refresh Token을 전달하지 않는다. + +### 서브도메인 정책 + +- 영문 소문자, 숫자, 하이픈만 허용한다. +- 한 단계 서브도메인만 제공한다. +- `login`, `auth`, `admin`, `billing`, `support`, `official`, `verify`, `status`, `api`, `www` 등을 예약한다. +- `login-*`, `verify-*`, `account-*`, `support-*` 등의 사칭 패턴도 차단한다. + +--- + +## 5. 인스턴스 생성과 연결 + +### 생성 흐름 + +1. 사용자 권한을 확인한다. +2. 서브도메인을 검증·예약한다. +3. `pending` 인스턴스를 생성한다. +4. 인스턴스 전용 Cloudflare Tunnel을 생성한다. +5. private hostname 또는 VPC Service를 생성한다. +6. 공개 hostname과 내부 목적지의 매핑을 저장한다. +7. 사용자에게 Agent 설치 절차를 표시한다. + +사용자 Tunnel에는 공개 DNS route를 직접 만들지 않는다. 공개 주소는 항상 Gateway로 향한다. + +### Agent 페어링 + +```text +1. 사용자가 Agent 설치 +2. Agent가 짧은 수명의 페어링 코드 생성 +3. 사용자가 대시보드에서 승인 +4. Agent가 키 쌍 생성 후 공개키 등록 +5. Control Plane이 Agent 인증 정보와 Tunnel Token 전달 +6. Agent와 cloudflared를 시스템 서비스로 등록 +``` + +장기 비밀값을 명령행 인자나 Shell history에 남기지 않는다. + +### 설치 원칙 + +- Agent는 전용 OS 사용자로 실행한다. +- 인증 파일은 관리자만 읽을 수 있도록 제한한다. +- OpenCodex는 `127.0.0.1`에 바인딩한다. +- 바이너리는 서명 또는 체크섬으로 검증한다. +- `curl | sh`는 편의 래퍼로만 제공하고 원문과 무결성 정보를 공개한다. + +### 온라인 판정 + +```text +Agent Heartbeat 정상 +AND Tunnel Connector 정상 +AND Gateway를 통한 /healthz 성공 += online +``` + +일부만 정상인 경우 `degraded`, 일정 시간 모두 확인되지 않으면 `offline`으로 표시한다. + +--- + +## 6. 보안·악용 대응 + +사용자 서버는 완전한 비신뢰 환경이다. 서버 소유자는 Agent, Connector, OpenCodex와 응답 내용을 수정할 수 있다. + +| 위험 | 대응 | +|---|---| +| 임의 콘텐츠 반환 | Private only, 모든 요청을 Gateway에서 인증 | +| 공식 서비스 사칭 | 공식·사용자 도메인 분리, 예약어 차단 | +| Cloudflare API 유출 | Control Plane의 Secret Manager에만 저장 | +| Tunnel Token 유출 | 인스턴스별 분리, 회전·폐기·연결 종료 기능 | +| Agent 위조 | 인스턴스별 키, 짧은 수명 Token, 재등록 시 기존 인증 폐기 | +| 상태 조작 | Agent 보고값과 Provider·Gateway 검증값 분리 | +| SSRF·교차 인스턴스 접근 | 고정 목적지 매핑만 사용, 사용자 입력 목적지 금지 | +| 트래픽 남용 | 요청·동시 연결·본문·응답·WebSocket·사용량 제한 | + +### 즉시 정지 절차 + +1. Gateway 라우팅 차단 +2. private route 또는 VPC Service 비활성화 +3. Tunnel Token 회전·폐기 +4. 활성 Connector 연결 종료 +5. Agent 인증 폐기 +6. 인스턴스·계정 정지 +7. 관련 Audit Log 보존 + +### 운영 정책 + +- 초대받은 사용자만 가입 +- 사용자별 인스턴스 수 제한 +- OpenCodex와 무관한 웹 호스팅 금지 +- 피싱, 악성코드, 스팸, 공개 프록시, 불법 콘텐츠 금지 +- Abuse 신고 페이지와 관리자 긴급 정지 기능 운영 + +Repository, 프롬프트, 생성 결과물은 Harness 도입 전까지 기본 수집 대상에 포함하지 않는다. + +--- + +## 7. Cloudflare 의존성과 한계 + +### 기본 한도 + +2026-07-29 기준 공식 문서의 기본 한도다. + +| 리소스 | 기본 한도 | +|---|---:| +| Cloudflare Tunnel | 계정당 1,000개 | +| Tunnel·Mesh Route | 계정당 1,000개 | +| Workers VPC Service | 계정당 1,000개 | +| Cloudflare Access Application | 계정당 500개 | + +따라서 `1 Instance = 1 Tunnel`은 초기 베타에는 적합하지만 대규모 서비스의 최종 구조는 아니다. + +### 계약상 주의 + +Cloudflare Zero Trust 약관은 서면 허가 없는 제3자 재판매를 제한한다. Cloudflare Access를 사용자별 인증 기능으로 제공하려면 사전 서면 허가가 필요하다. + +Workers VPC와 Tunnel을 SaaS 내부 연결 계층으로 사용하는 구조도 공개·유료 출시 전에 Cloudflare에 서비스 형태, 허용 범위, 필요한 플랜을 확인한다. + +### API 주의 + +Cloudflare는 **2026-10-05**부터 Tunnel list/get 응답의 `connections` 필드를 제거할 예정이다. 처음부터 다음 전용 endpoint를 사용한다. + +```text +GET /accounts/{account_id}/cfd_tunnel/{tunnel_id}/connections +``` + +--- + +## 8. 개발 단계 + +### 0단계 — 기술 PoC + +- Tunnel 생성·삭제·Token 회전 자동화 +- private route 또는 VPC Service 자동 생성·삭제 +- Gateway 인증과 인스턴스 격리 +- SSE·WebSocket·스트리밍 테스트 +- Token 유출 후 회전과 연결 종료 +- Cloudflare 상용·다중 사용자 조건 확인 + +**통과 기준:** 다른 사용자의 인스턴스와 정지된 인스턴스에 접근할 수 없고, OpenCodex의 실시간 기능이 정상 동작해야 한다. + +### 1단계 — 비공개 MVP + +- 계정·인스턴스 관리 +- Linux Agent 페어링 +- Tunnel·private route 프로비저닝 +- Gateway 인증·권한 검사 +- 상태 확인, 재연결, 정지·삭제 +- Audit Log와 Abuse 대응 + +### 2단계 — 공개 출시 준비 + +- Rate Limit과 사용량 제한 +- 약관·개인정보·Abuse 절차 +- 모니터링, 알림, 백업, 장애 대응 +- Cloudflare 계약·플랜 확정 +- Workers VPC 유지 또는 자체 Reverse Tunnel 전환 결정 + +### 3단계 — Harness·팀 기능 + +- 작업 큐, 실시간 로그, 작업 취소 +- 컨테이너 또는 별도 샌드박스 +- 실행 시간·출력·동시 작업·네트워크 제한 +- Organization, 역할 기반 권한, SSO, 결제 + +범용 Shell은 기본 제공하지 않는다. + +--- + +## 9. 출시 전 체크리스트 + +- [ ] 공식 도메인과 사용자 콘텐츠 도메인을 실제로 소유하고 있는가? +- [ ] 사용자 Tunnel에 외부 공개 route가 없는가? +- [ ] Gateway가 사용자와 인스턴스 소유자를 매 요청마다 검증하는가? +- [ ] 사용자 입력으로 내부 목적지를 지정할 수 없는가? +- [ ] Tunnel, route, Agent 인증을 한 번에 폐기할 수 있는가? +- [ ] 삭제 실패와 고아 Cloudflare 리소스를 탐지·재처리할 수 있는가? +- [ ] `online` 상태가 Agent 자체 보고에만 의존하지 않는가? +- [ ] SSE·WebSocket·스트리밍이 실제 OpenCodex에서 정상 동작하는가? +- [ ] Cloudflare의 상용·다중 사용자 이용 조건을 서면 확인했는가? +- [ ] Abuse 신고, 긴급 정지, 로그 보존 절차가 준비됐는가? +- [ ] 설치와 업데이트의 서명·체크섬 검증이 가능한가? +- [ ] Harness가 원격 접속 MVP와 분리돼 있는가? + +--- + +## 최종 권장 구조 + +```text +공식 서비스 +- app. +- api. +- auth. + +사용자 주소 +- . +- 모든 요청은 OpenCodex Auth Gateway로 전달 + +중앙 시스템 +- Auth Gateway +- Control Plane +- Cloudflare API +- 상태 검증·Audit Log·Abuse 대응 + +사용자 서버 +- OpenCodex Server: 127.0.0.1 +- OpenCodex Agent: 최소 권한 +- cloudflared: 인스턴스 전용 Token +- 공개 hostname 없음 +``` + +**최종 결론:** 초대 기반 비공개 베타는 진행할 가치가 있다. 사용자 Tunnel을 직접 공개하지 않고 OpenCodex Auth Gateway를 반드시 앞단에 둔다. Workers VPC는 빠른 PoC에 적합하지만 Beta이므로 실시간 통신과 계약 조건을 검증한 뒤 유지 여부를 결정한다. + +--- + +## 참고 자료 + +- Cloudflare Tunnel API: +- Tunnel Permissions: +- Cloudflare Workers VPC: +- Workers VPC Networks: +- Workers VPC Limits: +- Cloudflare One Account Limits: +- Cloudflare Tunnel Changelog: +- Cloudflare Zero Trust Terms: diff --git a/platform/docs/assets/agent-onboarding-reference.png b/platform/docs/assets/agent-onboarding-reference.png new file mode 100644 index 000000000..7315ab5c8 Binary files /dev/null and b/platform/docs/assets/agent-onboarding-reference.png differ diff --git a/platform/docs/assets/instances-reference.png b/platform/docs/assets/instances-reference.png new file mode 100644 index 000000000..8b1be1a5e Binary files /dev/null and b/platform/docs/assets/instances-reference.png differ diff --git a/platform/package.json b/platform/package.json new file mode 100644 index 000000000..5798f344b --- /dev/null +++ b/platform/package.json @@ -0,0 +1,37 @@ +{ + "name": "@opencodex/remote-platform", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --watch server/src/control-plane.ts", + "dev:gateway": "bun --watch server/src/gateway.ts", + "dev:worker": "bun --watch server/src/worker.ts", + "dev:web": "vite --host 127.0.0.1", + "build:web": "vite build", + "typecheck": "bun x tsc --noEmit", + "test": "bun test server/tests web/src", + "start:control": "bun server/src/control-plane.ts", + "start:gateway": "bun server/src/gateway.ts", + "start:worker": "bun server/src/worker.ts" + }, + "dependencies": { + "@noble/hashes": "2.2.0", + "@tabler/icons-react": "^3.34.1", + "@xterm/xterm": "6.0.0", + "better-auth": "^1.6.25", + "hono": "^4.9.8", + "pg": "^8.16.3", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "zod": "^4.1.11" + }, + "devDependencies": { + "@types/bun": "1.3.14", + "@types/pg": "^8.15.5", + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.4", + "typescript": "5.9.3", + "vite": "^7.1.7" + } +} diff --git a/platform/scripts/phase0-origin.ts b/platform/scripts/phase0-origin.ts new file mode 100644 index 000000000..7794ba568 --- /dev/null +++ b/platform/scripts/phase0-origin.ts @@ -0,0 +1,71 @@ +const port = Number(process.env.PHASE0_ORIGIN_PORT ?? "10102"); +const chunk = new Uint8Array(1024 * 1024).fill(0x61); + +function byteStream(size: number): ReadableStream { + let remaining = size; + return new ReadableStream({ + pull(controller) { + if (remaining <= 0) { + controller.close(); + return; + } + const length = Math.min(remaining, chunk.byteLength); + controller.enqueue(length === chunk.byteLength ? chunk : chunk.slice(0, length)); + remaining -= length; + }, + }); +} + +const server = Bun.serve<{ openedAt: number }>({ + hostname: "0.0.0.0", + port, + async fetch(request, server) { + const url = new URL(request.url); + const path = url.pathname.replace(/^\/v1(?=\/)/, ""); + if (path === "/healthz") return Response.json({ ok: true }); + if (path === "/download") { + const size = Math.max(0, Math.min(Number(url.searchParams.get("bytes") ?? 0), 512 * 1024 * 1024)); + return new Response(byteStream(size), { + headers: { "content-length": String(size), "content-type": "application/octet-stream" }, + }); + } + if (path === "/upload" && request.method === "POST") { + let size = 0; + if (request.body) { + for await (const part of request.body) size += part.byteLength; + } + return Response.json({ size }); + } + if (path === "/sse") { + const seconds = Math.max(1, Math.min(Number(url.searchParams.get("seconds") ?? 60), 3600)); + let elapsed = 0; + let timer: Timer | undefined; + return new Response(new ReadableStream({ + start(controller) { + const send = () => { + elapsed += 1; + controller.enqueue(new TextEncoder().encode(`data: ${elapsed}\n\n`)); + if (elapsed >= seconds) { + if (timer) clearInterval(timer); + controller.close(); + } + }; + send(); + timer = setInterval(send, 1000); + }, + cancel() { + if (timer) clearInterval(timer); + }, + }), { headers: { "cache-control": "no-store", "content-type": "text/event-stream" } }); + } + if (path === "/ws" && server.upgrade(request, { data: { openedAt: Date.now() } })) return; + return new Response("not found", { status: 404 }); + }, + websocket: { + message(socket, message) { + socket.send(message); + }, + }, +}); + +console.log(`OpenCodex Remote Phase 0 origin listening on ${server.hostname}:${server.port}`); diff --git a/platform/server/migrations/0001_remote_platform.sql b/platform/server/migrations/0001_remote_platform.sql new file mode 100644 index 000000000..a5f8e9ff5 --- /dev/null +++ b/platform/server/migrations/0001_remote_platform.sql @@ -0,0 +1,218 @@ +BEGIN; + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TYPE user_role AS ENUM ('user', 'admin'); +CREATE TYPE user_status AS ENUM ('pending_invite', 'active', 'suspended'); +CREATE TYPE instance_status AS ENUM ( + 'pending', 'provisioning', 'awaiting_agent', 'connecting', 'online', + 'degraded', 'offline', 'suspending', 'suspended', 'deleting', 'delete_failed', 'deleted' +); +CREATE TYPE job_status AS ENUM ('queued', 'running', 'retry', 'completed', 'failed'); +CREATE TYPE health_signal AS ENUM ('agent', 'tunnel', 'gateway'); + +-- Better Auth owns these four tables. Security-sensitive authorization fields +-- stay server-owned; GitHub profile mapping may write only github_numeric_id. +CREATE TABLE users ( + id text PRIMARY KEY, + name text NOT NULL, + email text NOT NULL UNIQUE, + email_verified boolean NOT NULL DEFAULT false, + image text, + github_numeric_id text UNIQUE, + role user_role NOT NULL DEFAULT 'user', + status user_status NOT NULL DEFAULT 'pending_invite', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE TABLE sessions ( + id text PRIMARY KEY, + token text NOT NULL UNIQUE, + expires_at timestamptz NOT NULL, + ip_address text, + user_agent text, + user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX sessions_user_id_idx ON sessions(user_id); +CREATE TABLE accounts ( + id text PRIMARY KEY, + account_id text NOT NULL, + provider_id text NOT NULL, + user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE, + access_token text, + refresh_token text, + id_token text, + access_token_expires_at timestamptz, + refresh_token_expires_at timestamptz, + scope text, + password text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX accounts_user_id_idx ON accounts(user_id); +CREATE TABLE verifications ( + id text PRIMARY KEY, + identifier text NOT NULL, + value text NOT NULL, + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE invites ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + token_hash bytea NOT NULL UNIQUE, + created_by text NOT NULL REFERENCES users(id), + expected_github_numeric_id text, + expires_at timestamptz NOT NULL, + consumed_at timestamptz, + consumed_by text REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE instances ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id text NOT NULL REFERENCES users(id), + name text NOT NULL, + slug text NOT NULL, + private_hostname text NOT NULL UNIQUE, + private_origin_ip inet NOT NULL UNIQUE, + status instance_status NOT NULL DEFAULT 'pending', + suspended_at timestamptz, + deleted_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX instances_live_slug_idx ON instances(slug) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX instances_one_live_per_user_idx ON instances(owner_id) WHERE deleted_at IS NULL; + +CREATE TABLE slug_tombstones ( + slug text PRIMARY KEY, + instance_id uuid NOT NULL, + expires_at timestamptz NOT NULL +); + +CREATE TABLE agents ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + instance_id uuid NOT NULL UNIQUE REFERENCES instances(id) ON DELETE CASCADE, + public_key bytea NOT NULL, + status text NOT NULL DEFAULT 'paired', + challenge_hash bytea, + challenge_expires_at timestamptz, + auth_token_hash bytea, + auth_token_expires_at timestamptz, + last_heartbeat_at timestamptz, + version text, + created_at timestamptz NOT NULL DEFAULT now(), + revoked_at timestamptz +); + +CREATE TABLE pairing_codes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + instance_id uuid NOT NULL REFERENCES instances(id) ON DELETE CASCADE, + code_hash bytea NOT NULL UNIQUE, + expires_at timestamptz NOT NULL, + consumed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE access_tokens ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + instance_id uuid NOT NULL REFERENCES instances(id) ON DELETE CASCADE, + user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name text NOT NULL, + token_hash bytea NOT NULL UNIQUE, + token_hint text NOT NULL, + expires_at timestamptz NOT NULL, + last_used_at timestamptz, + revoked_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE instance_authorization_codes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + instance_id uuid NOT NULL REFERENCES instances(id) ON DELETE CASCADE, + user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code_hash bytea NOT NULL UNIQUE, + expires_at timestamptz NOT NULL, + consumed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE TABLE instance_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + instance_id uuid NOT NULL REFERENCES instances(id) ON DELETE CASCADE, + user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash bytea NOT NULL UNIQUE, + expires_at timestamptz NOT NULL, + revoked_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE provisioning_jobs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + instance_id uuid NOT NULL REFERENCES instances(id) ON DELETE CASCADE, + kind text NOT NULL, + idempotency_key text NOT NULL UNIQUE, + status job_status NOT NULL DEFAULT 'queued', + step text NOT NULL DEFAULT 'start', + attempts integer NOT NULL DEFAULT 0, + available_at timestamptz NOT NULL DEFAULT now(), + locked_at timestamptz, + locked_by text, + last_error text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX provisioning_jobs_claim_idx ON provisioning_jobs(status, available_at); + +CREATE TABLE cloudflare_resources ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + instance_id uuid NOT NULL REFERENCES instances(id) ON DELETE CASCADE, + resource_type text NOT NULL, + external_id text NOT NULL, + encrypted_secret bytea, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + disabled_at timestamptz, + deleted_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(instance_id, resource_type) +); + +CREATE TABLE health_observations ( + id bigserial PRIMARY KEY, + instance_id uuid NOT NULL REFERENCES instances(id) ON DELETE CASCADE, + signal health_signal NOT NULL, + healthy boolean NOT NULL, + observed_at timestamptz NOT NULL DEFAULT now(), + request_id uuid, + detail jsonb NOT NULL DEFAULT '{}'::jsonb +); +CREATE INDEX health_observations_latest_idx ON health_observations(instance_id, signal, observed_at DESC); + +CREATE TABLE audit_logs ( + id bigserial PRIMARY KEY, + actor_id text REFERENCES users(id), + action text NOT NULL, + instance_id uuid REFERENCES instances(id), + result text NOT NULL, + request_id uuid NOT NULL, + network_hmac text, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX audit_logs_retention_idx ON audit_logs(created_at); + +CREATE TABLE abuse_reports ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + instance_id uuid REFERENCES instances(id), + reporter_contact text, + category text NOT NULL, + details text NOT NULL, + status text NOT NULL DEFAULT 'open', + created_at timestamptz NOT NULL DEFAULT now(), + resolved_at timestamptz +); + +COMMIT; diff --git a/platform/server/migrations/0002_remote_devices.sql b/platform/server/migrations/0002_remote_devices.sql new file mode 100644 index 000000000..a20a5b2ff --- /dev/null +++ b/platform/server/migrations/0002_remote_devices.sql @@ -0,0 +1,56 @@ +BEGIN; + +CREATE TABLE remote_access_profiles ( + user_id text PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + password_hash text, + password_set_at timestamptz, + failed_attempts integer NOT NULL DEFAULT 0, + locked_until timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE remote_devices ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name text NOT NULL, + platform text NOT NULL, + public_key bytea NOT NULL, + instance_id uuid REFERENCES instances(id) ON DELETE SET NULL, + token_hash bytea UNIQUE, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz, + revoked_at timestamptz, + UNIQUE(user_id, public_key) +); +CREATE INDEX remote_devices_user_id_idx ON remote_devices(user_id); + +CREATE TABLE device_authorization_requests ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + poll_secret_hash bytea NOT NULL UNIQUE, + user_code text NOT NULL, + device_name text NOT NULL, + platform text NOT NULL, + public_key bytea NOT NULL, + network_hmac text NOT NULL, + expires_at timestamptz NOT NULL, + approved_by text REFERENCES users(id) ON DELETE CASCADE, + approved_at timestamptz, + device_id uuid REFERENCES remote_devices(id) ON DELETE SET NULL, + encrypted_device_token bytea, + consumed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX device_authorization_requests_expires_idx ON device_authorization_requests(expires_at); +CREATE INDEX device_authorization_requests_network_idx ON device_authorization_requests(network_hmac,created_at); + +-- [Decision Log] +-- - 목적과 의도: 로컬 `ocx gui`가 GitHub 자격증명을 직접 받지 않고 중앙 OAuth 결과에 자기 장치 공개키를 연결하게 한다. +-- - 기존 구현 및 제약 조건: 기존 플랫폼은 웹 dashboard session과 별도 Rust Agent pairing만 있었고 npm OCX GUI가 중앙 계정에 연결되는 장치 승인 상태가 없었다. +-- - 검토한 주요 대안: GitHub token을 localhost callback으로 전달, 장기 API token을 URL에 포함, polling secret이 분리된 일회용 device authorization. +-- - 선택한 방식: 10분 device authorization request, 브라우저에는 UUID와 확인 코드만 표시하고 polling secret은 로컬 OCX만 보유한다. 승인 후 장치 token은 암호화해 ACK 전까지만 보관한다. +-- - 다른 대안 대신 이 방식을 선택한 이유: GitHub token과 장기 장치 token이 브라우저 URL·history·central frontend에 노출되지 않고 proxy 재시작 중에도 승인을 재개할 수 있다. +-- - 장점, 단점 및 영향: 일반 사용자의 시작점이 로컬 GUI가 되며 장치별 폐기가 가능하다. 대신 만료 요청 청소와 장치 token lifecycle을 중앙에서 운영해야 한다. + +COMMIT; diff --git a/platform/server/migrations/0003_outbound_e2ee_relay.sql b/platform/server/migrations/0003_outbound_e2ee_relay.sql new file mode 100644 index 000000000..bb082d4aa --- /dev/null +++ b/platform/server/migrations/0003_outbound_e2ee_relay.sql @@ -0,0 +1,70 @@ +BEGIN; + +ALTER TABLE remote_access_profiles + ADD COLUMN auth_version text NOT NULL DEFAULT 'legacy-password-v1', + ADD COLUMN vault_salt bytea, + ADD COLUMN vault_nonce bytea, + ADD COLUMN encrypted_vault_key bytea, + ADD COLUMN vault_kdf jsonb, + ADD COLUMN root_public_key bytea; + +ALTER TABLE instances + ADD COLUMN transport_mode text NOT NULL DEFAULT 'mesh-tunnel' + CHECK (transport_mode IN ('mesh-tunnel', 'outbound-relay')); + +ALTER TABLE remote_devices + ADD COLUMN ecdh_public_key bytea, + ADD COLUMN relay_token_hash bytea UNIQUE, + ADD COLUMN relay_connected_at timestamptz, + ADD COLUMN relay_disconnected_at timestamptz, + ADD COLUMN agent_version text; + +ALTER TABLE device_authorization_requests + ADD COLUMN ecdh_public_key bytea, + ADD COLUMN encrypted_relay_token bytea; + +-- Earlier private-MVP builds allowed two device keys to reuse a display name. +-- Preserve both devices while making the name stable and unique inside one account. +WITH ranked AS ( + SELECT id,row_number() OVER (PARTITION BY user_id,lower(name) ORDER BY created_at,id) AS position + FROM remote_devices + WHERE revoked_at IS NULL +) +UPDATE remote_devices d +SET name=left(d.name,67)||'-'||left(d.id::text,8),updated_at=now() +FROM ranked r +WHERE d.id=r.id AND r.position>1; + +CREATE UNIQUE INDEX remote_devices_live_user_name_idx + ON remote_devices(user_id,lower(name)) + WHERE revoked_at IS NULL; + +CREATE TABLE terminal_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + instance_id uuid NOT NULL REFERENCES instances(id) ON DELETE CASCADE, + device_id uuid NOT NULL REFERENCES remote_devices(id) ON DELETE CASCADE, + user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE, + browser_token_hash bytea NOT NULL UNIQUE, + command_profile text NOT NULL CHECK (command_profile IN ('shell','codex','claude')), + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','connected','closed','expired')), + expires_at timestamptz NOT NULL, + connected_at timestamptz, + closed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX terminal_sessions_device_status_idx + ON terminal_sessions(device_id,status,expires_at); +CREATE INDEX terminal_sessions_user_created_idx + ON terminal_sessions(user_id,created_at DESC); + +-- [Decision Log] +-- - 목적과 의도: 계정 도메인 하나 아래 여러 컴퓨터를 연결하고 중앙 서버가 터미널 평문이나 복호화 키를 보지 않는 outbound relay를 추가한다. +-- - 기존 구현 및 제약 조건: instance는 계정당 하나였고 Agent도 instance당 하나였으며 비밀번호는 중앙 Argon2id 검증용으로만 저장됐다. +-- - 검토한 주요 대안: 기존 agents 테이블 확장, 컴퓨터별 instance 생성, local device identity를 relay node identity로 재사용. +-- - 선택한 방식: instance를 계정 workspace로 유지하고 remote_devices를 다중 relay node로 확장하며 E2EE 봉투와 짧은 terminal session만 추가한다. +-- - 다른 대안 대신 이 방식을 선택한 이유: 기존 도메인·소유권·세션을 보존하면서 인스턴스별 Cloudflare 리소스와 DB-per-frame 처리를 제거할 수 있다. +-- - 장점, 단점 및 영향: 마이그레이션과 롤백이 작고 기존 Mesh 경로가 공존하지만 과도기 동안 instance라는 이름이 workspace 의미도 함께 가진다. + +COMMIT; diff --git a/platform/server/src/app.ts b/platform/server/src/app.ts new file mode 100644 index 000000000..02bdbcaef --- /dev/null +++ b/platform/server/src/app.ts @@ -0,0 +1,453 @@ +import { Hono, type Context } from "hono"; +import { secureHeaders } from "hono/secure-headers"; +import { serveStatic } from "hono/bun"; +import { z } from "zod"; +import type { PlatformConfig } from "./config"; +import type { PlatformAuth } from "./auth"; +import type { Actor, PlatformStore } from "./store"; +import { gatewayPublicKeyPem } from "./security"; + +interface AppVariables { + actor: Actor | null; + remoteDeviceId: string | null; +} + +const e2eeEnvelopeSchema = z.object({ + version: z.literal("ocx-e2ee-v1"), + salt: z.string().min(20).max(32), + nonce: z.string().min(16).max(24), + ciphertext: z.string().min(24).max(6_000), + rootPublicKey: z.string().min(40).max(256), + kdf: z.object({ + algorithm: z.literal("argon2id"), + memoryKiB: z.number().int().min(32_768).max(262_144), + iterations: z.number().int().min(2).max(8), + parallelism: z.number().int().min(1).max(4), + outputLength: z.literal(32), + }), +}); + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "request failed"; +} + +function bearer(req: Request): string { + return req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim() ?? ""; +} + +async function requireActor(c: Context<{ Variables: AppVariables }>): Promise { + const actor = c.get("actor"); + if (!actor) return c.json({ error: "not found" }, 404); + if (actor.status === "suspended") return c.json({ error: "not found" }, 404); + return actor; +} + +function requireRemoteDevice(c: Context<{ Variables: AppVariables }>): string | Response { + const deviceId = c.get("remoteDeviceId"); + return deviceId ?? c.json({ error: "remote device authentication required" }, 401); +} + +export function createControlPlaneApp(config: PlatformConfig, auth: PlatformAuth, store: PlatformStore) { + const app = new Hono<{ Variables: AppVariables }>(); + app.use("*", secureHeaders({ + crossOriginEmbedderPolicy: false, + contentSecurityPolicy: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", "data:", "https://avatars.githubusercontent.com"], + connectSrc: ["'self'"], + frameAncestors: ["'none'"], + }, + })); + app.use("/api/v1/*", async (c, next) => { + let actor: Actor | null = null; + const device = await store.authorizeDeviceToken(bearer(c.req.raw)); + if (device) { + actor = device.actor; + c.set("remoteDeviceId", device.deviceId); + } else if (config.NODE_ENV !== "production" && config.PLATFORM_DEV_AUTH_GITHUB_ID) { + const result = await store.db.query<{ id: string; name: string; email: string; github_numeric_id: string }>( + "SELECT id,name,email,github_numeric_id FROM users WHERE github_numeric_id=$1", + [config.PLATFORM_DEV_AUTH_GITHUB_ID], + ); + const user = result.rows[0]; + if (user) actor = await store.authorizeUser({ id: user.id, name: user.name, email: user.email, githubNumericId: user.github_numeric_id }); + } else { + const session = await auth.api.getSession({ headers: c.req.raw.headers }); + if (session?.user) { + const user = session.user as typeof session.user & { githubNumericId?: string | null }; + actor = await store.authorizeUser({ + id: user.id, + name: user.name, + email: user.email, + githubNumericId: user.githubNumericId, + }); + } + } + if (!device) c.set("remoteDeviceId", null); + c.set("actor", actor); + await next(); + }); + + app.on(["GET", "POST"], "/api/auth/*", c => auth.handler(c.req.raw)); + app.get("/healthz", c => c.json({ ok: true, service: "control-plane" })); + + app.post("/api/v1/device-links", async c => { + try { + const body = z.object({ + deviceName: z.string().trim().min(1).max(80), + platform: z.string().trim().min(1).max(40), + publicKey: z.string().min(40).max(512), + ecdhPublicKey: z.string().min(80).max(512).optional(), + }).parse(await c.req.json()); + const networkSignal = c.req.header("cf-connecting-ip") + ?? c.req.header("x-forwarded-for")?.split(",")[0]?.trim() + ?? "unknown"; + const deviceLink = await store.createDeviceAuthorization({ + deviceName: body.deviceName, + platform: body.platform, + publicKeyDer: Buffer.from(body.publicKey, "base64url"), + ecdhPublicKeyDer: body.ecdhPublicKey ? Buffer.from(body.ecdhPublicKey, "base64url") : undefined, + networkSignal, + }); + const requestOrigin = new URL(c.req.url); + const loopbackRequest = requestOrigin.protocol === "http:" + && ["127.0.0.1", "localhost"].includes(requestOrigin.hostname); + if ( + config.NODE_ENV !== "production" + && config.PLATFORM_DEV_AUTO_APPROVE_DEVICE_LINKS === "true" + && loopbackRequest + ) { + // The client rejects cross-origin authorization URLs. Keep a local test + // entirely on loopback while the public response continues to use the + // configured HTTPS origin. + deviceLink.authorizeUrl = `${requestOrigin.origin}/connect/${deviceLink.id}`; + } + // [Decision Log] + // - 목적과 의도: 실제 GitHub OAuth 준비 전에도 로컬 OCX Remote 연결 전체 흐름을 한 번의 클릭으로 검증한다. + // - 기존 구현 및 제약 조건: 개발 계정 우회는 API actor만 제공해서 장치 승인 버튼을 별도로 눌러야 했고, 공개 배포에서는 절대 자동 승인되면 안 된다. + // - 검토한 주요 대안: 프런트엔드에서 승인 API 호출, 모든 개발 환경에서 무조건 자동 승인, 명시적인 서버 플래그. + // - 선택한 방식: non-production, 개발 actor, 명시적 auto-approve 플래그가 모두 존재할 때만 생성 직후 승인한다. + // - 다른 대안 대신 이 방식을 선택한 이유: 비밀이나 우회 토큰을 브라우저에 추가하지 않고 production의 GitHub 소유권 경계를 그대로 보존한다. + // - 장점, 단점 및 영향: 테스트는 한 번의 클릭으로 진행되지만 이 플래그를 켠 개발 배포는 접근 가능한 사람이 bootstrap 계정으로 장치를 등록할 수 있으므로 공개 운영에는 사용할 수 없다. + const actor = c.get("actor"); + if ( + config.NODE_ENV !== "production" + && config.PLATFORM_DEV_AUTH_GITHUB_ID + && config.PLATFORM_DEV_AUTO_APPROVE_DEVICE_LINKS === "true" + && actor + ) { + await store.approveDeviceAuthorization(actor, deviceLink.id); + } + return c.json(deviceLink, 201); + } catch (error) { + return c.json({ error: errorMessage(error) }, 400); + } + }); + + app.get("/api/v1/device-links/:id", async c => { + const pollSecret = c.req.header("x-ocxr-link-secret"); + if (pollSecret) { + const result = await store.pollDeviceAuthorization(c.req.param("id"), pollSecret); + return result ? c.json(result) : c.json({ error: "not found" }, 404); + } + const display = await store.getDeviceAuthorizationDisplay(c.req.param("id")); + return display ? c.json({ request: display }) : c.json({ error: "not found" }, 404); + }); + + app.post("/api/v1/device-links/:id/approve", async c => { + const actor = await requireActor(c); + if (actor instanceof Response) return actor; + try { + return c.json({ request: await store.approveDeviceAuthorization(actor, c.req.param("id")) }); + } catch (error) { + return c.json({ error: errorMessage(error) }, 409); + } + }); + + app.post("/api/v1/device-links/:id/ack", async c => { + const pollSecret = c.req.header("x-ocxr-link-secret") ?? ""; + return await store.acknowledgeDeviceAuthorization(c.req.param("id"), pollSecret) + ? c.json({ ok: true }) + : c.json({ error: "not found" }, 404); + }); + + app.get("/api/v1/me", async c => { + const actor = await requireActor(c); + return actor instanceof Response ? actor : c.json({ user: actor }); + }); + + app.get("/api/v1/remote/profile", async c => { + const actor = await requireActor(c); + return actor instanceof Response ? actor : c.json({ profile: await store.remoteProfile(actor) }); + }); + + app.put("/api/v1/remote/password", async c => { + const actor = await requireActor(c); + if (actor instanceof Response) return actor; + const body = z.object({ password: z.string().min(10).max(128) }).parse(await c.req.json()); + await store.setRemotePassword(actor, body.password); + return c.json({ ok: true }); + }); + + app.put("/api/v1/remote/e2ee-profile", async c => { + const actor = await requireActor(c); + if (actor instanceof Response) return actor; + try { + const body = z.object({ + authSecret: z.string().min(43).max(44), + envelope: e2eeEnvelopeSchema, + }).parse(await c.req.json()); + await store.setRemoteE2eeProfile(actor, body.authSecret, body.envelope); + return c.json({ profile: await store.remoteProfile(actor) }); + } catch (error) { + return c.json({ error: errorMessage(error) }, 400); + } + }); + + app.post("/api/v1/remote/e2ee-profile/change", async c => { + const actor = await requireActor(c); + if (actor instanceof Response) return actor; + try { + const body = z.object({ + oldAuthSecret: z.string().min(43).max(44), + newAuthSecret: z.string().min(43).max(44), + envelope: e2eeEnvelopeSchema, + }).parse(await c.req.json()); + await store.changeRemoteE2eeProfile(actor, body.oldAuthSecret, body.newAuthSecret, body.envelope); + return c.json({ profile: await store.remoteProfile(actor) }); + } catch (error) { + return c.json({ error: errorMessage(error) }, 401); + } + }); + + app.post("/api/v1/devices/current/relay-credentials", async c => { + const actor = await requireActor(c); + if (actor instanceof Response) return actor; + const deviceId = requireRemoteDevice(c); + if (deviceId instanceof Response) return deviceId; + try { + const body = z.object({ ecdhPublicKey: z.string().min(80).max(512) }).parse(await c.req.json()); + return c.json(await store.rotateRelayCredentials(actor, deviceId, Buffer.from(body.ecdhPublicKey, "base64url")), 201); + } catch (error) { + return c.json({ error: errorMessage(error) }, 400); + } + }); + + app.post("/api/v1/remote/activate", async c => { + const actor = await requireActor(c); + if (actor instanceof Response) return actor; + const deviceId = requireRemoteDevice(c); + if (deviceId instanceof Response) return deviceId; + try { + const body = z.object({ + name: z.string().trim().min(1).max(80), + slug: z.string().trim().min(1).max(63), + }).parse(await c.req.json()); + return c.json({ profile: await store.activateRemoteInstance(actor, deviceId, body) }, 202); + } catch (error) { + return c.json({ error: errorMessage(error) }, 400); + } + }); + + app.post("/api/v1/remote/pairing-code", async c => { + const actor = await requireActor(c); + if (actor instanceof Response) return actor; + const deviceId = requireRemoteDevice(c); + if (deviceId instanceof Response) return deviceId; + try { + return c.json(await store.createRemotePairingCode(actor, deviceId), 201); + } catch (error) { + return c.json({ error: errorMessage(error) }, 409); + } + }); + + app.post("/api/v1/remote/access/:slug", async c => { + const actor = await requireActor(c); + if (actor instanceof Response) return actor; + try { + const body = z.object({ + authSecret: z.string().min(43).max(44).optional(), + password: z.string().min(10).max(128).optional(), + }).refine(value => !!value.authSecret || !!value.password).parse(await c.req.json()); + return c.json({ + url: await store.issueInstanceAuthorizationForSlug(actor, c.req.param("slug"), body.authSecret ?? body.password ?? ""), + }, 201); + } catch (error) { + return c.json({ error: errorMessage(error) }, 401); + } + }); + + app.delete("/api/v1/devices/current", async c => { + const token = bearer(c.req.raw); + return await store.revokeDeviceToken(token) + ? c.json({ ok: true }) + : c.json({ error: "not found" }, 404); + }); + + app.post("/api/v1/invites/redeem", async c => { + const actor = await requireActor(c); + if (actor instanceof Response) return actor; + const body = z.object({ token: z.string().min(32).max(128) }).parse(await c.req.json()); + return await store.redeemInvite(actor, body.token) + ? c.json({ ok: true }) + : c.json({ error: "invalid or expired invite" }, 400); + }); + + app.post("/api/v1/admin/invites", async c => { + const actor = await requireActor(c); + if (actor instanceof Response) return actor; + try { + const body = z.object({ githubNumericId: z.string().regex(/^\d+$/).optional() }).parse(await c.req.json()); + return c.json(await store.createInvite(actor, body.githubNumericId), 201); + } catch (error) { + return c.json({ error: errorMessage(error) }, 400); + } + }); + + app.get("/api/v1/instances", async c => { + const actor = await requireActor(c); + return actor instanceof Response ? actor : c.json({ instances: await store.listInstances(actor) }); + }); + + app.post("/api/v1/instances", async c => { + const actor = await requireActor(c); + if (actor instanceof Response) return actor; + try { + const body = z.object({ name: z.string(), slug: z.string() }).parse(await c.req.json()); + return c.json({ instance: await store.createInstance(actor, body) }, 202); + } catch (error) { + return c.json({ error: errorMessage(error) }, 400); + } + }); + + app.get("/api/v1/instances/:id", async c => { + const actor = await requireActor(c); + if (actor instanceof Response) return actor; + const instance = await store.getOwnedInstance(actor, c.req.param("id")); + return instance ? c.json({ instance }) : c.json({ error: "not found" }, 404); + }); + + app.post("/api/v1/instances/:id/pairing-code", async c => { + const actor = await requireActor(c); + if (actor instanceof Response) return actor; + try { + return c.json(await store.createPairingCode(actor, c.req.param("id")), 201); + } catch (error) { + return c.json({ error: errorMessage(error) }, 400); + } + }); + + app.post("/api/v1/instances/:id/tokens", async c => { + const actor = await requireActor(c); + if (actor instanceof Response) return actor; + try { + const body = z.object({ name: z.string().max(80).default("CLI token") }).parse(await c.req.json()); + return c.json(await store.issueDataToken(actor, c.req.param("id"), body.name), 201); + } catch (error) { + return c.json({ error: errorMessage(error) }, 400); + } + }); + + app.post("/api/v1/instances/:id/authorize", async c => { + const actor = await requireActor(c); + if (actor instanceof Response) return actor; + try { + const body = z.object({ password: z.string().min(10).max(128) }).parse(await c.req.json()); + return c.json({ url: await store.issueInstanceAuthorization(actor, c.req.param("id"), body.password) }, 201); + } catch (error) { + return c.json({ error: errorMessage(error) }, 409); + } + }); + + app.post("/api/v1/instances/:id/suspend", async c => { + const actor = await requireActor(c); + if (actor instanceof Response) return actor; + try { + await store.suspendInstance(actor, c.req.param("id")); + return c.json({ ok: true }, 202); + } catch { + return c.json({ error: "not found" }, 404); + } + }); + + app.delete("/api/v1/instances/:id", async c => { + const actor = await requireActor(c); + if (actor instanceof Response) return actor; + try { + await store.deleteInstance(actor, c.req.param("id")); + return c.json({ ok: true }, 202); + } catch { + return c.json({ error: "not found" }, 404); + } + }); + + app.post("/agent/pair", async c => { + try { + const body = z.object({ + code: z.string().length(12), + publicKey: z.string().min(40).max(512), + version: z.string().min(1).max(64), + }).parse(await c.req.json()); + const paired = await store.consumePairingCode({ + code: body.code, + publicKeyDer: Buffer.from(body.publicKey, "base64url"), + version: body.version, + }); + return c.json({ + ...paired, + gateway: { + issuer: config.PLATFORM_GATEWAY_ISSUER, + keys: [{ kid: config.PLATFORM_GATEWAY_KID, publicKeyPem: gatewayPublicKeyPem(config) }], + }, + }, 201); + } catch (error) { + return c.json({ error: errorMessage(error) }, 400); + } + }); + + app.post("/agent/challenge", async c => { + try { + const body = z.object({ agentId: z.string().uuid(), clientNonce: z.string() }).parse(await c.req.json()); + return c.json({ challenge: await store.createAgentChallenge(body.agentId, body.clientNonce) }); + } catch { + return c.json({ error: "agent not found" }, 404); + } + }); + + app.post("/agent/token", async c => { + try { + const body = z.object({ agentId: z.string().uuid(), challenge: z.string(), signature: z.string() }).parse(await c.req.json()); + return c.json(await store.exchangeAgentChallenge( + body.agentId, + body.challenge, + Buffer.from(body.signature, "base64url"), + )); + } catch { + return c.json({ error: "challenge rejected" }, 401); + } + }); + + app.post("/agent/heartbeat", async c => { + try { + const body = z.object({ opencodexHealthy: z.boolean(), version: z.string().max(64) }).parse(await c.req.json()); + return c.json(await store.heartbeat(bearer(c.req.raw), body)); + } catch { + return c.json({ error: "agent authentication required" }, 401); + } + }); + + // Keep missing machine endpoints JSON-shaped. The SPA fallback below is for + // browser routes only and must never turn an API typo into a successful HTML response. + app.all("/api/*", c => c.json({ error: "not found" }, 404)); + app.all("/agent/*", c => c.json({ error: "not found" }, 404)); + app.get("*", serveStatic({ root: "./web/dist" })); + app.get("*", serveStatic({ path: "./web/dist/index.html" })); + app.notFound(c => c.json({ error: "not found" }, 404)); + app.onError((error, c) => { + if (error instanceof z.ZodError) return c.json({ error: "invalid request", issues: error.issues }, 400); + return c.json({ error: "internal error" }, 500); + }); + return app; +} diff --git a/platform/server/src/auth.ts b/platform/server/src/auth.ts new file mode 100644 index 000000000..444d7877e --- /dev/null +++ b/platform/server/src/auth.ts @@ -0,0 +1,113 @@ +import { betterAuth } from "better-auth"; +import type { Database } from "./db"; +import type { PlatformConfig } from "./config"; + +export function createPlatformAuth(config: PlatformConfig, database: Database) { + if (!config.BETTER_AUTH_SECRET || !config.GITHUB_CLIENT_ID || !config.GITHUB_CLIENT_SECRET) { + throw new Error("Better Auth and GitHub OAuth credentials are required"); + } + return betterAuth({ + appName: "OpenCodex Remote", + baseURL: config.PLATFORM_BASE_URL, + secret: config.BETTER_AUTH_SECRET, + database: database.pool, + socialProviders: { + github: { + clientId: config.GITHUB_CLIENT_ID, + clientSecret: config.GITHUB_CLIENT_SECRET, + scope: ["read:user", "user:email"], + mapProfileToUser: profile => ({ githubNumericId: String(profile.id) }), + }, + }, + databaseHooks: { + account: { + create: { + before: async account => account.providerId === "github" ? { + data: { + ...account, + accessToken: null, + refreshToken: null, + idToken: null, + accessTokenExpiresAt: null, + refreshTokenExpiresAt: null, + }, + } : undefined, + }, + update: { + before: async account => account.providerId === "github" ? { + data: { + ...account, + accessToken: null, + refreshToken: null, + idToken: null, + accessTokenExpiresAt: null, + refreshTokenExpiresAt: null, + }, + } : undefined, + }, + }, + }, + account: { + modelName: "accounts", + fields: { + accountId: "account_id", + providerId: "provider_id", + userId: "user_id", + accessToken: "access_token", + refreshToken: "refresh_token", + idToken: "id_token", + accessTokenExpiresAt: "access_token_expires_at", + refreshTokenExpiresAt: "refresh_token_expires_at", + createdAt: "created_at", + updatedAt: "updated_at", + }, + accountLinking: { disableImplicitLinking: true }, + }, + user: { + modelName: "users", + fields: { + emailVerified: "email_verified", + createdAt: "created_at", + updatedAt: "updated_at", + }, + additionalFields: { + githubNumericId: { type: "string", fieldName: "github_numeric_id", required: false, input: true }, + role: { type: "string", required: false, input: false, defaultValue: "user" }, + status: { type: "string", required: false, input: false, defaultValue: "pending_invite" }, + }, + }, + session: { + modelName: "sessions", + fields: { + userId: "user_id", + expiresAt: "expires_at", + ipAddress: "ip_address", + userAgent: "user_agent", + createdAt: "created_at", + updatedAt: "updated_at", + }, + expiresIn: 7 * 24 * 60 * 60, + updateAge: 24 * 60 * 60, + }, + verification: { + modelName: "verifications", + fields: { + expiresAt: "expires_at", + createdAt: "created_at", + updatedAt: "updated_at", + }, + }, + advanced: { + cookiePrefix: "__Host-ocxr", + useSecureCookies: true, + defaultCookieAttributes: { + httpOnly: true, + secure: true, + sameSite: "lax", + path: "/", + }, + }, + }); +} + +export type PlatformAuth = ReturnType; diff --git a/platform/server/src/cloudflare.ts b/platform/server/src/cloudflare.ts new file mode 100644 index 000000000..d9ee961aa --- /dev/null +++ b/platform/server/src/cloudflare.ts @@ -0,0 +1,188 @@ +import type { PlatformConfig } from "./config"; + +export interface TunnelResource { + id: string; + token: string; +} + +export interface CloudflareProvider { + createTunnel(name: string): Promise; + createPrivateDnsRecord(hostname: string, originIp: string): Promise; + createCidrActivationRoute(tunnelId: string, originIp: string): Promise; + createPrivateHostnameRoute(tunnelId: string, hostname: string): Promise; + listActiveConnections(tunnelId: string): Promise; + getTunnelToken(tunnelId: string): Promise; + cleanupTunnelConnections(tunnelId: string): Promise; + disablePrivateHostnameRoute(routeId: string): Promise; + deleteCidrActivationRoute(routeId: string): Promise; + deletePrivateDnsRecord(recordId: string): Promise; + deleteTunnel(tunnelId: string): Promise; +} + +interface CloudflareEnvelope { + success: boolean; + result: T; + errors?: Array<{ code: number; message: string }>; +} + +export class CloudflareApi implements CloudflareProvider { + constructor(private readonly config: PlatformConfig) { + if (!config.cloudflareApiToken || !config.CLOUDFLARE_ACCOUNT_ID || !config.CLOUDFLARE_ZONE_ID) { + throw new Error("Cloudflare API credential, account id, and zone id are required"); + } + } + + private authenticationHeaders(): Record { + return this.config.cloudflareAuthEmail + ? { + "x-auth-email": this.config.cloudflareAuthEmail, + "x-auth-key": this.config.cloudflareApiToken!, + } + : { authorization: `Bearer ${this.config.cloudflareApiToken}` }; + } + + private async request(path: string, init: RequestInit = {}): Promise { + const response = await fetch(`${this.config.CLOUDFLARE_API_BASE_URL}${path}`, { + ...init, + headers: { + ...this.authenticationHeaders(), + "content-type": "application/json", + ...init.headers, + }, + }); + const body = await response.json() as CloudflareEnvelope; + if (!response.ok || !body.success) { + throw new Error(`Cloudflare API ${response.status}: ${body.errors?.map(error => error.message).join(", ") || "request failed"}`); + } + return body.result; + } + + createTunnel(name: string): Promise { + return this.request(`/accounts/${this.config.CLOUDFLARE_ACCOUNT_ID}/cfd_tunnel`, { + method: "POST", + body: JSON.stringify({ name, config_src: "cloudflare" }), + }); + } + + async createPrivateDnsRecord(hostname: string, originIp: string): Promise { + const record = await this.request<{ id: string }>( + `/zones/${this.config.CLOUDFLARE_ZONE_ID}/dns_records`, + { + method: "POST", + body: JSON.stringify({ + type: "A", + name: hostname, + content: originIp, + ttl: 60, + proxied: false, + comment: "OpenCodex Remote private origin resolver target", + }), + }, + ); + return record.id; + } + + async createCidrActivationRoute(tunnelId: string, originIp: string): Promise { + const route = await this.request<{ id: string }>( + `/accounts/${this.config.CLOUDFLARE_ACCOUNT_ID}/teamnet/routes`, + { + method: "POST", + body: JSON.stringify({ + network: `${originIp}/32`, + tunnel_id: tunnelId, + comment: "OpenCodex Remote private-flow activation", + }), + }, + ); + return route.id; + } + + async createPrivateHostnameRoute(tunnelId: string, hostname: string): Promise { + const route = await this.request<{ id: string }>( + `/accounts/${this.config.CLOUDFLARE_ACCOUNT_ID}/zerotrust/routes/hostname`, + { method: "POST", body: JSON.stringify({ hostname, tunnel_id: tunnelId, comment: "OpenCodex Remote private instance" }) }, + ); + return route.id; + } + + async listActiveConnections(tunnelId: string): Promise { + const clients = await this.request }>>( + `/accounts/${this.config.CLOUDFLARE_ACCOUNT_ID}/cfd_tunnel/${tunnelId}/connections`, + ); + return clients.flatMap(client => client.conns ?? []).filter(connection => !connection.is_pending_reconnect).length; + } + + getTunnelToken(tunnelId: string): Promise { + return this.request(`/accounts/${this.config.CLOUDFLARE_ACCOUNT_ID}/cfd_tunnel/${tunnelId}/token`); + } + + async cleanupTunnelConnections(tunnelId: string): Promise { + await this.request( + `/accounts/${this.config.CLOUDFLARE_ACCOUNT_ID}/cfd_tunnel/${tunnelId}/connections`, + { method: "DELETE" }, + ); + } + + async disablePrivateHostnameRoute(routeId: string): Promise { + await this.request(`/accounts/${this.config.CLOUDFLARE_ACCOUNT_ID}/zerotrust/routes/hostname/${routeId}`, { method: "DELETE" }); + } + + async deleteCidrActivationRoute(routeId: string): Promise { + await this.request(`/accounts/${this.config.CLOUDFLARE_ACCOUNT_ID}/teamnet/routes/${routeId}`, { method: "DELETE" }); + } + + async deletePrivateDnsRecord(recordId: string): Promise { + await this.request(`/zones/${this.config.CLOUDFLARE_ZONE_ID}/dns_records/${recordId}`, { method: "DELETE" }); + } + + async deleteTunnel(tunnelId: string): Promise { + await this.request(`/accounts/${this.config.CLOUDFLARE_ACCOUNT_ID}/cfd_tunnel/${tunnelId}`, { method: "DELETE" }); + } +} + +/** Deterministic provider for unit tests and local UI development only. */ +export class FakeCloudflareProvider implements CloudflareProvider { + readonly tunnels = new Map(); + readonly routes = new Map(); + readonly dnsRecords = new Map(); + readonly cidrRoutes = new Map(); + + async createTunnel(): Promise { + const id = crypto.randomUUID(); + const token = `fake.${crypto.randomUUID()}`; + this.tunnels.set(id, { token, connections: 0 }); + return { id, token }; + } + async createPrivateHostnameRoute(tunnelId: string, hostname: string): Promise { + const id = crypto.randomUUID(); + this.routes.set(id, { tunnelId, hostname }); + return id; + } + async createPrivateDnsRecord(hostname: string, originIp: string): Promise { + const id = crypto.randomUUID(); + this.dnsRecords.set(id, { hostname, originIp }); + return id; + } + async createCidrActivationRoute(tunnelId: string, originIp: string): Promise { + const id = crypto.randomUUID(); + this.cidrRoutes.set(id, { tunnelId, originIp }); + return id; + } + async listActiveConnections(tunnelId: string): Promise { + return this.tunnels.get(tunnelId)?.connections ?? 0; + } + async getTunnelToken(tunnelId: string): Promise { + const tunnel = this.tunnels.get(tunnelId); + if (!tunnel) throw new Error("tunnel not found"); + return tunnel.token; + } + async cleanupTunnelConnections(tunnelId: string): Promise { + const tunnel = this.tunnels.get(tunnelId); + if (!tunnel) throw new Error("tunnel not found"); + tunnel.connections = 0; + } + async disablePrivateHostnameRoute(routeId: string): Promise { this.routes.delete(routeId); } + async deleteCidrActivationRoute(routeId: string): Promise { this.cidrRoutes.delete(routeId); } + async deletePrivateDnsRecord(recordId: string): Promise { this.dnsRecords.delete(recordId); } + async deleteTunnel(tunnelId: string): Promise { this.tunnels.delete(tunnelId); } +} diff --git a/platform/server/src/config.ts b/platform/server/src/config.ts new file mode 100644 index 000000000..010df5d56 --- /dev/null +++ b/platform/server/src/config.ts @@ -0,0 +1,85 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { z } from "zod"; + +const envSchema = z.object({ + NODE_ENV: z.enum(["development", "test", "production"]).default("development"), + PLATFORM_DATABASE_URL: z.string().min(1).optional(), + PLATFORM_BASE_URL: z.string().url(), + PLATFORM_INSTANCE_DOMAIN: z.string().min(3), + PLATFORM_PRIVATE_HOSTNAME_DOMAIN: z.string().min(3).default("private.remote.opencodexpages.me"), + PLATFORM_BOOTSTRAP_GITHUB_ID: z.string().regex(/^\d+$/), + PLATFORM_SIGNUP_MODE: z.enum(["private", "open"]).default("private"), + PLATFORM_GATEWAY_ISSUER: z.string().default("opencodex-remote"), + PLATFORM_GATEWAY_KID: z.string().default("gateway-1"), + PLATFORM_CONTROL_PORT: z.coerce.number().int().min(1).max(65535).default(10200), + PLATFORM_GATEWAY_PORT: z.coerce.number().int().min(1).max(65535).default(10201), + PLATFORM_GATEWAY_HOST: z.string().default("127.0.0.1"), + PLATFORM_RELAY_URL: z.string().url().optional(), + PLATFORM_DEV_AUTH_GITHUB_ID: z.string().regex(/^\d+$/).optional(), + PLATFORM_DEV_AUTO_APPROVE_DEVICE_LINKS: z.enum(["true", "false"]).default("false"), + CLOUDFLARE_ACCOUNT_ID: z.string().optional(), + CLOUDFLARE_ZONE_ID: z.string().optional(), + CLOUDFLARE_AUTH_EMAIL: z.string().email().optional(), + CLOUDFLARE_API_BASE_URL: z.string().url().default("https://api.cloudflare.com/client/v4"), + CLOUDFLARE_MESH_ENABLED: z.enum(["true", "false"]).default("false"), + BETTER_AUTH_SECRET: z.string().min(32).optional(), + GITHUB_CLIENT_ID: z.string().optional(), + GITHUB_CLIENT_SECRET: z.string().optional(), +}); + +function credential(name: string, envValue?: string): string | undefined { + if (envValue) return envValue; + const directory = process.env.CREDENTIALS_DIRECTORY; + if (!directory) return undefined; + try { + return readFileSync(join(directory, name), "utf8").trim(); + } catch { + return undefined; + } +} + +export interface PlatformConfig extends z.infer { + PLATFORM_DATABASE_URL: string; + PLATFORM_RELAY_URL: string; + cloudflareApiToken?: string; + cloudflareAuthEmail?: string; + gatewayPrivateKeyPem: string; + encryptionKey: Buffer; + auditHmacKey: Buffer; + syntheticHealthToken: string; +} + +export function loadPlatformConfig(env: NodeJS.ProcessEnv = process.env): PlatformConfig { + const parsed = envSchema.parse({ + ...env, + BETTER_AUTH_SECRET: credential("better-auth-secret", env.BETTER_AUTH_SECRET), + GITHUB_CLIENT_SECRET: credential("github-client-secret", env.GITHUB_CLIENT_SECRET), + }); + const gatewayPrivateKeyPem = credential("gateway-ed25519-private-key", env.PLATFORM_GATEWAY_PRIVATE_KEY); + const databaseUrl = credential("database-url", env.PLATFORM_DATABASE_URL); + const encryptionKeyValue = credential("platform-encryption-key", env.PLATFORM_ENCRYPTION_KEY); + const auditHmacValue = credential("audit-hmac-key", env.PLATFORM_AUDIT_HMAC_KEY); + const syntheticHealthToken = credential("synthetic-health-token", env.PLATFORM_SYNTHETIC_HEALTH_TOKEN); + if (!gatewayPrivateKeyPem) throw new Error("gateway Ed25519 private key credential is required"); + if (!databaseUrl) throw new Error("platform database URL credential is required"); + if (!encryptionKeyValue || Buffer.from(encryptionKeyValue, "base64url").length !== 32) { + throw new Error("platform encryption key must be 32 base64url-encoded bytes"); + } + if (!auditHmacValue || Buffer.from(auditHmacValue, "base64url").length < 32) { + throw new Error("audit HMAC key must be at least 32 base64url-encoded bytes"); + } + if (!syntheticHealthToken || syntheticHealthToken.length < 32) throw new Error("synthetic health token is required"); + return { + ...parsed, + PLATFORM_DATABASE_URL: databaseUrl, + PLATFORM_RELAY_URL: parsed.PLATFORM_RELAY_URL + ?? `wss://relay.${parsed.PLATFORM_INSTANCE_DOMAIN}/_ocxr/agent`, + cloudflareApiToken: credential("cloudflare-api-token", env.CLOUDFLARE_API_TOKEN), + cloudflareAuthEmail: credential("cloudflare-auth-email", env.CLOUDFLARE_AUTH_EMAIL), + gatewayPrivateKeyPem, + encryptionKey: Buffer.from(encryptionKeyValue, "base64url"), + auditHmacKey: Buffer.from(auditHmacValue, "base64url"), + syntheticHealthToken, + }; +} diff --git a/platform/server/src/control-plane.ts b/platform/server/src/control-plane.ts new file mode 100644 index 000000000..96275149f --- /dev/null +++ b/platform/server/src/control-plane.ts @@ -0,0 +1,19 @@ +import { createPlatformAuth } from "./auth"; +import { createControlPlaneApp } from "./app"; +import { loadPlatformConfig } from "./config"; +import { Database } from "./db"; +import { PlatformStore } from "./store"; + +const config = loadPlatformConfig(); +const database = new Database(config.PLATFORM_DATABASE_URL); +const auth = createPlatformAuth(config, database); +const store = new PlatformStore(database, config); +const app = createControlPlaneApp(config, auth, store); + +Bun.serve({ + port: config.PLATFORM_CONTROL_PORT, + hostname: "127.0.0.1", + fetch: app.fetch, +}); + +console.log(`OpenCodex Remote control plane listening on 127.0.0.1:${config.PLATFORM_CONTROL_PORT}`); diff --git a/platform/server/src/db.ts b/platform/server/src/db.ts new file mode 100644 index 000000000..072052211 --- /dev/null +++ b/platform/server/src/db.ts @@ -0,0 +1,37 @@ +import { Pool, type PoolClient, type QueryResultRow } from "pg"; + +export class Database { + readonly pool: Pool; + + constructor(connectionString: string) { + this.pool = new Pool({ + connectionString, + max: 20, + idleTimeoutMillis: 30_000, + application_name: "opencodex-remote", + }); + } + + query(text: string, values: unknown[] = []) { + return this.pool.query(text, values); + } + + async transaction(callback: (client: PoolClient) => Promise): Promise { + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + const value = await callback(client); + await client.query("COMMIT"); + return value; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + close(): Promise { + return this.pool.end(); + } +} diff --git a/platform/server/src/gateway.ts b/platform/server/src/gateway.ts new file mode 100644 index 000000000..1c38b1b6f --- /dev/null +++ b/platform/server/src/gateway.ts @@ -0,0 +1,637 @@ +import { timingSafeEqual } from "node:crypto"; +import { resolve, sep } from "node:path"; +import { loadPlatformConfig } from "./config"; +import { Database } from "./db"; +import { GATEWAY_ASSERTION_HEADER, signGatewayAssertion } from "./security"; +import { + PlatformStore, + type InstanceRecord, + type RelayDeviceActor, + type TerminalSessionRecord, +} from "./store"; + +const config = loadPlatformConfig(); +const database = new Database(config.PLATFORM_DATABASE_URL); +const store = new PlatformStore(database, config); + +const INSTANCE_COOKIE = "__Host-ocxr_instance"; +const RELAY_MAX_PAYLOAD = 64 * 1024; +const RELAY_MAX_BUFFERED = 1024 * 1024; +const RELAY_FRAME_HEADER_SIZE = 18; +const RELAY_PROTOCOL_VERSION = 1; +const RELAY_FRAME_OPEN = 1; +const RELAY_FRAME_DATA = 2; +const RELAY_FRAME_CLOSE = 3; +const RELAY_HOST = new URL(config.PLATFORM_RELAY_URL).hostname.toLowerCase(); +const WEB_ROOT = resolve(import.meta.dir, "../../web/dist"); +const REQUEST_HEADER_DENY = new Set([ + "connection", "cookie", "host", "proxy-authorization", "proxy-authenticate", + "upgrade", + "x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "x-real-ip", + "x-opencodex-api-key", GATEWAY_ASSERTION_HEADER, "x-ocxr-synthetic", + "x-opencodex-remote-token", +]); +const RESPONSE_HEADER_DENY = new Set([ + "connection", "set-cookie", "set-cookie2", "proxy-authenticate", "proxy-authorization", + GATEWAY_ASSERTION_HEADER, +]); + +function cookieValue(req: Request, name: string): string | null { + for (const segment of (req.headers.get("cookie") ?? "").split(";")) { + const [key, ...value] = segment.trim().split("="); + if (key === name) return decodeURIComponent(value.join("=")); + } + return null; +} + +function safeEqual(actual: string | null, expected: string): boolean { + if (!actual) return false; + const left = Buffer.from(actual); + const right = Buffer.from(expected); + return left.length === right.length && timingSafeEqual(left, right); +} + +function remoteCredential(req: Request): string | null { + const explicit = req.headers.get("x-opencodex-remote-token")?.trim(); + if (explicit) return `Bearer ${explicit}`; + const authorization = req.headers.get("authorization"); + const token = authorization?.replace(/^Bearer\s+/i, "").trim(); + return token?.startsWith("ocxr_") ? authorization : null; +} + +function bearerToken(req: Request): string | null { + return req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim() || null; +} + +function requestHostname(req: Request): string { + return (req.headers.get("host") ?? "").toLowerCase().replace(/:\d+$/, ""); +} + +function requestOriginMatches(req: Request): boolean { + const origin = req.headers.get("origin"); + if (!origin) return true; + try { + const parsed = new URL(origin); + return parsed.hostname.toLowerCase() === requestHostname(req) + && (parsed.protocol === "https:" || (parsed.protocol === "http:" && parsed.hostname === "localhost")); + } catch { + return false; + } +} + +function browserAccessRedirect(req: Request): Response | null { + if (req.method !== "GET" || req.headers.get("upgrade")?.toLowerCase() === "websocket") return null; + const acceptsHtml = req.headers.get("sec-fetch-dest") === "document" + || (req.headers.get("accept") ?? "").includes("text/html"); + if (!acceptsHtml) return null; + const host = (req.headers.get("host") ?? "").toLowerCase().replace(/:\d+$/, ""); + const suffix = `.${config.PLATFORM_INSTANCE_DOMAIN.toLowerCase()}`; + if (!host.endsWith(suffix)) return null; + const slug = host.slice(0, -suffix.length); + if (!/^[a-z0-9-]{1,63}$/.test(slug)) return null; + return new Response(null, { + status: 302, + headers: { + location: `${config.PLATFORM_BASE_URL.replace(/\/$/, "")}/access/${encodeURIComponent(slug)}`, + "cache-control": "no-store", + }, + }); +} + +function sanitizeRequestHeaders(req: Request, target: URL, assertion: string): Headers { + const headers = new Headers(); + for (const [name, value] of req.headers) { + const lower = name.toLowerCase(); + if ( + REQUEST_HEADER_DENY.has(lower) + || lower.startsWith("cf-") + || lower.startsWith("sec-websocket-") + || lower.startsWith("x-ocxr-") + ) continue; + if (lower === "authorization" && value.replace(/^Bearer\s+/i, "").startsWith("ocxr_")) continue; + headers.append(name, value); + } + headers.set("host", "127.0.0.1:10100"); + headers.set("origin", "http://127.0.0.1:10100"); + headers.set(GATEWAY_ASSERTION_HEADER, assertion); + headers.set("x-ocxr-target-path", `${target.pathname}${target.search}`); + return headers; +} + +function sanitizeResponseHeaders(response: Response, publicHost: string): Headers { + const headers = new Headers(); + for (const [name, value] of response.headers) { + const lower = name.toLowerCase(); + if (RESPONSE_HEADER_DENY.has(lower) || lower.startsWith("cf-") || lower.startsWith("x-ocxr-")) continue; + if (lower === "location") { + headers.set(name, value.replace(/^https?:\/\/(?:127\.0\.0\.1|localhost):10100/i, `https://${publicHost}`)); + } else { + headers.append(name, value); + } + } + headers.set("cache-control", headers.get("cache-control") ?? "no-store"); + headers.set("x-content-type-options", "nosniff"); + return headers; +} + +function privateUrl(instance: InstanceRecord, requestUrl: URL): URL { + return new URL(`${requestUrl.pathname}${requestUrl.search}`, `http://${instance.privateHostname}:10101`); +} + +const activeRequests = new Map>(); +const activeSockets = new Map>>(); +const relayAgents = new Map>(); +const relayTerminals = new Map>(); +function track(instanceId: string, controller: AbortController): () => void { + const set = activeRequests.get(instanceId) ?? new Set(); + set.add(controller); + activeRequests.set(instanceId, set); + return () => { + set.delete(controller); + if (!set.size) activeRequests.delete(instanceId); + }; +} + +function socketInstanceId(socket: Bun.ServerWebSocket): string { + return socket.data.kind === "relay-agent" ? socket.data.device.instanceId : socket.data.instance.id; +} + +function trackSocket(socket: Bun.ServerWebSocket): void { + const instanceId = socketInstanceId(socket); + const sockets = activeSockets.get(instanceId) ?? new Set>(); + sockets.add(socket); + activeSockets.set(instanceId, sockets); +} + +function untrackSocket(socket: Bun.ServerWebSocket): void { + const instanceId = socketInstanceId(socket); + const sockets = activeSockets.get(instanceId); + sockets?.delete(socket); + if (!sockets?.size) activeSockets.delete(instanceId); +} + +function streamWithLifecycle( + body: ReadableStream, + controller: AbortController, + cleanup: () => void, +): ReadableStream { + const reader = body.getReader(); + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + cleanup(); + }; + return new ReadableStream({ + async pull(output) { + try { + const chunk = await reader.read(); + if (chunk.done) { + finish(); + output.close(); + } else { + output.enqueue(chunk.value); + } + } catch (error) { + finish(); + output.error(error); + } + }, + async cancel(reason) { + controller.abort(reason); + try { + await reader.cancel(reason); + } finally { + finish(); + } + }, + }); +} + +async function listenForRevocations(): Promise { + const client = await database.pool.connect(); + client.on("notification", message => { + if (message.channel !== "instance_state" || !message.payload) return; + try { + const { instanceId, status } = JSON.parse(message.payload) as { instanceId: string; status?: string }; + if (!["suspending", "suspended", "deleting", "deleted"].includes(status ?? "")) return; + for (const controller of activeRequests.get(instanceId) ?? []) controller.abort("instance disabled"); + for (const socket of activeSockets.get(instanceId) ?? []) socket.close(1008, "instance disabled"); + activeRequests.delete(instanceId); + activeSockets.delete(instanceId); + } catch { /* malformed NOTIFY payload is ignored */ } + }); + client.on("error", error => console.error("gateway PostgreSQL listener failed", error.message)); + await client.query("LISTEN instance_state"); +} + +interface ProxySocketData { + kind: "proxy"; + instance: InstanceRecord; + userId: string; + url: URL; + headers: Headers; + queued: Array; + upstream?: WebSocket; +} + +interface RelayAgentSocketData { + kind: "relay-agent"; + device: RelayDeviceActor & { instanceId: string }; +} + +interface RelayTerminalSocketData { + kind: "relay-terminal"; + instance: InstanceRecord; + userId: string; + session: TerminalSessionRecord; +} + +type SocketData = ProxySocketData | RelayAgentSocketData | RelayTerminalSocketData; + +function uuidBytes(value: string): Buffer { + const hex = value.replaceAll("-", ""); + if (!/^[0-9a-f]{32}$/i.test(hex)) throw new Error("invalid relay session id"); + return Buffer.from(hex, "hex"); +} + +function uuidString(value: Buffer): string { + if (value.length !== 16) throw new Error("invalid relay session bytes"); + const hex = value.toString("hex"); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +function relayFrame(kind: number, sessionId: string, payload: Buffer = Buffer.alloc(0)): Buffer { + if (![RELAY_FRAME_OPEN, RELAY_FRAME_DATA, RELAY_FRAME_CLOSE].includes(kind)) throw new Error("invalid relay frame kind"); + if (payload.length > RELAY_MAX_PAYLOAD) throw new Error("relay frame is too large"); + return Buffer.concat([Buffer.from([RELAY_PROTOCOL_VERSION, kind]), uuidBytes(sessionId), payload]); +} + +function parseRelayFrame(message: Buffer): { kind: number; sessionId: string; payload: Buffer } { + if (message.length < RELAY_FRAME_HEADER_SIZE || message.length > RELAY_FRAME_HEADER_SIZE + RELAY_MAX_PAYLOAD) { + throw new Error("invalid relay frame length"); + } + if (message[0] !== RELAY_PROTOCOL_VERSION || ![RELAY_FRAME_OPEN, RELAY_FRAME_DATA, RELAY_FRAME_CLOSE].includes(message[1])) { + throw new Error("invalid relay frame header"); + } + return { + kind: message[1], + sessionId: uuidString(message.subarray(2, RELAY_FRAME_HEADER_SIZE)), + payload: message.subarray(RELAY_FRAME_HEADER_SIZE), + }; +} + +function sendBounded(socket: Bun.ServerWebSocket, message: string | Buffer): boolean { + if (socket.getBufferedAmount() > RELAY_MAX_BUFFERED) { + socket.close(1013, "relay backpressure"); + return false; + } + socket.send(message); + return true; +} + +function jsonResponse(body: unknown, status = 200): Response { + return Response.json(body, { + status, + headers: { + "cache-control": "no-store", + "content-security-policy": "default-src 'none'; frame-ancestors 'none'", + "x-content-type-options": "nosniff", + }, + }); +} + +async function workspaceAsset(req: Request, url: URL): Promise { + if (req.method !== "GET" && req.method !== "HEAD") return new Response("Method not allowed", { status: 405 }); + const requestedPath = url.pathname === "/" ? "/index.html" : url.pathname; + const indexPath = resolve(WEB_ROOT, "index.html"); + const candidate = resolve(WEB_ROOT, `.${requestedPath}`); + if (!candidate.startsWith(`${WEB_ROOT}${sep}`)) return new Response("Not found", { status: 404 }); + + let file = Bun.file(candidate); + let fellBackToIndex = false; + if (!(await file.exists())) { + // Missing immutable assets must fail instead of returning HTML under a JS/CSS + // cache key. Only extensionless browser routes participate in SPA fallback. + if (requestedPath.startsWith("/assets/") || /\/[^/]+\.[^/]+$/.test(requestedPath)) { + return new Response("Not found", { status: 404 }); + } + file = Bun.file(indexPath); + fellBackToIndex = true; + } + if (!(await file.exists())) return new Response("Workspace UI is not built", { status: 503 }); + const response = new Response(req.method === "HEAD" ? null : file, { + headers: { + "cache-control": fellBackToIndex || requestedPath === "/index.html" ? "no-store" : "public, max-age=31536000, immutable", + "content-type": file.type, + "content-security-policy": "default-src 'self'; connect-src 'self' wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'", + "referrer-policy": "no-referrer", + "x-content-type-options": "nosniff", + "x-frame-options": "DENY", + }, + }); + return response; +} + +let gatewayServer: Bun.Server; + +async function authenticate(req: Request): Promise<{ instance: InstanceRecord; userId: string } | null> { + const url = new URL(req.url); + const host = req.headers.get("host") ?? ""; + if (url.pathname === "/healthz" && safeEqual(req.headers.get("x-ocxr-synthetic"), config.syntheticHealthToken)) { + const result = await store.resolveGatewayRequest(host, null, null, "/_synthetic"); + if (result) return { ...result, userId: "synthetic-health" }; + // Synthetic health still needs to resolve an active instance, but has no + // user session. Resolve it through a constrained direct query. + const slug = host.toLowerCase().replace(/:\d+$/, "").replace(`.${config.PLATFORM_INSTANCE_DOMAIN.toLowerCase()}`, ""); + const found = await database.query<{ + id: string; owner_id: string; name: string; slug: string; private_hostname: string; + transport_mode: InstanceRecord["transportMode"]; + status: InstanceRecord["status"]; created_at: Date; updated_at: Date; + }>(`SELECT i.* FROM instances i JOIN users u ON u.id=i.owner_id + WHERE i.slug=$1 AND i.status IN ('connecting','online','degraded','offline') AND i.deleted_at IS NULL AND u.status='active'`, [slug]); + const row = found.rows[0]; + return row ? { userId: "synthetic-health", instance: { + id: row.id, ownerId: row.owner_id, name: row.name, slug: row.slug, + privateHostname: row.private_hostname, transportMode: row.transport_mode, status: row.status, + createdAt: row.created_at.toISOString(), updatedAt: row.updated_at.toISOString(), + } } : null; + } + return store.resolveGatewayRequest( + host, + remoteCredential(req), + cookieValue(req, INSTANCE_COOKIE), + url.pathname, + ); +} + +gatewayServer = Bun.serve({ + port: config.PLATFORM_GATEWAY_PORT, + hostname: config.PLATFORM_GATEWAY_HOST, + async fetch(req, server) { + const url = new URL(req.url); + const host = req.headers.get("host") ?? ""; + if (url.pathname === "/_ocxr/agent") { + if ( + req.headers.get("upgrade")?.toLowerCase() !== "websocket" + || requestHostname(req) !== RELAY_HOST + ) return new Response("Not found", { status: 404 }); + const token = bearerToken(req); + const device = token ? await store.authorizeRelayToken(token) : null; + if (!device?.instanceId) return new Response("Unauthorized", { status: 401 }); + const upgraded = server.upgrade(req, { + data: { kind: "relay-agent", device: { ...device, instanceId: device.instanceId } }, + }); + return upgraded ? undefined : new Response("WebSocket upgrade failed", { status: 502 }); + } + if (url.pathname === "/_ocxr/exchange" && req.method === "GET") { + const code = url.searchParams.get("code"); + const exchanged = code ? await store.exchangeInstanceAuthorization(host, code) : null; + if (!exchanged) return new Response("Not found", { status: 404 }); + return new Response(null, { + status: 302, + headers: { + location: "/", + "set-cookie": `${INSTANCE_COOKIE}=${encodeURIComponent(exchanged.token)}; Path=/; Expires=${exchanged.expiresAt.toUTCString()}; HttpOnly; Secure; SameSite=Lax`, + "cache-control": "no-store", + }, + }); + } + + const auth = await authenticate(req); + if (!auth) { + return browserAccessRedirect(req) + ?? new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } }); + } + + if (auth.instance.transportMode === "outbound-relay") { + if (url.pathname === "/healthz" && req.method === "GET") { + return jsonResponse({ ok: true, relay: true }); + } + const terminalPath = url.pathname.match(/^\/_ocxr\/terminal\/([0-9a-f-]{36})$/i); + if (terminalPath && req.headers.get("upgrade")?.toLowerCase() === "websocket") { + const session = await store.resolveTerminalSession(auth.userId, auth.instance.id, terminalPath[1]); + if (!session || !relayAgents.has(session.deviceId)) { + return new Response("Remote computer unavailable", { status: 409 }); + } + const upgraded = server.upgrade(req, { + data: { kind: "relay-terminal", instance: auth.instance, userId: auth.userId, session }, + }); + return upgraded ? undefined : new Response("WebSocket upgrade failed", { status: 502 }); + } + if (url.pathname === "/api/v1/workspace" && req.method === "GET") { + const devices = (await store.listRelayDevices(auth.userId, auth.instance.id)).map(device => ({ + ...device, + relayOnline: relayAgents.has(device.id), + })); + return jsonResponse({ + instance: { + id: auth.instance.id, + name: auth.instance.name, + slug: auth.instance.slug, + status: devices.some(device => device.relayOnline) ? "online" : "offline", + }, + devices, + limits: { maxSessionsPerDevice: 4, maxFrameBytes: RELAY_MAX_PAYLOAD }, + }); + } + if (url.pathname === "/api/v1/terminal-sessions" && req.method === "POST") { + if (!requestOriginMatches(req)) return jsonResponse({ error: "origin rejected" }, 403); + if (Number(req.headers.get("content-length") ?? "0") > 4096) { + return jsonResponse({ error: "request too large" }, 413); + } + let input: { deviceId?: unknown; commandProfile?: unknown }; + try { + input = await req.json(); + } catch { + return jsonResponse({ error: "invalid request" }, 400); + } + const commandProfile = input.commandProfile; + if ( + typeof input.deviceId !== "string" + || !/^[0-9a-f-]{36}$/i.test(input.deviceId) + || !["shell", "codex", "claude"].includes(String(commandProfile)) + ) return jsonResponse({ error: "invalid request" }, 400); + if (!relayAgents.has(input.deviceId)) return jsonResponse({ error: "remote computer unavailable" }, 409); + try { + const session = await store.createTerminalSession( + auth.userId, + auth.instance.id, + input.deviceId, + commandProfile as TerminalSessionRecord["commandProfile"], + ); + return jsonResponse({ + session, + websocketPath: `/_ocxr/terminal/${session.id}`, + }, 201); + } catch (error) { + return jsonResponse({ error: error instanceof Error ? error.message : "session rejected" }, 409); + } + } + if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/_ocxr/")) { + return jsonResponse({ error: "not found" }, 404); + } + return workspaceAsset(req, url); + } + const target = privateUrl(auth.instance, url); + const assertion = signGatewayAssertion(config, { + instanceId: auth.instance.id, + userId: auth.userId, + method: req.method, + url, + }); + const headers = sanitizeRequestHeaders(req, target, assertion); + + if (req.headers.get("upgrade")?.toLowerCase() === "websocket") { + const wsTarget = new URL(target); + wsTarget.protocol = "ws:"; + const upgraded = server.upgrade(req, { + data: { kind: "proxy", instance: auth.instance, userId: auth.userId, url: wsTarget, headers, queued: [] }, + }); + return upgraded ? undefined : new Response("WebSocket upgrade failed", { status: 502 }); + } + + const controller = new AbortController(); + const untrack = track(auth.instance.id, controller); + const abort = () => controller.abort(req.signal.reason); + const cleanup = () => { + req.signal.removeEventListener("abort", abort); + untrack(); + }; + if (req.signal.aborted) abort(); + else req.signal.addEventListener("abort", abort, { once: true }); + try { + const response = await fetch(target, { + method: req.method, + headers, + body: req.body, + redirect: "manual", + signal: controller.signal, + // Required by Bun for streaming request bodies. + duplex: req.body ? "half" : undefined, + } as RequestInit); + if (url.pathname === "/healthz") await store.recordHealth(auth.instance.id, "gateway", response.ok); + const body = response.body + ? streamWithLifecycle(response.body, controller, cleanup) + : null; + if (!body) cleanup(); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: sanitizeResponseHeaders(response, host), + }); + } catch { + cleanup(); + if (url.pathname === "/healthz") await store.recordHealth(auth.instance.id, "gateway", false); + return new Response("Upstream unavailable", { status: 502 }); + } + }, + websocket: { + open(ws) { + trackSocket(ws); + if (ws.data.kind === "proxy") { + const data = ws.data; + const upstream = new WebSocket(data.url, { headers: Object.fromEntries(data.headers) } as never); + data.upstream = upstream; + upstream.binaryType = "arraybuffer"; + upstream.addEventListener("open", () => { + for (const message of data.queued.splice(0)) upstream.send(message); + }); + upstream.addEventListener("message", event => { + if (typeof event.data === "string") ws.send(event.data); + else if (event.data instanceof ArrayBuffer) ws.send(event.data); + }); + upstream.addEventListener("close", event => ws.close(event.code, event.reason)); + upstream.addEventListener("error", () => ws.close(1011, "upstream error")); + return; + } + if (ws.data.kind === "relay-agent") { + const previous = relayAgents.get(ws.data.device.deviceId); + relayAgents.set(ws.data.device.deviceId, ws); + if (previous && previous !== ws) previous.close(1012, "agent reconnected"); + return; + } + const previous = relayTerminals.get(ws.data.session.id); + relayTerminals.set(ws.data.session.id, ws); + if (previous && previous !== ws) previous.close(1008, "terminal session already connected"); + const agent = relayAgents.get(ws.data.session.deviceId); + if (!agent || agent.data.kind !== "relay-agent") { + ws.close(1013, "remote computer unavailable"); + return; + } + void store.markTerminalConnected(ws.data.session.id); + sendBounded(agent, relayFrame( + RELAY_FRAME_OPEN, + ws.data.session.id, + Buffer.from(JSON.stringify({ commandProfile: ws.data.session.commandProfile })), + )); + }, + message(ws, message) { + if (ws.data.kind === "proxy") { + if (ws.data.upstream?.readyState === WebSocket.OPEN) ws.data.upstream.send(message); + else ws.data.queued.push(typeof message === "string" ? message : Buffer.from(message)); + return; + } + if (typeof message === "string") { + ws.close(1003, "binary relay frames required"); + return; + } + const bytes = Buffer.from(message); + if (ws.data.kind === "relay-agent") { + let frame: ReturnType; + try { + frame = parseRelayFrame(bytes); + } catch { + ws.close(1008, "invalid relay frame"); + return; + } + const terminal = relayTerminals.get(frame.sessionId); + if (!terminal || terminal.data.kind !== "relay-terminal") return; + if (terminal.data.session.deviceId !== ws.data.device.deviceId) { + ws.close(1008, "relay session ownership mismatch"); + return; + } + if (frame.kind === RELAY_FRAME_CLOSE) terminal.close(1000, "remote terminal closed"); + else sendBounded(terminal, frame.payload); + return; + } + if (bytes.length > RELAY_MAX_PAYLOAD) { + ws.close(1009, "relay frame is too large"); + return; + } + const agent = relayAgents.get(ws.data.session.deviceId); + if (!agent || agent.data.kind !== "relay-agent") { + ws.close(1013, "remote computer unavailable"); + return; + } + sendBounded(agent, relayFrame(RELAY_FRAME_DATA, ws.data.session.id, bytes)); + }, + close(ws, code, reason) { + untrackSocket(ws); + if (ws.data.kind === "proxy") { + if (ws.data.upstream && ws.data.upstream.readyState < WebSocket.CLOSING) ws.data.upstream.close(code, reason); + return; + } + if (ws.data.kind === "relay-terminal") { + if (relayTerminals.get(ws.data.session.id) === ws) relayTerminals.delete(ws.data.session.id); + void store.markTerminalClosed(ws.data.session.id); + const agent = relayAgents.get(ws.data.session.deviceId); + if (agent?.data.kind === "relay-agent") { + sendBounded(agent, relayFrame(RELAY_FRAME_CLOSE, ws.data.session.id)); + } + return; + } + if (relayAgents.get(ws.data.device.deviceId) !== ws) return; + relayAgents.delete(ws.data.device.deviceId); + for (const terminal of relayTerminals.values()) { + if (terminal.data.kind === "relay-terminal" && terminal.data.session.deviceId === ws.data.device.deviceId) { + terminal.close(1013, "remote computer disconnected"); + } + } + void store.markRelayDisconnected(ws.data.device.deviceId); + }, + }, +}); + +void listenForRevocations(); +console.log(`OpenCodex Remote gateway listening on ${config.PLATFORM_GATEWAY_HOST}:${config.PLATFORM_GATEWAY_PORT}`); diff --git a/platform/server/src/security.ts b/platform/server/src/security.ts new file mode 100644 index 000000000..661f7d057 --- /dev/null +++ b/platform/server/src/security.ts @@ -0,0 +1,81 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + createHmac, + createPrivateKey, + createPublicKey, + randomBytes, + randomUUID, + sign, +} from "node:crypto"; +import type { PlatformConfig } from "./config"; + +export const GATEWAY_ASSERTION_HEADER = "x-opencodex-remote-assertion"; +export const GATEWAY_ASSERTION_AUDIENCE = "opencodex-management"; +const PAIRING_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; + +export function sha256(value: string): Buffer { + return createHash("sha256").update(value).digest(); +} + +export function issueOpaqueToken(prefix: "ocxr_" | "ocxr_session_" | "ocxr_agent_" | "ocxr_device_"): string { + return `${prefix}${randomBytes(32).toString("base64url")}`; +} + +export function issueShortCode(length = 12): string { + const bytes = randomBytes(length); + return Array.from(bytes, byte => PAIRING_ALPHABET[byte % PAIRING_ALPHABET.length]).join(""); +} + +export function normalizedPathHash(input: URL | string): string { + const url = input instanceof URL ? new URL(input) : new URL(input, "https://instance.invalid"); + url.searchParams.sort(); + return createHash("sha256").update(`${url.pathname}${url.search}`).digest("hex"); +} + +export function encryptSecret(value: string, key: Buffer): Buffer { + const nonce = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, nonce); + const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); + return Buffer.concat([Buffer.from([1]), nonce, cipher.getAuthTag(), ciphertext]); +} + +export function decryptSecret(value: Buffer, key: Buffer): string { + if (value[0] !== 1 || value.length < 30) throw new Error("unsupported encrypted secret envelope"); + const decipher = createDecipheriv("aes-256-gcm", key, value.subarray(1, 13)); + decipher.setAuthTag(value.subarray(13, 29)); + return Buffer.concat([decipher.update(value.subarray(29)), decipher.final()]).toString("utf8"); +} + +export function networkSignalHmac(value: string, key: Buffer): string { + return createHmac("sha256", key).update(value).digest("base64url"); +} + +export function gatewayPublicKeyPem(config: PlatformConfig): string { + return createPublicKey(createPrivateKey(config.gatewayPrivateKeyPem)) + .export({ type: "spki", format: "pem" }).toString(); +} + +export function signGatewayAssertion( + config: PlatformConfig, + input: { instanceId: string; userId: string; method: string; url: URL }, + now = Date.now(), +): string { + const iat = Math.floor(now / 1000); + const header = Buffer.from(JSON.stringify({ alg: "EdDSA", typ: "JWT", kid: config.PLATFORM_GATEWAY_KID })).toString("base64url"); + const claims = Buffer.from(JSON.stringify({ + iss: config.PLATFORM_GATEWAY_ISSUER, + aud: GATEWAY_ASSERTION_AUDIENCE, + instance_id: input.instanceId, + user_id: input.userId, + method: input.method.toUpperCase(), + path_sha256: normalizedPathHash(input.url), + iat, + exp: iat + 30, + jti: randomUUID(), + })).toString("base64url"); + const signingInput = `${header}.${claims}`; + const signature = sign(null, Buffer.from(signingInput), config.gatewayPrivateKeyPem).toString("base64url"); + return `${signingInput}.${signature}`; +} diff --git a/platform/server/src/store.ts b/platform/server/src/store.ts new file mode 100644 index 000000000..96cfe21c2 --- /dev/null +++ b/platform/server/src/store.ts @@ -0,0 +1,1260 @@ +import { createHash, createPublicKey, randomBytes, randomUUID, timingSafeEqual, verify } from "node:crypto"; +import type { PoolClient } from "pg"; +import type { Database } from "./db"; +import type { PlatformConfig } from "./config"; +import { + decryptSecret, + encryptSecret, + issueOpaqueToken, + issueShortCode, + networkSignalHmac, + sha256, +} from "./security"; + +export type InstanceStatus = + | "pending" | "provisioning" | "awaiting_agent" | "connecting" | "online" + | "degraded" | "offline" | "suspending" | "suspended" | "deleting" + | "delete_failed" | "deleted"; + +export interface Actor { + id: string; + githubNumericId: string; + role: "user" | "admin"; + status: "active" | "pending_invite" | "suspended"; + name: string; + email: string; +} + +export interface DeviceAuthorizationDisplay { + id: string; + userCode: string; + deviceName: string; + platform: string; + expiresAt: string; + status: "pending" | "approved" | "expired" | "consumed"; +} + +export interface RemoteDeviceActor { + actor: Actor; + deviceId: string; +} + +export interface RelayDeviceActor extends RemoteDeviceActor { + instanceId: string | null; + name: string; + signingPublicKey: Buffer; + ecdhPublicKey: Buffer; +} + +export interface TerminalSessionRecord { + id: string; + instanceId: string; + deviceId: string; + userId: string; + commandProfile: "shell" | "codex" | "claude"; + expiresAt: string; +} + +export interface InstanceRecord { + id: string; + ownerId: string; + name: string; + slug: string; + privateHostname: string; + transportMode: "mesh-tunnel" | "outbound-relay"; + status: InstanceStatus; + createdAt: string; + updatedAt: string; +} + +export interface RemoteProfile { + passwordSet: boolean; + canActivate: boolean; + instance: (InstanceRecord & { publicUrl: string }) | null; + e2ee: RemoteE2eeEnvelopeRecord | null; + devices: RemoteDeviceRecord[]; +} + +export interface RemoteE2eeEnvelopeRecord { + version: "ocx-e2ee-v1"; + salt: string; + nonce: string; + ciphertext: string; + rootPublicKey: string; + kdf: { + algorithm: "argon2id"; + memoryKiB: number; + iterations: number; + parallelism: number; + outputLength: 32; + }; +} + +export interface RemoteDeviceRecord { + id: string; + name: string; + platform: string; + signingPublicKey: string; + ecdhPublicKey: string | null; + relayOnline: boolean; + lastSeenAt: string | null; + instanceId: string | null; +} + +interface InstanceRow { + id: string; + owner_id: string; + name: string; + slug: string; + private_hostname: string; + private_origin_ip: string; + transport_mode: "mesh-tunnel" | "outbound-relay"; + status: InstanceStatus; + created_at: Date; + updated_at: Date; +} + +function instanceFromRow(row: InstanceRow): InstanceRecord { + return { + id: row.id, + ownerId: row.owner_id, + name: row.name, + slug: row.slug, + privateHostname: row.private_hostname, + transportMode: row.transport_mode, + status: row.status, + createdAt: row.created_at.toISOString(), + updatedAt: row.updated_at.toISOString(), + }; +} + +function duplicateConstraint(error: unknown): boolean { + return !!error && typeof error === "object" && (error as { code?: string }).code === "23505"; +} + +const E2EE_AUTH_PREFIX = "ocxe2ee1$"; + +function decodeFixed(value: string, length: number, label: string): Buffer { + const decoded = Buffer.from(value, "base64url"); + if (decoded.length !== length) throw new Error(`${label} must be ${length} bytes`); + return decoded; +} + +function e2eeAuthHash(authSecret: string): string { + const decoded = decodeFixed(authSecret, 32, "auth secret"); + return `${E2EE_AUTH_PREFIX}${createHash("sha256").update(decoded).digest("base64url")}`; +} + +function safeStringEqual(left: string, right: string): boolean { + const a = Buffer.from(left); + const b = Buffer.from(right); + return a.length === b.length && timingSafeEqual(a, b); +} + +function validateE2eeEnvelope(envelope: RemoteE2eeEnvelopeRecord): { + salt: Buffer; nonce: Buffer; ciphertext: Buffer; rootPublicKey: Buffer; +} { + if (envelope.version !== "ocx-e2ee-v1" || envelope.kdf.algorithm !== "argon2id") { + throw new Error("unsupported remote vault profile"); + } + if ( + envelope.kdf.memoryKiB < 32_768 || envelope.kdf.memoryKiB > 262_144 + || envelope.kdf.iterations < 2 || envelope.kdf.iterations > 8 + || envelope.kdf.parallelism < 1 || envelope.kdf.parallelism > 4 + || envelope.kdf.outputLength !== 32 + ) throw new Error("invalid remote vault KDF parameters"); + const ciphertext = Buffer.from(envelope.ciphertext, "base64url"); + if (ciphertext.length < 17 || ciphertext.length > 4096) throw new Error("invalid encrypted remote vault"); + const rootPublicKey = Buffer.from(envelope.rootPublicKey, "base64url"); + if (rootPublicKey.length < 32 || rootPublicKey.length > 128) throw new Error("invalid remote root public key"); + const parsed = createPublicKey({ key: rootPublicKey, format: "der", type: "spki" }); + if (parsed.asymmetricKeyType !== "ed25519") throw new Error("remote root key must be Ed25519"); + return { + salt: decodeFixed(envelope.salt, 16, "vault salt"), + nonce: decodeFixed(envelope.nonce, 12, "vault nonce"), + ciphertext, + rootPublicKey, + }; +} + +function envelopeFromRow(row: { + auth_version: string; + vault_salt: Buffer | null; + vault_nonce: Buffer | null; + encrypted_vault_key: Buffer | null; + vault_kdf: RemoteE2eeEnvelopeRecord["kdf"] | null; + root_public_key: Buffer | null; +} | undefined): RemoteE2eeEnvelopeRecord | null { + if ( + !row || row.auth_version !== "ocx-e2ee-v1" + || !row.vault_salt || !row.vault_nonce || !row.encrypted_vault_key + || !row.vault_kdf || !row.root_public_key + ) return null; + return { + version: "ocx-e2ee-v1", + salt: row.vault_salt.toString("base64url"), + nonce: row.vault_nonce.toString("base64url"), + ciphertext: row.encrypted_vault_key.toString("base64url"), + rootPublicKey: row.root_public_key.toString("base64url"), + kdf: row.vault_kdf, + }; +} + +const RESERVED_INSTANCE_SLUGS = new Set([ + "account", "admin", "api", "auth", "billing", "login", "official", "status", + "support", "verify", "www", +]); + +function validInstanceSlug(slug: string): boolean { + return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(slug) + && !RESERVED_INSTANCE_SLUGS.has(slug) + && !/^(?:account|login|support|verify)-/.test(slug); +} + +function issuePrivateOriginIp(): string { + const bytes = randomBytes(3); + return `10.${192 + bytes[0] % 64}.${bytes[1]}.${1 + bytes[2] % 254}`; +} + +export class PlatformStore { + constructor(readonly db: Database, readonly config: PlatformConfig) {} + + async authorizeUser(user: { id: string; name: string; email: string; githubNumericId?: string | null }): Promise { + if (!user.githubNumericId) return null; + if (user.githubNumericId === this.config.PLATFORM_BOOTSTRAP_GITHUB_ID) { + await this.db.query( + "UPDATE users SET role='admin', status='active', updated_at=now() WHERE id=$1 AND github_numeric_id=$2", + [user.id, user.githubNumericId], + ); + } else if (this.config.PLATFORM_SIGNUP_MODE === "open") { + await this.db.query( + "UPDATE users SET status='active',updated_at=now() WHERE id=$1 AND status='pending_invite'", + [user.id], + ); + } + const result = await this.db.query<{ + id: string; name: string; email: string; github_numeric_id: string; + role: "user" | "admin"; status: Actor["status"]; + }>("SELECT id,name,email,github_numeric_id,role,status FROM users WHERE id=$1", [user.id]); + const row = result.rows[0]; + return row ? { id: row.id, name: row.name, email: row.email, githubNumericId: row.github_numeric_id, role: row.role, status: row.status } : null; + } + + async createDeviceAuthorization(input: { + deviceName: string; + platform: string; + publicKeyDer: Buffer; + ecdhPublicKeyDer?: Buffer; + networkSignal: string; + }): Promise<{ id: string; pollSecret: string; userCode: string; authorizeUrl: string; expiresAt: string }> { + if (input.publicKeyDer.length < 32 || input.publicKeyDer.length > 256) throw new Error("invalid device public key"); + const signingKey = createPublicKey({ key: input.publicKeyDer, type: "spki", format: "der" }); + if (signingKey.asymmetricKeyType !== "ed25519") throw new Error("device signing key must be Ed25519"); + if (input.ecdhPublicKeyDer) { + if (input.ecdhPublicKeyDer.length < 64 || input.ecdhPublicKeyDer.length > 256) throw new Error("invalid device ECDH key"); + const ecdhKey = createPublicKey({ key: input.ecdhPublicKeyDer, type: "spki", format: "der" }); + if (ecdhKey.asymmetricKeyType !== "ec" || ecdhKey.asymmetricKeyDetails?.namedCurve !== "prime256v1") { + throw new Error("device ECDH key must use P-256"); + } + } + const pollSecret = issueOpaqueToken("ocxr_device_"); + const userCode = issueShortCode(8); + const expiresAt = new Date(Date.now() + 10 * 60_000); + const signal = networkSignalHmac(input.networkSignal, this.config.auditHmacKey); + const id = await this.db.transaction(async client => { + await client.query("DELETE FROM device_authorization_requests WHERE expires_at( + `SELECT + count(*) FILTER(WHERE network_hmac=$1)::text AS network_count, + count(*)::text AS global_count + FROM device_authorization_requests + WHERE created_at>now()-interval '10 minutes'`, + [signal], + ); + const counts = capacity.rows[0]; + if (Number(counts.network_count) >= 5 || Number(counts.global_count) >= 500) { + throw new Error("too many device authorization requests"); + } + const result = await client.query<{ id: string }>( + `INSERT INTO device_authorization_requests( + poll_secret_hash,user_code,device_name,platform,public_key,ecdh_public_key,network_hmac,expires_at + ) VALUES($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`, + [sha256(pollSecret), userCode, input.deviceName, input.platform, input.publicKeyDer, input.ecdhPublicKeyDer ?? null, signal, expiresAt], + ); + return result.rows[0].id; + }); + return { + id, + pollSecret, + userCode, + authorizeUrl: `${this.config.PLATFORM_BASE_URL.replace(/\/$/, "")}/connect/${id}`, + expiresAt: expiresAt.toISOString(), + }; + } + + async getDeviceAuthorizationDisplay(id: string): Promise { + const result = await this.db.query<{ + id: string; user_code: string; device_name: string; platform: string; expires_at: Date; + approved_at: Date | null; consumed_at: Date | null; + }>( + `SELECT id,user_code,device_name,platform,expires_at,approved_at,consumed_at + FROM device_authorization_requests WHERE id=$1`, + [id], + ); + const row = result.rows[0]; + if (!row) return null; + const status = row.expires_at.getTime() <= Date.now() + ? "expired" + : row.consumed_at + ? "consumed" + : row.approved_at + ? "approved" + : "pending"; + return { + id: row.id, + userCode: row.user_code, + deviceName: row.device_name, + platform: row.platform, + expiresAt: row.expires_at.toISOString(), + status, + }; + } + + async approveDeviceAuthorization(actor: Actor, id: string): Promise { + const display = await this.db.transaction(async client => { + const pending = await client.query<{ + device_name: string; platform: string; public_key: Buffer; ecdh_public_key: Buffer | null; expires_at: Date; + approved_by: string | null; consumed_at: Date | null; + }>( + `SELECT device_name,platform,public_key,ecdh_public_key,expires_at,approved_by,consumed_at + FROM device_authorization_requests WHERE id=$1 FOR UPDATE`, + [id], + ); + const request = pending.rows[0]; + if (!request || request.expires_at.getTime() <= Date.now() || request.consumed_at) { + throw new Error("device authorization expired"); + } + if (request.approved_by && request.approved_by !== actor.id) throw new Error("device authorization already approved"); + if (!request.approved_by) { + const token = issueOpaqueToken("ocxr_device_"); + const relayToken = issueOpaqueToken("ocxr_agent_"); + const device = await client.query<{ id: string }>( + `INSERT INTO remote_devices(user_id,name,platform,public_key,ecdh_public_key,token_hash,relay_token_hash,last_seen_at) + VALUES($1,$2,$3,$4,$5,$6,$7,now()) + ON CONFLICT(user_id,public_key) DO UPDATE SET + name=excluded.name,platform=excluded.platform,ecdh_public_key=excluded.ecdh_public_key, + token_hash=excluded.token_hash,relay_token_hash=excluded.relay_token_hash, + last_seen_at=now(),revoked_at=NULL + RETURNING id`, + [actor.id, request.device_name, request.platform, request.public_key, request.ecdh_public_key, sha256(token), sha256(relayToken)], + ); + await client.query( + `UPDATE device_authorization_requests SET + approved_by=$2,approved_at=now(),device_id=$3,encrypted_device_token=$4,encrypted_relay_token=$5 + WHERE id=$1`, + [ + id, + actor.id, + device.rows[0].id, + encryptSecret(token, this.config.encryptionKey), + encryptSecret(relayToken, this.config.encryptionKey), + ], + ); + await this.audit(client, actor.id, "remote_device.approve", null, "success"); + } + return id; + }); + const result = await this.getDeviceAuthorizationDisplay(display); + if (!result) throw new Error("device authorization missing"); + return result; + } + + async pollDeviceAuthorization(id: string, pollSecret: string): Promise<{ + status: DeviceAuthorizationDisplay["status"]; + deviceId?: string; + deviceToken?: string; + relayToken?: string; + relayUrl?: string; + user?: { name: string; email: string; githubNumericId: string }; + } | null> { + const result = await this.db.query<{ + expires_at: Date; approved_at: Date | null; consumed_at: Date | null; + device_id: string | null; encrypted_device_token: Buffer | null; encrypted_relay_token: Buffer | null; + name: string | null; email: string | null; github_numeric_id: string | null; + }>( + `SELECT r.expires_at,r.approved_at,r.consumed_at,r.device_id,r.encrypted_device_token,r.encrypted_relay_token, + u.name,u.email,u.github_numeric_id + FROM device_authorization_requests r + LEFT JOIN users u ON u.id=r.approved_by + WHERE r.id=$1 AND r.poll_secret_hash=$2`, + [id, sha256(pollSecret)], + ); + const row = result.rows[0]; + if (!row) return null; + if (row.expires_at.getTime() <= Date.now()) return { status: "expired" }; + if (row.consumed_at) return { status: "consumed" }; + if (!row.approved_at || !row.device_id || !row.encrypted_device_token || !row.name || !row.email || !row.github_numeric_id) { + return { status: "pending" }; + } + return { + status: "approved", + deviceId: row.device_id, + deviceToken: decryptSecret(row.encrypted_device_token, this.config.encryptionKey), + ...(row.encrypted_relay_token ? { + relayToken: decryptSecret(row.encrypted_relay_token, this.config.encryptionKey), + relayUrl: this.config.PLATFORM_RELAY_URL, + } : {}), + user: { name: row.name, email: row.email, githubNumericId: row.github_numeric_id }, + }; + } + + async acknowledgeDeviceAuthorization(id: string, pollSecret: string): Promise { + const result = await this.db.query( + `UPDATE device_authorization_requests SET consumed_at=now(),encrypted_device_token=NULL,encrypted_relay_token=NULL + WHERE id=$1 AND poll_secret_hash=$2 AND approved_at IS NOT NULL + AND consumed_at IS NULL AND expires_at>now()`, + [id, sha256(pollSecret)], + ); + return (result.rowCount ?? 0) > 0; + } + + async authorizeDeviceToken(token: string): Promise { + if (!token.startsWith("ocxr_device_")) return null; + const result = await this.db.query<{ + device_id: string; id: string; name: string; email: string; github_numeric_id: string; + role: "admin" | "user"; status: "active" | "pending_invite" | "suspended"; + }>( + `UPDATE remote_devices d SET last_seen_at=now() + FROM users u + WHERE d.token_hash=$1 AND d.revoked_at IS NULL AND u.id=d.user_id + RETURNING d.id AS device_id,u.id,u.name,u.email,u.github_numeric_id,u.role,u.status`, + [sha256(token)], + ); + const row = result.rows[0]; + if (!row || row.status === "suspended") return null; + return { + deviceId: row.device_id, + actor: { + id: row.id, + name: row.name, + email: row.email, + githubNumericId: row.github_numeric_id, + role: row.role, + status: row.status, + }, + }; + } + + async rotateRelayCredentials(actor: Actor, deviceId: string, ecdhPublicKey: Buffer): Promise<{ + relayToken: string; + relayUrl: string; + }> { + const parsed = createPublicKey({ key: ecdhPublicKey, type: "spki", format: "der" }); + if (parsed.asymmetricKeyType !== "ec" || parsed.asymmetricKeyDetails?.namedCurve !== "prime256v1") { + throw new Error("device ECDH key must use P-256"); + } + const relayToken = issueOpaqueToken("ocxr_agent_"); + const result = await this.db.query( + `UPDATE remote_devices SET ecdh_public_key=$3,relay_token_hash=$4, + relay_connected_at=NULL,relay_disconnected_at=now(),updated_at=now() + WHERE id=$1 AND user_id=$2 AND revoked_at IS NULL`, + [deviceId, actor.id, ecdhPublicKey, sha256(relayToken)], + ); + if (!result.rowCount) throw new Error("remote device is unavailable"); + return { relayToken, relayUrl: this.config.PLATFORM_RELAY_URL }; + } + + async authorizeRelayToken(token: string): Promise { + if (!token.startsWith("ocxr_agent_")) return null; + return this.db.transaction(async client => { + const result = await client.query<{ + device_id: string; instance_id: string | null; device_name: string; + signing_public_key: Buffer; ecdh_public_key: Buffer | null; + id: string; name: string; email: string; github_numeric_id: string; + role: "admin" | "user"; status: "active"; + }>( + `UPDATE remote_devices d SET relay_connected_at=now(),relay_disconnected_at=NULL,last_seen_at=now(),updated_at=now() + FROM users u,instances i + WHERE d.relay_token_hash=$1 AND d.revoked_at IS NULL AND u.id=d.user_id AND u.status='active' + AND i.id=d.instance_id AND i.owner_id=d.user_id AND i.transport_mode='outbound-relay' + AND i.deleted_at IS NULL AND i.status NOT IN ('suspending','suspended','deleting','deleted') + RETURNING d.id AS device_id,d.instance_id,d.name AS device_name, + d.public_key AS signing_public_key,d.ecdh_public_key, + u.id,u.name,u.email,u.github_numeric_id,u.role,u.status`, + [sha256(token)], + ); + const row = result.rows[0]; + if (!row || !row.instance_id || !row.ecdh_public_key) return null; + await client.query( + `UPDATE instances SET status='online',updated_at=now() + WHERE id=$1 AND owner_id=$2 AND transport_mode='outbound-relay' AND deleted_at IS NULL`, + [row.instance_id, row.id], + ); + await client.query( + "SELECT pg_notify('instance_state',$1)", + [JSON.stringify({ instanceId: row.instance_id, status: "online" })], + ); + return { + deviceId: row.device_id, + instanceId: row.instance_id, + name: row.device_name, + signingPublicKey: row.signing_public_key, + ecdhPublicKey: row.ecdh_public_key, + actor: { + id: row.id, + name: row.name, + email: row.email, + githubNumericId: row.github_numeric_id, + role: row.role, + status: row.status, + }, + }; + }); + } + + async markRelayDisconnected(deviceId: string): Promise { + await this.db.transaction(async client => { + const disconnected = await client.query<{ instance_id: string | null }>( + `UPDATE remote_devices SET relay_disconnected_at=now(),updated_at=now() + WHERE id=$1 AND relay_disconnected_at IS NULL RETURNING instance_id`, + [deviceId], + ); + const instanceId = disconnected.rows[0]?.instance_id; + if (!instanceId) return; + const offline = await client.query( + `UPDATE instances i SET status='offline',updated_at=now() + WHERE i.id=$1 AND i.transport_mode='outbound-relay' AND i.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM remote_devices d + WHERE d.instance_id=i.id AND d.revoked_at IS NULL + AND d.relay_connected_at IS NOT NULL + AND (d.relay_disconnected_at IS NULL OR d.relay_connected_at>d.relay_disconnected_at) + )`, + [instanceId], + ); + if (offline.rowCount) { + await client.query( + "SELECT pg_notify('instance_state',$1)", + [JSON.stringify({ instanceId, status: "offline" })], + ); + } + }); + } + + async listRelayDevices(userId: string, instanceId: string): Promise { + const result = await this.db.query<{ + id: string; name: string; platform: string; public_key: Buffer; ecdh_public_key: Buffer | null; + relay_connected_at: Date | null; relay_disconnected_at: Date | null; last_seen_at: Date | null; instance_id: string | null; + }>( + `SELECT id,name,platform,public_key,ecdh_public_key,relay_connected_at,relay_disconnected_at,last_seen_at,instance_id + FROM remote_devices + WHERE user_id=$1 AND instance_id=$2 AND revoked_at IS NULL + ORDER BY lower(name),created_at`, + [userId, instanceId], + ); + return result.rows.map(device => ({ + id: device.id, + name: device.name, + platform: device.platform, + signingPublicKey: device.public_key.toString("base64url"), + ecdhPublicKey: device.ecdh_public_key?.toString("base64url") ?? null, + relayOnline: !!device.relay_connected_at + && (!device.relay_disconnected_at || device.relay_connected_at > device.relay_disconnected_at), + lastSeenAt: device.last_seen_at?.toISOString() ?? null, + instanceId: device.instance_id, + })); + } + + async createTerminalSession( + userId: string, + instanceId: string, + deviceId: string, + commandProfile: TerminalSessionRecord["commandProfile"], + ): Promise { + return this.db.transaction(async client => { + await client.query( + `UPDATE terminal_sessions SET status='expired',closed_at=now(),updated_at=now() + WHERE expires_at<=now() AND status IN ('pending','connected')`, + ); + const device = await client.query( + `SELECT 1 FROM remote_devices + WHERE id=$1 AND user_id=$2 AND instance_id=$3 AND revoked_at IS NULL AND ecdh_public_key IS NOT NULL + FOR UPDATE`, + [deviceId, userId, instanceId], + ); + if (!device.rowCount) throw new Error("remote computer is unavailable"); + const active = await client.query<{ count: string }>( + `SELECT count(*)::text AS count FROM terminal_sessions + WHERE device_id=$1 AND status IN ('pending','connected') AND expires_at>now()`, + [deviceId], + ); + if (Number(active.rows[0]?.count ?? "0") >= 4) throw new Error("remote computer session limit reached"); + const browserToken = issueOpaqueToken("ocxr_session_"); + const expiresAt = new Date(Date.now() + 12 * 60 * 60_000); + const inserted = await client.query<{ id: string }>( + `INSERT INTO terminal_sessions(instance_id,device_id,user_id,browser_token_hash,command_profile,expires_at) + VALUES($1,$2,$3,$4,$5,$6) RETURNING id`, + [instanceId, deviceId, userId, sha256(browserToken), commandProfile, expiresAt], + ); + return { + id: inserted.rows[0].id, + instanceId, + deviceId, + userId, + commandProfile, + expiresAt: expiresAt.toISOString(), + }; + }); + } + + async resolveTerminalSession( + userId: string, + instanceId: string, + terminalSessionId: string, + ): Promise { + const result = await this.db.query<{ + id: string; instance_id: string; device_id: string; user_id: string; + command_profile: TerminalSessionRecord["commandProfile"]; expires_at: Date; + }>( + `SELECT id,instance_id,device_id,user_id,command_profile,expires_at + FROM terminal_sessions + WHERE id=$1 AND user_id=$2 AND instance_id=$3 + AND status IN ('pending','connected') AND expires_at>now()`, + [terminalSessionId, userId, instanceId], + ); + const row = result.rows[0]; + return row ? { + id: row.id, + instanceId: row.instance_id, + deviceId: row.device_id, + userId: row.user_id, + commandProfile: row.command_profile, + expiresAt: row.expires_at.toISOString(), + } : null; + } + + async markTerminalConnected(sessionId: string): Promise { + await this.db.query( + `UPDATE terminal_sessions SET status='connected',connected_at=coalesce(connected_at,now()),updated_at=now() + WHERE id=$1 AND status IN ('pending','connected') AND expires_at>now()`, + [sessionId], + ); + } + + async markTerminalClosed(sessionId: string): Promise { + await this.db.query( + `UPDATE terminal_sessions SET status='closed',closed_at=now(),updated_at=now() + WHERE id=$1 AND status IN ('pending','connected')`, + [sessionId], + ); + } + + async remoteProfile(actor: Actor): Promise { + const [profile, instance, devices] = await Promise.all([ + this.db.query<{ + password_set: boolean; auth_version: string; vault_salt: Buffer | null; vault_nonce: Buffer | null; + encrypted_vault_key: Buffer | null; vault_kdf: RemoteE2eeEnvelopeRecord["kdf"] | null; root_public_key: Buffer | null; + }>( + `SELECT (password_hash IS NOT NULL AND auth_version='ocx-e2ee-v1') AS password_set,auth_version,vault_salt,vault_nonce, + encrypted_vault_key,vault_kdf,root_public_key + FROM remote_access_profiles WHERE user_id=$1`, + [actor.id], + ), + this.db.query( + "SELECT * FROM instances WHERE owner_id=$1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1", + [actor.id], + ), + this.db.query<{ + id: string; name: string; platform: string; public_key: Buffer; ecdh_public_key: Buffer | null; + relay_connected_at: Date | null; relay_disconnected_at: Date | null; last_seen_at: Date | null; instance_id: string | null; + }>( + `SELECT id,name,platform,public_key,ecdh_public_key,relay_connected_at,relay_disconnected_at,last_seen_at,instance_id + FROM remote_devices WHERE user_id=$1 AND revoked_at IS NULL ORDER BY created_at`, + [actor.id], + ), + ]); + const current = instance.rows[0] ? instanceFromRow(instance.rows[0]) : null; + return { + passwordSet: profile.rows[0]?.password_set ?? false, + canActivate: actor.status === "active", + e2ee: envelopeFromRow(profile.rows[0]), + devices: devices.rows.map(device => ({ + id: device.id, + name: device.name, + platform: device.platform, + signingPublicKey: device.public_key.toString("base64url"), + ecdhPublicKey: device.ecdh_public_key?.toString("base64url") ?? null, + relayOnline: !!device.relay_connected_at + && (!device.relay_disconnected_at || device.relay_connected_at > device.relay_disconnected_at), + lastSeenAt: device.last_seen_at?.toISOString() ?? null, + instanceId: device.instance_id, + })), + instance: current ? { + ...current, + publicUrl: `https://${current.slug}.${this.config.PLATFORM_INSTANCE_DOMAIN}`, + } : null, + }; + } + + async setRemotePassword(actor: Actor, password: string): Promise { + const passwordHash = await Bun.password.hash(password, { + algorithm: "argon2id", + memoryCost: 65_536, + timeCost: 3, + }); + await this.db.transaction(async client => { + await client.query( + `INSERT INTO remote_access_profiles(user_id,password_hash,password_set_at) + VALUES($1,$2,now()) + ON CONFLICT(user_id) DO UPDATE SET + password_hash=excluded.password_hash,password_set_at=now(),failed_attempts=0, + locked_until=NULL,updated_at=now()`, + [actor.id, passwordHash], + ); + await client.query( + `UPDATE instance_sessions SET revoked_at=now() + WHERE user_id=$1 AND revoked_at IS NULL`, + [actor.id], + ); + await this.audit(client, actor.id, "remote_password.set", null, "success"); + }); + } + + async setRemoteE2eeProfile(actor: Actor, authSecret: string, envelope: RemoteE2eeEnvelopeRecord): Promise { + const material = validateE2eeEnvelope(envelope); + const passwordHash = e2eeAuthHash(authSecret); + await this.db.transaction(async client => { + await client.query( + `INSERT INTO remote_access_profiles( + user_id,password_hash,password_set_at,auth_version,vault_salt,vault_nonce, + encrypted_vault_key,vault_kdf,root_public_key + ) VALUES($1,$2,now(),'ocx-e2ee-v1',$3,$4,$5,$6,$7) + ON CONFLICT(user_id) DO UPDATE SET + password_hash=excluded.password_hash,password_set_at=now(),auth_version='ocx-e2ee-v1', + vault_salt=excluded.vault_salt,vault_nonce=excluded.vault_nonce, + encrypted_vault_key=excluded.encrypted_vault_key,vault_kdf=excluded.vault_kdf, + root_public_key=excluded.root_public_key,failed_attempts=0,locked_until=NULL,updated_at=now()`, + [actor.id, passwordHash, material.salt, material.nonce, material.ciphertext, JSON.stringify(envelope.kdf), material.rootPublicKey], + ); + await client.query( + "UPDATE instance_sessions SET revoked_at=now() WHERE user_id=$1 AND revoked_at IS NULL", + [actor.id], + ); + await this.audit(client, actor.id, "remote_e2ee_profile.set", null, "success"); + }); + } + + async changeRemoteE2eeProfile( + actor: Actor, + oldAuthSecret: string, + newAuthSecret: string, + envelope: RemoteE2eeEnvelopeRecord, + ): Promise { + await this.verifyRemotePassword(actor, oldAuthSecret); + await this.setRemoteE2eeProfile(actor, newAuthSecret, envelope); + } + + async activateRemoteInstance( + actor: Actor, + deviceId: string, + input: { name: string; slug: string }, + ): Promise { + if (actor.status !== "active") throw new Error("private beta access is required"); + const password = await this.db.query( + `SELECT 1 FROM remote_access_profiles + WHERE user_id=$1 AND password_hash IS NOT NULL AND auth_version='ocx-e2ee-v1'`, + [actor.id], + ); + if (!password.rowCount) throw new Error("set an end-to-end encrypted remote password first"); + + const existing = await this.db.query( + "SELECT * FROM instances WHERE owner_id=$1 AND deleted_at IS NULL LIMIT 1", + [actor.id], + ); + let instance = existing.rows[0] ? instanceFromRow(existing.rows[0]) : await this.createRelayWorkspace(actor, input.slug); + if (instance.transportMode === "mesh-tunnel") { + if (instance.slug !== input.slug.trim().toLowerCase()) throw new Error("existing remote domain must be preserved during relay upgrade"); + const upgraded = await this.db.query( + `UPDATE instances SET transport_mode='outbound-relay',status='awaiting_agent',updated_at=now() + WHERE id=$1 AND owner_id=$2 AND deleted_at IS NULL RETURNING *`, + [instance.id, actor.id], + ); + instance = instanceFromRow(upgraded.rows[0]); + await this.db.query( + "SELECT pg_notify('instance_state',$1)", + [JSON.stringify({ instanceId: instance.id, status: "awaiting_agent" })], + ); + } + const linked = await this.db.query( + `UPDATE remote_devices SET instance_id=$3,name=$4,updated_at=now() + WHERE id=$1 AND user_id=$2 AND revoked_at IS NULL`, + [deviceId, actor.id, instance.id, input.name.trim()], + ); + if (!linked.rowCount) throw new Error("remote device is unavailable"); + return this.remoteProfile(actor); + } + + async createRemotePairingCode(actor: Actor, deviceId: string): Promise<{ code: string; expiresAt: string }> { + const linked = await this.db.query<{ instance_id: string }>( + `SELECT instance_id FROM remote_devices + WHERE id=$1 AND user_id=$2 AND revoked_at IS NULL AND instance_id IS NOT NULL`, + [deviceId, actor.id], + ); + const instanceId = linked.rows[0]?.instance_id; + if (!instanceId) throw new Error("remote instance is not activated"); + return this.createPairingCode(actor, instanceId); + } + + async revokeDeviceToken(token: string): Promise { + if (!token.startsWith("ocxr_device_")) return false; + const result = await this.db.query( + "UPDATE remote_devices SET revoked_at=now(),token_hash=NULL WHERE token_hash=$1 AND revoked_at IS NULL", + [sha256(token)], + ); + return (result.rowCount ?? 0) > 0; + } + + async redeemInvite(actor: Actor, token: string): Promise { + const result = await this.db.transaction(async client => { + const invite = await client.query<{ id: string; expected_github_numeric_id: string | null }>( + `SELECT id,expected_github_numeric_id FROM invites + WHERE token_hash=$1 AND consumed_at IS NULL AND expires_at>now() FOR UPDATE`, + [sha256(token)], + ); + const row = invite.rows[0]; + if (!row || (row.expected_github_numeric_id && row.expected_github_numeric_id !== actor.githubNumericId)) return false; + await client.query("UPDATE invites SET consumed_at=now(),consumed_by=$2 WHERE id=$1", [row.id, actor.id]); + await client.query("UPDATE users SET status='active',updated_at=now() WHERE id=$1", [actor.id]); + return true; + }); + return result; + } + + async createInvite(actor: Actor, expectedGithubNumericId?: string): Promise<{ token: string; expiresAt: string }> { + if (actor.role !== "admin" || actor.status !== "active") throw new Error("forbidden"); + const count = await this.db.query<{ count: string }>("SELECT count(*)::text AS count FROM users WHERE status='active'"); + if (Number(count.rows[0]?.count ?? "0") >= 50) throw new Error("private beta user limit reached"); + const token = randomBytes(32).toString("base64url"); + const expiresAt = new Date(Date.now() + 24 * 60 * 60_000); + await this.db.query( + "INSERT INTO invites(token_hash,created_by,expected_github_numeric_id,expires_at) VALUES($1,$2,$3,$4)", + [sha256(token), actor.id, expectedGithubNumericId ?? null, expiresAt], + ); + return { token, expiresAt: expiresAt.toISOString() }; + } + + async listInstances(actor: Actor): Promise { + const result = actor.role === "admin" + ? await this.db.query("SELECT * FROM instances WHERE deleted_at IS NULL ORDER BY created_at DESC") + : await this.db.query("SELECT * FROM instances WHERE owner_id=$1 AND deleted_at IS NULL ORDER BY created_at DESC", [actor.id]); + return result.rows.map(instanceFromRow); + } + + async getOwnedInstance(actor: Actor, id: string): Promise { + const result = await this.db.query( + `SELECT * FROM instances WHERE id=$1 AND deleted_at IS NULL AND (owner_id=$2 OR $3='admin')`, + [id, actor.id, actor.role], + ); + return result.rows[0] ? instanceFromRow(result.rows[0]) : null; + } + + async createInstance(actor: Actor, input: { name: string; slug: string }): Promise { + if (actor.status !== "active") throw new Error("invite required"); + const name = input.name.trim(); + const slug = input.slug.trim().toLowerCase(); + if (name.length < 1 || name.length > 80) throw new Error("name must be 1-80 characters"); + if (!validInstanceSlug(slug)) throw new Error("invalid or reserved slug"); + const privateHostname = `${randomBytes(20).toString("hex")}.${this.config.PLATFORM_PRIVATE_HOSTNAME_DOMAIN}`; + const privateOriginIp = issuePrivateOriginIp(); + try { + return await this.db.transaction(async client => { + const tombstone = await client.query("SELECT 1 FROM slug_tombstones WHERE slug=$1 AND expires_at>now()", [slug]); + if (tombstone.rowCount) throw new Error("slug is temporarily reserved"); + const result = await client.query( + `INSERT INTO instances(owner_id,name,slug,private_hostname,private_origin_ip) + VALUES($1,$2,$3,$4,$5) RETURNING *`, + [actor.id, name, slug, privateHostname, privateOriginIp], + ); + await client.query( + `INSERT INTO provisioning_jobs(instance_id,kind,idempotency_key) + VALUES($1,'provision',$2)`, + [result.rows[0].id, `provision:${result.rows[0].id}:v1`], + ); + await this.audit(client, actor.id, "instance.create", result.rows[0].id, "accepted"); + return instanceFromRow(result.rows[0]); + }); + } catch (error) { + if (duplicateConstraint(error)) throw new Error("slug is unavailable or instance limit reached"); + throw error; + } + } + + async createRelayWorkspace(actor: Actor, requestedSlug: string): Promise { + if (actor.status !== "active") throw new Error("invite required"); + const slug = requestedSlug.trim().toLowerCase(); + if (!validInstanceSlug(slug)) throw new Error("invalid or reserved slug"); + const privateHostname = `relay-${randomBytes(20).toString("hex")}.invalid`; + const privateOriginIp = issuePrivateOriginIp(); + try { + return await this.db.transaction(async client => { + const tombstone = await client.query("SELECT 1 FROM slug_tombstones WHERE slug=$1 AND expires_at>now()", [slug]); + if (tombstone.rowCount) throw new Error("slug is temporarily reserved"); + const result = await client.query( + `INSERT INTO instances(owner_id,name,slug,private_hostname,private_origin_ip,status,transport_mode) + VALUES($1,$2,$3,$4,$5,'awaiting_agent','outbound-relay') RETURNING *`, + [actor.id, `${actor.name} Remote`, slug, privateHostname, privateOriginIp], + ); + await this.audit(client, actor.id, "relay_workspace.create", result.rows[0].id, "success"); + return instanceFromRow(result.rows[0]); + }); + } catch (error) { + if (duplicateConstraint(error)) throw new Error("domain is unavailable or workspace already exists"); + throw error; + } + } + + async createPairingCode(actor: Actor, instanceId: string): Promise<{ code: string; expiresAt: string }> { + const instance = await this.getOwnedInstance(actor, instanceId); + if (!instance || !["awaiting_agent", "connecting", "offline"].includes(instance.status)) throw new Error("instance is not ready for pairing"); + const code = issueShortCode(); + const expiresAt = new Date(Date.now() + 10 * 60_000); + await this.db.transaction(async client => { + await client.query("UPDATE pairing_codes SET consumed_at=now() WHERE instance_id=$1 AND consumed_at IS NULL", [instanceId]); + await client.query("INSERT INTO pairing_codes(instance_id,code_hash,expires_at) VALUES($1,$2,$3)", [instanceId, sha256(code), expiresAt]); + await this.audit(client, actor.id, "pairing_code.create", instanceId, "success"); + }); + return { code, expiresAt: expiresAt.toISOString() }; + } + + async consumePairingCode(input: { code: string; publicKeyDer: Buffer; version: string }): Promise<{ + agentId: string; instanceId: string; tunnelToken: string; privateOriginIp: string; + }> { + if (input.publicKeyDer.length < 32 || input.publicKeyDer.length > 256) throw new Error("invalid Ed25519 public key"); + return this.db.transaction(async client => { + const pairing = await client.query<{ id: string; instance_id: string }>( + `SELECT id,instance_id FROM pairing_codes + WHERE code_hash=$1 AND consumed_at IS NULL AND expires_at>now() FOR UPDATE`, + [sha256(input.code.trim().toUpperCase())], + ); + const row = pairing.rows[0]; + if (!row) throw new Error("invalid or expired pairing code"); + const agent = await client.query<{ id: string }>( + `INSERT INTO agents(instance_id,public_key,version) + VALUES($1,$2,$3) + ON CONFLICT(instance_id) DO UPDATE SET public_key=excluded.public_key,version=excluded.version, + status='paired',revoked_at=NULL,auth_token_hash=NULL,auth_token_expires_at=NULL + RETURNING id`, + [row.instance_id, input.publicKeyDer, input.version], + ); + const resource = await client.query<{ encrypted_secret: Buffer }>( + "SELECT encrypted_secret FROM cloudflare_resources WHERE instance_id=$1 AND resource_type='tunnel' AND deleted_at IS NULL", + [row.instance_id], + ); + if (!resource.rows[0]?.encrypted_secret) throw new Error("tunnel token is not provisioned"); + const instance = await client.query<{ private_origin_ip: string }>( + "SELECT host(private_origin_ip) AS private_origin_ip FROM instances WHERE id=$1", + [row.instance_id], + ); + if (!instance.rows[0]?.private_origin_ip) throw new Error("private origin IP is not provisioned"); + await client.query("UPDATE pairing_codes SET consumed_at=now() WHERE id=$1", [row.id]); + await client.query("UPDATE instances SET status='connecting',updated_at=now() WHERE id=$1", [row.instance_id]); + return { + agentId: agent.rows[0].id, + instanceId: row.instance_id, + tunnelToken: decryptSecret(resource.rows[0].encrypted_secret, this.config.encryptionKey), + privateOriginIp: instance.rows[0].private_origin_ip, + }; + }); + } + + async createAgentChallenge(agentId: string, clientNonce: string): Promise { + if (!/^[A-Za-z0-9_-]{16,128}$/.test(clientNonce)) throw new Error("invalid client nonce"); + const challenge = `${agentId}.${clientNonce}.${randomBytes(32).toString("base64url")}`; + const result = await this.db.query( + `UPDATE agents SET challenge_hash=$2,challenge_expires_at=now()+interval '60 seconds' + WHERE id=$1 AND revoked_at IS NULL RETURNING id`, + [agentId, sha256(challenge)], + ); + if (!result.rowCount) throw new Error("agent not found"); + return challenge; + } + + async exchangeAgentChallenge(agentId: string, challenge: string, signature: Buffer): Promise<{ token: string; expiresAt: string }> { + return this.db.transaction(async client => { + const result = await client.query<{ public_key: Buffer; challenge_hash: Buffer }>( + `SELECT public_key,challenge_hash FROM agents + WHERE id=$1 AND revoked_at IS NULL AND challenge_expires_at>now() FOR UPDATE`, + [agentId], + ); + const agent = result.rows[0]; + if (!agent || !agent.challenge_hash.equals(sha256(challenge))) throw new Error("challenge expired"); + const publicKey = createPublicKey({ key: agent.public_key, format: "der", type: "spki" }); + if (!verify(null, Buffer.from(challenge), publicKey, signature)) throw new Error("invalid challenge signature"); + const token = issueOpaqueToken("ocxr_agent_"); + const expiresAt = new Date(Date.now() + 5 * 60_000); + await client.query( + `UPDATE agents SET auth_token_hash=$2,auth_token_expires_at=$3,challenge_hash=NULL,challenge_expires_at=NULL + WHERE id=$1`, + [agentId, sha256(token), expiresAt], + ); + return { token, expiresAt: expiresAt.toISOString() }; + }); + } + + async heartbeat(token: string, input: { opencodexHealthy: boolean; version: string }): Promise<{ instanceId: string }> { + const result = await this.db.query<{ instance_id: string }>( + `UPDATE agents SET last_heartbeat_at=now(),version=$2 + WHERE auth_token_hash=$1 AND auth_token_expires_at>now() AND revoked_at IS NULL + RETURNING instance_id`, + [sha256(token), input.version], + ); + const row = result.rows[0]; + if (!row) throw new Error("agent token expired"); + await this.recordHealth(row.instance_id, "agent", input.opencodexHealthy); + return { instanceId: row.instance_id }; + } + + async issueDataToken(actor: Actor, instanceId: string, name: string): Promise<{ token: string; expiresAt: string }> { + const instance = await this.getOwnedInstance(actor, instanceId); + if (!instance || ["suspended", "deleting", "deleted"].includes(instance.status)) throw new Error("instance not found"); + const token = issueOpaqueToken("ocxr_"); + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60_000); + await this.db.query( + `INSERT INTO access_tokens(instance_id,user_id,name,token_hash,token_hint,expires_at) + VALUES($1,$2,$3,$4,$5,$6)`, + [instanceId, actor.id, name.trim().slice(0, 80) || "CLI token", sha256(token), token.slice(-6), expiresAt], + ); + return { token, expiresAt: expiresAt.toISOString() }; + } + + private async verifyRemotePassword(actor: Actor, password: string): Promise { + const result = await this.db.transaction(async client => { + const profile = await client.query<{ + password_hash: string | null; failed_attempts: number; locked_until: Date | null; + }>( + `SELECT password_hash,failed_attempts,locked_until FROM remote_access_profiles + WHERE user_id=$1 FOR UPDATE`, + [actor.id], + ); + const row = profile.rows[0]; + if (!row?.password_hash) return "invalid" as const; + if (row.locked_until && row.locked_until.getTime() > Date.now()) return "locked" as const; + let valid = false; + if (row.password_hash.startsWith(E2EE_AUTH_PREFIX)) { + try { valid = safeStringEqual(row.password_hash, e2eeAuthHash(password)); } catch { valid = false; } + } else { + valid = await Bun.password.verify(password, row.password_hash); + } + if (!valid) { + const attempts = row.failed_attempts + 1; + await client.query( + `UPDATE remote_access_profiles SET failed_attempts=$2, + locked_until=CASE WHEN $2>=5 THEN now()+interval '15 minutes' ELSE NULL END, + updated_at=now() WHERE user_id=$1`, + [actor.id, attempts], + ); + await this.audit(client, actor.id, "remote_password.verify", null, "failure"); + return "invalid" as const; + } + await client.query( + "UPDATE remote_access_profiles SET failed_attempts=0,locked_until=NULL,updated_at=now() WHERE user_id=$1", + [actor.id], + ); + await this.audit(client, actor.id, "remote_password.verify", null, "success"); + return "valid" as const; + }); + if (result === "locked") throw new Error("remote password is temporarily locked"); + if (result !== "valid") throw new Error("invalid remote password"); + } + + async issueInstanceAuthorization(actor: Actor, instanceId: string, password: string): Promise { + const result = await this.db.query( + "SELECT * FROM instances WHERE id=$1 AND owner_id=$2 AND deleted_at IS NULL", + [instanceId, actor.id], + ); + const instance = result.rows[0] ? instanceFromRow(result.rows[0]) : null; + const available = instance && ( + ["online", "degraded"].includes(instance.status) + || (instance.transportMode === "outbound-relay" && ["awaiting_agent", "offline"].includes(instance.status)) + ); + if (!available) throw new Error("instance is unavailable"); + await this.verifyRemotePassword(actor, password); + const code = randomBytes(32).toString("base64url"); + await this.db.query( + `INSERT INTO instance_authorization_codes(instance_id,user_id,code_hash,expires_at) + VALUES($1,$2,$3,now()+interval '60 seconds')`, + [instanceId, actor.id, sha256(code)], + ); + return `https://${instance.slug}.${this.config.PLATFORM_INSTANCE_DOMAIN}/_ocxr/exchange?code=${encodeURIComponent(code)}`; + } + + async issueInstanceAuthorizationForSlug(actor: Actor, slug: string, password: string): Promise { + const result = await this.db.query<{ id: string }>( + "SELECT id FROM instances WHERE owner_id=$1 AND slug=$2 AND deleted_at IS NULL", + [actor.id, slug.toLowerCase()], + ); + const instanceId = result.rows[0]?.id; + if (!instanceId) throw new Error("instance is unavailable"); + return this.issueInstanceAuthorization(actor, instanceId, password); + } + + async exchangeInstanceAuthorization(host: string, code: string): Promise<{ token: string; expiresAt: Date } | null> { + return this.db.transaction(async client => { + const authorization = await client.query<{ id: string; instance_id: string; user_id: string }>( + `SELECT c.id,c.instance_id,c.user_id FROM instance_authorization_codes c + JOIN instances i ON i.id=c.instance_id + JOIN users u ON u.id=c.user_id + WHERE c.code_hash=$1 AND c.consumed_at IS NULL AND c.expires_at>now() + AND i.slug=$2 AND ( + i.status IN ('online','degraded') + OR (i.transport_mode='outbound-relay' AND i.status IN ('awaiting_agent','offline')) + ) AND u.status='active' + FOR UPDATE OF c`, + [sha256(code), this.slugFromHost(host)], + ); + const row = authorization.rows[0]; + if (!row) return null; + const token = issueOpaqueToken("ocxr_session_"); + const expiresAt = new Date(Date.now() + 12 * 60 * 60_000); + await client.query("UPDATE instance_authorization_codes SET consumed_at=now() WHERE id=$1", [row.id]); + await client.query( + "INSERT INTO instance_sessions(instance_id,user_id,token_hash,expires_at) VALUES($1,$2,$3,$4)", + [row.instance_id, row.user_id, sha256(token), expiresAt], + ); + return { token, expiresAt }; + }); + } + + async resolveGatewayRequest(host: string, authorization: string | null, sessionToken: string | null, path: string): Promise<{ + instance: InstanceRecord; userId: string; + } | null> { + const slug = this.slugFromHost(host); + if (!slug) return null; + const instanceResult = await this.db.query( + `SELECT i.* FROM instances i JOIN users u ON u.id=i.owner_id + WHERE i.slug=$1 AND ( + i.status IN ('online','degraded') + OR (i.transport_mode='outbound-relay' AND i.status IN ('awaiting_agent','offline')) + ) AND i.deleted_at IS NULL AND u.status='active'`, + [slug], + ); + const row = instanceResult.rows[0]; + if (!row) return null; + if (path.startsWith("/v1/")) { + const token = authorization?.replace(/^Bearer\s+/i, "").trim(); + if (token?.startsWith("ocxr_") && !token.startsWith("ocxr_session_") && !token.startsWith("ocxr_agent_")) { + const access = await this.db.query<{ user_id: string }>( + `UPDATE access_tokens SET last_used_at=now() + WHERE instance_id=$1 AND token_hash=$2 AND expires_at>now() AND revoked_at IS NULL + RETURNING user_id`, + [row.id, sha256(token)], + ); + return access.rows[0] ? { instance: instanceFromRow(row), userId: access.rows[0].user_id } : null; + } + // Browser-driven `/v1/*` calls are bound to the same instance session as + // the GUI. A separate remote token can ride in x-opencodex-remote-token + // when Authorization must be preserved for ChatGPT Direct. + if (!sessionToken) return null; + } + if (!sessionToken) return null; + const session = await this.db.query<{ user_id: string }>( + `SELECT user_id FROM instance_sessions + WHERE instance_id=$1 AND token_hash=$2 AND expires_at>now() AND revoked_at IS NULL`, + [row.id, sha256(sessionToken)], + ); + return session.rows[0] ? { instance: instanceFromRow(row), userId: session.rows[0].user_id } : null; + } + + async suspendInstance(actor: Actor, instanceId: string): Promise { + const instance = await this.getOwnedInstance(actor, instanceId); + if (!instance) throw new Error("instance not found"); + await this.db.transaction(async client => { + await client.query("UPDATE instances SET status='suspending',suspended_at=now(),updated_at=now() WHERE id=$1", [instanceId]); + await client.query("UPDATE agents SET revoked_at=now(),auth_token_hash=NULL WHERE instance_id=$1", [instanceId]); + await client.query("UPDATE access_tokens SET revoked_at=now() WHERE instance_id=$1 AND revoked_at IS NULL", [instanceId]); + await client.query("UPDATE instance_sessions SET revoked_at=now() WHERE instance_id=$1 AND revoked_at IS NULL", [instanceId]); + await client.query( + `INSERT INTO provisioning_jobs(instance_id,kind,idempotency_key) + VALUES($1,'suspend',$2) ON CONFLICT(idempotency_key) DO NOTHING`, + [instanceId, `suspend:${instanceId}:${Date.now()}`], + ); + await client.query("SELECT pg_notify('instance_state',$1)", [JSON.stringify({ instanceId, status: "suspending" })]); + await this.audit(client, actor.id, "instance.suspend", instanceId, "accepted"); + }); + } + + async deleteInstance(actor: Actor, instanceId: string): Promise { + const instance = await this.getOwnedInstance(actor, instanceId); + if (!instance) throw new Error("instance not found"); + await this.db.transaction(async client => { + await client.query("UPDATE instances SET status='deleting',updated_at=now() WHERE id=$1", [instanceId]); + await client.query("UPDATE agents SET revoked_at=now(),auth_token_hash=NULL WHERE instance_id=$1", [instanceId]); + await client.query("UPDATE access_tokens SET revoked_at=now() WHERE instance_id=$1 AND revoked_at IS NULL", [instanceId]); + await client.query("UPDATE instance_sessions SET revoked_at=now() WHERE instance_id=$1 AND revoked_at IS NULL", [instanceId]); + await client.query( + `INSERT INTO provisioning_jobs(instance_id,kind,idempotency_key) + VALUES($1,'delete',$2) ON CONFLICT(idempotency_key) DO NOTHING`, + [instanceId, `delete:${instanceId}:v1`], + ); + await client.query("SELECT pg_notify('instance_state',$1)", [JSON.stringify({ instanceId, status: "deleting" })]); + await this.audit(client, actor.id, "instance.delete", instanceId, "accepted"); + }); + } + + async recordHealth(instanceId: string, signal: "agent" | "tunnel" | "gateway", healthy: boolean): Promise { + await this.db.query( + "INSERT INTO health_observations(instance_id,signal,healthy) VALUES($1,$2,$3)", + [instanceId, signal, healthy], + ); + } + + async refreshComputedHealth(instanceId: string): Promise { + const result = await this.db.query<{ signal: string; healthy: boolean; observed_at: Date }>( + `SELECT DISTINCT ON(signal) signal,healthy,observed_at FROM health_observations + WHERE instance_id=$1 ORDER BY signal,observed_at DESC`, + [instanceId], + ); + const fresh = new Map(result.rows.filter(row => Date.now() - row.observed_at.getTime() <= 90_000).map(row => [row.signal, row.healthy])); + const values = ["agent", "tunnel", "gateway"].map(signal => fresh.get(signal)); + const status: InstanceStatus = values.every(value => value === true) ? "online" + : values.every(value => value === undefined) ? "offline" : "degraded"; + await this.db.query( + `UPDATE instances SET status=$2,updated_at=now() + WHERE id=$1 AND status NOT IN ('suspending','suspended','deleting','delete_failed','deleted')`, + [instanceId, status], + ); + return status; + } + + async saveCloudflareResource( + instanceId: string, + resourceType: "tunnel" | "private_dns" | "cidr_activation_route" | "hostname_route", + externalId: string, + secret?: string, + ): Promise { + await this.db.query( + `INSERT INTO cloudflare_resources(instance_id,resource_type,external_id,encrypted_secret) + VALUES($1,$2,$3,$4) + ON CONFLICT(instance_id,resource_type) DO UPDATE SET external_id=excluded.external_id, + encrypted_secret=excluded.encrypted_secret,disabled_at=NULL,deleted_at=NULL`, + [instanceId, resourceType, externalId, secret ? encryptSecret(secret, this.config.encryptionKey) : null], + ); + } + + private slugFromHost(host: string): string | null { + const hostname = host.toLowerCase().replace(/:\d+$/, ""); + const suffix = `.${this.config.PLATFORM_INSTANCE_DOMAIN.toLowerCase()}`; + if (!hostname.endsWith(suffix)) return null; + const slug = hostname.slice(0, -suffix.length); + return /^[a-z0-9-]{1,63}$/.test(slug) ? slug : null; + } + + private audit(client: PoolClient, actorId: string | null, action: string, instanceId: string | null, result: string): Promise { + const requestId = randomUUID(); + return client.query( + `INSERT INTO audit_logs(actor_id,action,instance_id,result,request_id,network_hmac) + VALUES($1,$2,$3,$4,$5,$6)`, + [actorId, action, instanceId, result, requestId, networkSignalHmac(requestId, this.config.auditHmacKey)], + ); + } +} diff --git a/platform/server/src/worker.ts b/platform/server/src/worker.ts new file mode 100644 index 000000000..0abe79878 --- /dev/null +++ b/platform/server/src/worker.ts @@ -0,0 +1,213 @@ +import { hostname } from "node:os"; +import type { PoolClient } from "pg"; +import { CloudflareApi, FakeCloudflareProvider, type CloudflareProvider } from "./cloudflare"; +import { loadPlatformConfig } from "./config"; +import { Database } from "./db"; +import { PlatformStore } from "./store"; + +interface Job { + id: string; + instance_id: string; + kind: "provision" | "suspend" | "delete"; + step: string; + attempts: number; +} + +const config = loadPlatformConfig(); +const database = new Database(config.PLATFORM_DATABASE_URL); +const store = new PlatformStore(database, config); +const cloudflare: CloudflareProvider = config.CLOUDFLARE_MESH_ENABLED === "true" + ? new CloudflareApi(config) + : new FakeCloudflareProvider(); +const workerId = `${hostname()}:${process.pid}`; + +async function claimJob(): Promise { + return database.transaction(async client => { + const result = await client.query( + `SELECT id,instance_id,kind,step,attempts FROM provisioning_jobs + WHERE status IN ('queued','retry') AND available_at<=now() + ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1`, + ); + const job = result.rows[0]; + if (!job) return null; + await client.query( + `UPDATE provisioning_jobs SET status='running',locked_at=now(),locked_by=$2, + attempts=attempts+1,updated_at=now() WHERE id=$1`, + [job.id, workerId], + ); + job.attempts += 1; + return job; + }); +} + +async function resource(instanceId: string, type: string): Promise { + const result = await database.query<{ external_id: string }>( + "SELECT external_id FROM cloudflare_resources WHERE instance_id=$1 AND resource_type=$2 AND deleted_at IS NULL", + [instanceId, type], + ); + return result.rows[0]?.external_id ?? null; +} + +async function deleteTunnelAndConnections(tunnelId: string): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < 10; attempt += 1) { + // Cloudflare rejects ordinary Tunnel deletion while a connector is live. + // Cleanup is the documented equivalent of `cloudflared tunnel delete -f`; + // retrying closes the narrow race where an old-token Agent reconnects. + await cloudflare.cleanupTunnelConnections(tunnelId); + try { + await cloudflare.deleteTunnel(tunnelId); + return; + } catch (error) { + lastError = error; + if (!(error instanceof Error) || !error.message.toLowerCase().includes("active connections")) throw error; + await Bun.sleep(500); + } + } + throw lastError ?? new Error("Tunnel deletion failed after connector cleanup"); +} + +async function provision(job: Job): Promise { + await database.query("UPDATE instances SET status='provisioning',updated_at=now() WHERE id=$1", [job.instance_id]); + let tunnelId = await resource(job.instance_id, "tunnel"); + if (!tunnelId) { + const tunnel = await cloudflare.createTunnel(`ocxr-${job.instance_id}`); + await store.saveCloudflareResource(job.instance_id, "tunnel", tunnel.id, tunnel.token); + tunnelId = tunnel.id; + await database.query("UPDATE provisioning_jobs SET step='tunnel_created',updated_at=now() WHERE id=$1", [job.id]); + } + const instance = await database.query<{ private_hostname: string; private_origin_ip: string }>( + "SELECT private_hostname,host(private_origin_ip) AS private_origin_ip FROM instances WHERE id=$1", + [job.instance_id], + ); + const destination = instance.rows[0]; + if (!destination) throw new Error("instance not found"); + let dnsRecordId = await resource(job.instance_id, "private_dns"); + if (!dnsRecordId) { + dnsRecordId = await cloudflare.createPrivateDnsRecord(destination.private_hostname, destination.private_origin_ip); + await store.saveCloudflareResource(job.instance_id, "private_dns", dnsRecordId); + await database.query("UPDATE provisioning_jobs SET step='dns_created',updated_at=now() WHERE id=$1", [job.id]); + // Live Phase 0 showed that querying before the zone edge had the new A + // record could cache NXDOMAIN for the SOA negative TTL. Human pairing is + // usually slower, but provisioning must also be safe for automated Agents. + if (config.CLOUDFLARE_MESH_ENABLED === "true") await Bun.sleep(15_000); + } + let cidrRouteId = await resource(job.instance_id, "cidr_activation_route"); + if (!cidrRouteId) { + cidrRouteId = await cloudflare.createCidrActivationRoute(tunnelId, destination.private_origin_ip); + await store.saveCloudflareResource(job.instance_id, "cidr_activation_route", cidrRouteId); + await database.query("UPDATE provisioning_jobs SET step='cidr_route_created',updated_at=now() WHERE id=$1", [job.id]); + } + let routeId = await resource(job.instance_id, "hostname_route"); + if (!routeId) { + routeId = await cloudflare.createPrivateHostnameRoute(tunnelId, destination.private_hostname); + await store.saveCloudflareResource(job.instance_id, "hostname_route", routeId); + await database.query("UPDATE provisioning_jobs SET step='route_created',updated_at=now() WHERE id=$1", [job.id]); + } + await database.query("UPDATE instances SET status='awaiting_agent',updated_at=now() WHERE id=$1", [job.instance_id]); +} + +async function deactivateCloudflare(instanceId: string): Promise { + const routeId = await resource(instanceId, "hostname_route"); + if (routeId) { + await cloudflare.disablePrivateHostnameRoute(routeId); + await database.query("UPDATE cloudflare_resources SET disabled_at=now(),deleted_at=now() WHERE instance_id=$1 AND resource_type='hostname_route'", [instanceId]); + } + const cidrRouteId = await resource(instanceId, "cidr_activation_route"); + if (cidrRouteId) { + await cloudflare.deleteCidrActivationRoute(cidrRouteId); + await database.query("UPDATE cloudflare_resources SET disabled_at=now(),deleted_at=now() WHERE instance_id=$1 AND resource_type='cidr_activation_route'", [instanceId]); + } + const dnsRecordId = await resource(instanceId, "private_dns"); + if (dnsRecordId) { + await cloudflare.deletePrivateDnsRecord(dnsRecordId); + await database.query("UPDATE cloudflare_resources SET disabled_at=now(),deleted_at=now() WHERE instance_id=$1 AND resource_type='private_dns'", [instanceId]); + } + const tunnelId = await resource(instanceId, "tunnel"); + if (tunnelId) { + // Resume provisions a fresh Tunnel and token. Central connector cleanup + // ensures suspend does not depend on the Agent being online or cooperative. + await deleteTunnelAndConnections(tunnelId); + await database.query("UPDATE cloudflare_resources SET deleted_at=now(),encrypted_secret=NULL WHERE instance_id=$1 AND resource_type='tunnel'", [instanceId]); + } +} + +async function suspend(job: Job): Promise { + await deactivateCloudflare(job.instance_id); + await database.query("UPDATE instances SET status='suspended',updated_at=now() WHERE id=$1", [job.instance_id]); +} + +async function remove(job: Job): Promise { + await deactivateCloudflare(job.instance_id); + await database.transaction(async (client: PoolClient) => { + const instance = await client.query<{ slug: string }>("SELECT slug FROM instances WHERE id=$1 FOR UPDATE", [job.instance_id]); + if (!instance.rows[0]) return; + await client.query( + `INSERT INTO slug_tombstones(slug,instance_id,expires_at) VALUES($1,$2,now()+interval '30 days') + ON CONFLICT(slug) DO UPDATE SET instance_id=excluded.instance_id,expires_at=excluded.expires_at`, + [instance.rows[0].slug, job.instance_id], + ); + await client.query("UPDATE instances SET status='deleted',deleted_at=now(),updated_at=now() WHERE id=$1", [job.instance_id]); + }); +} + +async function finish(job: Job): Promise { + await database.query("UPDATE provisioning_jobs SET status='completed',last_error=NULL,locked_at=NULL,locked_by=NULL,updated_at=now() WHERE id=$1", [job.id]); +} + +async function fail(job: Job, error: unknown): Promise { + const terminal = job.attempts >= 10; + const message = error instanceof Error ? error.message.slice(0, 500) : "job failed"; + await database.query( + `UPDATE provisioning_jobs SET status=$2,last_error=$3,available_at=now()+($4 || ' seconds')::interval, + locked_at=NULL,locked_by=NULL,updated_at=now() WHERE id=$1`, + [job.id, terminal ? "failed" : "retry", message, Math.min(300, 2 ** job.attempts)], + ); + if (job.kind === "delete") { + await database.query("UPDATE instances SET status='delete_failed',updated_at=now() WHERE id=$1", [job.instance_id]); + } +} + +async function refreshHealth(): Promise { + const instances = await database.query<{ id: string; slug: string }>( + "SELECT id,slug FROM instances WHERE status IN ('connecting','online','degraded','offline') AND deleted_at IS NULL", + ); + for (const instance of instances.rows) { + const tunnelId = await resource(instance.id, "tunnel"); + try { + await store.recordHealth(instance.id, "tunnel", !!tunnelId && await cloudflare.listActiveConnections(tunnelId) > 0); + } catch { await store.recordHealth(instance.id, "tunnel", false); } + try { + const response = await fetch(`https://${instance.slug}.${config.PLATFORM_INSTANCE_DOMAIN}/healthz`, { + headers: { "x-ocxr-synthetic": config.syntheticHealthToken }, + signal: AbortSignal.timeout(10_000), + }); + await store.recordHealth(instance.id, "gateway", response.ok); + } catch { await store.recordHealth(instance.id, "gateway", false); } + await store.refreshComputedHealth(instance.id); + } +} + +async function loop(): Promise { + let lastHealth = 0; + while (true) { + const job = await claimJob(); + if (job) { + try { + if (job.kind === "provision") await provision(job); + else if (job.kind === "suspend") await suspend(job); + else await remove(job); + await finish(job); + } catch (error) { await fail(job, error); } + continue; + } + if (Date.now() - lastHealth >= 30_000) { + await refreshHealth(); + lastHealth = Date.now(); + } + await Bun.sleep(500); + } +} + +console.log(`OpenCodex Remote worker started as ${workerId}`); +await loop(); diff --git a/platform/server/tests/cloudflare-live.test.ts b/platform/server/tests/cloudflare-live.test.ts new file mode 100644 index 000000000..508c8c0f9 --- /dev/null +++ b/platform/server/tests/cloudflare-live.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from "bun:test"; +import { CloudflareApi } from "../src/cloudflare"; +import type { PlatformConfig } from "../src/config"; + +const enabled = process.env.CLOUDFLARE_LIVE_TESTS === "true"; +const liveTest = enabled ? test : test.skip; + +liveTest("Cloudflare creates and removes an isolated Tunnel hostname route", async () => { + const accountId = process.env.CLOUDFLARE_ACCOUNT_ID; + const credential = process.env.CLOUDFLARE_API_TOKEN; + const zoneId = process.env.CLOUDFLARE_ZONE_ID; + const privateDomain = process.env.CLOUDFLARE_LIVE_TEST_DOMAIN; + if (!accountId || !credential || !zoneId || !privateDomain) { + throw new Error("Cloudflare live-test account, credential, zone, and private domain are required"); + } + + const api = new CloudflareApi({ + CLOUDFLARE_ACCOUNT_ID: accountId, + CLOUDFLARE_ZONE_ID: zoneId, + CLOUDFLARE_API_BASE_URL: "https://api.cloudflare.com/client/v4", + cloudflareApiToken: credential, + cloudflareAuthEmail: process.env.CLOUDFLARE_AUTH_EMAIL, + } as PlatformConfig); + const suffix = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`; + const tunnel = await api.createTunnel(`ocxr-phase0-api-${suffix}`); + let routeId: string | null = null; + let cidrRouteId: string | null = null; + let dnsRecordId: string | null = null; + try { + expect(tunnel.id).toMatch(/^[0-9a-f-]{36}$/); + expect(tunnel.token.length).toBeGreaterThan(32); + expect(await api.getTunnelToken(tunnel.id)).toBe(tunnel.token); + + const originIp = `10.223.${Math.floor(Math.random() * 256)}.${1 + Math.floor(Math.random() * 254)}`; + const hostname = `phase0-${suffix}.${privateDomain}`; + dnsRecordId = await api.createPrivateDnsRecord(hostname, originIp); + cidrRouteId = await api.createCidrActivationRoute(tunnel.id, originIp); + routeId = await api.createPrivateHostnameRoute(tunnel.id, hostname); + expect(dnsRecordId).toMatch(/^[0-9a-f]{32}$/); + expect(cidrRouteId).toMatch(/^[0-9a-f-]{36}$/); + expect(routeId).toMatch(/^[0-9a-f-]{36}$/); + expect(await api.listActiveConnections(tunnel.id)).toBe(0); + } finally { + const cleanup = await Promise.allSettled([ + ...(routeId ? [api.disablePrivateHostnameRoute(routeId)] : []), + ...(cidrRouteId ? [api.deleteCidrActivationRoute(cidrRouteId)] : []), + ...(dnsRecordId ? [api.deletePrivateDnsRecord(dnsRecordId)] : []), + ]); + await api.cleanupTunnelConnections(tunnel.id); + await api.deleteTunnel(tunnel.id); + const failed = cleanup.find(result => result.status === "rejected"); + if (failed?.status === "rejected") throw failed.reason; + } +}, 30_000); diff --git a/platform/server/tests/cloudflare.test.ts b/platform/server/tests/cloudflare.test.ts new file mode 100644 index 000000000..6570b419b --- /dev/null +++ b/platform/server/tests/cloudflare.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { CloudflareApi } from "../src/cloudflare"; +import type { PlatformConfig } from "../src/config"; + +const originalFetch = globalThis.fetch; +let requests: Array<{ body?: BodyInit | null; headers: Headers; method: string; url: string }>; + +function config(overrides: Partial = {}): PlatformConfig { + return { + CLOUDFLARE_ACCOUNT_ID: "account-id", + CLOUDFLARE_ZONE_ID: "zone-id", + CLOUDFLARE_API_BASE_URL: "https://api.cloudflare.test/client/v4", + cloudflareApiToken: "test-credential", + ...overrides, + } as PlatformConfig; +} + +beforeEach(() => { + requests = []; + globalThis.fetch = (async (input, init = {}) => { + const url = String(input); + const method = init.method ?? "GET"; + requests.push({ body: init.body, headers: new Headers(init.headers), method, url }); + const result = url.endsWith("/token") + ? "tunnel-token" + : { id: "resource-id", token: "created-token" }; + return Response.json({ success: true, errors: [], result }); + }) as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test("Cloudflare Global API Key uses email/key headers without a bearer token", async () => { + const api = new CloudflareApi(config({ cloudflareAuthEmail: "admin@example.test" })); + expect(await api.createTunnel("phase0")).toEqual({ id: "resource-id", token: "created-token" }); + + const request = requests[0]; + expect(request.headers.get("x-auth-email")).toBe("admin@example.test"); + expect(request.headers.get("x-auth-key")).toBe("test-credential"); + expect(request.headers.get("authorization")).toBeNull(); +}); + +test("Cloudflare scoped token keeps bearer authentication", async () => { + const api = new CloudflareApi(config()); + await api.createPrivateHostnameRoute("tunnel-id", "private.ocxr.internal"); + + const request = requests[0]; + expect(request.headers.get("authorization")).toBe("Bearer test-credential"); + expect(request.headers.get("x-auth-email")).toBeNull(); + expect(request.headers.get("x-auth-key")).toBeNull(); +}); + +test("Tunnel token retrieval uses the current GET endpoint", async () => { + const api = new CloudflareApi(config()); + expect(await api.getTunnelToken("tunnel-id")).toBe("tunnel-token"); + expect(requests[0].method).toBe("GET"); + expect(requests[0].url).toEndWith("/accounts/account-id/cfd_tunnel/tunnel-id/token"); +}); + +test("Tunnel shutdown centrally removes every live connector before deletion", async () => { + const api = new CloudflareApi(config()); + await api.cleanupTunnelConnections("tunnel-id"); + await api.deleteTunnel("tunnel-id"); + + expect(requests[0].method).toBe("DELETE"); + expect(requests[0].url).toEndWith("/accounts/account-id/cfd_tunnel/tunnel-id/connections"); + expect(requests[1].method).toBe("DELETE"); + expect(requests[1].url).toEndWith("/accounts/account-id/cfd_tunnel/tunnel-id"); +}); + +test("private hostname provisioning uses DNS-only A and narrow CIDR routes", async () => { + const api = new CloudflareApi(config()); + expect(await api.createPrivateDnsRecord("random.private.example.test", "10.200.1.9")).toBe("resource-id"); + expect(await api.createCidrActivationRoute("tunnel-id", "10.200.1.9")).toBe("resource-id"); + + expect(requests[0].url).toEndWith("/zones/zone-id/dns_records"); + expect(requests[0].method).toBe("POST"); + expect(JSON.parse(String(requests[0].body))).toMatchObject({ + type: "A", + name: "random.private.example.test", + content: "10.200.1.9", + proxied: false, + }); + expect(requests[1].url).toEndWith("/accounts/account-id/teamnet/routes"); + expect(requests[1].method).toBe("POST"); + expect(JSON.parse(String(requests[1].body))).toMatchObject({ + network: "10.200.1.9/32", + tunnel_id: "tunnel-id", + }); +}); diff --git a/platform/server/tests/postgres-integration.test.ts b/platform/server/tests/postgres-integration.test.ts new file mode 100644 index 000000000..23ed058c3 --- /dev/null +++ b/platform/server/tests/postgres-integration.test.ts @@ -0,0 +1,767 @@ +import { expect, test } from "bun:test"; +import { generateKeyPairSync, randomBytes } from "node:crypto"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Pool } from "pg"; +import { verifyRemoteManagementAssertion } from "../../../src/server/remote-assertion"; +import { BrowserTerminalCipher, type RemoteVault } from "../../web/src/e2ee"; +import { createPlatformAuth } from "../src/auth"; +import { loadPlatformConfig } from "../src/config"; +import { Database } from "../src/db"; +import { PlatformStore } from "../src/store"; + +const adminDatabaseUrl = process.env.PLATFORM_TEST_DATABASE_URL; +const postgresTest = adminDatabaseUrl ? test : test.skip; + +async function reservePort(): Promise { + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: () => new Response("reserved"), + }); + const port = server.port; + await server.stop(true); + if (port === undefined) throw new Error("Bun did not allocate a test port"); + return port; +} + +async function waitFor(probe: () => Promise, timeoutMs = 15_000): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const value = await probe(); + if (value !== null) return value; + } catch (error) { + lastError = error; + } + await Bun.sleep(100); + } + throw new Error(`integration wait timed out${lastError instanceof Error ? `: ${lastError.message}` : ""}`); +} + +async function stopProcess(process: ReturnType): Promise { + if (process.exitCode !== null) return; + process.kill(); + await Promise.race([process.exited, Bun.sleep(2_000)]); + if (process.exitCode === null) process.kill(9); +} + +function fixedSizeBody(size: number): ReadableStream { + const chunkSize = 1024 * 1024; + let remaining = size; + return new ReadableStream({ + pull(controller) { + if (remaining === 0) { + controller.close(); + return; + } + const length = Math.min(chunkSize, remaining); + remaining -= length; + controller.enqueue(new Uint8Array(length).fill(0x61)); + }, + }); +} + +async function readSize(body: ReadableStream | null): Promise { + if (!body) return 0; + const reader = body.getReader(); + let size = 0; + while (true) { + const chunk = await reader.read(); + if (chunk.done) return size; + size += chunk.value.byteLength; + } +} + +postgresTest("PostgreSQL 17 auth, lifecycle, isolation, and streaming stay compatible", async () => { + const databaseName = `ocxr_test_${process.pid}_${randomBytes(4).toString("hex")}`; + const quotedDatabaseName = `"${databaseName}"`; + const adminPool = new Pool({ connectionString: adminDatabaseUrl, max: 1 }); + const databaseUrl = new URL(adminDatabaseUrl!); + databaseUrl.pathname = `/${databaseName}`; + + const processes: Array> = []; + let database: Database | null = null; + let fakeAgent: ReturnType | null = null; + const temporaryDirectory = mkdtempSync(join(tmpdir(), "ocxr-relay-integration-")); + + try { + await adminPool.query(`CREATE DATABASE ${quotedDatabaseName}`); + const migrationPool = new Pool({ connectionString: databaseUrl.toString(), max: 1 }); + try { + for (const migrationName of ["0001_remote_platform.sql", "0002_remote_devices.sql", "0003_outbound_e2ee_relay.sql"]) { + const migration = readFileSync(join(import.meta.dir, "..", "migrations", migrationName), "utf8"); + await migrationPool.query(migration); + } + } finally { + await migrationPool.end(); + } + + const controlPort = await reservePort(); + const gatewayPort = await reservePort(); + const { privateKey, publicKey } = generateKeyPairSync("ed25519"); + const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString(); + const environment = { + ...process.env, + NODE_ENV: "test", + PLATFORM_DATABASE_URL: databaseUrl.toString(), + PLATFORM_BASE_URL: `http://127.0.0.1:${controlPort}`, + PLATFORM_INSTANCE_DOMAIN: "instances.example.test", + PLATFORM_BOOTSTRAP_GITHUB_ID: "999999", + PLATFORM_DEV_AUTH_GITHUB_ID: "111111", + PLATFORM_GATEWAY_ISSUER: "ocxr-integration", + PLATFORM_GATEWAY_KID: "integration-key", + PLATFORM_GATEWAY_PRIVATE_KEY: privateKeyPem, + PLATFORM_ENCRYPTION_KEY: randomBytes(32).toString("base64url"), + PLATFORM_AUDIT_HMAC_KEY: randomBytes(32).toString("base64url"), + PLATFORM_SYNTHETIC_HEALTH_TOKEN: randomBytes(32).toString("base64url"), + PLATFORM_CONTROL_PORT: String(controlPort), + PLATFORM_GATEWAY_PORT: String(gatewayPort), + PLATFORM_RELAY_URL: `ws://127.0.0.1:${gatewayPort}/_ocxr/agent`, + CLOUDFLARE_MESH_ENABLED: "false", + BETTER_AUTH_SECRET: randomBytes(32).toString("hex"), + GITHUB_CLIENT_ID: "integration-client", + GITHUB_CLIENT_SECRET: "integration-secret", + } satisfies NodeJS.ProcessEnv; + const config = loadPlatformConfig(environment); + database = new Database(databaseUrl.toString()); + const auth = createPlatformAuth(config, database); + const adapter = (await auth.$context).internalAdapter; + + const firstUserInput = { + name: "First User", + email: "FIRST@EXAMPLE.TEST", + emailVerified: true, + image: null, + githubNumericId: "111111", + } as Parameters[0] & { githubNumericId: string }; + const secondUserInput = { + name: "Second User", + email: "second@example.test", + emailVerified: true, + image: null, + githubNumericId: "222222", + } as Parameters[0] & { githubNumericId: string }; + const first = await adapter.createOAuthUser( + firstUserInput, + { + accountId: "111111", + providerId: "github", + accessToken: "fake-first-access-token", + scope: "read:user user:email", + }, + ); + const second = await adapter.createOAuthUser( + secondUserInput, + { + accountId: "222222", + providerId: "github", + accessToken: "fake-second-access-token", + scope: "read:user user:email", + }, + ); + expect(first.user.email).toBe("first@example.test"); + const persistedFirstUser = await database.query<{ + githubNumericId: string; + role: string; + status: string; + }>( + "SELECT github_numeric_id AS \"githubNumericId\",role::text,status::text FROM users WHERE id=$1", + [first.user.id], + ); + expect(persistedFirstUser.rows[0]).toEqual({ + githubNumericId: "111111", + role: "user", + status: "pending_invite", + }); + + const session = await adapter.createSession(first.user.id, false, { + ipAddress: "127.0.0.1", + userAgent: "ocxr-postgres-integration", + }); + expect((await adapter.findSession(session.token))?.user.id).toBe(first.user.id); + await adapter.createVerificationValue({ + identifier: "postgres-integration", + value: "opaque-verification-value", + expiresAt: new Date(Date.now() + 60_000), + }); + expect((await adapter.findVerificationValue("postgres-integration"))?.value).toBe("opaque-verification-value"); + expect((await adapter.findAccountByProviderId("111111", "github"))?.userId).toBe(first.user.id); + const githubSecrets = await database.query<{ access_token: string | null; refresh_token: string | null; id_token: string | null }>( + "SELECT access_token,refresh_token,id_token FROM accounts WHERE provider_id='github' AND user_id=$1", + [first.user.id], + ); + expect(githubSecrets.rows[0]).toEqual({ access_token: null, refresh_token: null, id_token: null }); + + await database.query("UPDATE users SET status='active' WHERE id IN ($1,$2)", [first.user.id, second.user.id]); + const store = new PlatformStore(database, config); + const secondActor = await store.authorizeUser({ + id: second.user.id, + name: second.user.name, + email: second.user.email, + githubNumericId: "222222", + }); + if (!secondActor) throw new Error("second actor was not authorized"); + const firstActor = await store.authorizeUser({ + id: first.user.id, + name: first.user.name, + email: first.user.email, + githubNumericId: "111111", + }); + if (!firstActor) throw new Error("first actor was not authorized"); + + const deviceKey = generateKeyPairSync("ed25519").publicKey.export({ type: "spki", format: "der" }); + const deviceLink = await store.createDeviceAuthorization({ + deviceName: "Integration workstation", + platform: "linux-x64", + publicKeyDer: deviceKey, + networkSignal: "127.0.0.1", + }); + expect(deviceLink.pollSecret.startsWith("ocxr_device_")).toBe(true); + expect((await store.pollDeviceAuthorization(deviceLink.id, deviceLink.pollSecret))?.status).toBe("pending"); + expect((await store.approveDeviceAuthorization(firstActor, deviceLink.id)).status).toBe("approved"); + const approvedDevice = await store.pollDeviceAuthorization(deviceLink.id, deviceLink.pollSecret); + expect(approvedDevice?.status).toBe("approved"); + expect(approvedDevice?.deviceToken?.startsWith("ocxr_device_")).toBe(true); + const deviceActor = await store.authorizeDeviceToken(approvedDevice?.deviceToken ?? ""); + expect(deviceActor?.actor.id).toBe(firstActor.id); + expect(await store.remoteProfile(firstActor)).toMatchObject({ passwordSet: false, canActivate: true, instance: null }); + await store.setRemotePassword(firstActor, "integration-password"); + await store.setRemotePassword(secondActor, "second-integration-password"); + // Legacy password hashes remain valid for the rollback Mesh path but must + // not masquerade as an end-to-end encrypted profile in the new GUI. + expect(await store.remoteProfile(firstActor)).toMatchObject({ passwordSet: false, canActivate: true, instance: null }); + expect(await store.acknowledgeDeviceAuthorization(deviceLink.id, deviceLink.pollSecret)).toBe(true); + expect((await store.pollDeviceAuthorization(deviceLink.id, deviceLink.pollSecret))?.status).toBe("consumed"); + + const relayUser = await adapter.createOAuthUser( + { + name: "Relay User", + email: "relay@example.test", + emailVerified: true, + image: null, + githubNumericId: "333333", + } as Parameters[0] & { githubNumericId: string }, + { accountId: "333333", providerId: "github", accessToken: "discard-me" }, + ); + await database.query("UPDATE users SET status='active' WHERE id=$1", [relayUser.user.id]); + const relayActor = await store.authorizeUser({ + id: relayUser.user.id, + name: relayUser.user.name, + email: relayUser.user.email, + githubNumericId: "333333", + }); + if (!relayActor) throw new Error("relay actor was not authorized"); + const relaySigning = generateKeyPairSync("ed25519"); + const relayEcdh = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + const relayLink = await store.createDeviceAuthorization({ + deviceName: "Relay workstation", + platform: "linux-x64", + publicKeyDer: relaySigning.publicKey.export({ type: "spki", format: "der" }), + ecdhPublicKeyDer: relayEcdh.publicKey.export({ type: "spki", format: "der" }), + networkSignal: "127.0.0.1", + }); + await store.approveDeviceAuthorization(relayActor, relayLink.id); + const relayApproval = await store.pollDeviceAuthorization(relayLink.id, relayLink.pollSecret); + expect(relayApproval?.relayToken?.startsWith("ocxr_agent_")).toBe(true); + const rootKey = generateKeyPairSync("ed25519"); + const rootPublicKey = rootKey.publicKey.export({ type: "spki", format: "der" }); + const relayAuthSecret = randomBytes(32).toString("base64url"); + await store.setRemoteE2eeProfile(relayActor, relayAuthSecret, { + version: "ocx-e2ee-v1", + salt: randomBytes(16).toString("base64url"), + nonce: randomBytes(12).toString("base64url"), + ciphertext: randomBytes(48).toString("base64url"), + rootPublicKey: rootPublicKey.toString("base64url"), + kdf: { algorithm: "argon2id", memoryKiB: 65_536, iterations: 3, parallelism: 1, outputLength: 32 }, + }); + const relayWorkspace = await store.activateRemoteInstance( + relayActor, + relayApproval?.deviceId ?? "", + { name: "Relay workstation", slug: "relay-user" }, + ); + expect(relayWorkspace).toMatchObject({ + passwordSet: true, + instance: { slug: "relay-user", transportMode: "outbound-relay", status: "awaiting_agent" }, + }); + const relayConnection = await store.authorizeRelayToken(relayApproval?.relayToken ?? ""); + expect(relayConnection).toMatchObject({ actor: { id: relayActor.id }, name: "Relay workstation" }); + const terminal = await store.createTerminalSession( + relayActor.id, + relayWorkspace.instance!.id, + relayConnection!.deviceId, + "codex", + ); + expect(await store.resolveTerminalSession(relayActor.id, relayWorkspace.instance!.id, terminal.id)).toMatchObject({ + id: terminal.id, + commandProfile: "codex", + }); + await store.markTerminalConnected(terminal.id); + await store.markTerminalClosed(terminal.id); + expect(await store.resolveTerminalSession(relayActor.id, relayWorkspace.instance!.id, terminal.id)).toBeNull(); + expect(await store.issueInstanceAuthorization(relayActor, relayWorkspace.instance!.id, relayAuthSecret)).toStartWith( + "https://relay-user.instances.example.test/_ocxr/exchange?code=", + ); + await store.markRelayDisconnected(relayConnection!.deviceId); + expect((await store.remoteProfile(relayActor)).instance?.status).toBe("offline"); + + let firstInstanceId: string | null = null; + let upstreamStreamCancelled = false; + fakeAgent = Bun.serve({ + hostname: "127.0.0.1", + port: 10101, + async fetch(request, server) { + const assertionValid = firstInstanceId !== null && verifyRemoteManagementAssertion( + new Request(request.url, { method: request.method, headers: request.headers }), + { + remoteAccess: { + enabled: true, + instanceId: firstInstanceId, + issuer: environment.PLATFORM_GATEWAY_ISSUER, + publicKeys: [{ kid: environment.PLATFORM_GATEWAY_KID, publicKeyPem }], + }, + } as never, + ); + const url = new URL(request.url); + if (!assertionValid) return new Response("Not found", { status: 404 }); + if (url.pathname === "/v1/socket" && request.headers.get("upgrade")?.toLowerCase() === "websocket") { + const upgraded = server.upgrade(request, { + data: { + assertionValid, + authorization: request.headers.get("authorization"), + cookie: request.headers.get("cookie"), + forwardedFor: request.headers.get("x-forwarded-for"), + host: request.headers.get("host"), + origin: request.headers.get("origin"), + }, + }); + return upgraded ? undefined : new Response("upgrade failed", { status: 502 }); + } + if (url.pathname === "/v1/sse") { + const encoder = new TextEncoder(); + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("data: first\n\n")); + setTimeout(() => { + controller.enqueue(encoder.encode("data: second\n\n")); + controller.close(); + }, 30); + }, + }), { headers: { "content-type": "text/event-stream" } }); + } + if (url.pathname === "/v1/stream-until-abort") { + const encoder = new TextEncoder(); + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("ready\n")); + }, + cancel() { + upstreamStreamCancelled = true; + }, + }), { headers: { "content-type": "application/octet-stream" } }); + } + if (url.pathname === "/v1/echo-bytes") { + return Response.json({ bytes: await readSize(request.body) }); + } + if (url.pathname === "/v1/large-response") { + return new Response(fixedSizeBody(100 * 1024 * 1024), { + headers: { "content-type": "application/octet-stream" }, + }); + } + return Response.json({ + assertionValid, + authorization: request.headers.get("authorization"), + cookie: request.headers.get("cookie"), + forwardedFor: request.headers.get("x-forwarded-for"), + host: request.headers.get("host"), + origin: request.headers.get("origin"), + targetPath: request.headers.get("x-ocxr-target-path"), + }, { + headers: { + "cf-ray": "must-not-leak", + location: "http://127.0.0.1:10100/redirect", + "set-cookie": "upstream=must-not-leak", + "x-ocxr-private": "must-not-leak", + }, + }); + }, + websocket: { + open(socket) { + socket.send(JSON.stringify(socket.data)); + }, + message(socket, message) { + socket.send(message); + }, + }, + }); + + const platformRoot = join(import.meta.dir, "..", ".."); + const spawnService = (entrypoint: string) => { + const child = Bun.spawn([process.execPath, entrypoint], { + cwd: platformRoot, + env: environment, + stdout: "pipe", + stderr: "pipe", + }); + processes.push(child); + return child; + }; + const control = spawnService("server/src/control-plane.ts"); + let gateway = spawnService("server/src/gateway.ts"); + const worker = spawnService("server/src/worker.ts"); + + await waitFor(async () => { + if (control.exitCode !== null || gateway.exitCode !== null || worker.exitCode !== null) { + throw new Error("a platform process exited during startup"); + } + const response = await fetch(`http://127.0.0.1:${controlPort}/healthz`); + return response.ok ? true : null; + }); + + const relayStatePath = join(temporaryDirectory, "remote.json"); + const relayVault: RemoteVault = { + version: 1, + vaultKey: randomBytes(32).toString("base64url"), + rootPrivateKeyPem: rootKey.privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + }; + writeFileSync(relayStatePath, JSON.stringify({ + version: 2, + state: "connected", + relayUrl: environment.PLATFORM_RELAY_URL, + relayToken: relayApproval?.relayToken, + deviceId: relayApproval?.deviceId, + privateKeyPem: relaySigning.privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + e2ee: { rootPublicKey: rootPublicKey.toString("base64url") }, + instance: { id: relayWorkspace.instance!.id }, + }), { mode: 0o600 }); + chmodSync(relayStatePath, 0o600); + const relayAgent = Bun.spawn([ + join(platformRoot, "..", "remote-agent", "target", "debug", "opencodex-remote-agent"), + "relay", + "--state", + relayStatePath, + ], { + cwd: join(platformRoot, ".."), + env: environment, + stdout: "pipe", + stderr: "pipe", + }); + processes.push(relayAgent); + const relayAuthorizationUrl = new URL(await store.issueInstanceAuthorization( + relayActor, + relayWorkspace.instance!.id, + relayAuthSecret, + )); + const relayExchange = await fetch( + `http://127.0.0.1:${gatewayPort}${relayAuthorizationUrl.pathname}${relayAuthorizationUrl.search}`, + { headers: { host: "relay-user.instances.example.test" }, redirect: "manual" }, + ); + expect(relayExchange.status).toBe(302); + const relaySessionCookie = relayExchange.headers.get("set-cookie")?.split(";", 1)[0]; + expect(relaySessionCookie).toStartWith("__Host-ocxr_instance="); + const relayWorkspaceView = await waitFor(async () => { + if (relayAgent.exitCode !== null) throw new Error("Rust relay Agent exited during startup"); + const response = await fetch(`http://127.0.0.1:${gatewayPort}/api/v1/workspace`, { + headers: { host: "relay-user.instances.example.test", cookie: relaySessionCookie! }, + }); + if (!response.ok) return null; + const body = await response.json() as { devices: Array<{ id: string; relayOnline: boolean }> }; + return body.devices.some(device => device.id === relayApproval?.deviceId && device.relayOnline) ? body : null; + }); + expect(relayWorkspaceView.devices).toHaveLength(1); + const missingWorkspaceAsset = await fetch(`http://127.0.0.1:${gatewayPort}/assets/missing-workspace.js`, { + headers: { host: "relay-user.instances.example.test", cookie: relaySessionCookie! }, + }); + expect(missingWorkspaceAsset.status).toBe(404); + expect(await missingWorkspaceAsset.text()).toBe("Not found"); + const terminalResponse = await fetch(`http://127.0.0.1:${gatewayPort}/api/v1/terminal-sessions`, { + method: "POST", + headers: { + host: "relay-user.instances.example.test", + cookie: relaySessionCookie!, + "content-type": "application/json", + }, + body: JSON.stringify({ deviceId: relayApproval?.deviceId, commandProfile: "shell" }), + }); + expect(terminalResponse.status).toBe(201); + const terminalBody = await terminalResponse.json() as { session: { id: string }; websocketPath: string }; + const terminalSocket = new WebSocket( + `ws://127.0.0.1:${gatewayPort}${terminalBody.websocketPath}`, + { headers: { host: "relay-user.instances.example.test", cookie: relaySessionCookie! } } as never, + ); + terminalSocket.binaryType = "arraybuffer"; + await new Promise((resolve, reject) => { + terminalSocket.addEventListener("open", () => resolve(), { once: true }); + terminalSocket.addEventListener("error", () => reject(new Error("terminal WebSocket failed to open")), { once: true }); + }); + const terminalCipher = await BrowserTerminalCipher.handshake( + terminalSocket, + terminalBody.session.id, + "shell", + { + id: relayApproval!.deviceId!, + signingPublicKey: relaySigning.publicKey.export({ type: "spki", format: "der" }).toString("base64url"), + }, + relayVault, + ); + let terminalOutput = ""; + let terminalReceiveQueue = Promise.resolve(); + terminalSocket.addEventListener("message", event => { + if (!(event.data instanceof ArrayBuffer)) return; + terminalReceiveQueue = terminalReceiveQueue.then(async () => { + const plaintext = await terminalCipher.decrypt(event.data); + if (plaintext[0] === 3) terminalOutput += new TextDecoder().decode(plaintext.subarray(1)); + }); + }); + const terminalCommand = new TextEncoder().encode("printf 'OCXR_RELAY_OK\\n'; exit\\n"); + const applicationInput = new Uint8Array(1 + terminalCommand.length); + applicationInput[0] = 1; + applicationInput.set(terminalCommand, 1); + terminalSocket.send(await terminalCipher.encrypt(applicationInput)); + await waitFor(async () => terminalOutput.includes("OCXR_RELAY_OK") ? true : null, 10_000); + expect(terminalOutput).toContain("OCXR_RELAY_OK"); + terminalSocket.close(); + const spa = await fetch(`http://127.0.0.1:${controlPort}/instances/first`); + expect(spa.status).toBe(200); + expect(spa.headers.get("content-type")).toContain("text/html"); + expect(await spa.text()).toContain("OpenCodex Remote"); + const missingApi = await fetch(`http://127.0.0.1:${controlPort}/api/v1/not-a-route`); + expect(missingApi.status).toBe(404); + expect(missingApi.headers.get("content-type")).toContain("application/json"); + + const createdFirst = await store.createInstance(firstActor, { name: "First Instance", slug: "first" }); + firstInstanceId = createdFirst.id; + await database.query( + "UPDATE remote_devices SET instance_id=$1 WHERE id=$2 AND user_id=$3", + [firstInstanceId, approvedDevice?.deviceId, firstActor.id], + ); + const createdSecond = await store.createInstance(secondActor, { name: "Second Instance", slug: "second" }); + + await waitFor(async () => { + const state = await database!.query<{ id: string; status: string }>( + "SELECT id,status::text FROM instances WHERE id IN ($1,$2) ORDER BY id", + [firstInstanceId, createdSecond.id], + ); + const resources = await database!.query<{ count: number }>( + "SELECT count(*)::int AS count FROM cloudflare_resources WHERE instance_id IN ($1,$2)", + [firstInstanceId, createdSecond.id], + ); + return state.rows.length === 2 + && state.rows.every(row => row.status === "awaiting_agent") + && resources.rows[0]?.count === 8 + ? true + : null; + }); + const pairingResponse = await fetch(`http://127.0.0.1:${controlPort}/api/v1/remote/pairing-code`, { + method: "POST", + headers: { authorization: `Bearer ${approvedDevice?.deviceToken}` }, + }); + expect(pairingResponse.status).toBe(201); + expect((await pairingResponse.json() as { code: string }).code).toHaveLength(12); + await stopProcess(worker); + + const invisible = await fetch(`http://127.0.0.1:${controlPort}/api/v1/instances/${createdSecond.id}`); + expect(invisible.status).toBe(404); + + const tokenResponse = await fetch( + `http://127.0.0.1:${controlPort}/api/v1/instances/${firstInstanceId}/tokens`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "integration" }), + }, + ); + expect(tokenResponse.status).toBe(201); + const firstToken = (await tokenResponse.json() as { token: string }).token; + const secondToken = (await store.issueDataToken(secondActor, createdSecond.id, "integration")).token; + await database.query( + `UPDATE instances SET private_hostname=CASE id WHEN $1 THEN '127.0.0.1' ELSE 'localhost' END, + status='online',updated_at=now() WHERE id IN ($1,$2)`, + [firstInstanceId, createdSecond.id], + ); + + const gatewayRequest = (host: string, token?: string) => fetch( + `http://127.0.0.1:${gatewayPort}/v1/models?z=2&a=1`, + { + headers: { + host, + ...(token ? { authorization: `Bearer ${token}` } : {}), + cookie: "official-session=must-not-leak", + "x-forwarded-for": "203.0.113.10", + "x-opencodex-api-key": "must-not-leak", + }, + redirect: "manual", + }, + ); + const allowed = await gatewayRequest("first.instances.example.test", firstToken); + expect(allowed.status).toBe(200); + const upstream = await allowed.json() as Record; + expect(upstream.assertionValid).toBe(true); + expect(upstream.authorization).toBeNull(); + expect(upstream.cookie).toBeNull(); + expect(upstream.forwardedFor).toBeNull(); + expect(upstream.host).toBe("127.0.0.1:10100"); + expect(upstream.origin).toBe("http://127.0.0.1:10100"); + expect(upstream.targetPath).toBe("/v1/models?z=2&a=1"); + expect(allowed.headers.get("set-cookie")).toBeNull(); + expect(allowed.headers.get("cf-ray")).toBeNull(); + expect(allowed.headers.get("x-ocxr-private")).toBeNull(); + expect(allowed.headers.get("location")).toBe("https://first.instances.example.test/redirect"); + + expect((await gatewayRequest("first.instances.example.test", secondToken)).status).toBe(404); + expect((await gatewayRequest("second.instances.example.test", firstToken)).status).toBe(404); + expect((await gatewayRequest("first.instances.example.test")).status).toBe(404); + expect((await gatewayRequest("second.instances.example.test", secondToken)).status).toBe(404); + + const browserRedirect = await fetch(`http://127.0.0.1:${gatewayPort}/`, { + headers: { host: "first.instances.example.test", accept: "text/html", "sec-fetch-dest": "document" }, + redirect: "manual", + }); + expect(browserRedirect.status).toBe(302); + expect(browserRedirect.headers.get("location")).toBe(`${environment.PLATFORM_BASE_URL}/access/first`); + + const wrongPassword = await fetch( + `http://127.0.0.1:${controlPort}/api/v1/remote/access/first`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ password: "incorrect-password" }), + }, + ); + expect(wrongPassword.status).toBe(401); + + const firstAuthorization = await fetch( + `http://127.0.0.1:${controlPort}/api/v1/remote/access/first`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ password: "integration-password" }), + }, + ); + expect(firstAuthorization.status).toBe(201); + const firstAuthorizationUrl = new URL((await firstAuthorization.json() as { url: string }).url); + const firstExchange = await fetch( + `http://127.0.0.1:${gatewayPort}${firstAuthorizationUrl.pathname}${firstAuthorizationUrl.search}`, + { headers: { host: "first.instances.example.test" }, redirect: "manual" }, + ); + expect(firstExchange.status).toBe(302); + const firstSessionCookie = firstExchange.headers.get("set-cookie")?.split(";", 1)[0]; + expect(firstSessionCookie).toStartWith("__Host-ocxr_instance="); + const secondAuthorizationUrl = new URL(await store.issueInstanceAuthorization( + secondActor, + createdSecond.id, + "second-integration-password", + )); + const secondExchange = await fetch( + `http://127.0.0.1:${gatewayPort}${secondAuthorizationUrl.pathname}${secondAuthorizationUrl.search}`, + { headers: { host: "second.instances.example.test" }, redirect: "manual" }, + ); + expect(secondExchange.status).toBe(302); + const secondSessionCookie = secondExchange.headers.get("set-cookie")?.split(";", 1)[0]; + expect(secondSessionCookie).toStartWith("__Host-ocxr_instance="); + const browserRequest = (host: string, cookie: string | undefined) => fetch( + `http://127.0.0.1:${gatewayPort}/api/config`, + { headers: { host, ...(cookie ? { cookie } : {}) } }, + ); + expect((await browserRequest("first.instances.example.test", firstSessionCookie)).status).toBe(200); + expect((await browserRequest("second.instances.example.test", firstSessionCookie)).status).toBe(404); + expect((await browserRequest("first.instances.example.test", secondSessionCookie)).status).toBe(404); + + const sse = await fetch(`http://127.0.0.1:${gatewayPort}/v1/sse`, { + headers: { host: "first.instances.example.test", authorization: `Bearer ${firstToken}` }, + }); + expect(sse.status).toBe(200); + expect(sse.headers.get("content-type")).toContain("text/event-stream"); + expect(await sse.text()).toBe("data: first\n\ndata: second\n\n"); + + const hundredMiB = 100 * 1024 * 1024; + const upload = await fetch(`http://127.0.0.1:${gatewayPort}/v1/echo-bytes`, { + method: "POST", + headers: { host: "first.instances.example.test", authorization: `Bearer ${firstToken}` }, + body: fixedSizeBody(hundredMiB), + duplex: "half", + } as RequestInit); + expect(upload.status).toBe(200); + expect((await upload.json() as { bytes: number }).bytes).toBe(hundredMiB); + const download = await fetch(`http://127.0.0.1:${gatewayPort}/v1/large-response`, { + headers: { host: "first.instances.example.test", authorization: `Bearer ${firstToken}` }, + }); + expect(download.status).toBe(200); + expect(await readSize(download.body)).toBe(hundredMiB); + + const socket = new WebSocket(`ws://127.0.0.1:${gatewayPort}/v1/socket`, { + headers: { host: "first.instances.example.test", authorization: `Bearer ${firstToken}` }, + } as never); + const socketMessages: string[] = []; + socket.addEventListener("message", event => socketMessages.push(String(event.data))); + await waitFor(async () => socketMessages.length ? true : null); + const socketHeaders = JSON.parse(socketMessages[0]) as Record; + expect(socketHeaders.assertionValid).toBe(true); + expect(socketHeaders.authorization).toBeNull(); + expect(socketHeaders.cookie).toBeNull(); + expect(socketHeaders.forwardedFor).toBeNull(); + expect(socketHeaders.host).toBe("127.0.0.1:10100"); + expect(socketHeaders.origin).toBe("http://127.0.0.1:10100"); + socket.send("socket-round-trip"); + await waitFor(async () => socketMessages.includes("socket-round-trip") ? true : null); + socket.close(); + + await stopProcess(gateway); + gateway = spawnService("server/src/gateway.ts"); + await waitFor(async () => { + if (gateway.exitCode !== null) throw new Error("gateway exited during restart"); + const response = await gatewayRequest("first.instances.example.test", firstToken); + return response.status === 200 ? true : null; + }); + + const revokedSocket = new WebSocket(`ws://127.0.0.1:${gatewayPort}/v1/socket`, { + headers: { host: "first.instances.example.test", authorization: `Bearer ${firstToken}` }, + } as never); + let revokedSocketOpened = false; + let revokedSocketClose: { code: number; reason: string } | null = null; + revokedSocket.addEventListener("message", () => { revokedSocketOpened = true; }); + revokedSocket.addEventListener("close", event => { + revokedSocketClose = { code: event.code, reason: event.reason }; + }); + await waitFor(async () => revokedSocketOpened ? true : null); + + const longStream = await fetch(`http://127.0.0.1:${gatewayPort}/v1/stream-until-abort`, { + headers: { host: "first.instances.example.test", authorization: `Bearer ${firstToken}` }, + }); + expect(longStream.status).toBe(200); + const longReader = longStream.body!.getReader(); + expect(new TextDecoder().decode((await longReader.read()).value)).toBe("ready\n"); + + const suspend = await fetch( + `http://127.0.0.1:${controlPort}/api/v1/instances/${firstInstanceId}/suspend`, + { method: "POST" }, + ); + expect(suspend.status).toBe(202); + const streamClosed = await Promise.race([ + longReader.read().then(result => result.done).catch(() => true), + Bun.sleep(5_000).then(() => false), + ]); + expect(streamClosed).toBe(true); + await waitFor(async () => upstreamStreamCancelled ? true : null, 5_000); + await waitFor(async () => revokedSocketClose ? true : null, 5_000); + const observedSocketClose = revokedSocketClose as unknown as { code: number; reason: string }; + expect(observedSocketClose).toEqual({ code: 1008, reason: "instance disabled" }); + expect((await gatewayRequest("first.instances.example.test", firstToken)).status).toBe(404); + + expect(await store.revokeDeviceToken(approvedDevice?.deviceToken ?? "")).toBe(true); + expect(await store.authorizeDeviceToken(approvedDevice?.deviceToken ?? "")).toBeNull(); + + await adapter.deleteVerificationByIdentifier("postgres-integration"); + } finally { + await Promise.all(processes.map(stopProcess)); + if (fakeAgent) await fakeAgent.stop(true); + if (database) await database.close(); + await adminPool.query("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname=$1", [databaseName]); + await adminPool.query(`DROP DATABASE IF EXISTS ${quotedDatabaseName}`); + await adminPool.end(); + rmSync(temporaryDirectory, { recursive: true, force: true }); + } +}, 60_000); diff --git a/platform/tsconfig.json b/platform/tsconfig.json new file mode 100644 index 000000000..8b3670990 --- /dev/null +++ b/platform/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "allowJs": false, + "jsx": "react-jsx", + "types": ["bun-types"], + "noEmit": true + }, + "include": ["server/src", "server/tests", "web/src", "vite.config.ts"] +} diff --git a/platform/vite.config.ts b/platform/vite.config.ts new file mode 100644 index 000000000..866df67a5 --- /dev/null +++ b/platform/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { resolve } from "node:path"; + +export default defineConfig({ + root: resolve(import.meta.dirname, "web"), + publicDir: resolve(import.meta.dirname, "../gui/public"), + plugins: [react()], + build: { outDir: "dist", emptyOutDir: true }, + server: { + port: 4173, + allowedHosts: [".opencodexpages.me"], + proxy: { "/api": "http://127.0.0.1:10200" }, + }, + preview: { allowedHosts: [".opencodexpages.me"] }, +}); diff --git a/platform/web/index.html b/platform/web/index.html new file mode 100644 index 000000000..745e4a39c --- /dev/null +++ b/platform/web/index.html @@ -0,0 +1,13 @@ + + + + + + + OpenCodex Remote + + +
+ + + diff --git a/platform/web/src/App.tsx b/platform/web/src/App.tsx new file mode 100644 index 000000000..eb5a9f81d --- /dev/null +++ b/platform/web/src/App.tsx @@ -0,0 +1,343 @@ +import { useEffect, useMemo, useState } from "react"; +import { + IconActivityHeartbeat, IconArrowLeft, IconBox, IconCheck, IconChevronDown, + IconCircleCheckFilled, IconClipboard, IconCopy, IconExternalLink, + IconGlobe, IconKey, IconLink, IconLoader2, IconMenu2, IconPlayerPause, + IconLock, IconPlus, IconServer2, IconShield, IconTerminal2, IconTrash, IconX, +} from "@tabler/icons-react"; +import { + createInstance, createPairingCode, deleteInstance, getInstance, issueToken, listInstances, + getCurrentUser, openInstance, redeemInvite, suspendInstance, getDeviceAuthorizationRequest, + approveDeviceAuthorizationRequest, type CurrentUser, type DeviceAuthorizationRequest, + type Instance, type InstanceStatus, accessInstance, getRemoteAccessProfile, +} from "./api"; +import { authClient } from "./auth-client"; +import { unlockRemoteEnvelope } from "./e2ee"; +import { vaultHandoff, WorkspaceApp } from "./Workspace"; + +type View = "instances" | "activity" | "security" | "new"; +const instanceDomain = import.meta.env.VITE_INSTANCE_DOMAIN ?? "opencodexpages.me"; + +const statusCopy: Record = { + pending: "Pending", provisioning: "Provisioning", awaiting_agent: "Awaiting agent", + connecting: "Connecting", online: "Online", degraded: "Degraded", offline: "Offline", + suspending: "Suspending", suspended: "Suspended", deleting: "Deleting", + delete_failed: "Delete failed", deleted: "Deleted", +}; + +function relativeTime(value: string): string { + const seconds = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 1000)); + if (seconds < 60) return `${seconds} seconds ago`; + if (seconds < 3600) return `${Math.round(seconds / 60)} minutes ago`; + return `${Math.round(seconds / 3600)} hours ago`; +} + +function Status({ value }: { value: InstanceStatus }) { + return {statusCopy[value]}; +} + +function Sidebar({ view, setView, open, close }: { view: View; setView: (view: View) => void; open: boolean; close: () => void }) { + const items = [ + { id: "instances" as const, label: "Instances", icon: IconServer2 }, + { id: "activity" as const, label: "Activity", icon: IconActivityHeartbeat }, + { id: "security" as const, label: "Security", icon: IconShield }, + ]; + return ; +} + +function HealthNode({ icon: Icon, title, value, ok = true }: { icon: typeof IconBox; title: string; value: string; ok?: boolean }) { + return
+
{ok ? : "!"}
+ {title}{value} +
; +} + +function Detail({ instance, onChanged }: { instance: Instance; onChanged: () => Promise }) { + const [notice, setNotice] = useState(null); + const hostname = `${instance.slug}.${instanceDomain}`; + const healthy = instance.status === "online"; + + async function copy(value: string, message: string) { + await navigator.clipboard.writeText(value); + setNotice(message); setTimeout(() => setNotice(null), 1800); + } + async function handleOpen() { + const password = window.prompt("Remote access password"); + if (!password) return; + const url = await openInstance(instance.id, password); + if (url.startsWith("#")) setNotice("Demo instance is ready"); else window.location.assign(url); + } + async function rotate() { + const issued = await issueToken(instance.id); + await copy(issued.token, "New API token copied — it will not be shown again"); + } + async function suspend() { + if (!window.confirm(`Suspend ${instance.name}? Active connections will close immediately.`)) return; + await suspendInstance(instance.id); await onChanged(); + } + async function remove() { + if (!window.confirm(`Delete ${instance.name}? The hostname will be reserved for 30 days.`)) return; + await deleteInstance(instance.id); await onChanged(); + } + return
+

{hostname}

+ {notice &&
{notice}
} +
+ + + + +
+
+
Hostname
{hostname}
+
Version
2.7.41
+
Last seen
{relativeTime(instance.updatedAt)}
+
+
Actions + + + +
+
; +} + +function Instances({ instances, selected, select, refresh, newInstance }: { instances: Instance[]; selected: Instance | null; select: (id: string) => void; refresh: () => Promise; newInstance: () => void }) { + return <> +

Instances

GHoctocat
+
+
+
NameHostnameStatusLast seen
+ {instances.map(instance => )} + {!instances.length &&
No instances yetCreate a private endpoint for your Linux server.
} +
+ {selected ? :
Select an instance
} +
+ ; +} + +function Stepper({ step }: { step: number }) { + return
{["Instance", "Install Agent", "Verify"].map((label, index) =>
= index + 1 ? "current" : ""}`} key={label}>{step > index + 1 ? : index + 1}{label}
)}
; +} + +function NewInstance({ onCancel, onCreated }: { onCancel: () => void; onCreated: () => Promise }) { + const [step, setStep] = useState(1); + const [name, setName] = useState("home-server"); + const [slug, setSlug] = useState("home-server"); + const [instance, setInstance] = useState(null); + const [pairing, setPairing] = useState<{ code: string; expiresAt: string } | null>(null); + const [seconds, setSeconds] = useState(600); + const [error, setError] = useState(null); + const [reserving, setReserving] = useState(false); + const command = `curl -fsSL https://install.ocx.run/agent.sh | sudo sh`; + + useEffect(() => { + if (!pairing) return; + const tick = () => setSeconds(Math.max(0, Math.floor((new Date(pairing.expiresAt).getTime() - Date.now()) / 1000))); + tick(); const timer = window.setInterval(tick, 1000); return () => window.clearInterval(timer); + }, [pairing]); + + async function reserve() { + setError(null); + setReserving(true); + try { + const created = await createInstance(name, slug); + setInstance(created); + let ready = created; + for (let attempt = 0; attempt < 60 && ready.status !== "awaiting_agent"; attempt += 1) { + await new Promise(resolve => window.setTimeout(resolve, 1_000)); + ready = await getInstance(created.id); + if (["delete_failed", "deleted", "suspended"].includes(ready.status)) { + throw new Error(`Provisioning stopped with status ${ready.status}`); + } + } + if (ready.status !== "awaiting_agent") throw new Error("Provisioning is still running; try again shortly"); + setInstance(ready); + const code = await createPairingCode(ready.id); + setPairing(code); setStep(2); + } catch (reason) { setError(reason instanceof Error ? reason.message : "Could not create instance"); } + finally { setReserving(false); } + } + async function verify() { setStep(3); await onCreated(); } + const time = `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; + return <> +

New instance

+
+ {step === 1 ?
+ + +

Choose a stable name for your private OpenCodex instance.

+ {error &&
{error}
} +
+
: step === 2 ? <> +

We’ve reserved this hostname for your new private instance.

+

Install the Linux Agent

Run this command on the server where OpenCodex is installed.

{command}

Pair this Agent

Enter this code when prompted by the agent.

{pairing?.code.match(/.{1,4}/g)?.join("-")}◷ Expires in {time}

The code can be used once and does not contain a long-lived credential.

+

Verify

We’ll check the connection once the agent is paired.

{[[IconBox,"OpenCodex detected"],[IconTerminal2,"Agent authenticated"],[IconLink,"Tunnel connected"],[IconGlobe,"Gateway health check"]].map(([Icon,label]) =>
{label as string}●  Waiting
)}
+
+ :

Instance verification started

Health becomes Online when OpenCodex, the Agent, Tunnel, and Gateway checks are all fresh.

} +
+ ; +} + +function SecondaryPage({ view, instances }: { view: "activity" | "security"; instances: Instance[] }) { + return <>

{view === "activity" ? "Activity" : "Security"}

+ {view === "activity" ? <>

Recent instance activity

{instances.map(item =>
{item.name}
)} : <>

Access is instance-scoped

Browser sessions stay on one hostname. CLI tokens can call only /v1/*, expire after 30 days, and are stored as SHA-256 hashes.

Gateway assertions expire after 30 seconds and are verified again by the local Agent and OpenCodex management API.
} +
; +} + +function DeviceConnection({ requestId }: { requestId: string }) { + const [request, setRequest] = useState(null); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [approving, setApproving] = useState(false); + const [error, setError] = useState(null); + + async function load() { + setLoading(true); + try { + const next = await getDeviceAuthorizationRequest(requestId); + setRequest(next); + try { setUser(await getCurrentUser()); } catch { setUser(null); } + setError(null); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Device request unavailable"); + } finally { setLoading(false); } + } + useEffect(() => { void load(); }, [requestId]); + async function signIn() { + await authClient.signIn.social({ provider: "github", callbackURL: window.location.pathname }); + } + async function approve() { + setApproving(true); + try { setRequest(await approveDeviceAuthorizationRequest(requestId)); setError(null); } + catch (reason) { setError(reason instanceof Error ? reason.message : "Could not approve device"); } + finally { setApproving(false); } + } + if (loading) return
Loading device request
; + if (error || !request) return

Device link unavailable

{error ?? "This request does not exist or has expired."}

; + const complete = request.status === "approved" || request.status === "consumed"; + const unavailable = request.status === "expired"; + return
+
opencodexRemote device approval
+ {complete ?

Computer approved

Return to the local ocx gui window. It will finish linking automatically.

+ : unavailable ?

Request expired

Start GitHub sign-in again from ocx gui → Remote.

+ : <>
Connect this computer

{request.deviceName}

Confirm that this code matches the one shown in the local OpenCodex GUI.

+
Device code{request.userCode.match(/.{1,4}/g)?.join(" ")}
+
Platform
{request.platform}
Expires
{new Date(request.expiresAt).toLocaleTimeString()}
+ {!user ? + : <>
GH
{user.name}{user.email}
} + {error &&
{error}
} +

GitHub credentials stay on the service. The local OCX receives only a revocable OpenCodex device credential.

+ } +
; +} + +function CentralLanding() { + return
+
opencodexRemote
+

Remote starts in your local OCX

+

Open ocx gui, choose Remote, and continue with GitHub. This site handles identity and device approval; your computer runs the connector.

+
1

Run ocx gui

2

Open Remote

3

Approve this computer here

+
; +} + +function InstanceAccess({ slug }: { slug: string }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + void getCurrentUser().then(setUser).catch(() => setUser(null)).finally(() => setLoading(false)); + }, []); + async function signIn() { + await authClient.signIn.social({ provider: "github", callbackURL: window.location.pathname }); + } + async function unlock() { + setBusy(true); + setError(null); + try { + if (!user) throw new Error("GitHub sign-in is required"); + const profile = await getRemoteAccessProfile(); + if (!profile.e2ee) throw new Error("Set the encrypted Remote password from local ocx gui first"); + const unlocked = await unlockRemoteEnvelope(password, user.githubNumericId, profile.e2ee); + const destination = await accessInstance(slug, unlocked.authSecret); + window.location.replace(`${destination}${vaultHandoff(unlocked.vault)}`); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Could not open Remote"); + } finally { + setBusy(false); + } + } + return
+
opencodexRemote access
+

{slug}.{instanceDomain}

+

Sign in with the GitHub account that owns this computer, then enter its separate Remote password.

+ {loading ?
Checking GitHub session
+ : !user ? + : user.status !== "active" ?
This GitHub account cannot access this Remote instance.
+ : <>
GH
{user.name}{user.email}
+ + } + {error &&
{error}
} +

Argon2id runs in this browser. The service receives a separate authentication secret, while the vault decryption key stays in this tab.

+
; +} + +function AdminDashboard() { + const [view, setView] = useState("instances"); + const [instances, setInstances] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [loading, setLoading] = useState(true); + const [menu, setMenu] = useState(false); + const [error, setError] = useState(null); + const [user, setUser] = useState(null); + const [invite, setInvite] = useState(""); + async function refresh() { + try { + const currentUser = await getCurrentUser(); + setUser(currentUser); + const data = currentUser.status === "active" ? await listInstances() : []; + setInstances(data); setSelectedId(current => current && data.some(item => item.id === current) ? current : data[0]?.id ?? null); setError(null); + } catch (reason) { setError(reason instanceof Error ? reason.message : "Could not load instances"); } + finally { setLoading(false); } + } + useEffect(() => { void refresh(); }, []); + const selected = useMemo(() => instances.find(item => item.id === selectedId) ?? null, [instances, selectedId]); + async function signIn() { await authClient.signIn.social({ provider: "github", callbackURL: "/admin" }); } + async function acceptInvite() { + try { await redeemInvite(invite); await refresh(); } + catch (reason) { setError(reason instanceof Error ? reason.message : "Invite rejected"); } + } + return
setMenu(false)} />
+ {loading ?
Loading instances
: error === "not found" ?

Private access

Sign in with the GitHub account that received an invitation.

: user?.status === "pending_invite" ?

Redeem your invite

Paste the one-time invitation token. It expires 24 hours after it was created.

setInvite(event.target.value)} placeholder="Invitation token" />{error && {error}}
: error ?
{error}
: view === "instances" ? setView("new")} /> : view === "new" ? setView("instances")} onCreated={refresh} /> : } +
; +} + +export function App() { + const hostname = window.location.hostname.toLowerCase(); + const suffix = `.${instanceDomain.toLowerCase()}`; + if (hostname.endsWith(suffix)) { + const slug = hostname.slice(0, -suffix.length); + if (/^[a-z0-9-]{1,63}$/.test(slug)) { + const centralOrigin = import.meta.env.VITE_PLATFORM_ORIGIN ?? `https://${instanceDomain}`; + return ; + } + } + const deviceMatch = window.location.pathname.match(/^\/connect\/([0-9a-f-]{36})$/i); + if (deviceMatch) return ; + const accessMatch = window.location.pathname.match(/^\/access\/([a-z0-9-]{1,63})$/i); + if (accessMatch) return ; + if (window.location.pathname !== "/admin") return ; + return ; +} diff --git a/platform/web/src/Workspace.tsx b/platform/web/src/Workspace.tsx new file mode 100644 index 000000000..aeb4440ed --- /dev/null +++ b/platform/web/src/Workspace.tsx @@ -0,0 +1,239 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { IconDeviceDesktop, IconLoader2, IconLock, IconShield, IconTerminal2 } from "@tabler/icons-react"; +import { Terminal } from "@xterm/xterm"; +import "@xterm/xterm/css/xterm.css"; +import { createTerminalSession, getWorkspace, type Workspace, type WorkspaceDevice } from "./api"; +import { + BrowserTerminalCipher, + base64UrlDecode, + base64UrlEncode, + type CommandProfile, + type RemoteVault, +} from "./e2ee"; + +const encoder = new TextEncoder(); +const VAULT_HANDOFF_PREFIX = "#ocxr-vault="; +const VAULT_SESSION_KEY = "ocxr.remote.vault.v1"; + +function validVault(value: unknown): value is RemoteVault { + if (!value || typeof value !== "object") return false; + const vault = value as Partial; + try { + return vault.version === 1 + && typeof vault.vaultKey === "string" + && base64UrlDecode(vault.vaultKey).length === 32 + && typeof vault.rootPrivateKeyPem === "string" + && vault.rootPrivateKeyPem.includes("BEGIN PRIVATE KEY") + && vault.rootPrivateKeyPem.length <= 2048; + } catch { + return false; + } +} + +export function vaultHandoff(vault: RemoteVault): string { + return `${VAULT_HANDOFF_PREFIX}${base64UrlEncode(encoder.encode(JSON.stringify(vault)))}`; +} + +function loadVault(): RemoteVault | null { + try { + if (window.location.hash.startsWith(VAULT_HANDOFF_PREFIX)) { + const parsed = JSON.parse(new TextDecoder().decode(base64UrlDecode( + window.location.hash.slice(VAULT_HANDOFF_PREFIX.length), + ))) as unknown; + if (!validVault(parsed)) throw new Error("invalid vault handoff"); + window.sessionStorage.setItem(VAULT_SESSION_KEY, JSON.stringify(parsed)); + window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}`); + return parsed; + } + const stored = window.sessionStorage.getItem(VAULT_SESSION_KEY); + if (!stored) return null; + const parsed = JSON.parse(stored) as unknown; + return validVault(parsed) ? parsed : null; + } catch { + window.sessionStorage.removeItem(VAULT_SESSION_KEY); + window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}`); + return null; + } +} + +function appFrame(kind: number, body = new Uint8Array()): Uint8Array { + const frame = new Uint8Array(1 + body.length); + frame[0] = kind; + frame.set(body, 1); + return frame; +} + +function TerminalPane({ + sessionId, + websocketPath, + profile, + device, + vault, + close, +}: { + sessionId: string; + websocketPath: string; + profile: CommandProfile; + device: WorkspaceDevice; + vault: RemoteVault; + close: () => void; +}) { + const container = useRef(null); + + useEffect(() => { + if (!container.current) return; + const terminal = new Terminal({ + cursorBlink: true, + convertEol: true, + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", + fontSize: 14, + theme: { background: "#0c0d0f", foreground: "#ececef", cursor: "#79d49d" }, + scrollback: 5_000, + cols: 80, + rows: 24, + }); + terminal.open(container.current); + terminal.writeln(`\x1b[2mConnecting securely to ${device.name}…\x1b[0m`); + const socketUrl = new URL(websocketPath, window.location.href); + socketUrl.protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const socket = new WebSocket(socketUrl); + socket.binaryType = "arraybuffer"; + let cipher: BrowserTerminalCipher | null = null; + let disposed = false; + let sendQueue = Promise.resolve(); + let receiveQueue = Promise.resolve(); + + function encryptedSend(plaintext: Uint8Array) { + sendQueue = sendQueue.then(async () => { + if (!cipher || socket.readyState !== WebSocket.OPEN) return; + socket.send(await cipher.encrypt(plaintext)); + }).catch(error => terminal.writeln(`\r\n\x1b[31m${error instanceof Error ? error.message : "Secure send failed"}\x1b[0m`)); + } + + const input = terminal.onData(data => encryptedSend(appFrame(1, encoder.encode(data)))); + const resize = terminal.onResize(size => { + const dimensions = new Uint8Array(4); + const view = new DataView(dimensions.buffer); + view.setUint16(0, size.cols, false); + view.setUint16(2, size.rows, false); + encryptedSend(appFrame(2, dimensions)); + }); + const observer = new ResizeObserver(entries => { + const box = entries[0]?.contentRect; + if (!box) return; + terminal.resize( + Math.max(20, Math.min(240, Math.floor((box.width - 20) / 8.5))), + Math.max(5, Math.min(100, Math.floor((box.height - 16) / 18))), + ); + }); + observer.observe(container.current); + + socket.addEventListener("open", () => { + void BrowserTerminalCipher.handshake(socket, sessionId, profile, device, vault).then(ready => { + if (disposed) return; + cipher = ready; + terminal.clear(); + terminal.focus(); + encryptedSend(appFrame(2, Uint8Array.of( + terminal.cols >> 8, terminal.cols & 255, + terminal.rows >> 8, terminal.rows & 255, + ))); + }).catch(error => { + terminal.writeln(`\r\n\x1b[31m${error instanceof Error ? error.message : "Secure handshake failed"}\x1b[0m`); + socket.close(1008, "handshake failed"); + }); + }); + socket.addEventListener("message", event => { + if (!cipher || !(event.data instanceof ArrayBuffer)) return; + receiveQueue = receiveQueue.then(async () => { + const plaintext = await cipher!.decrypt(event.data); + const kind = plaintext[0]; + if (kind === 3) terminal.write(plaintext.subarray(1)); + else if (kind === 4 && plaintext.length === 5) { + const code = new DataView(plaintext.buffer, plaintext.byteOffset + 1, 4).getUint32(0, false); + terminal.writeln(`\r\n\x1b[2mProcess exited with code ${code}.\x1b[0m`); + } else throw new Error("Remote computer sent an invalid application frame"); + }).catch(error => { + terminal.writeln(`\r\n\x1b[31m${error instanceof Error ? error.message : "Secure receive failed"}\x1b[0m`); + socket.close(1008, "encrypted frame rejected"); + }); + }); + socket.addEventListener("close", () => { + if (!disposed) terminal.writeln("\r\n\x1b[2mRemote computer disconnected.\x1b[0m"); + }); + return () => { + disposed = true; + observer.disconnect(); + input.dispose(); + resize.dispose(); + socket.close(1000, "terminal closed"); + terminal.dispose(); + }; + }, [sessionId, websocketPath, profile, device, vault]); + + return
+
{device.name}{profile}
+
+
; +} + +export function WorkspaceApp({ slug, centralOrigin }: { slug: string; centralOrigin: string }) { + const [vault] = useState(loadVault); + const [workspace, setWorkspace] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + const [active, setActive] = useState<{ + sessionId: string; websocketPath: string; profile: CommandProfile; device: WorkspaceDevice; + } | null>(null); + + useEffect(() => { + if (!vault) return; + void getWorkspace().then(setWorkspace).catch(reason => setError( + reason instanceof Error ? reason.message : "Could not load Remote workspace", + )); + }, [vault]); + + const online = useMemo(() => workspace?.devices.filter(device => device.relayOnline) ?? [], [workspace]); + + async function open(device: WorkspaceDevice, profile: CommandProfile) { + setBusy(`${device.id}:${profile}`); + setError(null); + try { + const created = await createTerminalSession(device.id, profile); + setActive({ sessionId: created.session.id, websocketPath: created.websocketPath, profile, device }); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Could not open terminal"); + } finally { + setBusy(null); + } + } + + function lock() { + window.sessionStorage.removeItem(VAULT_SESSION_KEY); + window.location.replace(`${centralOrigin}/access/${encodeURIComponent(slug)}`); + } + + if (!vault) return
+

Remote is locked

+

The encryption key is kept only in this browser tab. Unlock again with GitHub and your Remote password.

+ Unlock Remote +
; + return
+
opencodex{slug} Remote
+
+
End-to-end encrypted workspace

Your computers

Terminal plaintext stays between this browser and your computer. The relay sees only bounded ciphertext frames.

{online.length} of {workspace?.devices.length ?? 0} online
+ {error &&
{error}
} + {!workspace ?
Loading encrypted workspace
+ :
+ {workspace.devices.map(device =>
+
{device.name}{device.platform}
{device.relayOnline ? "Online" : "Offline"}
+
+ {(["shell", "codex", "claude"] as const).map(profile => )} +
+
)} + {!workspace.devices.length &&
No paired computersPair this OCX from the local Remote tab first.
} +
} + {active && setActive(null)} />} +
+
; +} diff --git a/platform/web/src/api.ts b/platform/web/src/api.ts new file mode 100644 index 000000000..6f56698ec --- /dev/null +++ b/platform/web/src/api.ts @@ -0,0 +1,183 @@ +export type InstanceStatus = "pending" | "provisioning" | "awaiting_agent" | "connecting" | "online" | "degraded" | "offline" | "suspending" | "suspended" | "deleting" | "delete_failed" | "deleted"; + +export interface Instance { + id: string; + ownerId: string; + name: string; + slug: string; + privateHostname: string; + status: InstanceStatus; + createdAt: string; + updatedAt: string; +} + +const demoInstances: Instance[] = [ + { + id: "a1d31b31-197c-49a8-b3b0-17f0f8cab001", + ownerId: "demo-user", + name: "home-server", + slug: "home-server", + privateHostname: "a3f9d7c1e2b4.ocxr.internal", + status: "online", + createdAt: new Date(Date.now() - 86_400_000).toISOString(), + updatedAt: new Date(Date.now() - 12_000).toISOString(), + }, + { + id: "a1d31b31-197c-49a8-b3b0-17f0f8cab002", + ownerId: "demo-user", + name: "dev-vps", + slug: "dev-vps", + privateHostname: "7c12e6f4a5b8.ocxr.internal", + status: "degraded", + createdAt: new Date(Date.now() - 172_800_000).toISOString(), + updatedAt: new Date(Date.now() - 180_000).toISOString(), + }, +]; + +const demo = import.meta.env.VITE_REMOTE_DEMO === "true"; + +export interface CurrentUser { + id: string; name: string; email: string; role: "user" | "admin"; + githubNumericId: string; + status: "active" | "pending_invite" | "suspended"; +} + +export interface RemoteAccessProfile { + passwordSet: boolean; + e2ee: import("./e2ee").RemoteE2eeEnvelope | null; +} + +export interface WorkspaceDevice { + id: string; + name: string; + platform: string; + signingPublicKey: string; + ecdhPublicKey: string | null; + relayOnline: boolean; + lastSeenAt: string | null; +} + +export interface Workspace { + instance: { id: string; name: string; slug: string; status: "online" | "offline" }; + devices: WorkspaceDevice[]; + limits: { maxSessionsPerDevice: number; maxFrameBytes: number }; +} + +export interface DeviceAuthorizationRequest { + id: string; + userCode: string; + deviceName: string; + platform: string; + expiresAt: string; + status: "pending" | "approved" | "expired" | "consumed"; +} + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(path, { + ...init, + credentials: "same-origin", + headers: { "content-type": "application/json", ...init?.headers }, + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.error ?? `Request failed (${response.status})`); + return body as T; +} + +export async function listInstances(): Promise { + if (demo) return structuredClone(demoInstances); + return (await request<{ instances: Instance[] }>("/api/v1/instances")).instances; +} + +export async function getInstance(instanceId: string): Promise { + if (demo) { + const instance = demoInstances.find(item => item.id === instanceId); + if (!instance) throw new Error("not found"); + return structuredClone(instance); + } + return (await request<{ instance: Instance }>(`/api/v1/instances/${instanceId}`)).instance; +} + +export async function getCurrentUser(): Promise { + if (demo) return { id: "demo-user", name: "Octo Cat", email: "octocat@example.test", githubNumericId: "1", role: "admin", status: "active" }; + return (await request<{ user: CurrentUser }>("/api/v1/me")).user; +} + +export async function getRemoteAccessProfile(): Promise { + return (await request<{ profile: RemoteAccessProfile }>("/api/v1/remote/profile")).profile; +} + +export async function getDeviceAuthorizationRequest(id: string): Promise { + return (await request<{ request: DeviceAuthorizationRequest }>(`/api/v1/device-links/${id}`)).request; +} + +export async function approveDeviceAuthorizationRequest(id: string): Promise { + return (await request<{ request: DeviceAuthorizationRequest }>(`/api/v1/device-links/${id}/approve`, { + method: "POST", + body: "{}", + })).request; +} + +export async function redeemInvite(token: string): Promise { + if (demo) return; + await request("/api/v1/invites/redeem", { method: "POST", body: JSON.stringify({ token }) }); +} + +export async function createInstance(name: string, slug: string): Promise { + if (demo) { + return { + id: crypto.randomUUID(), ownerId: "demo-user", name, slug, + privateHostname: `${crypto.randomUUID().replaceAll("-", "")}.ocxr.internal`, + status: "awaiting_agent", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }; + } + return (await request<{ instance: Instance }>("/api/v1/instances", { method: "POST", body: JSON.stringify({ name, slug }) })).instance; +} + +export async function createPairingCode(instanceId: string): Promise<{ code: string; expiresAt: string }> { + if (demo) return { code: "7K4M2P9Q6RXC", expiresAt: new Date(Date.now() + 10 * 60_000).toISOString() }; + return request(`/api/v1/instances/${instanceId}/pairing-code`, { method: "POST", body: "{}" }); +} + +export async function openInstance(instanceId: string, password: string): Promise { + if (demo) return "#demo-instance"; + return (await request<{ url: string }>(`/api/v1/instances/${instanceId}/authorize`, { + method: "POST", + body: JSON.stringify({ password }), + })).url; +} + +export async function accessInstance(slug: string, authSecret: string): Promise { + return (await request<{ url: string }>(`/api/v1/remote/access/${encodeURIComponent(slug)}`, { + method: "POST", + body: JSON.stringify({ authSecret }), + })).url; +} + +export async function getWorkspace(): Promise { + return request("/api/v1/workspace"); +} + +export async function createTerminalSession( + deviceId: string, + commandProfile: import("./e2ee").CommandProfile, +): Promise<{ session: { id: string }; websocketPath: string }> { + return request("/api/v1/terminal-sessions", { + method: "POST", + body: JSON.stringify({ deviceId, commandProfile }), + }); +} + +export async function issueToken(instanceId: string): Promise<{ token: string; expiresAt: string }> { + if (demo) return { token: `ocxr_${"demo".repeat(11)}`, expiresAt: new Date(Date.now() + 30 * 86_400_000).toISOString() }; + return request(`/api/v1/instances/${instanceId}/tokens`, { method: "POST", body: JSON.stringify({ name: "Dashboard token" }) }); +} + +export async function suspendInstance(instanceId: string): Promise { + if (demo) return; + await request(`/api/v1/instances/${instanceId}/suspend`, { method: "POST", body: "{}" }); +} + +export async function deleteInstance(instanceId: string): Promise { + if (demo) return; + await request(`/api/v1/instances/${instanceId}`, { method: "DELETE" }); +} diff --git a/platform/web/src/auth-client.ts b/platform/web/src/auth-client.ts new file mode 100644 index 000000000..e5a31f8be --- /dev/null +++ b/platform/web/src/auth-client.ts @@ -0,0 +1,3 @@ +import { createAuthClient } from "better-auth/react"; + +export const authClient = createAuthClient({ baseURL: window.location.origin }); diff --git a/platform/web/src/e2ee.ts b/platform/web/src/e2ee.ts new file mode 100644 index 000000000..aa3636465 --- /dev/null +++ b/platform/web/src/e2ee.ts @@ -0,0 +1,312 @@ +import { argon2idAsync } from "@noble/hashes/argon2.js"; +import { hkdf } from "@noble/hashes/hkdf.js"; +import { sha256 } from "@noble/hashes/sha2.js"; + +export interface RemoteE2eeEnvelope { + version: "ocx-e2ee-v1"; + salt: string; + nonce: string; + ciphertext: string; + rootPublicKey: string; + kdf: { + algorithm: "argon2id"; + memoryKiB: number; + iterations: number; + parallelism: number; + outputLength: 32; + }; +} + +export interface RemoteVault { + version: 1; + vaultKey: string; + rootPrivateKeyPem: string; +} + +export interface RemoteDeviceIdentity { + id: string; + signingPublicKey: string; +} + +export type CommandProfile = "shell" | "codex" | "claude"; + +const encoder = new TextEncoder(); + +export function base64UrlDecode(value: string): Uint8Array { + if (!/^[A-Za-z0-9_-]*$/.test(value)) throw new Error("invalid base64url data"); + const padded = value.replaceAll("-", "+").replaceAll("_", "/").padEnd(Math.ceil(value.length / 4) * 4, "="); + const binary = atob(padded); + return Uint8Array.from(binary, character => character.charCodeAt(0)); +} + +export function base64UrlEncode(value: Uint8Array): string { + let binary = ""; + for (let offset = 0; offset < value.length; offset += 0x8000) { + binary += String.fromCharCode(...value.subarray(offset, offset + 0x8000)); + } + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} + +function concat(...values: Uint8Array[]): Uint8Array { + const result = new Uint8Array(values.reduce((total, value) => total + value.length, 0)); + let offset = 0; + for (const value of values) { + result.set(value, offset); + offset += value.length; + } + return result; +} + +function arrayBuffer(value: Uint8Array): ArrayBuffer { + return Uint8Array.from(value).buffer; +} + +function pemDer(value: string, label: string): Uint8Array { + const match = value.match(new RegExp(`-----BEGIN ${label}-----([\\s\\S]+?)-----END ${label}-----`)); + if (!match) throw new Error(`invalid ${label.toLowerCase()}`); + return Uint8Array.from(atob(match[1].replace(/\s/g, "")), character => character.charCodeAt(0)); +} + +function accountAad(githubNumericId: string): Uint8Array { + return encoder.encode(`ocx-e2ee-v1\0github:${githubNumericId}`); +} + +export async function unlockRemoteEnvelope( + password: string, + githubNumericId: string, + envelope: RemoteE2eeEnvelope, +): Promise<{ authSecret: string; vault: RemoteVault }> { + if (password.length < 10 || password.length > 128) throw new Error("Remote password must be 10 to 128 characters"); + if ( + envelope.version !== "ocx-e2ee-v1" + || envelope.kdf.algorithm !== "argon2id" + || envelope.kdf.outputLength !== 32 + || envelope.kdf.memoryKiB !== 65_536 + || envelope.kdf.iterations !== 3 + || envelope.kdf.parallelism !== 1 + ) throw new Error("Unsupported Remote encryption profile"); + const salt = base64UrlDecode(envelope.salt); + const nonce = base64UrlDecode(envelope.nonce); + const ciphertext = base64UrlDecode(envelope.ciphertext); + if (salt.length !== 16 || nonce.length !== 12 || ciphertext.length < 17 || ciphertext.length > 4096) { + throw new Error("Invalid Remote encryption envelope"); + } + const root = await argon2idAsync(encoder.encode(password), salt, { + t: envelope.kdf.iterations, + m: envelope.kdf.memoryKiB, + p: envelope.kdf.parallelism, + dkLen: 32, + maxmem: 72 * 1024 * 1024, + asyncTick: 10, + }); + try { + const authSecret = hkdf(sha256, root, salt, encoder.encode("opencodex remote auth v1"), 32); + const wrapKey = hkdf(sha256, root, salt, encoder.encode("opencodex remote vault wrap v1"), 32); + try { + const aes = await crypto.subtle.importKey("raw", arrayBuffer(wrapKey), "AES-GCM", false, ["decrypt"]); + const plaintext = new Uint8Array(await crypto.subtle.decrypt({ + name: "AES-GCM", + iv: arrayBuffer(nonce), + additionalData: arrayBuffer(accountAad(githubNumericId)), + tagLength: 128, + }, aes, arrayBuffer(ciphertext))); + try { + const vault = JSON.parse(new TextDecoder().decode(plaintext)) as Partial; + if ( + vault.version !== 1 + || typeof vault.vaultKey !== "string" + || base64UrlDecode(vault.vaultKey).length !== 32 + || typeof vault.rootPrivateKeyPem !== "string" + || vault.rootPrivateKeyPem.length > 2048 + ) throw new Error("Invalid Remote vault"); + return { authSecret: base64UrlEncode(authSecret), vault: vault as RemoteVault }; + } finally { + plaintext.fill(0); + } + } finally { + wrapKey.fill(0); + } + } catch { + throw new Error("Remote password is incorrect or the encrypted vault is damaged"); + } finally { + root.fill(0); + } +} + +function uuidBytes(value: string): Uint8Array { + const hex = value.replaceAll("-", ""); + if (!/^[0-9a-f]{32}$/i.test(hex)) throw new Error("invalid terminal session ID"); + return Uint8Array.from(hex.match(/.{2}/g)!, byte => Number.parseInt(byte, 16)); +} + +function clientTranscript( + sessionId: string, + deviceId: string, + profile: CommandProfile, + publicKey: Uint8Array, + nonce: Uint8Array, +): Uint8Array { + return concat( + encoder.encode("ocx-terminal-client-v1\0"), + uuidBytes(sessionId), + uuidBytes(deviceId), + encoder.encode(profile), + Uint8Array.of(0), + publicKey, + nonce, + ); +} + +function agentTranscript(client: Uint8Array, publicKey: Uint8Array, nonce: Uint8Array): Uint8Array { + return concat(encoder.encode("ocx-terminal-agent-v1\0"), client, publicKey, nonce); +} + +function nonce(prefix: Uint8Array, counter: bigint): Uint8Array { + const result = new Uint8Array(12); + result.set(prefix, 0); + new DataView(result.buffer).setBigUint64(4, counter, false); + return result; +} + +function aad(sessionId: string, direction: number, counter: bigint): Uint8Array { + const counterBytes = new Uint8Array(8); + new DataView(counterBytes.buffer).setBigUint64(0, counter, false); + return concat( + encoder.encode("ocx-terminal-frame-v1\0"), + uuidBytes(sessionId), + Uint8Array.of(direction), + counterBytes, + ); +} + +export class BrowserTerminalCipher { + private sendCounter = 0n; + private receiveCounter = 0n; + + private constructor( + private readonly sessionId: string, + private readonly sendKey: CryptoKey, + private readonly receiveKey: CryptoKey, + private readonly sendNoncePrefix: Uint8Array, + private readonly receiveNoncePrefix: Uint8Array, + ) {} + + static async handshake( + socket: WebSocket, + sessionId: string, + profile: CommandProfile, + device: RemoteDeviceIdentity, + vault: RemoteVault, + ): Promise { + const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]); + const publicKey = new Uint8Array(await crypto.subtle.exportKey("spki", pair.publicKey)); + const clientNonce = crypto.getRandomValues(new Uint8Array(32)); + const transcript = clientTranscript(sessionId, device.id, profile, publicKey, clientNonce); + const rootPrivateKey = await crypto.subtle.importKey( + "pkcs8", + arrayBuffer(pemDer(vault.rootPrivateKeyPem, "PRIVATE KEY")), + { name: "Ed25519" }, + false, + ["sign"], + ); + const signature = new Uint8Array(await crypto.subtle.sign("Ed25519", rootPrivateKey, arrayBuffer(transcript))); + const response = await new Promise((resolve, reject) => { + const timeout = globalThis.setTimeout(() => reject(new Error("Remote computer handshake timed out")), 15_000); + const onMessage = (event: MessageEvent) => { + globalThis.clearTimeout(timeout); + socket.removeEventListener("close", onClose); + if (event.data instanceof ArrayBuffer) resolve(event.data); + else reject(new Error("Remote computer sent an invalid handshake")); + }; + const onClose = () => { + globalThis.clearTimeout(timeout); + socket.removeEventListener("message", onMessage); + reject(new Error("Remote computer disconnected during handshake")); + }; + socket.addEventListener("message", onMessage, { once: true }); + socket.addEventListener("close", onClose, { once: true }); + socket.send(encoder.encode(JSON.stringify({ + commandProfile: profile, + ephemeralPublicKey: base64UrlEncode(publicKey), + clientNonce: base64UrlEncode(clientNonce), + signature: base64UrlEncode(signature), + }))); + }); + const hello = JSON.parse(new TextDecoder().decode(response)) as { + ephemeralPublicKey?: unknown; serverNonce?: unknown; signature?: unknown; + }; + if (typeof hello.ephemeralPublicKey !== "string" || typeof hello.serverNonce !== "string" || typeof hello.signature !== "string") { + throw new Error("Remote computer sent an invalid handshake"); + } + const agentPublicBytes = base64UrlDecode(hello.ephemeralPublicKey); + const serverNonce = base64UrlDecode(hello.serverNonce); + if (serverNonce.length !== 32) throw new Error("Remote computer sent an invalid nonce"); + const deviceSigningKey = await crypto.subtle.importKey( + "spki", + arrayBuffer(base64UrlDecode(device.signingPublicKey)), + { name: "Ed25519" }, + false, + ["verify"], + ); + if (!await crypto.subtle.verify( + "Ed25519", + deviceSigningKey, + arrayBuffer(base64UrlDecode(hello.signature)), + arrayBuffer(agentTranscript(transcript, agentPublicBytes, serverNonce)), + )) throw new Error("Remote computer identity verification failed"); + const agentPublicKey = await crypto.subtle.importKey( + "spki", + arrayBuffer(agentPublicBytes), + { name: "ECDH", namedCurve: "P-256" }, + false, + [], + ); + const shared = new Uint8Array(await crypto.subtle.deriveBits({ + name: "ECDH", + public: agentPublicKey, + }, pair.privateKey, 256)); + const salt = sha256(concat(clientNonce, serverNonce)); + const sendKey = hkdf(sha256, shared, salt, encoder.encode("ocx terminal browser to agent key v1"), 32); + const receiveKey = hkdf(sha256, shared, salt, encoder.encode("ocx terminal agent to browser key v1"), 32); + const sendNoncePrefix = hkdf(sha256, shared, salt, encoder.encode("ocx terminal browser to agent nonce v1"), 4); + const receiveNoncePrefix = hkdf(sha256, shared, salt, encoder.encode("ocx terminal agent to browser nonce v1"), 4); + shared.fill(0); + return new BrowserTerminalCipher( + sessionId, + await crypto.subtle.importKey("raw", arrayBuffer(sendKey), "AES-GCM", false, ["encrypt"]), + await crypto.subtle.importKey("raw", arrayBuffer(receiveKey), "AES-GCM", false, ["decrypt"]), + sendNoncePrefix, + receiveNoncePrefix, + ); + } + + async encrypt(plaintext: Uint8Array): Promise { + const counter = this.sendCounter; + const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ + name: "AES-GCM", + iv: arrayBuffer(nonce(this.sendNoncePrefix, counter)), + additionalData: arrayBuffer(aad(this.sessionId, 0, counter)), + tagLength: 128, + }, this.sendKey, arrayBuffer(plaintext))); + this.sendCounter += 1n; + const result = new Uint8Array(8 + ciphertext.length); + new DataView(result.buffer).setBigUint64(0, counter, false); + result.set(ciphertext, 8); + return result.buffer; + } + + async decrypt(encrypted: ArrayBuffer): Promise { + const bytes = new Uint8Array(encrypted); + if (bytes.length < 24) throw new Error("Encrypted terminal frame is too short"); + const counter = new DataView(bytes.buffer, bytes.byteOffset, 8).getBigUint64(0, false); + if (counter !== this.receiveCounter) throw new Error("Replayed or out-of-order terminal frame"); + const plaintext = new Uint8Array(await crypto.subtle.decrypt({ + name: "AES-GCM", + iv: arrayBuffer(nonce(this.receiveNoncePrefix, counter)), + additionalData: arrayBuffer(aad(this.sessionId, 1, counter)), + tagLength: 128, + }, this.receiveKey, arrayBuffer(bytes.subarray(8)))); + this.receiveCounter += 1n; + return plaintext; + } +} diff --git a/platform/web/src/main.tsx b/platform/web/src/main.tsx new file mode 100644 index 000000000..7dbff5a72 --- /dev/null +++ b/platform/web/src/main.tsx @@ -0,0 +1,8 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render( + , +); diff --git a/platform/web/src/styles.css b/platform/web/src/styles.css new file mode 100644 index 000000000..45c0dee92 --- /dev/null +++ b/platform/web/src/styles.css @@ -0,0 +1,140 @@ +:root { + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #f4f4f5; + background: #101113; + font-synthesis: none; + --bg: #101113; + --sidebar: #111214; + --panel: #1a1b1e; + --panel-soft: #1f2023; + --border: #34363b; + --border-soft: #2a2c31; + --muted: #b2b3b8; + --green: #79d49d; + --yellow: #f3c84b; + --red: #ff4c4c; +} +* { box-sizing: border-box; } +body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--bg); } +button, input { font: inherit; } +button { color: inherit; } +.app-shell { min-height: 100vh; display: grid; grid-template-columns: 258px 1fr; } +.sidebar { position: fixed; inset: 0 auto 0 0; width: 258px; padding: 27px 13px 23px; border-right: 1px solid #2b2d31; background: var(--sidebar); display: flex; flex-direction: column; z-index: 20; } +.brand { display: flex; align-items: center; gap: 12px; padding: 0 13px 27px; } +.brand img { width: 31px; height: 31px; object-fit: contain; } +.brand div { display: grid; line-height: 1.15; } +.brand strong { font-size: 19px; letter-spacing: -.3px; } +.brand span { color: #a5a6aa; font-size: 14px; margin-top: 3px; } +nav { display: grid; gap: 7px; } +nav button, .collapse { border: 0; background: transparent; min-height: 53px; padding: 0 15px; border-radius: 10px; display: flex; align-items: center; gap: 17px; cursor: pointer; text-align: left; font-weight: 560; } +nav button svg { width: 23px; color: #c8c9cc; } +nav button.active { background: #323438; box-shadow: inset 0 0 0 1px rgba(255,255,255,.025); } +.collapse { margin-top: auto; color: #b8b9bd; font-weight: 450; } +.collapse svg { width: 24px; border: 1px solid #9a9ba0; border-radius: 50%; padding: 3px; } +.mobile-close, .mobile-menu { display: none; } +main { grid-column: 2; min-width: 0; padding: 26px 24px 31px 27px; } +.page-header { min-height: 72px; display: flex; align-items: flex-start; justify-content: space-between; padding: 8px 12px 15px 5px; } +h1 { margin: 0; font-size: 28px; letter-spacing: -.65px; } +h2 { margin: 0; font-size: 22px; letter-spacing: -.35px; } +.account { display: flex; align-items: center; gap: 13px; margin-top: -1px; } +.account .divider { height: 63px; width: 1px; background: #303237; margin: -9px 7px -5px 8px; } +.avatar { width: 31px; height: 31px; border-radius: 50%; background: #f5f5f5; color: #17181a; display: grid; place-items: center; font-size: 10px; font-weight: 800; } +.primary { min-height: 46px; border: 0; border-radius: 8px; padding: 0 18px; background: #f5f5f5; color: #141517; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 8px; } +.primary { text-decoration: none; } +.primary svg { width: 18px; } +.instance-layout { display: grid; grid-template-columns: minmax(450px, .94fr) minmax(530px, 1fr); gap: 13px; height: calc(100vh - 116px); min-height: 650px; } +.instance-list, .detail-card, .onboarding-card, .secondary-card { border: 1px solid var(--border); background: var(--panel); border-radius: 12px; overflow: hidden; } +.table-head, .instance-row { display: grid; grid-template-columns: 1.05fr 1.55fr .95fr 1.05fr; align-items: center; min-height: 64px; padding: 0 22px; column-gap: 11px; } +.table-head { color: #c5c6ca; font-size: 14px; } +.instance-row { width: 100%; border: 0; border-top: 1px solid var(--border-soft); color: #e7e7e9; background: transparent; text-align: left; cursor: pointer; } +.instance-row.selected { background: #292a2d; border: 1px solid #414348; border-radius: 9px; } +.instance-name { display: flex; align-items: center; gap: 9px; } +.tiny-dot, .status-dot { width: 7px; height: 7px; border-radius: 50%; background: #7b7d82; display: inline-block; } +.tiny-dot.status-online, .status-online .status-dot { background: var(--green); } +.tiny-dot.status-degraded, .status-degraded .status-dot, .tiny-dot.status-awaiting_agent { background: var(--yellow); } +.status { display: inline-flex; align-items: center; gap: 9px; color: #c9cacf; white-space: nowrap; } +.status-online { color: var(--green); }.status-degraded, .status-awaiting_agent, .status-connecting { color: var(--yellow); }.status-delete_failed { color: var(--red); } +.detail-card { padding: 31px 34px 28px; overflow-y: auto; position: relative; } +.detail-heading { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; } +.detail-heading h2 { font-size: 24px; margin-bottom: 9px; } +.health-row { margin-top: 45px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 17px; border-bottom: 1px solid var(--border); padding: 0 2px 46px; } +.health-node { min-width: 0; text-align: center; display: grid; justify-items: center; gap: 9px; } +.health-icon { width: 72px; height: 72px; border-radius: 50%; border: 1px solid #5a5c61; display: grid; place-items: center; position: relative; margin-bottom: 9px; } +.health-icon > svg { color: #b9bbc0; width: 27px; } +.health-icon .check, .health-icon .warn { position: absolute; right: -1px; bottom: -1px; width: 22px; height: 22px; border-radius: 50%; background: var(--green); color: #17221b; display: grid; place-items: center; font-size: 12px; font-weight: 800; } +.health-icon .warn { background: var(--yellow); }.health-icon .check svg { width: 14px; } +.health-node strong { font-size: 15px; }.health-node .healthy { color: var(--green); }.health-node .warning { color: var(--yellow); } +.facts { margin: 19px 0 0; }.facts div { min-height: 60px; display: flex; align-items: center; justify-content: space-between; }.facts dt { color: #d0d1d3; }.facts dd { margin: 0; display: flex; align-items: center; gap: 10px; } +.icon-button { background: none; border: 0; padding: 4px; cursor: pointer; }.icon-button svg { width: 18px; color: #c4c5c8; } +.actions { margin-top: 19px; padding-top: 35px; border-top: 1px solid var(--border); display: grid; gap: 15px; }.actions > span { margin-bottom: 2px; color: #c9cace; } +.actions button { height: 50px; border: 1px solid #3a3c41; border-radius: 8px; background: transparent; text-align: left; display: flex; align-items: center; gap: 14px; padding: 0 18px; cursor: pointer; font-weight: 550; }.actions button svg { width: 22px; }.actions .danger { color: var(--red); border-color: #922d2d; margin-top: 1px; } +.toast { position: absolute; top: 88px; right: 34px; background: #292b2e; border: 1px solid #4b4d52; padding: 10px 14px; border-radius: 8px; font-size: 13px; } +.empty, .empty-detail, .loading, .load-error { height: 70%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 13px; color: var(--muted); }.empty svg { width: 38px; }.empty .primary { margin-top: 9px; }.loading svg { animation: spin 1s linear infinite; }.load-error button { border: 1px solid var(--border); background: var(--panel); border-radius: 7px; padding: 8px 15px; cursor: pointer; } +.stepper { margin: 20px auto 28px; max-width: 860px; display: grid; grid-template-columns: repeat(3, 1fr); }.step { display: grid; justify-items: center; gap: 13px; color: #e9e9eb; position: relative; }.step:not(:last-child)::after { content: ""; position: absolute; height: 1px; background: #5b5d62; width: calc(100% - 75px); left: calc(50% + 39px); top: 19px; }.step span { width: 40px; height: 40px; border: 1px solid #5c5e63; border-radius: 50%; display: grid; place-items: center; z-index: 1; background: var(--bg); }.step span svg { width: 19px; }.step.current span { border-color: #f5f5f5; box-shadow: inset 0 0 0 1px #f5f5f5; } +.onboarding-card { min-height: 630px; padding: 36px 32px 31px; }.instance-form, .reserved { display: grid; grid-template-columns: 1fr 1fr; gap: 25px 38px; }.instance-form label, .reserved label { display: grid; gap: 12px; color: #d0d1d4; }.instance-form input, .reserved input, .address-input { height: 48px; width: 100%; border: 1px solid #44464b; border-radius: 7px; background: #1b1c1f; color: #f2f2f3; padding: 0 17px; outline: none; }.instance-form input:focus { border-color: #85878d; }.address-input { padding: 0; display: flex; align-items: center; overflow: hidden; }.address-input input { border: 0; flex: 1; }.address-input span { color: #a9aaae; padding-right: 16px; }.instance-form p, .reserved p { grid-column: 1/-1; color: var(--muted); margin: -8px 0 3px; }.form-error { grid-column: 1/-1; color: #ff8e8e; } +.reserved { padding-bottom: 31px; border-bottom: 1px solid var(--border); }.install-grid { display: grid; grid-template-columns: 1fr 1fr; margin-top: 32px; }.install-column { padding-right: 35px; }.verify-column { border-left: 1px solid var(--border); padding-left: 35px; }.install-grid h2 { font-size: 20px; }.install-grid p { color: var(--muted); margin: 8px 0 20px; }.install-grid pre { margin: 0; min-height: 63px; border: 1px solid #4a4c51; border-radius: 8px; padding: 21px; overflow-x: auto; color: #f0f0f1; }.copy-command { float: right; margin-top: 12px; border: 1px solid #686a70; border-radius: 7px; background: transparent; min-height: 39px; padding: 0 14px; display: flex; align-items: center; gap: 8px; cursor: pointer; }.copy-command svg { width: 19px; }.install-column hr { clear: both; border: 0; border-top: 1px solid var(--border); margin: 80px 0 35px; }.pairing-code { border: 1px solid #494b50; border-radius: 8px; min-height: 66px; padding: 0 17px; display: flex; align-items: center; justify-content: space-between; }.pairing-code strong { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 21px; letter-spacing: 1px; }.pairing-code span { color: #b7b8bc; font-size: 13px; } +.verify-row { min-height: 82px; border-bottom: 1px solid var(--border); display: grid; grid-template-columns: 56px 1fr auto; align-items: center; gap: 17px; }.verify-row > span { width: 56px; height: 56px; border: 1px solid #4b4d52; border-radius: 50%; display: grid; place-items: center; }.verify-row svg { width: 24px; color: #bfc0c4; }.verify-row em { color: var(--yellow); font-style: normal; font-size: 14px; }.footer-actions { grid-column: 1/-1; border-top: 1px solid var(--border); margin-top: 35px; padding-top: 28px; display: flex; justify-content: flex-end; gap: 16px; }.footer-actions > button:not(.primary) { border: 1px solid #45474c; border-radius: 7px; background: transparent; padding: 0 22px; min-height: 48px; cursor: pointer; }.success-state { min-height: 520px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 15px; }.success-state > svg { width: 58px; height: 58px; color: var(--green); }.success-state p { color: var(--muted); max-width: 510px; } +.secondary-card { min-height: 500px; padding: 40px; }.secondary-card > svg { width: 35px; height: 35px; margin-bottom: 20px; }.secondary-card h2 { margin-bottom: 18px; }.secondary-card > p { color: var(--muted); line-height: 1.7; max-width: 700px; }.activity-row { min-height: 66px; display: grid; grid-template-columns: 150px 1fr auto; align-items: center; border-top: 1px solid var(--border); }.activity-row time { color: var(--muted); }.security-note { margin-top: 30px; border: 1px solid var(--border); border-radius: 8px; padding: 19px; display: flex; align-items: center; gap: 13px; }.security-note svg { width: 22px; color: var(--green); } +.access-card { min-height: calc(100vh - 60px); display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; gap: 16px; }.access-card > svg { width: 44px; height: 44px; }.access-card p { max-width: 430px; color: var(--muted); }.access-card input { width: min(440px, 90vw); height: 48px; border: 1px solid #44464b; border-radius: 7px; background: var(--panel); color: #f4f4f5; padding: 0 15px; } +.connect-shell { min-height: 100vh; display: grid; place-items: center; padding: 28px 18px; background: radial-gradient(circle at 50% 0, #202329 0, var(--bg) 48%); } +.connect-card { width: min(100%, 520px); border: 1px solid var(--border); border-radius: 14px; padding: 30px; background: #191a1d; box-shadow: 0 24px 70px #0007; } +.connect-card > svg { width: 34px; height: 34px; color: var(--muted); } +.connect-brand { display: flex; align-items: center; gap: 11px; padding-bottom: 25px; border-bottom: 1px solid var(--border-soft); } +.connect-brand img { width: 32px; height: 32px; object-fit: contain; } +.connect-brand div { display: grid; gap: 2px; }.connect-brand strong { font-size: 18px; }.connect-brand span { color: var(--muted); font-size: 13px; } +.connect-card header { padding: 28px 0 20px; }.connect-card header > span { color: var(--green); font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; } +.connect-card h1 { margin-top: 8px; font-size: 26px; }.connect-card header p, .connect-result p, .landing-card > p { color: var(--muted); line-height: 1.65; } +.connect-card code { color: #f3f3f5; background: #25272b; border: 1px solid #383a40; border-radius: 5px; padding: 2px 6px; } +.connect-code { display: grid; gap: 7px; padding: 15px 17px; border: 1px solid #4a4d53; border-radius: 9px; background: #212327; } +.connect-code span { color: var(--muted); font-size: 12px; }.connect-code strong { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 25px; letter-spacing: .08em; } +.connect-facts { margin: 18px 0; }.connect-facts div { display: flex; justify-content: space-between; gap: 18px; padding: 11px 0; border-top: 1px solid var(--border-soft); }.connect-facts dt { color: var(--muted); }.connect-facts dd { margin: 0; text-align: right; } +.connect-action { width: 100%; }.connect-action:disabled { opacity: .6; cursor: progress; } +.connect-account { display: flex; align-items: center; gap: 12px; padding: 13px; margin-bottom: 12px; border: 1px solid var(--border-soft); border-radius: 9px; }.connect-account > div { display: grid; min-width: 0; }.connect-account span:last-child { color: var(--muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; } +.connect-security { display: flex; align-items: flex-start; gap: 8px; margin: 18px 0 0; color: var(--muted); font-size: 12px; line-height: 1.55; }.connect-security svg { width: 16px; flex: 0 0 auto; } +.connect-result { display: grid; justify-items: center; text-align: center; padding: 46px 0 24px; }.connect-result > svg { width: 52px; height: 52px; color: var(--green); }.connect-result h1 { margin-top: 18px; } +.landing-card { text-align: left; }.landing-icon { display: block; margin: 34px 0 14px; color: var(--green) !important; }.landing-card > h1 { max-width: 410px; }.landing-steps { display: grid; grid-template-columns: 28px 1fr; align-items: center; gap: 9px 12px; margin-top: 25px; }.landing-steps > span { width: 28px; height: 28px; display: grid; place-items: center; border: 1px solid #4b4e54; border-radius: 50%; color: var(--green); font-size: 12px; font-weight: 700; }.landing-steps p { margin: 0; } +.access-instance-card h1 { overflow-wrap: anywhere; }.access-inline { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 18px 0; color: var(--muted); }.access-password { display: grid; gap: 8px; margin: 18px 0 12px; text-align: left; }.access-password span { color: var(--muted); font-size: 13px; font-weight: 650; }.access-password input { width: 100%; height: 48px; border: 1px solid #44464b; border-radius: 7px; background: var(--panel-soft); color: #f4f4f5; padding: 0 15px; } +.spin { animation: spin 1s linear infinite; } +.workspace-shell { min-height: 100vh; background: #0d0e10; color: #f4f4f5; } +.workspace-header { height: 76px; padding: 0 28px; border-bottom: 1px solid var(--border-soft); background: #131416; display: flex; align-items: center; justify-content: space-between; } +.workspace-header .connect-brand { border: 0; padding: 0; } +.workspace-header > button { min-height: 39px; border: 1px solid #414349; border-radius: 8px; padding: 0 14px; background: transparent; display: flex; align-items: center; gap: 8px; cursor: pointer; } +.workspace-header > button svg { width: 17px; } +.workspace-main { max-width: 1320px; margin: 0 auto; padding: 42px 28px 60px; } +.workspace-intro { display: flex; justify-content: space-between; gap: 35px; align-items: flex-end; } +.workspace-intro > div:first-child > span { color: var(--green); font-size: 13px; text-transform: uppercase; letter-spacing: .09em; font-weight: 750; } +.workspace-intro h1 { margin-top: 9px; font-size: 34px; } +.workspace-intro p { color: var(--muted); line-height: 1.6; margin: 10px 0 0; max-width: 700px; } +.workspace-security { border: 1px solid #34373c; background: #181a1d; border-radius: 10px; padding: 14px 17px; display: flex; align-items: center; gap: 11px; white-space: nowrap; } +.workspace-security svg { width: 20px; color: var(--green); } +.workspace-devices { margin-top: 28px; display: grid; grid-template-columns: repeat(auto-fit, minmax(330px, 1fr)); gap: 14px; } +.workspace-device { border: 1px solid #33353a; background: #17181b; border-radius: 12px; padding: 20px; } +.workspace-device-heading { display: grid; grid-template-columns: 47px 1fr auto; align-items: center; gap: 13px; } +.workspace-device-heading > span { width: 47px; height: 47px; border: 1px solid #3b3e43; border-radius: 10px; display: grid; place-items: center; color: #92949a; } +.workspace-device-heading > span.online { color: var(--green); border-color: #315643; background: #16241c; } +.workspace-device-heading > span svg { width: 24px; } +.workspace-device-heading > div { display: grid; min-width: 0; gap: 4px; } +.workspace-device-heading > div > span { color: var(--muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; } +.workspace-device-heading em { color: #85878c; font-size: 12px; font-style: normal; } +.workspace-device-heading .online ~ div + em { color: var(--green); } +.workspace-device-actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 20px; } +.workspace-device-actions button { min-height: 42px; border: 1px solid #3b3d42; border-radius: 7px; background: #202225; display: flex; align-items: center; justify-content: center; gap: 7px; cursor: pointer; font-size: 13px; } +.workspace-device-actions button:hover:not(:disabled) { background: #2b2d31; border-color: #5b5e64; } +.workspace-device-actions button:disabled { opacity: .4; cursor: not-allowed; } +.workspace-device-actions svg { width: 17px; } +.workspace-empty, .workspace-loading { min-height: 180px; border: 1px dashed #3a3d42; border-radius: 12px; color: var(--muted); display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 10px; } +.workspace-empty svg { width: 32px; } +.workspace-error { margin-top: 22px; border: 1px solid #7d3131; background: #2a1718; color: #ffaaaa; padding: 12px 15px; border-radius: 8px; } +.workspace-terminal-card { margin-top: 22px; border: 1px solid #383b40; border-radius: 12px; background: #0c0d0f; overflow: hidden; box-shadow: 0 24px 70px #0008; } +.workspace-terminal-card > header { min-height: 54px; padding: 0 15px 0 19px; border-bottom: 1px solid #303238; background: #18191c; display: flex; align-items: center; justify-content: space-between; } +.workspace-terminal-card > header > div { display: flex; align-items: center; gap: 10px; } +.workspace-terminal-card > header > div > span:last-child { color: var(--muted); font-size: 12px; text-transform: uppercase; } +.workspace-terminal-card > header button { border: 1px solid #414349; border-radius: 7px; background: transparent; padding: 7px 11px; cursor: pointer; font-size: 12px; } +.workspace-online-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 4px #79d49d18; } +.workspace-terminal { height: min(560px, calc(100vh - 225px)); min-height: 360px; padding: 9px 7px; } +.workspace-terminal .xterm { height: 100%; } +.workspace-terminal .xterm-viewport { scrollbar-color: #444 transparent; } +@keyframes spin { to { transform: rotate(360deg); } } +@media (max-width: 1120px) { .instance-layout { grid-template-columns: 1fr; height: auto; }.instance-list { min-height: 350px; }.detail-card { min-height: 720px; }.health-row { gap: 8px; }.table-head, .instance-row { grid-template-columns: 1fr 1.4fr 1fr; }.table-head span:last-child, .instance-row > span:last-child { display: none; } } +@media (max-width: 760px) { .app-shell { display: block; }.sidebar { transform: translateX(-100%); transition: transform .18s ease; box-shadow: 10px 0 30px #0008; }.sidebar-open { transform: translateX(0); }.mobile-close { display: grid; position: absolute; right: 10px; top: 10px; border: 0; background: transparent; }.mobile-menu { display: grid; place-items: center; position: absolute; left: 15px; top: 18px; z-index: 3; border: 1px solid var(--border); background: var(--panel); border-radius: 7px; width: 38px; height: 38px; }.mobile-menu svg { width: 20px; }.account > span, .account > svg { display: none; } main { padding: 18px 12px 24px; }.page-header { padding-left: 52px; align-items: center; }.page-header h1 { font-size: 24px; }.account .primary { padding: 0 13px; }.instance-layout { display: block; }.instance-list { margin-bottom: 12px; }.table-head, .instance-row { grid-template-columns: 1fr 1fr; }.table-head span:nth-child(2), .instance-row > span:nth-child(2) { display: none; }.detail-card { padding: 24px 18px; min-height: 0; }.detail-heading { flex-direction: column; }.health-row { grid-template-columns: 1fr 1fr; }.instance-form, .reserved, .install-grid { grid-template-columns: 1fr; }.onboarding-card { padding: 25px 18px; }.verify-column { border-left: 0; border-top: 1px solid var(--border); padding: 28px 0 0; }.install-column { padding-right: 0; }.stepper { margin-top: 14px; }.step strong { font-size: 12px; }.pairing-code { align-items: flex-start; flex-direction: column; padding: 14px; gap: 8px; }.activity-row { grid-template-columns: 120px 1fr; }.activity-row time { display: none; } } +@media (max-width: 760px) { .workspace-header { padding: 0 15px; }.workspace-main { padding: 28px 14px 40px; }.workspace-intro { display: grid; align-items: start; }.workspace-security { width: max-content; }.workspace-devices { grid-template-columns: 1fr; }.workspace-device-actions { grid-template-columns: 1fr; }.workspace-terminal { min-height: 430px; }.workspace-terminal-card > header strong { max-width: 120px; overflow: hidden; text-overflow: ellipsis; }.workspace-terminal-card > header > div > span:last-child { display: none; } } diff --git a/readme/README.ja.md b/readme/README.ja.md index fc8fdc020..236c96663 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -378,6 +378,14 @@ WebSocket トランスポートはデフォルトでオフです。Codex が HTT ### リモートアクセス +ダッシュボードには中央 `opencodexpages.me` MVP 用の招待制 **Remote** ページがあります。ローカル +`ocx gui` で GitHub からこの PC を承認し、別の Remote パスワードと hostname を設定して +Tunnel/Agent の provisioning 状態を確認します。GitHub OAuth トークンは保存せず PC にも送りません。 +管理者権限が必要な署名済み Linux helper installer と Windows/macOS helper はまだ提供されず、 +一般公開機能ではありません。**Super Sync も含まれません。** + +以下の LAN bind は中央 Remote サービスとは別の自己管理方式です。 + デフォルトで opencodex は `127.0.0.1`(ループバック)にバインドされ、追加の認証は不要です。 `"hostname": "0.0.0.0"` で LAN に公開する場合、opencodex は管理 API(`/api/*`)とデータプレーン (`/v1/responses`、`/v1/images/generations`、`/v1/images/edits`)の両方に bearer トークンを要求します: diff --git a/readme/README.ko.md b/readme/README.ko.md index ddb900a20..ddd7d6a07 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -392,6 +392,14 @@ WebSocket 전송은 기본적으로 꺼져 있습니다. Codex가 HTTP/SSE 대 ### 원격 접근 +대시보드에는 중앙 `opencodexpages.me` MVP용 초대형 **Remote** 페이지가 추가됐습니다. 로컬 +`ocx gui`에서 GitHub로 이 PC를 승인하고, 별도 Remote 비밀번호와 hostname을 만든 뒤 +Tunnel/Agent provisioning 상태를 확인합니다. GitHub OAuth 토큰은 저장하거나 PC로 보내지 않습니다. +관리자 권한이 필요한 서명된 Linux helper installer와 Windows/macOS helper는 아직 배포하지 않으므로 +일반 공개 기능이 아니며 **Super Sync는 포함하지 않습니다**. + +아래 LAN bind는 중앙 Remote 서비스와 별개의 자체 관리 방식입니다. + 기본적으로 opencodex는 `127.0.0.1`(루프백)에 바인딩되며 별도 인증이 필요 없습니다. `"hostname": "0.0.0.0"`으로 LAN에 노출할 경우, opencodex는 관리 API(`/api/*`)와 데이터 플레인 (`/v1/responses`, `/v1/images/generations`, `/v1/images/edits`) 모두에 bearer 토큰을 요구합니다: diff --git a/readme/README.ru.md b/readme/README.ru.md index 2f04d0634..b44153146 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -415,6 +415,15 @@ JSON обрезан или испорчен вручную), opencodex сохр ### Удалённый доступ +В дашборде появилась пригласительная страница **Remote** для центрального MVP +`opencodexpages.me`. Настройка начинается в локальном `ocx gui`: подтвердите ПК через GitHub, +задайте отдельный пароль Remote и hostname, затем отслеживайте provisioning Tunnel/Agent. +OAuth-токены GitHub не сохраняются и не передаются на ПК. Подписанный Linux helper installer с +правами администратора и помощники Windows/macOS ещё не поставляются; это не общий релиз и +**Super Sync в него не входит**. + +Приведённая ниже LAN-привязка — отдельный самостоятельно управляемый вариант без центрального Remote. + По умолчанию opencodex привязывается к `127.0.0.1` (loopback) и не требует дополнительной аутентификации. Если вы задаёте `"hostname": "0.0.0.0"`, открывая прокси в локальной сети, opencodex требует bearer-токен для защиты как управляющего API (`/api/*`), так и плоскости данных (`/v1/responses`, diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index b4ef7a8de..48dedabee 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -372,6 +372,14 @@ WebSocket 传输默认关闭。只有当你希望 Codex 使用 Responses WebSock ### 远程访问 +仪表盘新增了用于中央 `opencodexpages.me` MVP 的邀请制 **Remote** 页面。流程从本地 +`ocx gui` 开始:通过 GitHub 批准此电脑,设置独立 Remote 密码和 hostname,然后跟踪 +Tunnel/Agent 的 provisioning 状态。GitHub OAuth token 不会保存,也不会发送到电脑。 +需要管理员权限的签名 Linux helper installer 以及 Windows/macOS helper 尚未发布,因此这不是 +公开功能,且**不包含 Super Sync**。 + +下面的 LAN bind 是与中央 Remote 服务无关的自托管方式。 + 默认情况下 opencodex 绑定到 `127.0.0.1`(回环)且无需额外认证。 如果你设置 `"hostname": "0.0.0.0"` 把代理暴露到局域网,opencodex 会要求一个 bearer token 来同时保护管理 API(`/api/*`)和数据平面(`/v1/responses`、`/v1/images/generations`、`/v1/images/edits`): diff --git a/remote-agent/Cargo.lock b/remote-agent/Cargo.lock new file mode 100644 index 000000000..bedb9228e --- /dev/null +++ b/remote-agent/Cargo.lock @@ -0,0 +1,2551 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite 0.29.0", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.9", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "opencodex-remote-agent" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "anyhow", + "axum", + "base64", + "clap", + "ed25519-dalek", + "futures-util", + "hkdf", + "http-body-util", + "p256", + "portable-pty", + "rand 0.9.5", + "reqwest", + "serde", + "serde_json", + "sha2", + "tokio", + "tokio-tungstenite 0.28.0", + "tokio-util", + "tracing", + "tracing-subscriber", + "url", + "uuid", + "zeroize", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases 0.2.2", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.19", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.19", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases 0.2.2", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots 1.0.9", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serial2" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite 0.28.0", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.29.0", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.19", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror 2.0.19", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "serde", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/remote-agent/Cargo.toml b/remote-agent/Cargo.toml new file mode 100644 index 000000000..03e393106 --- /dev/null +++ b/remote-agent/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "opencodex-remote-agent" +version = "0.1.0" +edition = "2024" +license = "MIT" + +[dependencies] +anyhow = "1.0" +aes-gcm = "0.10" +axum = { version = "0.8", features = ["ws"] } +base64 = "0.22" +clap = { version = "4.5", features = ["derive"] } +ed25519-dalek = { version = "2.2", features = ["pem", "pkcs8"] } +futures-util = "0.3" +hkdf = "0.12" +http-body-util = "0.1" +p256 = { version = "0.13", features = ["ecdh", "pem", "pkcs8"] } +portable-pty = "0.9" +rand = "0.9" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +tokio = { version = "1", features = ["full"] } +tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] } +tokio-util = "0.7" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +url = "2.5" +uuid = { version = "1", features = ["serde", "v4"] } +zeroize = { version = "1", features = ["serde"] } + +[profile.release] +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/remote-agent/rust-toolchain.toml b/remote-agent/rust-toolchain.toml new file mode 100644 index 000000000..010f002cf --- /dev/null +++ b/remote-agent/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.97.1" +profile = "minimal" +components = ["rustfmt", "clippy"] diff --git a/remote-agent/src/assertion.rs b/remote-agent/src/assertion.rs new file mode 100644 index 000000000..9d3368460 --- /dev/null +++ b/remote-agent/src/assertion.rs @@ -0,0 +1,176 @@ +use std::{ + collections::HashMap, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{Context, Result, bail}; +use axum::http::{HeaderMap, Method, Uri}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use ed25519_dalek::{Signature, Verifier, VerifyingKey, pkcs8::DecodePublicKey}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use tokio::sync::Mutex; +use url::Url; + +use crate::config::AgentConfig; + +pub const ASSERTION_HEADER: &str = "x-opencodex-remote-assertion"; +const AUDIENCE: &str = "opencodex-management"; +const MAX_ASSERTION_BYTES: usize = 8 * 1024; + +#[derive(Deserialize)] +struct Header { + alg: String, + typ: String, + kid: String, +} + +#[derive(Deserialize)] +struct Claims { + iss: String, + aud: String, + instance_id: String, + user_id: String, + method: String, + path_sha256: String, + iat: i64, + exp: i64, + jti: String, +} + +#[derive(Clone)] +pub struct AssertionVerifier { + instance_id: String, + issuer: String, + keys: Arc>, + replay: Arc>>, +} + +impl AssertionVerifier { + pub fn new(config: &AgentConfig) -> Result { + let mut keys = HashMap::new(); + for key in &config.gateway.keys { + keys.insert( + key.kid.clone(), + VerifyingKey::from_public_key_pem(&key.public_key_pem) + .context("decode gateway public key")?, + ); + } + if keys.is_empty() { + bail!("at least one gateway public key is required"); + } + Ok(Self { + instance_id: config.instance_id.clone(), + issuer: config.gateway.issuer.clone(), + keys: Arc::new(keys), + replay: Arc::new(Mutex::new(HashMap::new())), + }) + } + + pub async fn verify(&self, method: &Method, uri: &Uri, headers: &HeaderMap) -> Result { + let token = headers + .get(ASSERTION_HEADER) + .and_then(|value| value.to_str().ok()) + .context("remote assertion required")?; + if token.len() > MAX_ASSERTION_BYTES { + bail!("remote assertion is too large"); + } + let segments: Vec<_> = token.split('.').collect(); + if segments.len() != 3 { + bail!("invalid remote assertion"); + } + let header: Header = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segments[0])?)?; + let claims: Claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segments[1])?)?; + if header.alg != "EdDSA" || header.typ != "JWT" { + bail!("unsupported assertion algorithm"); + } + if claims.iss != self.issuer + || claims.aud != AUDIENCE + || claims.instance_id != self.instance_id + { + bail!("assertion scope mismatch"); + } + if claims.user_id.is_empty() + || claims.user_id.len() > 128 + || claims.jti.is_empty() + || claims.jti.len() > 128 + { + bail!("invalid assertion identity"); + } + if claims.method != method.as_str() || claims.path_sha256 != normalized_path_hash(uri)? { + bail!("assertion request binding mismatch"); + } + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64; + if claims.iat > now + 5 + || claims.exp < now - 5 + || claims.exp <= claims.iat + || claims.exp - claims.iat > 30 + { + bail!("assertion expired"); + } + let key = self + .keys + .get(&header.kid) + .context("unknown assertion key")?; + let signature = Signature::from_slice(&URL_SAFE_NO_PAD.decode(segments[2])?)?; + key.verify( + format!("{}.{}", segments[0], segments[1]).as_bytes(), + &signature, + ) + .context("invalid assertion signature")?; + let mut replay = self.replay.lock().await; + replay.retain(|_, expiry| *expiry + 5 >= now); + if replay.contains_key(&claims.jti) { + bail!("assertion replayed"); + } + if replay.len() >= 4096 + && let Some(oldest) = replay + .iter() + .min_by_key(|(_, expiry)| *expiry) + .map(|(jti, _)| jti.clone()) + { + replay.remove(&oldest); + } + replay.insert(claims.jti, claims.exp); + Ok(claims.user_id) + } +} + +pub fn normalized_path_hash(uri: &Uri) -> Result { + let parsed = Url::parse(&format!( + "http://opencodex.invalid{}", + uri.path_and_query() + .map(|value| value.as_str()) + .unwrap_or("/") + ))?; + let mut pairs: Vec<(String, String)> = parsed + .query_pairs() + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect(); + pairs.sort_by(|left, right| left.0.cmp(&right.0)); + let mut canonical = parsed.path().to_string(); + if !pairs.is_empty() { + let query: String = url::form_urlencoded::Serializer::new(String::new()) + .extend_pairs(pairs) + .finish(); + canonical.push('?'); + canonical.push_str(&query); + } + Ok(format!("{:x}", Sha256::digest(canonical.as_bytes()))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonical_query_order_is_stable() { + let first: Uri = "/api/config?z=2&a=hello+world".parse().unwrap(); + let second: Uri = "/api/config?a=hello%20world&z=2".parse().unwrap(); + assert_eq!( + normalized_path_hash(&first).unwrap(), + normalized_path_hash(&second).unwrap() + ); + } +} diff --git a/remote-agent/src/config.rs b/remote-agent/src/config.rs new file mode 100644 index 000000000..01047916a --- /dev/null +++ b/remote-agent/src/config.rs @@ -0,0 +1,145 @@ +use std::{ + fs, + net::Ipv4Addr, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use ed25519_dalek::SigningKey; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayKey { + pub kid: String, + pub public_key_pem: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayConfig { + pub issuer: String, + pub keys: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentConfig { + pub control_plane_url: String, + pub agent_id: String, + pub instance_id: String, + pub signing_key: String, + pub tunnel_token_file: PathBuf, + pub gateway: GatewayConfig, + pub private_origin_ip: Ipv4Addr, + #[serde(default = "default_opencodex_url")] + pub opencodex_url: String, + #[serde(default = "default_ingress")] + pub ingress: String, +} + +fn default_opencodex_url() -> String { + "http://127.0.0.1:10100".into() +} +fn default_ingress() -> String { + "127.0.0.1:10101".into() +} + +impl AgentConfig { + pub fn load(path: &Path) -> Result { + let bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?; + let config: Self = serde_json::from_slice(&bytes).context("parse agent config")?; + config.signing_key()?; + config.validate_private_origin_ip()?; + Ok(config) + } + + pub fn save(&self, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let temporary = path.with_extension("tmp"); + fs::write(&temporary, serde_json::to_vec_pretty(self)?)?; + fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?; + fs::rename(&temporary, path)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + Ok(()) + } + + pub fn signing_key(&self) -> Result { + let bytes = URL_SAFE_NO_PAD + .decode(&self.signing_key) + .context("decode signing key")?; + let seed: [u8; 32] = bytes + .try_into() + .map_err(|_| anyhow::anyhow!("signing key must contain 32 bytes"))?; + Ok(SigningKey::from_bytes(&seed)) + } + + pub fn validate_private_origin_ip(&self) -> Result<()> { + let octets = self.private_origin_ip.octets(); + if octets[0] != 10 || octets[1] < 192 { + anyhow::bail!("private origin IP must be in 10.192.0.0/10"); + } + if self.ingress != format!("{}:10101", self.private_origin_ip) { + anyhow::bail!("ingress must match the assigned private origin IP on port 10101"); + } + Ok(()) + } +} + +pub fn save_secret(path: &Path, value: &str) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let temporary = path.with_extension("tmp"); + fs::write(&temporary, format!("{value}\n"))?; + fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?; + fs::rename(&temporary, path)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(private_origin_ip: Ipv4Addr, ingress: &str) -> AgentConfig { + AgentConfig { + control_plane_url: "https://remote.example.test".into(), + agent_id: "agent".into(), + instance_id: "instance".into(), + signing_key: URL_SAFE_NO_PAD.encode([0_u8; 32]), + tunnel_token_file: "/tmp/tunnel-token".into(), + gateway: GatewayConfig { + issuer: "issuer".into(), + keys: vec![], + }, + private_origin_ip, + opencodex_url: default_opencodex_url(), + ingress: ingress.into(), + } + } + + #[test] + fn private_origin_ip_must_be_reserved_and_match_ingress() { + let allowed = Ipv4Addr::new(10, 223, 1, 9); + assert!( + config(allowed, "10.223.1.9:10101") + .validate_private_origin_ip() + .is_ok() + ); + assert!( + config(Ipv4Addr::new(10, 10, 1, 9), "10.10.1.9:10101") + .validate_private_origin_ip() + .is_err() + ); + assert!( + config(allowed, "127.0.0.1:10101") + .validate_private_origin_ip() + .is_err() + ); + } +} diff --git a/remote-agent/src/control.rs b/remote-agent/src/control.rs new file mode 100644 index 000000000..9e44e2bdf --- /dev/null +++ b/remote-agent/src/control.rs @@ -0,0 +1,147 @@ +use std::time::Duration; + +use anyhow::{Context, Result}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use ed25519_dalek::{Signer, SigningKey}; +use rand::Rng; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use tokio_util::sync::CancellationToken; + +use crate::config::{AgentConfig, GatewayConfig}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PairRequest<'a> { + code: &'a str, + public_key: String, + version: &'a str, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PairResponse { + pub agent_id: String, + pub instance_id: String, + pub tunnel_token: String, + pub private_origin_ip: String, + pub gateway: GatewayConfig, +} + +#[derive(Deserialize)] +struct ChallengeResponse { + challenge: String, +} + +#[derive(Deserialize)] +struct TokenResponse { + token: String, +} + +pub fn public_key_der(signing_key: &SigningKey) -> Vec { + const ED25519_SPKI_PREFIX: [u8; 12] = [ + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, + ]; + [ + ED25519_SPKI_PREFIX.as_slice(), + signing_key.verifying_key().as_bytes(), + ] + .concat() +} + +pub async fn pair( + control_plane_url: &str, + code: &str, + signing_key: &SigningKey, +) -> Result { + let response = Client::new() + .post(format!( + "{}/agent/pair", + control_plane_url.trim_end_matches('/') + )) + .json(&PairRequest { + code, + public_key: URL_SAFE_NO_PAD.encode(public_key_der(signing_key)), + version: env!("CARGO_PKG_VERSION"), + }) + .send() + .await + .context("pairing request failed")?; + if !response.status().is_success() { + anyhow::bail!( + "pairing rejected: {}", + response.text().await.unwrap_or_default() + ); + } + response.json().await.context("parse pairing response") +} + +async fn agent_token(client: &Client, config: &AgentConfig, key: &SigningKey) -> Result { + let client_nonce = URL_SAFE_NO_PAD.encode(rand::random::<[u8; 24]>()); + let challenge: ChallengeResponse = client + .post(format!( + "{}/agent/challenge", + config.control_plane_url.trim_end_matches('/') + )) + .json(&serde_json::json!({ "agentId": config.agent_id, "clientNonce": client_nonce })) + .send() + .await? + .error_for_status()? + .json() + .await?; + let signature = key.sign(challenge.challenge.as_bytes()); + let token: TokenResponse = client + .post(format!( + "{}/agent/token", + config.control_plane_url.trim_end_matches('/') + )) + .json(&serde_json::json!({ + "agentId": config.agent_id, + "challenge": challenge.challenge, + "signature": URL_SAFE_NO_PAD.encode(signature.to_bytes()), + })) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(token.token) +} + +pub async fn heartbeat_loop(config: AgentConfig, shutdown: CancellationToken) -> Result<()> { + let client = Client::builder().timeout(Duration::from_secs(15)).build()?; + let key = config.signing_key()?; + loop { + if shutdown.is_cancelled() { + return Ok(()); + } + let healthy = client + .get(format!( + "{}/healthz", + config.opencodex_url.trim_end_matches('/') + )) + .timeout(Duration::from_secs(5)) + .send() + .await + .map(|response| response.status().is_success()) + .unwrap_or(false); + match agent_token(&client, &config, &key).await { + Ok(token) => { + let result = client + .post(format!("{}/agent/heartbeat", config.control_plane_url.trim_end_matches('/'))) + .bearer_auth(token) + .json(&serde_json::json!({ "opencodexHealthy": healthy, "version": env!("CARGO_PKG_VERSION") })) + .send().await; + if let Err(error) = result { + tracing::warn!(%error, "heartbeat failed"); + } + } + Err(error) => tracing::warn!(%error, "agent authentication failed"), + } + let jitter = rand::rng().random_range(-5_i64..=5); + tokio::select! { + _ = shutdown.cancelled() => return Ok(()), + _ = tokio::time::sleep(Duration::from_secs((30 + jitter) as u64)) => {} + } + } +} diff --git a/remote-agent/src/main.rs b/remote-agent/src/main.rs new file mode 100644 index 000000000..cf46a0aa6 --- /dev/null +++ b/remote-agent/src/main.rs @@ -0,0 +1,164 @@ +mod assertion; +mod config; +mod control; +mod proxy; +mod relay; +mod supervisor; + +use std::{path::PathBuf, process::Command as ProcessCommand}; + +use anyhow::{Context, Result}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use clap::{Parser, Subcommand}; +use ed25519_dalek::SigningKey; +use tokio_util::sync::CancellationToken; +use tracing_subscriber::EnvFilter; + +use config::{AgentConfig, save_secret}; + +#[derive(Parser)] +#[command( + name = "opencodex-remote-agent", + version, + about = "Private OpenCodex Remote tunnel agent" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + Pair { + #[arg(long)] + control_plane: String, + #[arg(long)] + code: String, + #[arg(long, default_value = "/var/lib/opencodex-remote/agent.json")] + config: PathBuf, + #[arg(long, default_value = "/var/lib/opencodex-remote/tunnel-token")] + tunnel_token_file: PathBuf, + }, + Run { + #[arg(long, default_value = "/var/lib/opencodex-remote/agent.json")] + config: PathBuf, + }, + /// Run the unprivileged, outbound-only relay used by `ocx gui` Remote. + Relay { + #[arg(long, default_value_os_t = relay::default_remote_state_path())] + state: PathBuf, + }, + PrepareNetwork { + #[arg(long, default_value = "/var/lib/opencodex-remote/agent.json")] + config: PathBuf, + }, + PrintOpencodexConfig { + #[arg(long, default_value = "/var/lib/opencodex-remote/agent.json")] + config: PathBuf, + }, +} + +fn initialize_logs() { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .without_time() + .with_target(false) + .init(); +} + +#[tokio::main] +async fn main() -> Result<()> { + initialize_logs(); + match Cli::parse().command { + Command::Pair { + control_plane, + code, + config, + tunnel_token_file, + } => { + let key = SigningKey::from_bytes(&rand::random::<[u8; 32]>()); + let paired = control::pair(&control_plane, &code.trim().to_uppercase(), &key).await?; + let private_origin_ip = paired + .private_origin_ip + .parse() + .context("parse private origin IP")?; + save_secret(&tunnel_token_file, &paired.tunnel_token)?; + AgentConfig { + control_plane_url: control_plane, + agent_id: paired.agent_id, + instance_id: paired.instance_id, + signing_key: URL_SAFE_NO_PAD.encode(key.to_bytes()), + tunnel_token_file, + gateway: paired.gateway, + private_origin_ip, + opencodex_url: "http://127.0.0.1:10100".into(), + ingress: format!("{private_origin_ip}:10101"), + } + .save(&config)?; + println!( + "Paired successfully. Run `opencodex-remote-agent prepare-network`, then start the Agent and merge the `print-opencodex-config` remoteAccess block into OpenCodex config." + ); + } + Command::PrintOpencodexConfig { config } => { + let config = AgentConfig::load(&config)?; + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "remoteAccess": { + "enabled": true, + "instanceId": config.instance_id, + "issuer": config.gateway.issuer, + "publicKeys": config.gateway.keys, + } + }))? + ); + } + Command::PrepareNetwork { config } => { + let config = AgentConfig::load(&config)?; + let address = format!("{}/32", config.private_origin_ip); + let status = ProcessCommand::new("ip") + .args(["address", "replace", &address, "dev", "lo"]) + .status() + .context("run ip address replace for private origin")?; + if !status.success() { + anyhow::bail!( + "failed to assign private origin IP to loopback; run prepare-network as root" + ); + } + println!("Private origin {address} is ready on loopback."); + } + Command::Run { config } => { + let config = AgentConfig::load(&config)?; + let shutdown = CancellationToken::new(); + let signal = shutdown.clone(); + tokio::spawn(async move { + let _ = tokio::signal::ctrl_c().await; + signal.cancel(); + }); + let proxy = tokio::spawn(proxy::serve(config.clone(), shutdown.clone())); + let heartbeat = tokio::spawn(control::heartbeat_loop(config.clone(), shutdown.clone())); + let tunnel = tokio::spawn(supervisor::supervise_cloudflared( + config.tunnel_token_file.clone(), + shutdown.clone(), + )); + tokio::select! { + result = proxy => result.context("proxy task failed")??, + result = heartbeat => result.context("heartbeat task failed")??, + result = tunnel => result.context("cloudflared supervisor failed")??, + } + shutdown.cancel(); + } + Command::Relay { state } => { + let shutdown = CancellationToken::new(); + let signal = shutdown.clone(); + tokio::spawn(async move { + let _ = tokio::signal::ctrl_c().await; + signal.cancel(); + }); + relay::run(state, shutdown).await?; + } + } + Ok(()) +} diff --git a/remote-agent/src/proxy.rs b/remote-agent/src/proxy.rs new file mode 100644 index 000000000..2687a48d2 --- /dev/null +++ b/remote-agent/src/proxy.rs @@ -0,0 +1,253 @@ +use anyhow::Result; +use axum::{ + Router, + body::Body, + extract::{ + FromRequestParts, State, WebSocketUpgrade, + ws::{Message, WebSocket}, + }, + http::{HeaderMap, HeaderName, HeaderValue, Request, Response, StatusCode, Uri}, + response::IntoResponse, + routing::any, +}; +use futures_util::{SinkExt, StreamExt}; +use reqwest::Client; +use tokio::net::TcpListener; +use tokio_tungstenite::{connect_async, tungstenite::client::IntoClientRequest}; +use tokio_util::sync::CancellationToken; + +use crate::{ + assertion::{ASSERTION_HEADER, AssertionVerifier}, + config::AgentConfig, +}; + +const REQUEST_DENY: &[&str] = &[ + "connection", + "cookie", + "host", + "proxy-authenticate", + "proxy-authorization", + "upgrade", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", + "x-real-ip", + "x-opencodex-api-key", + "x-opencodex-remote-token", + "x-ocxr-target-path", +]; +const RESPONSE_DENY: &[&str] = &[ + "connection", + "set-cookie", + "set-cookie2", + "proxy-authenticate", + "proxy-authorization", +]; + +#[derive(Clone)] +struct AppState { + verifier: AssertionVerifier, + client: Client, + opencodex_url: String, +} + +fn denied(name: &HeaderName, response: bool) -> bool { + let lower = name.as_str(); + lower.starts_with("cf-") + || lower.starts_with("x-ocxr-") + // Each relay hop owns its WebSocket handshake. Forwarding the client's + // generated key/version headers would duplicate those generated by the + // upstream WebSocket client and can make an otherwise valid upgrade fail. + || (!response && lower.starts_with("sec-websocket-")) + || (response && RESPONSE_DENY.contains(&lower)) + || (!response && (REQUEST_DENY.contains(&lower) || lower == ASSERTION_HEADER)) +} + +fn target_uri(base: &str, uri: &Uri) -> Result { + Ok(format!( + "{}{}", + base.trim_end_matches('/'), + uri.path_and_query() + .map(|value| value.as_str()) + .unwrap_or("/") + )) +} + +fn sanitized_request_headers(input: &HeaderMap) -> HeaderMap { + let mut output = HeaderMap::new(); + for (name, value) in input { + if denied(name, false) { + continue; + } + if name == axum::http::header::AUTHORIZATION + && value + .to_str() + .ok() + .and_then(|value| value.strip_prefix("Bearer ")) + .is_some_and(|token| token.starts_with("ocxr_")) + { + continue; + } + output.append(name, value.clone()); + } + output.insert( + axum::http::header::HOST, + HeaderValue::from_static("127.0.0.1:10100"), + ); + output.insert( + axum::http::header::ORIGIN, + HeaderValue::from_static("http://127.0.0.1:10100"), + ); + output +} + +async fn proxy(State(state): State, req: Request) -> impl IntoResponse { + if let Err(error) = state + .verifier + .verify(req.method(), req.uri(), req.headers()) + .await + { + tracing::warn!(reason=%error, "request assertion rejected"); + return StatusCode::NOT_FOUND.into_response(); + } + let (mut parts, body) = req.into_parts(); + if parts.headers.get(axum::http::header::UPGRADE).is_some() { + return match WebSocketUpgrade::from_request_parts(&mut parts, &state).await { + Ok(upgrade) => upgrade + .on_upgrade(move |socket| relay_websocket(socket, state, parts.uri, parts.headers)) + .into_response(), + Err(_) => StatusCode::BAD_REQUEST.into_response(), + }; + } + let target = match target_uri(&state.opencodex_url, &parts.uri) { + Ok(target) => target, + Err(_) => return StatusCode::BAD_REQUEST.into_response(), + }; + let headers = sanitized_request_headers(&parts.headers); + let request = state + .client + .request(parts.method, target) + .headers(headers) + .body(reqwest::Body::wrap_stream(body.into_data_stream())); + let upstream = match request.send().await { + Ok(response) => response, + Err(error) => { + tracing::warn!(%error, "OpenCodex upstream request failed"); + return StatusCode::BAD_GATEWAY.into_response(); + } + }; + let mut response = Response::builder().status(upstream.status()); + for (name, value) in upstream.headers() { + if !denied(name, true) { + response = response.header(name, value); + } + } + match response.body(Body::from_stream(upstream.bytes_stream())) { + Ok(response) => response.into_response(), + Err(_) => StatusCode::BAD_GATEWAY.into_response(), + } +} + +async fn relay_websocket( + client: WebSocket, + state: AppState, + uri: Uri, + incoming_headers: HeaderMap, +) { + let target = match target_uri(&state.opencodex_url, &uri) { + Ok(target) => target + .replacen("http://", "ws://", 1) + .replacen("https://", "wss://", 1), + Err(_) => return, + }; + let mut request = match target.into_client_request() { + Ok(request) => request, + Err(_) => return, + }; + for (name, value) in sanitized_request_headers(&incoming_headers) { + if let Some(name) = name { + request.headers_mut().append(name, value); + } + } + let (upstream, _) = match connect_async(request).await { + Ok(value) => value, + Err(error) => { + tracing::warn!(%error, "OpenCodex websocket connect failed"); + return; + } + }; + let (mut client_tx, mut client_rx) = client.split(); + let (mut upstream_tx, mut upstream_rx) = upstream.split(); + let to_upstream = async { + while let Some(Ok(message)) = client_rx.next().await { + let converted = match message { + Message::Text(value) => { + tokio_tungstenite::tungstenite::Message::Text(value.to_string().into()) + } + Message::Binary(value) => tokio_tungstenite::tungstenite::Message::Binary(value), + Message::Ping(value) => tokio_tungstenite::tungstenite::Message::Ping(value), + Message::Pong(value) => tokio_tungstenite::tungstenite::Message::Pong(value), + Message::Close(_) => tokio_tungstenite::tungstenite::Message::Close(None), + }; + if upstream_tx.send(converted).await.is_err() { + break; + } + } + }; + let to_client = async { + while let Some(Ok(message)) = upstream_rx.next().await { + let converted = match message { + tokio_tungstenite::tungstenite::Message::Text(value) => { + Message::Text(value.to_string().into()) + } + tokio_tungstenite::tungstenite::Message::Binary(value) => Message::Binary(value), + tokio_tungstenite::tungstenite::Message::Ping(value) => Message::Ping(value), + tokio_tungstenite::tungstenite::Message::Pong(value) => Message::Pong(value), + tokio_tungstenite::tungstenite::Message::Close(_) => Message::Close(None), + tokio_tungstenite::tungstenite::Message::Frame(_) => continue, + }; + if client_tx.send(converted).await.is_err() { + break; + } + } + }; + tokio::select! { _ = to_upstream => {}, _ = to_client => {} } +} + +pub async fn serve(config: AgentConfig, shutdown: CancellationToken) -> Result<()> { + let state = AppState { + verifier: AssertionVerifier::new(&config)?, + client: Client::builder().build()?, + opencodex_url: config.opencodex_url.clone(), + }; + let app = Router::new().fallback(any(proxy)).with_state(state); + let listener = TcpListener::bind(&config.ingress).await?; + tracing::info!(bind=%config.ingress, "local ingress ready"); + axum::serve(listener, app) + .with_graceful_shutdown(shutdown.cancelled_owned()) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn websocket_handshake_headers_are_regenerated_per_hop() { + let mut input = HeaderMap::new(); + input.insert("upgrade", HeaderValue::from_static("websocket")); + input.insert("sec-websocket-key", HeaderValue::from_static("client-key")); + input.insert("sec-websocket-version", HeaderValue::from_static("13")); + input.insert("x-client-feature", HeaderValue::from_static("preserved")); + + let output = sanitized_request_headers(&input); + assert!(!output.contains_key("upgrade")); + assert!(!output.contains_key("sec-websocket-key")); + assert!(!output.contains_key("sec-websocket-version")); + assert_eq!( + output.get("x-client-feature"), + Some(&HeaderValue::from_static("preserved")) + ); + } +} diff --git a/remote-agent/src/relay.rs b/remote-agent/src/relay.rs new file mode 100644 index 000000000..0a880570b --- /dev/null +++ b/remote-agent/src/relay.rs @@ -0,0 +1,828 @@ +use std::{ + collections::HashMap, + env, + io::{Read, Write}, + path::{Path, PathBuf}, + sync::mpsc, + thread, + time::Duration, +}; + +use aes_gcm::{ + Aes256Gcm, Nonce, + aead::{Aead, KeyInit, Payload}, +}; +use anyhow::{Context, Result, bail}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use futures_util::{SinkExt, StreamExt}; +use hkdf::Hkdf; +use p256::{ + PublicKey, + ecdh::EphemeralSecret, + elliptic_curve::rand_core::OsRng, + pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePublicKey}, +}; +use portable_pty::{CommandBuilder, PtySize, native_pty_system}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tokio::sync::mpsc as tokio_mpsc; +use tokio_tungstenite::{ + connect_async_with_config, + tungstenite::{ + Message, + client::IntoClientRequest, + http::{HeaderValue, header::AUTHORIZATION}, + protocol::WebSocketConfig, + }, +}; +use tokio_util::sync::CancellationToken; +use tracing::{info, warn}; +use url::Url; +use uuid::Uuid; +use zeroize::Zeroizing; + +const PROTOCOL_VERSION: u8 = 1; +const FRAME_OPEN: u8 = 1; +const FRAME_DATA: u8 = 2; +const FRAME_CLOSE: u8 = 3; +const FRAME_HEADER_SIZE: usize = 18; +const MAX_PAYLOAD: usize = 64 * 1024; +const MAX_SESSIONS: usize = 4; + +const APP_INPUT: u8 = 1; +const APP_RESIZE: u8 = 2; +const APP_OUTPUT: u8 = 3; +const APP_EXIT: u8 = 4; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RemoteState { + version: u8, + state: String, + relay_url: String, + relay_token: Zeroizing, + device_id: Uuid, + private_key_pem: Zeroizing, + e2ee: E2eeEnvelope, + instance: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct E2eeEnvelope { + root_public_key: String, +} + +#[derive(Debug, Deserialize)] +struct RemoteInstance { + id: Uuid, +} + +impl RemoteState { + fn load(path: &Path) -> Result { + let metadata = std::fs::metadata(path) + .with_context(|| format!("read metadata for {}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o077 != 0 { + bail!("{} must not be readable by other users", path.display()); + } + } + if metadata.len() > 256 * 1024 { + bail!("remote state is too large"); + } + let state: Self = serde_json::from_slice( + &std::fs::read(path).with_context(|| format!("read {}", path.display()))?, + ) + .context("parse OpenCodex remote state")?; + if state.version != 2 || state.state != "connected" || state.instance.is_none() { + bail!("Remote must be connected, encrypted, and activated before the Agent starts"); + } + let relay = Url::parse(&state.relay_url).context("parse relay URL")?; + let local_development = + relay.scheme() == "ws" && matches!(relay.host_str(), Some("127.0.0.1" | "localhost")); + if relay.scheme() != "wss" && !local_development { + bail!("relay URL must use WSS"); + } + Ok(state) + } + + fn signing_key(&self) -> Result { + SigningKey::from_pkcs8_pem(&self.private_key_pem).context("parse device signing key") + } + + fn root_verifying_key(&self) -> Result { + let der = URL_SAFE_NO_PAD + .decode(&self.e2ee.root_public_key) + .context("decode account root public key")?; + VerifyingKey::from_public_key_der(&der).context("parse account root public key") + } +} + +pub fn default_remote_state_path() -> PathBuf { + if let Some(home) = env::var_os("OPENCODEX_HOME") { + return PathBuf::from(home).join("remote.json"); + } + PathBuf::from(env::var_os("HOME").unwrap_or_else(|| ".".into())) + .join(".opencodex") + .join("remote.json") +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FrameKind { + Open, + Data, + Close, +} + +impl FrameKind { + fn from_byte(value: u8) -> Result { + match value { + FRAME_OPEN => Ok(Self::Open), + FRAME_DATA => Ok(Self::Data), + FRAME_CLOSE => Ok(Self::Close), + _ => bail!("unknown relay frame kind"), + } + } + + fn byte(self) -> u8 { + match self { + Self::Open => FRAME_OPEN, + Self::Data => FRAME_DATA, + Self::Close => FRAME_CLOSE, + } + } +} + +#[derive(Debug)] +struct RelayFrame { + kind: FrameKind, + session_id: Uuid, + payload: Vec, +} + +impl RelayFrame { + fn decode(bytes: &[u8]) -> Result { + if bytes.len() < FRAME_HEADER_SIZE || bytes.len() > FRAME_HEADER_SIZE + MAX_PAYLOAD { + bail!("invalid relay frame length"); + } + if bytes[0] != PROTOCOL_VERSION { + bail!("unsupported relay protocol version"); + } + Ok(Self { + kind: FrameKind::from_byte(bytes[1])?, + session_id: Uuid::from_slice(&bytes[2..FRAME_HEADER_SIZE]) + .context("parse relay session ID")?, + payload: bytes[FRAME_HEADER_SIZE..].to_vec(), + }) + } + + fn encode(&self) -> Result> { + if self.payload.len() > MAX_PAYLOAD { + bail!("relay payload is too large"); + } + let mut bytes = Vec::with_capacity(FRAME_HEADER_SIZE + self.payload.len()); + bytes.extend_from_slice(&[PROTOCOL_VERSION, self.kind.byte()]); + bytes.extend_from_slice(self.session_id.as_bytes()); + bytes.extend_from_slice(&self.payload); + Ok(bytes) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct OpenRequest { + command_profile: CommandProfile, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ClientHello { + command_profile: CommandProfile, + ephemeral_public_key: String, + client_nonce: String, + signature: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentHello { + ephemeral_public_key: String, + server_nonce: String, + signature: String, +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +enum CommandProfile { + Shell, + Codex, + Claude, +} + +impl CommandProfile { + fn as_str(self) -> &'static str { + match self { + Self::Shell => "shell", + Self::Codex => "codex", + Self::Claude => "claude", + } + } + + fn executable(self) -> &'static str { + match self { + Self::Shell => { + #[cfg(windows)] + { + "powershell.exe" + } + #[cfg(not(windows))] + { + "/bin/sh" + } + } + Self::Codex => "codex", + Self::Claude => "claude", + } + } +} + +struct SessionCrypto { + session_id: Uuid, + receive: Aes256Gcm, + send: Aes256Gcm, + receive_nonce_prefix: [u8; 4], + send_nonce_prefix: [u8; 4], + receive_counter: u64, + send_counter: u64, +} + +impl SessionCrypto { + fn derive( + session_id: Uuid, + shared: &[u8], + client_nonce: &[u8], + server_nonce: &[u8], + ) -> Result { + let mut salt_source = Vec::with_capacity(client_nonce.len() + server_nonce.len()); + salt_source.extend_from_slice(client_nonce); + salt_source.extend_from_slice(server_nonce); + let salt = Sha256::digest(&salt_source); + let hkdf = Hkdf::::new(Some(&salt), shared); + let mut receive_key = [0_u8; 32]; + let mut send_key = [0_u8; 32]; + let mut receive_nonce_prefix = [0_u8; 4]; + let mut send_nonce_prefix = [0_u8; 4]; + hkdf.expand(b"ocx terminal browser to agent key v1", &mut receive_key) + .map_err(|_| anyhow::anyhow!("derive browser-to-agent key"))?; + hkdf.expand(b"ocx terminal agent to browser key v1", &mut send_key) + .map_err(|_| anyhow::anyhow!("derive agent-to-browser key"))?; + hkdf.expand( + b"ocx terminal browser to agent nonce v1", + &mut receive_nonce_prefix, + ) + .map_err(|_| anyhow::anyhow!("derive browser-to-agent nonce"))?; + hkdf.expand( + b"ocx terminal agent to browser nonce v1", + &mut send_nonce_prefix, + ) + .map_err(|_| anyhow::anyhow!("derive agent-to-browser nonce"))?; + Ok(Self { + session_id, + receive: Aes256Gcm::new_from_slice(&receive_key).expect("AES-256 key length"), + send: Aes256Gcm::new_from_slice(&send_key).expect("AES-256 key length"), + receive_nonce_prefix, + send_nonce_prefix, + receive_counter: 0, + send_counter: 0, + }) + } + + fn aad(&self, direction: u8, counter: u64) -> Vec { + let mut aad = b"ocx-terminal-frame-v1\0".to_vec(); + aad.extend_from_slice(self.session_id.as_bytes()); + aad.push(direction); + aad.extend_from_slice(&counter.to_be_bytes()); + aad + } + + fn nonce(prefix: [u8; 4], counter: u64) -> [u8; 12] { + let mut nonce = [0_u8; 12]; + nonce[..4].copy_from_slice(&prefix); + nonce[4..].copy_from_slice(&counter.to_be_bytes()); + nonce + } + + fn decrypt(&mut self, bytes: &[u8]) -> Result> { + if bytes.len() < 8 + 16 { + bail!("encrypted terminal frame is too short"); + } + let counter = u64::from_be_bytes(bytes[..8].try_into().expect("counter length")); + if counter != self.receive_counter { + bail!("replayed or out-of-order terminal frame"); + } + let nonce = Self::nonce(self.receive_nonce_prefix, counter); + let plaintext = self + .receive + .decrypt( + Nonce::from_slice(&nonce), + Payload { + msg: &bytes[8..], + aad: &self.aad(0, counter), + }, + ) + .map_err(|_| anyhow::anyhow!("terminal frame authentication failed"))?; + self.receive_counter = self + .receive_counter + .checked_add(1) + .context("terminal receive counter exhausted")?; + Ok(plaintext) + } + + fn encrypt(&mut self, plaintext: &[u8]) -> Result> { + let counter = self.send_counter; + let nonce = Self::nonce(self.send_nonce_prefix, counter); + let encrypted = self + .send + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: plaintext, + aad: &self.aad(1, counter), + }, + ) + .map_err(|_| anyhow::anyhow!("encrypt terminal frame"))?; + self.send_counter = self + .send_counter + .checked_add(1) + .context("terminal send counter exhausted")?; + let mut bytes = Vec::with_capacity(8 + encrypted.len()); + bytes.extend_from_slice(&counter.to_be_bytes()); + bytes.extend_from_slice(&encrypted); + Ok(bytes) + } +} + +enum PtyCommand { + Input(Vec), + Resize { rows: u16, cols: u16 }, + Close, +} + +enum PtyEvent { + Output { session_id: Uuid, bytes: Vec }, + Exit { session_id: Uuid, code: u32 }, +} + +struct PtySession { + commands: mpsc::Sender, +} + +impl PtySession { + fn input(&self, bytes: Vec) -> Result<()> { + self.commands + .send(PtyCommand::Input(bytes)) + .map_err(|_| anyhow::anyhow!("terminal has exited")) + } + + fn resize(&self, rows: u16, cols: u16) -> Result<()> { + if !(2..=1000).contains(&rows) || !(2..=1000).contains(&cols) { + bail!("invalid terminal dimensions"); + } + self.commands + .send(PtyCommand::Resize { rows, cols }) + .map_err(|_| anyhow::anyhow!("terminal has exited")) + } +} + +impl Drop for PtySession { + fn drop(&mut self) { + let _ = self.commands.send(PtyCommand::Close); + } +} + +fn spawn_pty( + session_id: Uuid, + profile: CommandProfile, + events: tokio_mpsc::Sender, +) -> Result { + let pair = native_pty_system() + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .context("open local PTY")?; + let mut command = CommandBuilder::new(profile.executable()); + command.env("TERM", "xterm-256color"); + command.env("COLORTERM", "truecolor"); + if let Some(home) = env::var_os("HOME") { + command.cwd(home); + } + let mut child = pair + .slave + .spawn_command(command) + .with_context(|| format!("start {} terminal", profile.as_str()))?; + drop(pair.slave); + let mut reader = pair.master.try_clone_reader().context("clone PTY reader")?; + let mut writer = pair.master.take_writer().context("take PTY writer")?; + let mut killer = child.clone_killer(); + let master = pair.master; + let (commands, command_rx) = mpsc::channel(); + + let output_events = events.clone(); + thread::spawn(move || { + let mut buffer = [0_u8; 16 * 1024]; + loop { + match reader.read(&mut buffer) { + Ok(0) | Err(_) => break, + Ok(read) => { + if output_events + .blocking_send(PtyEvent::Output { + session_id, + bytes: buffer[..read].to_vec(), + }) + .is_err() + { + break; + } + } + } + } + }); + thread::spawn(move || { + while let Ok(command) = command_rx.recv() { + let result = match command { + PtyCommand::Input(bytes) => writer.write_all(&bytes).and_then(|_| writer.flush()), + PtyCommand::Resize { rows, cols } => master + .resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(std::io::Error::other), + PtyCommand::Close => { + let _ = killer.kill(); + break; + } + }; + if result.is_err() { + break; + } + } + }); + thread::spawn(move || { + let code = child.wait().map(|status| status.exit_code()).unwrap_or(1); + let _ = events.blocking_send(PtyEvent::Exit { session_id, code }); + }); + Ok(PtySession { commands }) +} + +enum SessionState { + Pending { + profile: CommandProfile, + }, + Handshaken { + profile: CommandProfile, + crypto: SessionCrypto, + }, + Active { + crypto: SessionCrypto, + pty: PtySession, + }, +} + +fn client_transcript( + session_id: Uuid, + device_id: Uuid, + profile: CommandProfile, + public_key: &[u8], + nonce: &[u8], +) -> Vec { + let mut transcript = b"ocx-terminal-client-v1\0".to_vec(); + transcript.extend_from_slice(session_id.as_bytes()); + transcript.extend_from_slice(device_id.as_bytes()); + transcript.extend_from_slice(profile.as_str().as_bytes()); + transcript.push(0); + transcript.extend_from_slice(public_key); + transcript.extend_from_slice(nonce); + transcript +} + +fn agent_transcript( + client_transcript: &[u8], + agent_public_key: &[u8], + server_nonce: &[u8], +) -> Vec { + let mut transcript = b"ocx-terminal-agent-v1\0".to_vec(); + transcript.extend_from_slice(client_transcript); + transcript.extend_from_slice(agent_public_key); + transcript.extend_from_slice(server_nonce); + transcript +} + +fn accept_client_hello( + state: &RemoteState, + session_id: Uuid, + expected_profile: CommandProfile, + payload: &[u8], +) -> Result<(AgentHello, SessionCrypto)> { + let hello: ClientHello = + serde_json::from_slice(payload).context("parse client terminal hello")?; + if hello.command_profile != expected_profile { + bail!("terminal command profile was changed in transit"); + } + let client_public_der = URL_SAFE_NO_PAD + .decode(&hello.ephemeral_public_key) + .context("decode browser ephemeral key")?; + let client_public = PublicKey::from_public_key_der(&client_public_der) + .context("parse browser ephemeral key")?; + let client_nonce = URL_SAFE_NO_PAD + .decode(&hello.client_nonce) + .context("decode browser nonce")?; + if client_nonce.len() != 32 { + bail!("browser nonce must be 32 bytes"); + } + let signature = URL_SAFE_NO_PAD + .decode(&hello.signature) + .context("decode browser signature")?; + let signature = Signature::from_slice(&signature).context("parse browser signature")?; + let client_transcript = client_transcript( + session_id, + state.device_id, + expected_profile, + &client_public_der, + &client_nonce, + ); + state + .root_verifying_key()? + .verify(&client_transcript, &signature) + .context("verify account root signature")?; + + let ephemeral = EphemeralSecret::random(&mut OsRng); + let agent_public = PublicKey::from(&ephemeral); + let agent_public_der = agent_public + .to_public_key_der() + .context("encode Agent ephemeral key")?; + let shared = ephemeral.diffie_hellman(&client_public); + let server_nonce = rand::random::<[u8; 32]>(); + let signature = state.signing_key()?.sign(&agent_transcript( + &client_transcript, + agent_public_der.as_bytes(), + &server_nonce, + )); + let crypto = SessionCrypto::derive( + session_id, + shared.raw_secret_bytes(), + &client_nonce, + &server_nonce, + )?; + Ok(( + AgentHello { + ephemeral_public_key: URL_SAFE_NO_PAD.encode(agent_public_der.as_bytes()), + server_nonce: URL_SAFE_NO_PAD.encode(server_nonce), + signature: URL_SAFE_NO_PAD.encode(signature.to_bytes()), + }, + crypto, + )) +} + +fn apply_terminal_input(pty: &PtySession, plaintext: &[u8]) -> Result<()> { + let Some((&kind, body)) = plaintext.split_first() else { + bail!("empty terminal application frame"); + }; + match kind { + APP_INPUT => pty.input(body.to_vec()), + APP_RESIZE if body.len() == 4 => { + let cols = u16::from_be_bytes([body[0], body[1]]); + let rows = u16::from_be_bytes([body[2], body[3]]); + pty.resize(rows, cols) + } + _ => bail!("invalid terminal application frame"), + } +} + +pub async fn run(state_path: PathBuf, shutdown: CancellationToken) -> Result<()> { + let mut retry = Duration::from_secs(1); + loop { + if shutdown.is_cancelled() { + return Ok(()); + } + match run_connection(&state_path, shutdown.clone()).await { + Ok(()) if shutdown.is_cancelled() => return Ok(()), + Ok(()) => warn!("relay disconnected"), + Err(error) => warn!(error = %error, "relay connection failed"), + } + tokio::select! { + _ = shutdown.cancelled() => return Ok(()), + _ = tokio::time::sleep(retry) => {} + } + retry = (retry * 2).min(Duration::from_secs(30)); + } +} + +async fn run_connection(state_path: &Path, shutdown: CancellationToken) -> Result<()> { + let state = RemoteState::load(state_path)?; + let instance_id = state.instance.as_ref().expect("validated instance").id; + let mut request = state + .relay_url + .as_str() + .into_client_request() + .context("build relay request")?; + request.headers_mut().insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {}", state.relay_token.as_str())) + .context("build relay authorization")?, + ); + let websocket_config = WebSocketConfig::default() + .max_message_size(Some(FRAME_HEADER_SIZE + MAX_PAYLOAD)) + .max_frame_size(Some(FRAME_HEADER_SIZE + MAX_PAYLOAD)); + let (socket, _) = connect_async_with_config(request, Some(websocket_config), false) + .await + .context("connect outbound relay")?; + info!(%instance_id, device_id = %state.device_id, "outbound relay connected"); + let (mut sink, mut stream) = socket.split(); + let (pty_events_tx, mut pty_events_rx) = tokio_mpsc::channel::(64); + let mut sessions = HashMap::::new(); + + loop { + tokio::select! { + _ = shutdown.cancelled() => { + let _ = sink.send(Message::Close(None)).await; + return Ok(()); + } + event = pty_events_rx.recv() => { + let Some(event) = event else { continue }; + match event { + PtyEvent::Output { session_id, bytes } => { + let Some(SessionState::Active { crypto, .. }) = sessions.get_mut(&session_id) else { continue }; + let mut plaintext = Vec::with_capacity(1 + bytes.len()); + plaintext.push(APP_OUTPUT); + plaintext.extend_from_slice(&bytes); + let encrypted = crypto.encrypt(&plaintext)?; + sink.send(Message::Binary(RelayFrame { kind: FrameKind::Data, session_id, payload: encrypted }.encode()?.into())).await?; + } + PtyEvent::Exit { session_id, code } => { + if let Some(SessionState::Active { crypto, .. }) = sessions.get_mut(&session_id) { + let mut plaintext = vec![APP_EXIT]; + plaintext.extend_from_slice(&code.to_be_bytes()); + let encrypted = crypto.encrypt(&plaintext)?; + sink.send(Message::Binary(RelayFrame { kind: FrameKind::Data, session_id, payload: encrypted }.encode()?.into())).await?; + } + sessions.remove(&session_id); + sink.send(Message::Binary(RelayFrame { kind: FrameKind::Close, session_id, payload: vec![] }.encode()?.into())).await?; + } + } + } + message = stream.next() => { + let Some(message) = message else { return Ok(()) }; + match message.context("read relay message")? { + Message::Binary(bytes) => { + let frame = RelayFrame::decode(&bytes)?; + match frame.kind { + FrameKind::Open => { + if sessions.contains_key(&frame.session_id) || sessions.len() >= MAX_SESSIONS { + sink.send(Message::Binary(RelayFrame { kind: FrameKind::Close, session_id: frame.session_id, payload: vec![] }.encode()?.into())).await?; + continue; + } + match serde_json::from_slice::(&frame.payload) { + Ok(request) => { + sessions.insert(frame.session_id, SessionState::Pending { profile: request.command_profile }); + } + Err(error) => { + warn!(session_id = %frame.session_id, error = %error, "terminal open request rejected"); + sink.send(Message::Binary(RelayFrame { kind: FrameKind::Close, session_id: frame.session_id, payload: vec![] }.encode()?.into())).await?; + } + } + } + FrameKind::Data => { + let Some(session) = sessions.remove(&frame.session_id) else { continue }; + match session { + SessionState::Pending { profile } => { + match accept_client_hello(&state, frame.session_id, profile, &frame.payload) { + Ok((hello, crypto)) => { + // The browser cannot decrypt application output until it has + // verified this hello and installed its receive handler. Starting + // the PTY here can lose an early shell prompt (counter 0), making + // the next frame look replayed. The first authenticated browser + // frame is therefore also the explicit PTY-start signal. + sessions.insert(frame.session_id, SessionState::Handshaken { profile, crypto }); + sink.send(Message::Binary(RelayFrame { + kind: FrameKind::Open, + session_id: frame.session_id, + payload: serde_json::to_vec(&hello)?, + }.encode()?.into())).await?; + } + Err(error) => { + warn!(session_id = %frame.session_id, error = %error, "terminal handshake rejected"); + sink.send(Message::Binary(RelayFrame { kind: FrameKind::Close, session_id: frame.session_id, payload: vec![] }.encode()?.into())).await?; + } + } + } + SessionState::Handshaken { profile, mut crypto } => { + let started = crypto.decrypt(&frame.payload).and_then(|plaintext| { + let pty = spawn_pty(frame.session_id, profile, pty_events_tx.clone())?; + apply_terminal_input(&pty, &plaintext)?; + Ok(pty) + }); + match started { + Ok(pty) => { + sessions.insert(frame.session_id, SessionState::Active { crypto, pty }); + } + Err(error) => { + warn!(session_id = %frame.session_id, error = %error, "terminal process failed to start"); + sink.send(Message::Binary(RelayFrame { kind: FrameKind::Close, session_id: frame.session_id, payload: vec![] }.encode()?.into())).await?; + } + } + } + SessionState::Active { mut crypto, pty } => { + match crypto.decrypt(&frame.payload).and_then(|plaintext| apply_terminal_input(&pty, &plaintext)) { + Ok(()) => { + sessions.insert(frame.session_id, SessionState::Active { crypto, pty }); + } + Err(error) => { + warn!(session_id = %frame.session_id, error = %error, "encrypted terminal input rejected"); + sink.send(Message::Binary(RelayFrame { kind: FrameKind::Close, session_id: frame.session_id, payload: vec![] }.encode()?.into())).await?; + } + } + } + } + } + FrameKind::Close => { + sessions.remove(&frame.session_id); + } + } + } + Message::Ping(payload) => sink.send(Message::Pong(payload)).await?, + Message::Close(_) => return Ok(()), + Message::Text(_) => bail!("relay sent a text frame"), + Message::Pong(_) | Message::Frame(_) => {} + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relay_frame_round_trip_and_bounds() { + let session_id = Uuid::new_v4(); + let encoded = RelayFrame { + kind: FrameKind::Data, + session_id, + payload: b"opaque".to_vec(), + } + .encode() + .unwrap(); + let decoded = RelayFrame::decode(&encoded).unwrap(); + assert_eq!(decoded.kind, FrameKind::Data); + assert_eq!(decoded.session_id, session_id); + assert_eq!(decoded.payload, b"opaque"); + assert!(RelayFrame::decode(&[0; FRAME_HEADER_SIZE - 1]).is_err()); + assert!( + RelayFrame { + kind: FrameKind::Data, + session_id, + payload: vec![0; MAX_PAYLOAD + 1], + } + .encode() + .is_err() + ); + } + + #[test] + fn command_profiles_never_accept_arbitrary_commands() { + assert_eq!(CommandProfile::Shell.as_str(), "shell"); + assert_eq!(CommandProfile::Codex.executable(), "codex"); + assert_eq!(CommandProfile::Claude.executable(), "claude"); + assert!(serde_json::from_str::("\"rm -rf /\"").is_err()); + } + + #[test] + fn receive_counter_rejects_replay() { + let session_id = Uuid::new_v4(); + let mut agent = SessionCrypto::derive(session_id, &[7; 32], &[8; 32], &[9; 32]).unwrap(); + let nonce = SessionCrypto::nonce(agent.receive_nonce_prefix, 0); + let ciphertext = agent + .receive + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: b"hello", + aad: &agent.aad(0, 0), + }, + ) + .unwrap(); + let mut encrypted = 0_u64.to_be_bytes().to_vec(); + encrypted.extend_from_slice(&ciphertext); + assert_eq!(agent.decrypt(&encrypted).unwrap(), b"hello"); + assert!(agent.decrypt(&encrypted).is_err()); + } +} diff --git a/remote-agent/src/supervisor.rs b/remote-agent/src/supervisor.rs new file mode 100644 index 000000000..77ca91919 --- /dev/null +++ b/remote-agent/src/supervisor.rs @@ -0,0 +1,33 @@ +use std::{path::PathBuf, time::Duration}; + +use anyhow::{Context, Result}; +use tokio::process::Command; +use tokio_util::sync::CancellationToken; + +pub async fn supervise_cloudflared(token_file: PathBuf, shutdown: CancellationToken) -> Result<()> { + let mut failures = 0_u32; + loop { + let mut child = Command::new("cloudflared") + .args(["tunnel", "--no-autoupdate", "run", "--token-file"]) + .arg(&token_file) + .kill_on_drop(true) + .spawn() + .context("start cloudflared")?; + tokio::select! { + result = child.wait() => { + tracing::warn!(status=?result?, "cloudflared exited; restarting"); + } + _ = shutdown.cancelled() => { + let _ = child.start_kill(); + let _ = child.wait().await; + return Ok(()); + } + } + failures = failures.saturating_add(1); + let delay = Duration::from_secs(2_u64.saturating_pow(failures.min(5))); + tokio::select! { + _ = shutdown.cancelled() => return Ok(()), + _ = tokio::time::sleep(delay) => {} + } + } +} diff --git a/scripts/prepare-package.ts b/scripts/prepare-package.ts index 437e0c265..af6b4174a 100644 --- a/scripts/prepare-package.ts +++ b/scripts/prepare-package.ts @@ -1,6 +1,11 @@ -import { chmodSync, existsSync, readdirSync, statSync } from "node:fs"; +import { chmodSync, existsSync, lstatSync, readFileSync, readdirSync, statSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { + REMOTE_AGENT_PUBLIC_KEY_PEM, + REMOTE_AGENT_SPECS, + verifyRemoteAgentBundle, +} from "../src/remote/agent-bundle"; const root = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); @@ -20,6 +25,38 @@ function chmodTree(path: string): void { chmodIfExists(path, 0o644); } +function packageVersion(): string { + const value = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { version?: unknown }; + if (typeof value.version !== "string" || value.version.length === 0) throw new Error("package.json version is unavailable"); + return value.version; +} + +function verificationPublicKey(): string { + const path = process.env.OPENCODEX_REMOTE_AGENT_DRY_RUN_PUBLIC_KEY_FILE?.trim(); + if (!path) return REMOTE_AGENT_PUBLIC_KEY_PEM; + if (process.env.OPENCODEX_RELEASE_DRY_RUN !== "true") { + throw new Error("Remote Agent public-key overrides are allowed only for release dry-runs"); + } + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > 16 * 1024) { + throw new Error("Remote Agent dry-run public key must be a bounded regular file"); + } + return readFileSync(path, "utf8"); +} + chmodIfExists(join(root, "bin", "ocx.mjs"), 0o755); chmodIfExists(join(root, "bin", "package-main.mjs"), 0o644); chmodTree(join(root, "gui", "dist")); + +const remoteAgentRoot = join(root, "bin", "remote-agent"); +if (existsSync(remoteAgentRoot)) { + verifyRemoteAgentBundle(remoteAgentRoot, verificationPublicKey(), packageVersion()); + for (const spec of REMOTE_AGENT_SPECS) { + chmodIfExists( + join(remoteAgentRoot, spec.package, spec.executable), + spec.package.startsWith("windows-") ? 0o644 : 0o755, + ); + } +} else if (process.env.OPENCODEX_REQUIRE_REMOTE_AGENT_BUNDLE === "1") { + throw new Error("A complete signed Remote Agent bundle is required for release packaging"); +} diff --git a/scripts/remote-agent-bundle.ts b/scripts/remote-agent-bundle.ts new file mode 100644 index 000000000..98f54be16 --- /dev/null +++ b/scripts/remote-agent-bundle.ts @@ -0,0 +1,68 @@ +import { lstatSync, readFileSync } from "node:fs"; +import { + REMOTE_AGENT_PUBLIC_KEY_PEM, + REMOTE_AGENT_SPECS, + assembleRemoteAgentBundle, + verifyRemoteAgentBundle, +} from "../src/remote/agent-bundle"; + +function usage(): never { + throw new Error("Usage: remote-agent-bundle.ts assemble | verify "); +} + +function readExpectedPublicKey(): string { + const path = process.env.REMOTE_AGENT_EXPECTED_PUBLIC_KEY_FILE?.trim(); + if (!path) return REMOTE_AGENT_PUBLIC_KEY_PEM; + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > 16 * 1024) { + throw new Error("Remote Agent expected public key must be a bounded regular file"); + } + return readFileSync(path, "utf8"); +} + +function packageVersion(): string { + const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version?: unknown }; + if (typeof pkg.version !== "string" || pkg.version.length === 0) throw new Error("package.json version is unavailable"); + return pkg.version; +} + +function readSigningKey(): string { + const path = process.env.REMOTE_AGENT_SIGNING_KEY_FILE?.trim(); + if (!path) throw new Error("REMOTE_AGENT_SIGNING_KEY_FILE is required"); + let stat; + try { + stat = lstatSync(path); + } catch { + throw new Error("Remote Agent signing key file is unavailable"); + } + if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > 64 * 1024) { + throw new Error("Remote Agent signing key must be a bounded regular file"); + } + if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) { + throw new Error("Remote Agent signing key permissions must be 0600 or stricter"); + } + return readFileSync(path, "utf8"); +} + +const [command, ...args] = process.argv.slice(2); + +if (command === "assemble") { + if (args.length !== 3) usage(); + const sourceCommit = process.env.GITHUB_SHA?.trim().toLowerCase() ?? ""; + const manifest = assembleRemoteAgentBundle({ + inputRoot: args[0], + bundleRoot: args[1], + releaseAssetsRoot: args[2], + privateKeyPem: readSigningKey(), + sourceCommit, + packageVersion: packageVersion(), + expectedPublicKeyPem: readExpectedPublicKey(), + }); + console.log(`Assembled and verified ${Object.keys(manifest.artifacts).length} signed Remote Agent artifacts.`); +} else if (command === "verify") { + if (args.length !== 1) usage(); + verifyRemoteAgentBundle(args[0], readExpectedPublicKey(), packageVersion()); + console.log(`Verified ${REMOTE_AGENT_SPECS.length} signed Remote Agent artifacts.`); +} else { + usage(); +} diff --git a/src/cli/help.ts b/src/cli/help.ts index 71850d375..fdf601e9c 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -175,6 +175,15 @@ const helpEntries: Record = { usage: "ocx system ...", summary: "Manage headless runtime settings, startup, sync, diagnostics, and updates.", }, + remote: { + usage: "ocx remote ...", + summary: "Manage the local-PC-first OpenCodex Remote onboarding flow.", + details: [ + "GitHub sign-in starts with link; status reports the same protected local state as the GUI.", + "Read the Remote password from piped stdin so it never appears in shell history or process arguments.", + "activate reserves one hostname; pairing-code prepares the separately installed signed Linux helper.", + ], + }, config: { usage: "ocx config ...", summary: "Inspect and safely modify validated OpenCodex configuration.", diff --git a/src/cli/index.ts b/src/cli/index.ts index 0cc8a45d2..36f1eefd6 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1055,6 +1055,11 @@ switch (command) { process.exitCode = await handleSystemCommand(args.slice(1)); break; } + case "remote": { + const { handleRemoteCommand } = await import("./remote-command"); + process.exitCode = await handleRemoteCommand(args.slice(1)); + break; + } case "config": { const { handleConfigCommand } = await import("./config-command"); process.exitCode = await handleConfigCommand(args.slice(1)); diff --git a/src/cli/remote-command.ts b/src/cli/remote-command.ts new file mode 100644 index 000000000..56d0df1cc --- /dev/null +++ b/src/cli/remote-command.ts @@ -0,0 +1,103 @@ +import { + CliUsageError, + printData, + readSecretLine, + rejectArgs, + runCliAction, + runtimeRequest, + summaryLines, + takeFlag, + takeOption, + type RuntimeApiDeps, +} from "./runtime-api"; + +const USAGE = `Usage: + ocx remote [status] [--json] + ocx remote link [--json] + printf '%s\\n' '' | ocx remote password [--json] + ocx remote activate --name --slug [--json] + ocx remote pairing-code [--json] + ocx remote disconnect --yes [--json]`; + +async function readStatus(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + rejectArgs(args, USAGE); + const result = await runtimeRequest("/api/remote/status", {}, deps); + printData(result, wantsJson, summaryLines(result)); +} + +async function link(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + rejectArgs(args, USAGE); + const result = await runtimeRequest>("/api/remote/link", { method: "POST" }, deps); + printData(result, wantsJson, [ + `Device code: ${String(result.userCode ?? "-")}`, + `Approve in browser: ${String(result.authorizeUrl ?? "-")}`, + ]); +} + +async function password(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + rejectArgs(args, USAGE, { redactValues: true }); + const value = await readSecretLine(deps, "Remote password"); + if (value.length < 10 || value.length > 128) throw new CliUsageError("Remote password must be 10 to 128 characters", USAGE); + const result = await runtimeRequest("/api/remote/password", { + method: "PUT", + body: JSON.stringify({ password: value }), + }, deps); + printData(result, wantsJson, ["Remote password updated. Existing browser sessions were revoked."]); +} + +async function activate(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + const name = takeOption(args, "--name"); + const slug = takeOption(args, "--slug"); + rejectArgs(args, USAGE); + if (!name || !slug) throw new CliUsageError("activate requires --name and --slug", USAGE); + const result = await runtimeRequest("/api/remote/activate", { + method: "POST", + body: JSON.stringify({ name, slug }), + }, deps); + printData(result, wantsJson, summaryLines(result)); +} + +async function pairingCode(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + rejectArgs(args, USAGE); + const result = await runtimeRequest>("/api/remote/pairing-code", { method: "POST" }, deps); + printData(result, wantsJson, [ + `One-time pairing code: ${String(result.code ?? "-")}`, + `Expires: ${String(result.expiresAt ?? "-")}`, + ]); +} + +async function disconnect(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + const yes = takeFlag(args, "--yes"); + rejectArgs(args, USAGE); + if (!yes) throw new CliUsageError("disconnect requires --yes", USAGE); + const result = await runtimeRequest("/api/remote/device", { method: "DELETE" }, deps); + printData(result, wantsJson, ["This computer was disconnected from OpenCodex Remote."]); +} + +/** Headless parity for the local Remote page; every action uses the same management API. */ +export async function handleRemoteCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { + return runCliAction(async () => { + const [sub = "status", ...rest] = argv; + if (sub === "status") await readStatus(rest, deps); + else if (sub === "link") await link(rest, deps); + else if (sub === "password") await password(rest, deps); + else if (sub === "activate") await activate(rest, deps); + else if (sub === "pairing-code" || sub === "pair") await pairingCode(rest, deps); + else if (sub === "disconnect") await disconnect(rest, deps); + else throw new CliUsageError(`unknown remote command ${sub}`, USAGE); + }); +} + +export const REMOTE_USAGE = USAGE; diff --git a/src/config.ts b/src/config.ts index 90265ddd5..fc944c9ec 100644 --- a/src/config.ts +++ b/src/config.ts @@ -697,6 +697,15 @@ const configSchema = z.object({ // parse: a hand-edited typo must never trip the backup-and-defaults repair // path below and wipe providers/pool accounts. Warning emitted in loadConfig. streamMode: z.enum(["auto", "legacy-tee", "eager-relay"]).optional().catch(undefined), + remoteAccess: z.object({ + enabled: z.boolean().optional(), + instanceId: z.string().regex(/^[A-Za-z0-9._:-]{1,128}$/), + issuer: z.string().regex(/^[A-Za-z0-9._:-]{1,128}$/), + publicKeys: z.array(z.object({ + kid: z.string().regex(/^[A-Za-z0-9._:-]{1,64}$/), + publicKeyPem: z.string().min(64).max(8192), + })).min(1).max(3), + }).optional(), // Same degrade-don't-reject rationale as the fields above: a hand-edited // non-string must not trip the backup-and-defaults repair path. Unset then // takes the canonical sideband path (src/server/live.ts normalizeSidebandRoot). diff --git a/src/remote/agent-bundle.ts b/src/remote/agent-bundle.ts new file mode 100644 index 000000000..da7a58c49 --- /dev/null +++ b/src/remote/agent-bundle.ts @@ -0,0 +1,399 @@ +import { + createHash, + createPrivateKey, + createPublicKey, + randomUUID, + sign, + verify, + type KeyObject, +} from "node:crypto"; +import { + chmodSync, + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; + +export const REMOTE_AGENT_MANIFEST_FILE = "remote-agent-manifest.json"; +export const REMOTE_AGENT_MANIFEST_SIGNATURE_FILE = `${REMOTE_AGENT_MANIFEST_FILE}.sig`; +export const REMOTE_AGENT_PUBLIC_KEY_FILE = "remote-agent-public-key.pem"; +export const REMOTE_AGENT_MAX_BYTES = 64 * 1024 * 1024; + +export const REMOTE_AGENT_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEABcZpftnO6RPhs2EyXahbCyNv61SF9lKGQubC+4vpXhA= +-----END PUBLIC KEY----- +`; + +export const REMOTE_AGENT_KEY_ID = "sha256:7e60478af3ab0994ca21b21fd6cd14e55e38d7ad204116d2e2cc4760e0c61446"; + +export const REMOTE_AGENT_SPECS = [ + { package: "linux-x64", executable: "opencodex-remote-agent", releaseFile: "opencodex-remote-agent-linux-x64" }, + { package: "linux-arm64", executable: "opencodex-remote-agent", releaseFile: "opencodex-remote-agent-linux-arm64" }, + { package: "macos-x64", executable: "opencodex-remote-agent", releaseFile: "opencodex-remote-agent-macos-x64" }, + { package: "macos-arm64", executable: "opencodex-remote-agent", releaseFile: "opencodex-remote-agent-macos-arm64" }, + { package: "windows-x64", executable: "opencodex-remote-agent.exe", releaseFile: "opencodex-remote-agent-windows-x64.exe" }, + { package: "windows-arm64", executable: "opencodex-remote-agent.exe", releaseFile: "opencodex-remote-agent-windows-arm64.exe" }, +] as const; + +export type RemoteAgentPackage = typeof REMOTE_AGENT_SPECS[number]["package"]; + +interface RemoteAgentManifestArtifact { + path: string; + releaseFile: string; + sha256: string; + signature: string; +} + +interface RemoteAgentUnsignedManifest { + schemaVersion: 1; + keyId: string; + sourceCommit: string; + packageVersion: string; + artifacts: Record; +} + +export interface RemoteAgentManifest extends RemoteAgentUnsignedManifest { + manifestSignature: string; +} + +interface AssembleRemoteAgentBundleOptions { + inputRoot: string; + bundleRoot: string; + releaseAssetsRoot: string; + privateKeyPem: string; + sourceCommit: string; + packageVersion: string; + expectedPublicKeyPem?: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasExactKeys(value: Record, expected: readonly string[]): boolean { + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]); +} + +function specForPackage(packageName: string) { + return REMOTE_AGENT_SPECS.find(spec => spec.package === packageName); +} + +export function remoteAgentPackageForRuntime( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch, +): RemoteAgentPackage | null { + const normalizedPlatform = platform === "win32" ? "windows" : platform === "darwin" ? "macos" : platform === "linux" ? "linux" : null; + if (!normalizedPlatform || (arch !== "x64" && arch !== "arm64")) return null; + const packageName = `${normalizedPlatform}-${arch}`; + return specForPackage(packageName) ? packageName as RemoteAgentPackage : null; +} + +function parseManifest(raw: string, expectedKeyId: string): RemoteAgentManifest { + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + throw new Error("Remote Agent manifest is not valid JSON"); + } + if (!isRecord(value) || !hasExactKeys(value, [ + "schemaVersion", "keyId", "sourceCommit", "packageVersion", "artifacts", "manifestSignature", + ])) { + throw new Error("Remote Agent manifest has an unsupported structure"); + } + if (value.schemaVersion !== 1 || value.keyId !== expectedKeyId) { + throw new Error("Remote Agent manifest uses an untrusted signing key"); + } + if (typeof value.sourceCommit !== "string" || !/^[0-9a-f]{40}$/.test(value.sourceCommit)) { + throw new Error("Remote Agent manifest has an invalid source commit"); + } + if ( + typeof value.packageVersion !== "string" + || value.packageVersion.length > 128 + || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(value.packageVersion) + ) { + throw new Error("Remote Agent manifest has an invalid package version"); + } + if (typeof value.manifestSignature !== "string" || !/^[A-Za-z0-9_-]{86}$/.test(value.manifestSignature)) { + throw new Error("Remote Agent manifest signature is invalid"); + } + if (!isRecord(value.artifacts)) throw new Error("Remote Agent manifest has no artifact map"); + const packageNames = REMOTE_AGENT_SPECS.map(spec => spec.package); + if (!hasExactKeys(value.artifacts, packageNames)) { + throw new Error("Remote Agent manifest does not contain the complete platform set"); + } + + for (const spec of REMOTE_AGENT_SPECS) { + const artifact = value.artifacts[spec.package]; + if (!isRecord(artifact) || !hasExactKeys(artifact, ["path", "releaseFile", "sha256", "signature"])) { + throw new Error(`Remote Agent manifest entry is invalid for ${spec.package}`); + } + if (artifact.path !== `${spec.package}/${spec.executable}` || artifact.releaseFile !== spec.releaseFile) { + throw new Error(`Remote Agent manifest path is invalid for ${spec.package}`); + } + if (typeof artifact.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(artifact.sha256)) { + throw new Error(`Remote Agent checksum is invalid for ${spec.package}`); + } + if (typeof artifact.signature !== "string" || !/^[A-Za-z0-9_-]{86}$/.test(artifact.signature)) { + throw new Error(`Remote Agent signature is invalid for ${spec.package}`); + } + } + return value as unknown as RemoteAgentManifest; +} + +function unsignedManifest(manifest: RemoteAgentManifest | RemoteAgentUnsignedManifest): RemoteAgentUnsignedManifest { + const artifacts = {} as Record; + for (const spec of REMOTE_AGENT_SPECS) { + const artifact = manifest.artifacts[spec.package]; + artifacts[spec.package] = { + path: artifact.path, + releaseFile: artifact.releaseFile, + sha256: artifact.sha256, + signature: artifact.signature, + }; + } + return { + schemaVersion: 1, + keyId: manifest.keyId, + sourceCommit: manifest.sourceCommit, + packageVersion: manifest.packageVersion, + artifacts, + }; +} + +function manifestSigningPayload(manifest: RemoteAgentManifest | RemoteAgentUnsignedManifest): Buffer { + return Buffer.from(`opencodex-remote-agent-manifest-v1\0${JSON.stringify(unsignedManifest(manifest))}`, "utf8"); +} + +function readBoundedRegularFile(path: string): Buffer { + let stat; + try { + stat = lstatSync(path); + } catch { + throw new Error("Remote Agent artifact is missing"); + } + if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > REMOTE_AGENT_MAX_BYTES) { + throw new Error("Remote Agent artifact is not a bounded regular file"); + } + return readFileSync(path); +} + +function publicKeyId(publicKey: KeyObject): string { + const der = publicKey.export({ type: "spki", format: "der" }); + return `sha256:${createHash("sha256").update(der).digest("hex")}`; +} + +function loadPublicKey(publicKeyPem: string): { key: KeyObject; keyId: string } { + try { + const key = createPublicKey(publicKeyPem); + if (key.asymmetricKeyType !== "ed25519") throw new Error("wrong key type"); + return { key, keyId: publicKeyId(key) }; + } catch { + throw new Error("Remote Agent public key is invalid"); + } +} + +function loadPrivateKey(privateKeyPem: string): KeyObject { + try { + const key = createPrivateKey(privateKeyPem); + if (key.asymmetricKeyType !== "ed25519") throw new Error("wrong key type"); + return key; + } catch { + throw new Error("Remote Agent signing key is invalid"); + } +} + +function verifyArtifactBytes(bytes: Buffer, artifact: RemoteAgentManifestArtifact, publicKey: KeyObject): void { + const digest = createHash("sha256").update(bytes).digest("hex"); + if (digest !== artifact.sha256) throw new Error("Remote Agent checksum verification failed"); + let signature: Buffer; + try { + signature = Buffer.from(artifact.signature, "base64url"); + } catch { + throw new Error("Remote Agent signature is malformed"); + } + if (signature.length !== 64 || !verify(null, bytes, publicKey, signature)) { + throw new Error("Remote Agent signature verification failed"); + } +} + +function verifyManifestSignature(manifest: RemoteAgentManifest, publicKey: KeyObject): void { + const signature = Buffer.from(manifest.manifestSignature, "base64url"); + if (signature.length !== 64 || !verify(null, manifestSigningPayload(manifest), publicKey, signature)) { + throw new Error("Remote Agent manifest signature verification failed"); + } +} + +function assertExpectedPackageVersion(manifest: RemoteAgentManifest, expectedPackageVersion?: string): void { + if (expectedPackageVersion !== undefined && manifest.packageVersion !== expectedPackageVersion) { + throw new Error("Remote Agent bundle does not match the installed package version"); + } +} + +export function readRemoteAgentManifest( + bundleRoot: string, + publicKeyPem: string = REMOTE_AGENT_PUBLIC_KEY_PEM, +): RemoteAgentManifest { + const { key, keyId } = loadPublicKey(publicKeyPem); + let raw: string; + try { + raw = readFileSync(join(bundleRoot, REMOTE_AGENT_MANIFEST_FILE), "utf8"); + } catch { + throw new Error("Remote Agent manifest is missing"); + } + const manifest = parseManifest(raw, keyId); + verifyManifestSignature(manifest, key); + return manifest; +} + +export function verifyRemoteAgentArtifact( + bundleRoot: string, + packageName: RemoteAgentPackage, + publicKeyPem: string = REMOTE_AGENT_PUBLIC_KEY_PEM, + expectedPackageVersion?: string, +): string { + const spec = specForPackage(packageName); + if (!spec) throw new Error("Remote Agent is not available for this platform"); + const { key, keyId } = loadPublicKey(publicKeyPem); + let raw: string; + try { + raw = readFileSync(join(bundleRoot, REMOTE_AGENT_MANIFEST_FILE), "utf8"); + } catch { + throw new Error("Remote Agent manifest is missing"); + } + const manifest = parseManifest(raw, keyId); + verifyManifestSignature(manifest, key); + assertExpectedPackageVersion(manifest, expectedPackageVersion); + const binary = join(bundleRoot, spec.package, spec.executable); + verifyArtifactBytes(readBoundedRegularFile(binary), manifest.artifacts[spec.package], key); + return binary; +} + +export function verifyRemoteAgentBundle( + bundleRoot: string, + publicKeyPem: string = REMOTE_AGENT_PUBLIC_KEY_PEM, + expectedPackageVersion?: string, +): RemoteAgentManifest { + const { key, keyId } = loadPublicKey(publicKeyPem); + let raw: string; + try { + raw = readFileSync(join(bundleRoot, REMOTE_AGENT_MANIFEST_FILE), "utf8"); + } catch { + throw new Error("Remote Agent manifest is missing"); + } + const manifest = parseManifest(raw, keyId); + verifyManifestSignature(manifest, key); + assertExpectedPackageVersion(manifest, expectedPackageVersion); + for (const spec of REMOTE_AGENT_SPECS) { + const bytes = readBoundedRegularFile(join(bundleRoot, spec.package, spec.executable)); + verifyArtifactBytes(bytes, manifest.artifacts[spec.package], key); + } + return manifest; +} + +function replaceDirectory(staged: string, destination: string): void { + const backup = `${destination}.old-${process.pid}-${randomUUID()}`; + const hadDestination = existsSync(destination); + if (hadDestination) renameSync(destination, backup); + try { + renameSync(staged, destination); + if (hadDestination) rmSync(backup, { recursive: true, force: true }); + } catch (error) { + if (hadDestination && existsSync(backup) && !existsSync(destination)) renameSync(backup, destination); + throw error; + } +} + +/** + * [Decision Log] + * - 목적과 의도: npm 설치 후 런타임 컴파일 없이 지원 플랫폼 Agent를 제공하되, 패키지나 캐시가 변조된 경우 실행을 차단한다. + * - 기존 구현 및 제약 조건: Agent source만 있었고 CI artifact, 서명 manifest, 런타임 신뢰 기준이 없었다. + * - 검토한 주요 대안: 사용자 PC cargo build, 플랫폼별 npm optional package, checksum만 사용, 단일 npm package에 서명 binary 동봉. + * - 선택한 방식: 공식 runner의 6개 native build를 release job에서 Ed25519로 서명하고, 정확한 allowlist manifest와 함께 원자적으로 조립한다. + * - 다른 대안 대신 이 방식을 선택한 이유: 설치 절차를 바꾸지 않으면서 공급망 변조를 fail-closed로 막고 build provenance를 한 release commit에 묶을 수 있다. + * - 장점, 단점 및 영향: Linux/macOS/Windows x64·arm64가 즉시 동작하지만 release secret 관리와 6개 runner 검증이 필수다. + */ +export function assembleRemoteAgentBundle(options: AssembleRemoteAgentBundleOptions): RemoteAgentManifest { + if (!/^[0-9a-f]{40}$/.test(options.sourceCommit)) throw new Error("Remote Agent source commit must be a full lowercase SHA"); + if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(options.packageVersion)) { + throw new Error("Remote Agent package version must be valid semver"); + } + const expectedPublicKeyPem = options.expectedPublicKeyPem ?? REMOTE_AGENT_PUBLIC_KEY_PEM; + const { key: expectedPublicKey, keyId } = loadPublicKey(expectedPublicKeyPem); + const privateKey = loadPrivateKey(options.privateKeyPem); + const derivedPublicKey = createPublicKey(privateKey.export({ type: "pkcs8", format: "pem" })); + if (publicKeyId(derivedPublicKey) !== keyId) throw new Error("Remote Agent signing key does not match the pinned public key"); + + const bundleRoot = resolve(options.bundleRoot); + const releaseRoot = resolve(options.releaseAssetsRoot); + const bundleStage = join(dirname(bundleRoot), `.${basename(bundleRoot)}.tmp-${process.pid}-${randomUUID()}`); + const releaseStage = join(dirname(releaseRoot), `.${basename(releaseRoot)}.tmp-${process.pid}-${randomUUID()}`); + const artifacts = {} as Record; + mkdirSync(bundleStage, { recursive: true, mode: 0o755 }); + mkdirSync(releaseStage, { recursive: true, mode: 0o755 }); + + try { + for (const spec of REMOTE_AGENT_SPECS) { + const source = join(options.inputRoot, `remote-agent-${spec.package}`, spec.executable); + const bytes = readBoundedRegularFile(source); + const signature = sign(null, bytes, privateKey); + const artifact: RemoteAgentManifestArtifact = { + path: `${spec.package}/${spec.executable}`, + releaseFile: spec.releaseFile, + sha256: createHash("sha256").update(bytes).digest("hex"), + signature: signature.toString("base64url"), + }; + artifacts[spec.package] = artifact; + + const packageDir = join(bundleStage, spec.package); + mkdirSync(packageDir, { recursive: true, mode: 0o755 }); + const packagedBinary = join(packageDir, spec.executable); + copyFileSync(source, packagedBinary); + if (!spec.package.startsWith("windows-")) chmodSync(packagedBinary, 0o755); + + const releaseBinary = join(releaseStage, spec.releaseFile); + copyFileSync(source, releaseBinary); + if (!spec.package.startsWith("windows-")) chmodSync(releaseBinary, 0o755); + writeFileSync(`${releaseBinary}.sig`, signature, { mode: 0o644 }); + } + + const unsigned: RemoteAgentUnsignedManifest = { + schemaVersion: 1, + keyId, + sourceCommit: options.sourceCommit, + packageVersion: options.packageVersion, + artifacts, + }; + const manifestSignature = sign(null, manifestSigningPayload(unsigned), privateKey); + const manifest: RemoteAgentManifest = { + ...unsigned, + manifestSignature: manifestSignature.toString("base64url"), + }; + const serialized = `${JSON.stringify(manifest, null, 2)}\n`; + writeFileSync(join(bundleStage, REMOTE_AGENT_MANIFEST_FILE), serialized, { mode: 0o644 }); + writeFileSync(join(releaseStage, REMOTE_AGENT_MANIFEST_FILE), serialized, { mode: 0o644 }); + writeFileSync(join(releaseStage, REMOTE_AGENT_MANIFEST_SIGNATURE_FILE), manifestSignature, { mode: 0o644 }); + writeFileSync(join(releaseStage, REMOTE_AGENT_PUBLIC_KEY_FILE), expectedPublicKeyPem, { mode: 0o644 }); + + verifyRemoteAgentBundle(bundleStage, expectedPublicKeyPem, options.packageVersion); + if (!verify(null, Buffer.from("bundle-key-check"), expectedPublicKey, sign(null, Buffer.from("bundle-key-check"), privateKey))) { + throw new Error("Remote Agent signing key verification failed"); + } + mkdirSync(dirname(bundleRoot), { recursive: true }); + mkdirSync(dirname(releaseRoot), { recursive: true }); + replaceDirectory(bundleStage, bundleRoot); + replaceDirectory(releaseStage, releaseRoot); + return manifest; + } catch (error) { + rmSync(bundleStage, { recursive: true, force: true }); + rmSync(releaseStage, { recursive: true, force: true }); + throw error; + } +} diff --git a/src/remote/agent.ts b/src/remote/agent.ts new file mode 100644 index 000000000..8057e4c7a --- /dev/null +++ b/src/remote/agent.ts @@ -0,0 +1,212 @@ +import { spawn } from "node:child_process"; +import { + accessSync, + closeSync, + constants, + existsSync, + openSync, + readFileSync, + readlinkSync, + realpathSync, + unlinkSync, +} from "node:fs"; +import { chmod, open } from "node:fs/promises"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { getConfigDir } from "../config"; +import { + REMOTE_AGENT_PUBLIC_KEY_PEM, + remoteAgentPackageForRuntime, + verifyRemoteAgentArtifact, +} from "./agent-bundle"; + +interface AgentPidFile { + pid: number; + binary: string; + startedAt: string; +} + +export interface RemoteRelayAgentStatus { + supported: boolean; + running: boolean; + pid?: number; + error?: string; +} + +function pidPath(): string { + return join(getConfigDir(), "remote-agent.pid.json"); +} + +function logPath(): string { + return join(getConfigDir(), "remote-agent.log"); +} + +function statePath(): string { + return join(getConfigDir(), "remote.json"); +} + +function processAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 1) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function recordedProcessAlive(recorded: AgentPidFile): boolean { + if (!processAlive(recorded.pid)) return false; + if (process.platform !== "linux") return true; + try { + // A stale pidfile must never make OCX signal an unrelated process after PID + // reuse. Linux exposes the actual executable without parsing a shell command. + const runningBinary = readlinkSync(`/proc/${recorded.pid}/exe`).replace(/ \(deleted\)$/, ""); + return resolve(runningBinary) === realpathSync(recorded.binary); + } catch { + return false; + } +} + +function readPidFile(): AgentPidFile | null { + try { + const parsed = JSON.parse(readFileSync(pidPath(), "utf8")) as Partial; + if ( + !Number.isInteger(parsed.pid) + || typeof parsed.binary !== "string" + || typeof parsed.startedAt !== "string" + ) return null; + return parsed as AgentPidFile; + } catch { + return null; + } +} + +function executableName(): string { + return process.platform === "win32" ? "opencodex-remote-agent.exe" : "opencodex-remote-agent"; +} + +function installedPackageVersion(packageRoot: string): string { + try { + const parsed = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as { version?: unknown }; + if (typeof parsed.version !== "string" || parsed.version.length === 0) throw new Error("missing version"); + return parsed.version; + } catch { + throw new Error("OpenCodex package version is unavailable for Remote Agent verification"); + } +} + +function resolveAgentBinary(): string | null { + const override = process.env.OPENCODEX_REMOTE_AGENT_BIN?.trim(); + if (override) { + if (!isAbsolute(override)) throw new Error("OPENCODEX_REMOTE_AGENT_BIN must be an absolute path"); + return override; + } + const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + const name = executableName(); + const bundleRoot = join(packageRoot, "bin", "remote-agent"); + const packageName = remoteAgentPackageForRuntime(); + if (existsSync(bundleRoot)) { + if (!packageName) throw new Error("Remote Agent is not available for this platform"); + // A present release bundle is a trust boundary. Never fall back to an + // unsigned development binary when that bundle is partial or tampered. + const binary = verifyRemoteAgentArtifact( + bundleRoot, + packageName, + REMOTE_AGENT_PUBLIC_KEY_PEM, + installedPackageVersion(packageRoot), + ); + if (process.platform !== "win32") accessSync(binary, constants.X_OK); + return binary; + } + + const candidates = [ + join(packageRoot, "remote-agent", "target", "release", name), + join(packageRoot, "remote-agent", "target", "debug", name), + ]; + return candidates.find(candidate => { + try { + if (!existsSync(candidate)) return false; + // Windows does not use POSIX execute bits; existence is sufficient there. + if (process.platform !== "win32") accessSync(candidate, constants.X_OK); + return true; + } catch { + return false; + } + }) ?? null; +} + +export function remoteRelayAgentStatus(): RemoteRelayAgentStatus { + const recorded = readPidFile(); + if (recorded && recordedProcessAlive(recorded)) { + return { supported: true, running: true, pid: recorded.pid }; + } + if (recorded) { + try { unlinkSync(pidPath()); } catch { /* stale PID cleanup is best-effort */ } + } + try { + const binary = resolveAgentBinary(); + return binary + ? { supported: true, running: false } + : { supported: false, running: false, error: "Remote Agent binary is not installed for this platform" }; + } catch (error) { + return { supported: false, running: false, error: error instanceof Error ? error.message : "Remote Agent unavailable" }; + } +} + +/** + * [Decision Log] + * - 목적과 의도: 사용자가 별도 root 설치나 페어링 코드를 다루지 않고 로컬 GUI 승인 뒤 동일 사용자 권한으로 outbound Agent를 시작한다. + * - 기존 구현 및 제약 조건: 기존 Agent는 root network 준비와 cloudflared를 전제로 했으며 npm 패키지에는 Agent source/binary가 포함되지 않았다. + * - 검토한 주요 대안: root systemd 서비스, 런타임 cargo build, Bun child pipe, 사전 빌드된 사용자 권한 Rust Agent. + * - 선택한 방식: 현재 플랫폼의 사전 빌드 binary만 명시적 argv로 detached 실행하고 remote.json은 0600 파일 경로로 전달한다. + * - 다른 대안 대신 이 방식을 선택한 이유: 포트·root·shell interpolation 없이 portable-pty와 WSS를 쓸 수 있고 런타임 컴파일 부하를 만들지 않는다. + * - 장점, 단점 및 영향: GUI에서 즉시 시작되고 기존 OCX 프로세스와 분리되지만 릴리스가 플랫폼별 서명 binary를 반드시 동봉해야 한다. + */ +export async function startRemoteRelayAgent(): Promise { + const current = remoteRelayAgentStatus(); + if (current.running || !current.supported) return current; + if (!existsSync(statePath())) return { supported: true, running: false, error: "Remote is not connected on this computer" }; + const binary = resolveAgentBinary(); + if (!binary) return { supported: false, running: false, error: "Remote Agent binary is not installed for this platform" }; + + await chmod(getConfigDir(), 0o700).catch(() => undefined); + const lock = await open(pidPath(), "wx", 0o600).catch(error => { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return null; + throw error; + }); + if (!lock) return remoteRelayAgentStatus(); + const logFd = openSync(logPath(), "a", 0o600); + try { + const child = spawn(binary, ["relay", "--state", statePath()], { + detached: true, + windowsHide: true, + stdio: ["ignore", logFd, logFd], + env: process.env, + }); + if (!child.pid) throw new Error("Remote Agent did not start"); + const record: AgentPidFile = { pid: child.pid, binary, startedAt: new Date().toISOString() }; + await lock.writeFile(`${JSON.stringify(record, null, 2)}\n`); + await lock.sync(); + child.unref(); + await new Promise(resolvePromise => setTimeout(resolvePromise, 150)); + return processAlive(child.pid) + ? { supported: true, running: true, pid: child.pid } + : { supported: true, running: false, error: "Remote Agent exited during startup" }; + } catch (error) { + await lock.close().catch(() => undefined); + try { unlinkSync(pidPath()); } catch { /* best effort */ } + return { supported: true, running: false, error: error instanceof Error ? error.message : "Remote Agent failed to start" }; + } finally { + closeSync(logFd); + await lock.close().catch(() => undefined); + } +} + +export function stopRemoteRelayAgent(): void { + const recorded = readPidFile(); + if (recorded && recordedProcessAlive(recorded)) { + try { process.kill(recorded.pid, "SIGTERM"); } catch { /* already exited */ } + } + try { unlinkSync(pidPath()); } catch { /* already absent */ } +} diff --git a/src/remote/client.ts b/src/remote/client.ts new file mode 100644 index 000000000..8728463a7 --- /dev/null +++ b/src/remote/client.ts @@ -0,0 +1,567 @@ +import { generateKeyPairSync } from "node:crypto"; +import { arch, hostname, platform } from "node:os"; +import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { z } from "zod"; +import { + createRemoteE2eeProfile, + rewrapRemoteE2eeProfile, + type RemoteE2eeEnvelope, +} from "./e2ee"; +import { + atomicWriteFile, + backupInvalidConfig, + getConfigDir, + hardenConfigDir, + hardenExistingSecret, +} from "../config"; + +const DEFAULT_CONTROL_PLANE_URL = "https://opencodexpages.me"; +const REMOTE_STATE_VERSION = 2; +const REMOTE_REQUEST_TIMEOUT_MS = 15_000; +const MAX_REMOTE_RESPONSE_BYTES = 256 * 1024; + +const remoteInstanceSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + slug: z.string(), + status: z.enum([ + "pending", "provisioning", "awaiting_agent", "connecting", "online", "degraded", "offline", + "suspending", "suspended", "deleting", "delete_failed", "deleted", + ]), + transportMode: z.enum(["mesh-tunnel", "outbound-relay"]).default("mesh-tunnel"), + publicUrl: z.string().url(), +}); + +const remoteAccountSchema = z.object({ + name: z.string(), + email: z.string().email(), + githubNumericId: z.string().regex(/^\d+$/), +}); + +const remoteE2eeEnvelopeSchema = z.object({ + version: z.literal("ocx-e2ee-v1"), + salt: z.string(), + nonce: z.string(), + ciphertext: z.string(), + rootPublicKey: z.string(), + kdf: z.object({ + algorithm: z.literal("argon2id"), + memoryKiB: z.number().int(), + iterations: z.number().int(), + parallelism: z.number().int(), + outputLength: z.literal(32), + }), +}); + +const remoteDeviceSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + platform: z.string(), + signingPublicKey: z.string(), + ecdhPublicKey: z.string().nullable(), + relayOnline: z.boolean(), + lastSeenAt: z.string().datetime().nullable(), + instanceId: z.string().uuid().nullable(), +}); + +const remoteProfileSchema = z.object({ + passwordSet: z.boolean(), + canActivate: z.boolean(), + e2ee: remoteE2eeEnvelopeSchema.nullable().default(null), + devices: z.array(remoteDeviceSchema).default([]), + instance: remoteInstanceSchema.nullable().default(null), +}); + +const legacyPendingStateSchema = z.object({ + version: z.literal(1), + state: z.literal("pending"), + controlPlaneUrl: z.string().url(), + privateKeyPem: z.string().min(1), + linkId: z.string().uuid(), + pollSecret: z.string().startsWith("ocxr_device_"), + userCode: z.string().min(4).max(32), + authorizeUrl: z.string().url(), + expiresAt: z.string().datetime(), +}); + +const pendingStateSchema = z.object({ + version: z.literal(REMOTE_STATE_VERSION), + state: z.literal("pending"), + controlPlaneUrl: z.string().url(), + privateKeyPem: z.string().min(1), + ecdhPrivateKeyPem: z.string().min(1), + linkId: z.string().uuid(), + pollSecret: z.string().startsWith("ocxr_device_"), + userCode: z.string().min(4).max(32), + authorizeUrl: z.string().url(), + expiresAt: z.string().datetime(), +}); + +const legacyConnectedStateSchema = z.object({ + version: z.literal(1), + state: z.literal("connected"), + controlPlaneUrl: z.string().url(), + privateKeyPem: z.string().min(1), + deviceId: z.string().uuid(), + deviceToken: z.string().startsWith("ocxr_device_"), + account: remoteAccountSchema, + passwordSet: z.boolean().default(false), + canActivate: z.boolean().default(false), + instance: remoteInstanceSchema.nullable().default(null), +}); + +const connectedStateSchema = z.object({ + version: z.literal(REMOTE_STATE_VERSION), + state: z.literal("connected"), + controlPlaneUrl: z.string().url(), + privateKeyPem: z.string().min(1), + ecdhPrivateKeyPem: z.string().min(1), + deviceId: z.string().uuid(), + deviceToken: z.string().startsWith("ocxr_device_"), + relayToken: z.string().startsWith("ocxr_agent_"), + relayUrl: z.string().url(), + account: remoteAccountSchema, + passwordSet: z.boolean().default(false), + canActivate: z.boolean().default(false), + e2ee: remoteE2eeEnvelopeSchema.nullable().default(null), + devices: z.array(remoteDeviceSchema).default([]), + instance: remoteInstanceSchema.nullable().default(null), +}); + +const remoteStateSchema = z.union([ + legacyPendingStateSchema, + pendingStateSchema, + legacyConnectedStateSchema, + connectedStateSchema, +]); +type RemoteState = z.infer; + +export type RemoteStatus = + | { state: "signed_out"; controlPlaneUrl: string; serviceReachable: boolean; error?: string } + | { + state: "pending"; + controlPlaneUrl: string; + serviceReachable: boolean; + authorizeUrl: string; + userCode: string; + expiresAt: string; + error?: string; + } + | { + state: "connected"; + controlPlaneUrl: string; + serviceReachable: boolean; + deviceId: string; + account: { name: string; email: string; githubNumericId: string }; + passwordSet: boolean; + canActivate: boolean; + relayReady: boolean; + e2ee: z.infer | null; + devices: z.infer[]; + instance: z.infer | null; + error?: string; + }; + +interface RemoteClientDependencies { + fetchImpl?: typeof fetch; + controlPlaneUrl?: string; + deviceName?: string; + devicePlatform?: string; +} + +interface RemoteJsonRequest { + method?: "GET" | "POST" | "PUT" | "DELETE"; + body?: unknown; + headers?: Record; +} + +function remoteStatePath(): string { + return join(getConfigDir(), "remote.json"); +} + +function configuredControlPlaneUrl(override?: string): string { + const candidate = override ?? process.env.OPENCODEX_REMOTE_CONTROL_URL ?? DEFAULT_CONTROL_PLANE_URL; + const url = new URL(candidate); + const localDevelopment = url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost"); + if (url.protocol !== "https:" && !localDevelopment) throw new Error("remote control plane must use HTTPS"); + url.pathname = ""; + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/$/, ""); +} + +function readState(): RemoteState | null { + const path = remoteStatePath(); + hardenConfigDir(); + if (!existsSync(path)) return null; + try { + hardenExistingSecret(path); + return remoteStateSchema.parse(JSON.parse(readFileSync(path, "utf8"))); + } catch { + backupInvalidConfig(path); + return null; + } +} + +function writeState(state: RemoteState): void { + const directory = getConfigDir(); + if (!existsSync(directory)) mkdirSync(directory, { recursive: true, mode: 0o700 }); + hardenConfigDir(); + atomicWriteFile(remoteStatePath(), `${JSON.stringify(state, null, 2)}\n`); +} + +function clearState(): void { + try { unlinkSync(remoteStatePath()); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } +} + +function safeError(error: unknown): string { + if (error instanceof Error && error.message) return error.message.slice(0, 240); + return "remote service unavailable"; +} + +async function remoteJson( + baseUrl: string, + path: string, + request: RemoteJsonRequest, + fetchImpl: typeof fetch, +): Promise { + const target = new URL(path, `${baseUrl}/`); + if (target.origin !== new URL(baseUrl).origin) throw new Error("remote request origin mismatch"); + const response = await fetchImpl(target, { + method: request.method ?? "GET", + redirect: "error", + signal: AbortSignal.timeout(REMOTE_REQUEST_TIMEOUT_MS), + headers: { + accept: "application/json", + ...(request.body === undefined ? {} : { "content-type": "application/json" }), + ...request.headers, + }, + body: request.body === undefined ? undefined : JSON.stringify(request.body), + }); + const declaredLength = Number(response.headers.get("content-length") ?? "0"); + if (Number.isFinite(declaredLength) && declaredLength > MAX_REMOTE_RESPONSE_BYTES) { + throw new Error("remote response is too large"); + } + const text = await response.text(); + if (text.length > MAX_REMOTE_RESPONSE_BYTES) throw new Error("remote response is too large"); + let body: unknown = {}; + try { body = text ? JSON.parse(text) : {}; } catch { throw new Error(`remote service returned HTTP ${response.status}`); } + if (!response.ok) { + const message = body && typeof body === "object" && "error" in body && typeof body.error === "string" + ? body.error + : `remote service returned HTTP ${response.status}`; + throw new Error(message); + } + return body as T; +} + +function publicStatus(state: RemoteState, serviceReachable: boolean, error?: string): RemoteStatus { + if (state.state === "pending") { + return { + state: "pending", + controlPlaneUrl: state.controlPlaneUrl, + serviceReachable, + authorizeUrl: state.authorizeUrl, + userCode: state.userCode, + expiresAt: state.expiresAt, + ...(error ? { error } : {}), + }; + } + return { + state: "connected", + controlPlaneUrl: state.controlPlaneUrl, + serviceReachable, + deviceId: state.deviceId, + account: state.account, + passwordSet: state.passwordSet, + canActivate: state.canActivate, + relayReady: state.version === REMOTE_STATE_VERSION, + e2ee: state.version === REMOTE_STATE_VERSION ? state.e2ee : null, + devices: state.version === REMOTE_STATE_VERSION ? state.devices : [], + instance: state.instance, + ...(error ? { error } : {}), + }; +} + +/** + * [Decision Log] + * - 목적과 의도: local dashboard가 GitHub token이나 Cloudflare account credential을 받지 않고 중앙 계정에 현재 PC만 연결한다. + * - 기존 구현 및 제약 조건: OCX management API는 loopback GUI session 경계가 이미 있고 remote platform은 별도 Agent pairing만 제공했다. + * - 검토한 주요 대안: localhost OAuth callback으로 GitHub token 전달, URL fragment에 장기 token 전달, one-time polling secret을 가진 device authorization. + * - 선택한 방식: local OCX가 Ed25519 key와 polling secret을 생성·보관하고 브라우저는 중앙의 request UUID와 확인 코드만 본다. + * - 다른 대안 대신 이 방식을 선택한 이유: 중앙 OAuth와 local management credential이 섞이지 않고 브라우저 history 및 referrer에 장기 secret이 남지 않는다. + * - 장점, 단점 및 영향: `ocx gui`가 실제 onboarding 시작점이 되며 장치별 폐기가 가능하다. `remote.json`은 새 장기 secret 파일이므로 기존 config hardening과 atomic write를 반드시 유지한다. + */ +export async function startRemoteLink(deps: RemoteClientDependencies = {}): Promise { + const existing = readState(); + if (existing?.state === "connected") return publicStatus(existing, true); + const controlPlaneUrl = configuredControlPlaneUrl(deps.controlPlaneUrl); + const { privateKey, publicKey } = generateKeyPairSync("ed25519"); + const { privateKey: ecdhPrivateKey, publicKey: ecdhPublicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const publicKeyDer = publicKey.export({ type: "spki", format: "der" }); + const ecdhPrivateKeyPem = ecdhPrivateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const ecdhPublicKeyDer = ecdhPublicKey.export({ type: "spki", format: "der" }); + const response = await remoteJson<{ + id: string; + pollSecret: string; + userCode: string; + authorizeUrl: string; + expiresAt: string; + }>(controlPlaneUrl, "/api/v1/device-links", { + method: "POST", + body: { + deviceName: (deps.deviceName ?? hostname()).slice(0, 80) || "OpenCodex device", + platform: deps.devicePlatform ?? `${platform()}-${arch()}`, + publicKey: Buffer.from(publicKeyDer).toString("base64url"), + ecdhPublicKey: Buffer.from(ecdhPublicKeyDer).toString("base64url"), + }, + }, deps.fetchImpl ?? fetch); + const authorizeUrl = new URL(response.authorizeUrl); + if (authorizeUrl.origin !== new URL(controlPlaneUrl).origin) throw new Error("remote authorization origin mismatch"); + const state = pendingStateSchema.parse({ + version: REMOTE_STATE_VERSION, + state: "pending", + controlPlaneUrl, + privateKeyPem, + ecdhPrivateKeyPem, + linkId: response.id, + pollSecret: response.pollSecret, + userCode: response.userCode, + authorizeUrl: authorizeUrl.toString(), + expiresAt: response.expiresAt, + }); + writeState(state); + return publicStatus(state, true); +} + +export async function getRemoteStatus(deps: RemoteClientDependencies = {}): Promise { + const state = readState(); + const controlPlaneUrl = state?.controlPlaneUrl ?? configuredControlPlaneUrl(deps.controlPlaneUrl); + if (!state) return { state: "signed_out", controlPlaneUrl, serviceReachable: true }; + const fetchImpl = deps.fetchImpl ?? fetch; + try { + if (state.state === "pending") { + if (new Date(state.expiresAt).getTime() <= Date.now()) { + clearState(); + return { state: "signed_out", controlPlaneUrl, serviceReachable: true, error: "device authorization expired" }; + } + const result = await remoteJson<{ + status: "pending" | "approved" | "expired" | "consumed"; + deviceId?: string; + deviceToken?: string; + relayToken?: string; + relayUrl?: string; + user?: { name: string; email: string; githubNumericId: string }; + }>(controlPlaneUrl, `/api/v1/device-links/${state.linkId}`, { + headers: { "x-ocxr-link-secret": state.pollSecret }, + }, fetchImpl); + if (result.status === "expired" || result.status === "consumed") { + clearState(); + return { state: "signed_out", controlPlaneUrl, serviceReachable: true, error: "device authorization expired" }; + } + if (result.status === "approved") { + if (!result.deviceId || !result.deviceToken || !result.user) throw new Error("device approval response is incomplete"); + let relayToken = result.relayToken; + let relayUrl = result.relayUrl; + let ecdhPrivateKeyPem = state.version === REMOTE_STATE_VERSION ? state.ecdhPrivateKeyPem : ""; + if (!relayToken || !relayUrl || !ecdhPrivateKeyPem) { + const pair = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + ecdhPrivateKeyPem = pair.privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const rotated = await remoteJson<{ relayToken: string; relayUrl: string }>( + controlPlaneUrl, + "/api/v1/devices/current/relay-credentials", + { + method: "POST", + headers: { authorization: `Bearer ${result.deviceToken}` }, + body: { ecdhPublicKey: pair.publicKey.export({ type: "spki", format: "der" }).toString("base64url") }, + }, + fetchImpl, + ); + relayToken = rotated.relayToken; + relayUrl = rotated.relayUrl; + } + const connected = connectedStateSchema.parse({ + version: REMOTE_STATE_VERSION, + state: "connected", + controlPlaneUrl, + privateKeyPem: state.privateKeyPem, + ecdhPrivateKeyPem, + deviceId: result.deviceId, + deviceToken: result.deviceToken, + relayToken, + relayUrl, + account: result.user, + passwordSet: false, + canActivate: false, + e2ee: null, + devices: [], + instance: null, + }); + writeState(connected); + await remoteJson(controlPlaneUrl, `/api/v1/device-links/${state.linkId}/ack`, { + method: "POST", + headers: { "x-ocxr-link-secret": state.pollSecret }, + }, fetchImpl); + return getRemoteStatus(deps); + } + return publicStatus(state, true); + } + + if (state.version === 1) { + const pair = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + const rotated = await remoteJson<{ relayToken: string; relayUrl: string }>( + controlPlaneUrl, + "/api/v1/devices/current/relay-credentials", + { + method: "POST", + headers: { authorization: `Bearer ${state.deviceToken}` }, + body: { ecdhPublicKey: pair.publicKey.export({ type: "spki", format: "der" }).toString("base64url") }, + }, + fetchImpl, + ); + const upgraded = connectedStateSchema.parse({ + ...state, + version: REMOTE_STATE_VERSION, + ecdhPrivateKeyPem: pair.privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + relayToken: rotated.relayToken, + relayUrl: rotated.relayUrl, + e2ee: null, + devices: [], + }); + writeState(upgraded); + return getRemoteStatus(deps); + } + + const result = await remoteJson<{ profile: z.input }>( + controlPlaneUrl, + "/api/v1/remote/profile", + { headers: { authorization: `Bearer ${state.deviceToken}` } }, + fetchImpl, + ); + const profile = remoteProfileSchema.parse(result.profile); + const refreshed = connectedStateSchema.parse({ ...state, ...profile }); + if (JSON.stringify(state) !== JSON.stringify(refreshed)) { + writeState(refreshed); + return publicStatus(refreshed, true); + } + return publicStatus(state, true); + } catch (error) { + return publicStatus(state, false, safeError(error)); + } +} + +export async function cancelRemoteLink(): Promise { + const state = readState(); + const controlPlaneUrl = state?.controlPlaneUrl ?? configuredControlPlaneUrl(); + if (state?.state === "pending") clearState(); + return { state: "signed_out", controlPlaneUrl, serviceReachable: true }; +} + +export async function setRemotePassword(password: string, deps: RemoteClientDependencies = {}): Promise { + if (password.length < 10 || password.length > 128) throw new Error("remote password must be 10 to 128 characters"); + const state = readState(); + if (!state || state.state !== "connected") throw new Error("remote account is not connected"); + if (state.version !== REMOTE_STATE_VERSION) throw new Error("refresh Remote before setting the encrypted password"); + const local = await createRemoteE2eeProfile(password, `github:${state.account.githubNumericId}`); + const result = await remoteJson<{ profile: z.input }>(state.controlPlaneUrl, "/api/v1/remote/e2ee-profile", { + method: "PUT", + headers: { authorization: `Bearer ${state.deviceToken}` }, + body: local, + }, deps.fetchImpl ?? fetch); + let profile = remoteProfileSchema.parse(result.profile); + if (profile.instance?.transportMode === "mesh-tunnel") { + const currentDeviceName = profile.devices.find(device => device.id === state.deviceId)?.name ?? profile.instance.name; + const activated = await remoteJson<{ profile: z.input }>( + state.controlPlaneUrl, + "/api/v1/remote/activate", + { + method: "POST", + headers: { authorization: `Bearer ${state.deviceToken}` }, + body: { name: currentDeviceName, slug: profile.instance.slug }, + }, + deps.fetchImpl ?? fetch, + ); + profile = remoteProfileSchema.parse(activated.profile); + } + const updated = connectedStateSchema.parse({ ...state, ...profile }); + writeState(updated); + return publicStatus(updated, true); +} + +export async function changeRemotePassword( + oldPassword: string, + newPassword: string, + deps: RemoteClientDependencies = {}, +): Promise { + const state = readState(); + if (!state || state.state !== "connected" || state.version !== REMOTE_STATE_VERSION || !state.e2ee) { + throw new Error("end-to-end encrypted Remote is not configured"); + } + const changed = await rewrapRemoteE2eeProfile( + oldPassword, + newPassword, + `github:${state.account.githubNumericId}`, + state.e2ee as RemoteE2eeEnvelope, + ); + const result = await remoteJson<{ profile: z.input }>( + state.controlPlaneUrl, + "/api/v1/remote/e2ee-profile/change", + { + method: "POST", + headers: { authorization: `Bearer ${state.deviceToken}` }, + body: changed, + }, + deps.fetchImpl ?? fetch, + ); + const updated = connectedStateSchema.parse({ ...state, ...remoteProfileSchema.parse(result.profile) }); + writeState(updated); + return publicStatus(updated, true); +} + +export async function activateRemoteInstance( + name: string, + slug: string, + deps: RemoteClientDependencies = {}, +): Promise { + const state = readState(); + if (!state || state.state !== "connected") throw new Error("remote account is not connected"); + const result = await remoteJson<{ profile: z.input }>(state.controlPlaneUrl, "/api/v1/remote/activate", { + method: "POST", + headers: { authorization: `Bearer ${state.deviceToken}` }, + body: { name, slug }, + }, deps.fetchImpl ?? fetch); + const updated = connectedStateSchema.parse({ ...state, ...remoteProfileSchema.parse(result.profile) }); + writeState(updated); + return publicStatus(updated, true); +} + +export async function createRemotePairingCode(deps: RemoteClientDependencies = {}): Promise<{ + code: string; + expiresAt: string; +}> { + const state = readState(); + if (!state || state.state !== "connected") throw new Error("remote account is not connected"); + return remoteJson(state.controlPlaneUrl, "/api/v1/remote/pairing-code", { + method: "POST", + headers: { authorization: `Bearer ${state.deviceToken}` }, + body: {}, + }, deps.fetchImpl ?? fetch); +} + +export async function disconnectRemoteDevice(deps: RemoteClientDependencies = {}): Promise { + const state = readState(); + const controlPlaneUrl = state?.controlPlaneUrl ?? configuredControlPlaneUrl(deps.controlPlaneUrl); + if (!state) return { state: "signed_out", controlPlaneUrl, serviceReachable: true }; + if (state.state === "connected") { + await remoteJson(state.controlPlaneUrl, "/api/v1/devices/current", { + method: "DELETE", + headers: { authorization: `Bearer ${state.deviceToken}` }, + }, deps.fetchImpl ?? fetch); + } + clearState(); + return { state: "signed_out", controlPlaneUrl, serviceReachable: true }; +} diff --git a/src/remote/e2ee.ts b/src/remote/e2ee.ts new file mode 100644 index 000000000..f66061f8d --- /dev/null +++ b/src/remote/e2ee.ts @@ -0,0 +1,195 @@ +import { + createCipheriv, + createDecipheriv, + generateKeyPairSync, + randomBytes, +} from "node:crypto"; +import { argon2idAsync } from "@noble/hashes/argon2.js"; +import { hkdf } from "@noble/hashes/hkdf.js"; +import { sha256 } from "@noble/hashes/sha2.js"; + +export const REMOTE_E2EE_VERSION = "ocx-e2ee-v1" as const; +export const REMOTE_E2EE_KDF = Object.freeze({ + algorithm: "argon2id" as const, + memoryKiB: 65_536, + iterations: 3, + parallelism: 1, + outputLength: 32, +}); + +export interface RemoteE2eeEnvelope { + version: typeof REMOTE_E2EE_VERSION; + salt: string; + nonce: string; + ciphertext: string; + rootPublicKey: string; + kdf: typeof REMOTE_E2EE_KDF; +} + +interface RemoteVaultPayload { + version: 1; + vaultKey: string; + rootPrivateKeyPem: string; +} + +const encoder = new TextEncoder(); + +function decode(value: string): Buffer { + return Buffer.from(value, "base64url"); +} + +function encode(value: Uint8Array): string { + return Buffer.from(value).toString("base64url"); +} + +function aad(accountBinding: string): Buffer { + return Buffer.from(`${REMOTE_E2EE_VERSION}\0${accountBinding}`, "utf8"); +} + +async function derive(password: string, salt: Uint8Array): Promise<{ authSecret: Buffer; wrapKey: Buffer }> { + if (password.length < 10 || password.length > 128) throw new Error("remote password must be 10 to 128 characters"); + if (salt.length !== 16) throw new Error("remote vault salt must be 16 bytes"); + const root = Buffer.from(await argon2idAsync(encoder.encode(password), salt, { + t: REMOTE_E2EE_KDF.iterations, + m: REMOTE_E2EE_KDF.memoryKiB, + p: REMOTE_E2EE_KDF.parallelism, + dkLen: REMOTE_E2EE_KDF.outputLength, + maxmem: 72 * 1024 * 1024, + asyncTick: 10, + })); + try { + return { + authSecret: Buffer.from(hkdf(sha256, root, salt, encoder.encode("opencodex remote auth v1"), 32)), + wrapKey: Buffer.from(hkdf(sha256, root, salt, encoder.encode("opencodex remote vault wrap v1"), 32)), + }; + } finally { + root.fill(0); + } +} + +function encryptPayload(payload: RemoteVaultPayload, wrapKey: Buffer, nonce: Buffer, accountBinding: string): Buffer { + const cipher = createCipheriv("aes-256-gcm", wrapKey, nonce, { authTagLength: 16 }); + cipher.setAAD(aad(accountBinding)); + const plaintext = Buffer.from(JSON.stringify(payload), "utf8"); + try { + return Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]); + } finally { + plaintext.fill(0); + } +} + +function decryptPayload(envelope: RemoteE2eeEnvelope, wrapKey: Buffer, accountBinding: string): RemoteVaultPayload { + if (envelope.version !== REMOTE_E2EE_VERSION) throw new Error("unsupported remote vault version"); + const nonce = decode(envelope.nonce); + const sealed = decode(envelope.ciphertext); + if (nonce.length !== 12 || sealed.length < 17 || sealed.length > 4096) throw new Error("invalid remote vault envelope"); + const ciphertext = sealed.subarray(0, sealed.length - 16); + const tag = sealed.subarray(sealed.length - 16); + const decipher = createDecipheriv("aes-256-gcm", wrapKey, nonce, { authTagLength: 16 }); + decipher.setAAD(aad(accountBinding)); + decipher.setAuthTag(tag); + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + try { + const parsed = JSON.parse(plaintext.toString("utf8")) as Partial; + if (parsed.version !== 1 || typeof parsed.vaultKey !== "string" || typeof parsed.rootPrivateKeyPem !== "string") { + throw new Error("invalid remote vault payload"); + } + if (decode(parsed.vaultKey).length !== 32 || parsed.rootPrivateKeyPem.length > 2048) { + throw new Error("invalid remote vault key material"); + } + return parsed as RemoteVaultPayload; + } finally { + plaintext.fill(0); + } +} + +export async function createRemoteE2eeProfile(password: string, accountBinding: string): Promise<{ + authSecret: string; + envelope: RemoteE2eeEnvelope; +}> { + const salt = randomBytes(16); + const nonce = randomBytes(12); + const vaultKey = randomBytes(32); + const { privateKey, publicKey } = generateKeyPairSync("ed25519"); + const payload: RemoteVaultPayload = { + version: 1, + vaultKey: encode(vaultKey), + rootPrivateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + }; + const derived = await derive(password, salt); + try { + const ciphertext = encryptPayload(payload, derived.wrapKey, nonce, accountBinding); + return { + authSecret: encode(derived.authSecret), + envelope: { + version: REMOTE_E2EE_VERSION, + salt: encode(salt), + nonce: encode(nonce), + ciphertext: encode(ciphertext), + rootPublicKey: encode(publicKey.export({ type: "spki", format: "der" })), + kdf: REMOTE_E2EE_KDF, + }, + }; + } finally { + vaultKey.fill(0); + derived.authSecret.fill(0); + derived.wrapKey.fill(0); + } +} + +export async function unlockRemoteE2eeProfile(password: string, accountBinding: string, envelope: RemoteE2eeEnvelope): Promise<{ + authSecret: string; + vault: RemoteVaultPayload; +}> { + const salt = decode(envelope.salt); + const derived = await derive(password, salt); + try { + return { + authSecret: encode(derived.authSecret), + vault: decryptPayload(envelope, derived.wrapKey, accountBinding), + }; + } finally { + derived.authSecret.fill(0); + derived.wrapKey.fill(0); + } +} + +export async function rewrapRemoteE2eeProfile( + oldPassword: string, + newPassword: string, + accountBinding: string, + envelope: RemoteE2eeEnvelope, +): Promise<{ oldAuthSecret: string; newAuthSecret: string; envelope: RemoteE2eeEnvelope }> { + const unlocked = await unlockRemoteE2eeProfile(oldPassword, accountBinding, envelope); + const salt = randomBytes(16); + const nonce = randomBytes(12); + const derived = await derive(newPassword, salt); + try { + const ciphertext = encryptPayload(unlocked.vault, derived.wrapKey, nonce, accountBinding); + return { + oldAuthSecret: unlocked.authSecret, + newAuthSecret: encode(derived.authSecret), + envelope: { + version: REMOTE_E2EE_VERSION, + salt: encode(salt), + nonce: encode(nonce), + ciphertext: encode(ciphertext), + rootPublicKey: envelope.rootPublicKey, + kdf: REMOTE_E2EE_KDF, + }, + }; + } finally { + derived.authSecret.fill(0); + derived.wrapKey.fill(0); + } +} + +/** + * [Decision Log] + * - 목적과 의도: 중앙 서버가 사용자의 원문 비밀번호나 vault 복호화 키를 보지 못하게 하면서 GitHub 외 별도 지식요소를 유지한다. + * - 기존 구현 및 제약 조건: 중앙 Argon2id password hash는 인증은 제공하지만 서버가 입력 비밀번호를 직접 받고 E2EE 키 봉투가 없었다. + * - 검토한 주요 대안: 서버측 password hash만 유지, 브라우저 PBKDF2, OPAQUE PAKE, client Argon2id와 HKDF domain separation. + * - 선택한 방식: 로컬에서 Argon2id root를 만들고 인증용 secret과 AES-GCM wrapping key를 서로 다른 HKDF info로 분리한다. + * - 다른 대안 대신 이 방식을 선택한 이유: 현재 Bun/브라우저에서 상호운용 가능하고 서버가 받은 인증 secret으로 vault key를 유도할 수 없다. + * - 장점, 단점 및 영향: DB 유출의 오프라인 대입 비용과 저장 데이터 기밀성이 개선되지만 auth secret 자체는 password-equivalent이므로 TLS, rate limit, session revocation이 계속 필요하다. OPAQUE는 후속 강화 후보다. + */ diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 07db2e453..1de486bb5 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -65,6 +65,7 @@ import { handleAgentSettingsRoutes } from "./management/agent-settings-routes"; import { handleOauthAccountRoutes } from "./management/oauth-account-routes"; import { handleComboRoutes } from "./management/combo-routes"; import { handleSystemRoutes } from "./management/system-routes"; +import { handleRemoteRoutes } from "./management/remote-routes"; import { handleSidebarRoutes } from "./management/sidebar-routes"; import type { ManagementContext } from "./management/context"; export type { ManagementApiDeps } from "./management/context"; @@ -131,6 +132,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon ?? (await handleAgentSettingsRoutes(ctx)) ?? (await handleOauthAccountRoutes(ctx)) ?? (await handleComboRoutes(ctx)) + ?? (await handleRemoteRoutes(ctx)) ?? (await handleSystemRoutes(ctx)) ?? (await handleSidebarRoutes(ctx)); if (routed) return routed; diff --git a/src/server/management-auth.ts b/src/server/management-auth.ts index 52fd46401..565eadb39 100644 --- a/src/server/management-auth.ts +++ b/src/server/management-auth.ts @@ -23,6 +23,7 @@ import { managementRequestOrigin, parseHttpHost, } from "./auth-cors"; +import { verifyRemoteManagementAssertion } from "./remote-assertion"; const GUI_SESSION_TTL_MS = 5 * 60_000; const GUI_SESSION_LIMIT = 128; @@ -192,6 +193,7 @@ export function requireManagementAuth( if (!state.available) { return Response.json({ error: "management API unavailable" }, { status: 503 }); } + if (config && verifyRemoteManagementAssertion(req, config)) return null; const actual = req.headers.get("x-opencodex-api-key")?.trim() || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); if (actual && equalSecret(actual, state.token)) return null; diff --git a/src/server/management/remote-routes.ts b/src/server/management/remote-routes.ts new file mode 100644 index 000000000..8e80b8e1b --- /dev/null +++ b/src/server/management/remote-routes.ts @@ -0,0 +1,95 @@ +import { + activateRemoteInstance, + cancelRemoteLink, + changeRemotePassword, + createRemotePairingCode, + disconnectRemoteDevice, + getRemoteStatus, + setRemotePassword, + startRemoteLink, +} from "../../remote/client"; +import { + remoteRelayAgentStatus, + startRemoteRelayAgent, + stopRemoteRelayAgent, +} from "../../remote/agent"; +import { jsonResponse } from "../auth-cors"; +import type { ManagementContext } from "./context"; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "remote request failed"; +} + +export async function handleRemoteRoutes(ctx: ManagementContext): Promise { + const { req, url, config } = ctx; + if (url.pathname === "/api/remote/status" && req.method === "GET") { + return jsonResponse({ ...await getRemoteStatus(), agent: remoteRelayAgentStatus() }, 200, req, config); + } + if (url.pathname === "/api/remote/link" && req.method === "POST") { + try { + return jsonResponse(await startRemoteLink(), 201, req, config); + } catch (error) { + return jsonResponse({ error: errorMessage(error) }, 400, req, config); + } + } + if (url.pathname === "/api/remote/link/cancel" && req.method === "POST") { + return jsonResponse(await cancelRemoteLink(), 200, req, config); + } + if (url.pathname === "/api/remote/password" && req.method === "PUT") { + try { + const body = await req.json() as { password?: unknown }; + if (typeof body.password !== "string") throw new Error("remote password is required"); + const status = await setRemotePassword(body.password); + const agent = status.state === "connected" && status.instance + ? await startRemoteRelayAgent() + : remoteRelayAgentStatus(); + return jsonResponse({ ...status, agent }, 200, req, config); + } catch (error) { + return jsonResponse({ error: errorMessage(error) }, 400, req, config); + } + } + if (url.pathname === "/api/remote/password" && req.method === "PATCH") { + try { + const body = await req.json() as { oldPassword?: unknown; newPassword?: unknown }; + if (typeof body.oldPassword !== "string" || typeof body.newPassword !== "string") { + throw new Error("old and new remote passwords are required"); + } + return jsonResponse(await changeRemotePassword(body.oldPassword, body.newPassword), 200, req, config); + } catch (error) { + return jsonResponse({ error: errorMessage(error) }, 400, req, config); + } + } + if (url.pathname === "/api/remote/activate" && req.method === "POST") { + try { + const body = await req.json() as { name?: unknown; slug?: unknown }; + if (typeof body.name !== "string" || typeof body.slug !== "string") { + throw new Error("remote name and domain are required"); + } + const status = await activateRemoteInstance(body.name, body.slug); + const agent = await startRemoteRelayAgent(); + return jsonResponse({ ...status, agent }, 202, req, config); + } catch (error) { + return jsonResponse({ error: errorMessage(error) }, 400, req, config); + } + } + if (url.pathname === "/api/remote/agent/start" && req.method === "POST") { + const agent = await startRemoteRelayAgent(); + return jsonResponse({ ...await getRemoteStatus(), agent }, agent.running ? 200 : 409, req, config); + } + if (url.pathname === "/api/remote/pairing-code" && req.method === "POST") { + try { + return jsonResponse(await createRemotePairingCode(), 201, req, config); + } catch (error) { + return jsonResponse({ error: errorMessage(error) }, 409, req, config); + } + } + if (url.pathname === "/api/remote/device" && req.method === "DELETE") { + try { + stopRemoteRelayAgent(); + return jsonResponse({ ...await disconnectRemoteDevice(), agent: remoteRelayAgentStatus() }, 200, req, config); + } catch (error) { + return jsonResponse({ error: errorMessage(error) }, 502, req, config); + } + } + return null; +} diff --git a/src/server/remote-assertion.ts b/src/server/remote-assertion.ts new file mode 100644 index 000000000..abbcf2a92 --- /dev/null +++ b/src/server/remote-assertion.ts @@ -0,0 +1,131 @@ +import { createHash, createPublicKey, verify } from "node:crypto"; +import type { OcxConfig } from "../types"; + +export const REMOTE_ASSERTION_HEADER = "x-opencodex-remote-assertion"; +export const REMOTE_ASSERTION_AUDIENCE = "opencodex-management"; + +const MAX_ASSERTION_BYTES = 8 * 1024; +const MAX_ASSERTION_LIFETIME_SECONDS = 30; +const CLOCK_SKEW_SECONDS = 5; +const REPLAY_LIMIT = 4096; + +interface RemoteAssertionHeader { + alg: "EdDSA"; + typ: "JWT"; + kid: string; +} + +interface RemoteAssertionClaims { + iss: string; + aud: string; + instance_id: string; + user_id: string; + method: string; + path_sha256: string; + iat: number; + exp: number; + jti: string; +} + +const seenAssertions = new Map(); + +function decodeJsonSegment(segment: string): T | null { + try { + const value = JSON.parse(Buffer.from(segment, "base64url").toString("utf8")); + return value && typeof value === "object" && !Array.isArray(value) ? value as T : null; + } catch { + return null; + } +} + +/** + * Canonicalize only the path/query that the gateway and local proxy are + * permitted to preserve. Sorting URLSearchParams makes equivalent query order + * produce one digest while URL parsing normalizes percent encoding. + */ +export function remoteAssertionPathHash(input: string | URL): string { + const url = input instanceof URL ? new URL(input) : new URL(input, "http://opencodex.invalid"); + url.searchParams.sort(); + const canonical = `${url.pathname}${url.search}`; + return createHash("sha256").update(canonical).digest("hex"); +} + +function isSafeIdentifier(value: unknown, maxLength = 128): value is string { + return typeof value === "string" && value.length >= 1 && value.length <= maxLength + && /^[A-Za-z0-9._:-]+$/.test(value); +} + +function isRemoteAssertionShape( + header: RemoteAssertionHeader | null, + claims: RemoteAssertionClaims | null, +): header is RemoteAssertionHeader { + return !!header && !!claims + && header.alg === "EdDSA" + && header.typ === "JWT" + && isSafeIdentifier(header.kid, 64) + && isSafeIdentifier(claims.iss) + && claims.aud === REMOTE_ASSERTION_AUDIENCE + && isSafeIdentifier(claims.instance_id) + && isSafeIdentifier(claims.user_id) + && typeof claims.method === "string" + && /^[A-Z]+$/.test(claims.method) + && typeof claims.path_sha256 === "string" + && /^[a-f0-9]{64}$/.test(claims.path_sha256) + && Number.isInteger(claims.iat) + && Number.isInteger(claims.exp) + && isSafeIdentifier(claims.jti, 128); +} + +function consumeJti(jti: string, exp: number, nowSeconds: number): boolean { + for (const [seenJti, seenExp] of seenAssertions) { + if (seenExp + CLOCK_SKEW_SECONDS < nowSeconds) seenAssertions.delete(seenJti); + } + if (seenAssertions.has(jti)) return false; + while (seenAssertions.size >= REPLAY_LIMIT) { + const oldest = seenAssertions.keys().next().value as string | undefined; + if (!oldest) break; + seenAssertions.delete(oldest); + } + seenAssertions.set(jti, exp); + return true; +} + +export function verifyRemoteManagementAssertion( + req: Request, + config: OcxConfig, + now = Date.now(), +): boolean { + const remote = config.remoteAccess; + if (!remote?.enabled) return false; + const token = req.headers.get(REMOTE_ASSERTION_HEADER)?.trim(); + if (!token || token.length > MAX_ASSERTION_BYTES) return false; + const segments = token.split("."); + if (segments.length !== 3 || segments.some(segment => !segment)) return false; + + const header = decodeJsonSegment(segments[0]); + const claims = decodeJsonSegment(segments[1]); + if (!isRemoteAssertionShape(header, claims) || !claims) return false; + if (claims.iss !== remote.issuer || claims.instance_id !== remote.instanceId) return false; + if (claims.method !== req.method.toUpperCase()) return false; + if (claims.path_sha256 !== remoteAssertionPathHash(req.url)) return false; + + const nowSeconds = Math.floor(now / 1000); + if (claims.iat > nowSeconds + CLOCK_SKEW_SECONDS || claims.exp < nowSeconds - CLOCK_SKEW_SECONDS) return false; + if (claims.exp <= claims.iat || claims.exp - claims.iat > MAX_ASSERTION_LIFETIME_SECONDS) return false; + + const configuredKey = remote.publicKeys.find(key => key.kid === header.kid); + if (!configuredKey) return false; + try { + const signingInput = Buffer.from(`${segments[0]}.${segments[1]}`, "ascii"); + const signature = Buffer.from(segments[2], "base64url"); + const valid = verify(null, signingInput, createPublicKey(configuredKey.publicKeyPem), signature); + return valid && consumeJti(claims.jti, claims.exp, nowSeconds); + } catch { + return false; + } +} + +/** Test-only reset; exported explicitly to keep replay tests deterministic. */ +export function resetRemoteAssertionReplayCacheForTest(): void { + seenAssertions.clear(); +} diff --git a/src/types.ts b/src/types.ts index d950eff1b..d1d2f4b59 100644 --- a/src/types.ts +++ b/src/types.ts @@ -739,6 +739,19 @@ export interface OcxConfig { tokenGuardian?: OcxTokenGuardianConfig; /** Additional origins allowed for CORS (e.g. ["https://clisu-oracle.tail19a2d7.ts.net"]). Loopback origins are always allowed. */ corsAllowOrigins?: string[]; + /** + * Optional OpenCodex Remote management credential verifier. + * + * The local Rust agent validates the gateway assertion before proxying any + * request. Management routes verify it again here so a compromised or + * accidentally exposed local ingress cannot silently widen `/api/*` access. + */ + remoteAccess?: { + enabled?: boolean; + instanceId: string; + issuer: string; + publicKeys: Array<{ kid: string; publicKeyPem: string }>; + }; } export type OcxAccountPoolRotationStrategy = "quota" | "round-robin" | "fill-first"; diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index f1821052c..dfe70400b 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -68,6 +68,7 @@ this document owns is which module holds which area and what invariant that area | Providers | Create/update/delete ordinary provider configs and enrich registry metadata. The reserved `openai` card exposes Pool(default)/Direct account mode; `openai-apikey` remains the separate API route. | | Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. | | OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, and remove one. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. | +| Remote | `GET /api/remote/status`, `POST /api/remote/link`, `POST /api/remote/link/cancel`, `PUT/PATCH /api/remote/password`, `POST /api/remote/activate`, `POST /api/remote/agent/start`, `POST /api/remote/pairing-code`, and `DELETE /api/remote/device` own the local-PC-first Remote flow. The browser DTO never includes the polling secret, device token, Relay token, or GitHub token. Central state is mirrored into protected `remote.json`; a packaged unprivileged Rust Agent opens the outbound encrypted Relay connection without user port forwarding. | | Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | | OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. | | Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | @@ -102,6 +103,32 @@ User aliases are display metadata only. Codex pool aliases live on `CodexAccount identity, active selection, and routing never consult these fields. The matching CLI is `ocx account alias ` (`rename` is accepted as a synonym). +## Remote onboarding boundary + +`/#remote` is the user entrypoint. The local proxy creates an Ed25519 device key and a ten-minute +polling secret, while the central browser receives only the request UUID and comparison code. After +GitHub approval, the central service returns a revocable OpenCodex device token through that polling +channel and deletes the encrypted handoff after the local ACK. GitHub OAuth access, refresh, and ID +tokens are discarded by the central auth database hooks and never enter `remote.json`. + +The separate Remote password is derived with Argon2id locally. The central service stores only a +hash of the HKDF-separated authentication secret and cannot unwrap the encrypted account vault. +Changing the password requires the old password, rewraps the vault locally, and revokes existing +instance browser sessions. Five failed attempts lock verification for 15 minutes. An unauthenticated +top-level request to an active instance hostname redirects to `/access/` on the central origin; +API and WebSocket requests still fail closed without redirection. Successful GitHub ownership and +password verification creates a 60-second exchange code and then a host-only instance cookie. + +```text +[Decision Log] +- 목적과 의도: 중앙 운영 콘솔이 아니라 각 사용자 PC의 `ocx gui`에서 Remote 설정을 시작하고, 원격 접속에는 GitHub 소유권과 별도 비밀번호를 모두 요구한다. +- 기존 구현 및 제약 조건: 기존 플랫폼 dashboard는 instance 생성과 Agent code를 직접 다뤘고 일반 npm OCX와 중앙 OAuth 사이의 장치 승인 상태가 없었다. +- 검토한 주요 대안: localhost OAuth callback으로 GitHub token 전달, 장기 token을 URL fragment에 포함, polling secret이 분리된 device authorization과 중앙 password gate. +- 선택한 방식: local device authorization, 중앙 GitHub identity-only OAuth, Argon2id password, hostname별 exchange session을 결합한다. +- 다른 대안 대신 이 방식을 선택한 이유: GitHub/Cloudflare credential이 사용자 브라우저 history나 PC로 이동하지 않으며 각 PC와 instance session을 따로 폐기할 수 있다. +- 장점, 단점 및 영향: Tailscale과 유사한 local-first UX와 중앙 정책 경계가 생긴다. Linux/macOS/Windows x64·arm64는 npm에 동봉된 서명 Agent로 연결되며 다른 조합은 명확히 미지원 처리한다. +``` + ## Sidebar stop button The dashboard sidebar includes a stop button that calls `POST /api/stop`. The button shows a diff --git a/structure/06_docs-and-release.md b/structure/06_docs-and-release.md index b357bd0ef..c7069cc9a 100644 --- a/structure/06_docs-and-release.md +++ b/structure/06_docs-and-release.md @@ -40,8 +40,8 @@ bun run build | Workflow | Trigger | Purpose | | --- | --- | --- | -| `.github/workflows/ci.yml` | `pull_request` to `main`/`dev`, `push` to `main`/`preview`/`dev`, or manual dispatch when runtime/package paths change | Cross-platform runtime/package quality gate on Linux, Windows, and macOS. The `test` job (Bun) runs typecheck, `bun test --isolate tests`, the GUI suite (`cd gui && bun test tests`), the privacy scan, release-helper syntax check, GUI lint/build, and `ocx help`; `npm-global-smoke` (Node only, **no setup-bun**) builds package assets, packs the tarball, installs it globally, and runs `ocx help` to prove the bundled-Bun launcher works without a separate Bun install. | -| `.github/workflows/release.yml` | Manual dispatch only | npm publish/dry-run workflow. It requires the exact `GITHUB_SHA` to have a successful Cross-platform CI run before publish or dry-run. | +| `.github/workflows/ci.yml` | `pull_request` to `main`/`dev`, `push` to `main`/`preview`/`dev`, or manual dispatch when runtime/package paths change | Cross-platform runtime/package quality gate on Linux, Windows, and macOS. The `test` job (Bun) runs typecheck, `bun test --isolate tests`, the GUI suite (`cd gui && bun test tests`), the privacy scan, release-helper syntax check, GUI lint/build, and `ocx help`; `npm-global-smoke` proves the bundled-Bun global install; the six-entry `remote-agent` matrix runs native Rust fmt/clippy/test/release builds for Linux/macOS/Windows x64 and arm64. | +| `.github/workflows/release.yml` | Manual dispatch only | npm publish/dry-run workflow. It requires successful Cross-platform CI for the exact `GITHUB_SHA`, rebuilds all six native Remote Agents, assembles an Ed25519-signed exact manifest, requires that bundle during npm packaging, and attaches binaries/signatures to the GitHub Release. | | `.github/workflows/deploy-docs.yml` | `push` to `main` touching `docs-site/**` or the workflow, or manual dispatch | Build and publish the Astro/Starlight docs site to GitHub Pages. | | `.github/workflows/service-lifecycle.yml` | `pull_request` to `main`/`dev` and `push`, both filtered on the service path set (`src/service.ts`, `src/cli.ts`, `src/cli/index.ts`, `src/lib/bun-runtime.ts`, `package.json`, `bun.lock`, the workflow), or manual dispatch | Service-lifecycle smoke on three platforms: Linux systemd, macOS launchd, and Windows Scheduled Tasks. Each installs, verifies, stops via `ocx stop`, and uninstalls. The path list is kept in sync with the `release.yml` service-gate regex. | | `.github/workflows/enforce-pr-target.yml` | `pull_request_target` (opened, reopened, edited, ready_for_review, synchronize) | The `enforce-target` gate: rejects pull requests whose head ancestry sits on the `main` tip while far behind `dev`, and rejects empty or malformed descriptions. Stacked child PRs targeting another open PR's head skip the wrong-base gate. | @@ -141,7 +141,12 @@ Invariants: Package release is npm-focused. `package.json` exposes `opencodex` and `ocx`, `prepublishOnly` runs typecheck and GUI build, and `scripts/release.ts` now runs local typecheck, `bun test --isolate tests`, and `bun run privacy:scan` before the version bump, commit/push, Cross-platform CI wait, and GitHub -Release workflow dispatch. Docs publishing is separate from npm release publishing. +Release workflow dispatch. The release workflow rebuilds and signs the six-platform Remote Agent bundle; +the private signing key exists only as the `REMOTE_AGENT_SIGNING_KEY` secret in the protected +`remote-agent-release` environment, while dry-runs use a one-run ephemeral key and never receive the +production key. The pinned public key and key ID are source-controlled for fail-closed runtime +verification. Docs publishing is +separate from npm release publishing. ## Release metadata invariants @@ -176,8 +181,8 @@ version through `scripts/release.ts`. ## Cross-platform CI -`.github/workflows/ci.yml` is the ordinary quality gate for runtime/package changes. It runs on -Linux, Windows, and macOS with two job families: +`.github/workflows/ci.yml` is the ordinary quality gate for runtime/package changes. It runs Bun/Node +checks on Linux, Windows, and macOS plus a native Remote Agent matrix: ```bash bun install --frozen-lockfile @@ -202,6 +207,12 @@ ocx help The CI intentionally does not build docs, run coverage, or perform remote Ubuntu/RDP smoke tests. Those stay outside the default gate until a concrete regression justifies the extra runtime. +The Agent matrix pins Rust via `remote-agent/rust-toolchain.toml` and covers Linux musl, macOS, and +Windows on x64 and arm64. It does not sign pull-request artifacts. Signing happens only after a manual +release dispatch, after all six native builds complete. Dry-runs sign with an ephemeral key; only a +real release selects the protected `remote-agent-release` environment and can read the production +signing secret. Every dispatch requires the exact audited SHA. + The Release workflow remains manual and publish-focused. Before any dry-run or publish step, it checks that the exact release commit (`GITHUB_SHA`) already has a successful Cross-platform CI run. This keeps release runs short and makes release a deployment of a verified commit rather than a diff --git a/structure/12_remote-platform.md b/structure/12_remote-platform.md new file mode 100644 index 000000000..0887f4f9c --- /dev/null +++ b/structure/12_remote-platform.md @@ -0,0 +1,123 @@ +# OpenCodex Remote platform boundary + +OpenCodex Remote는 로컬 `ocx gui` 계정/기기 설정, 중앙 Control Plane/Gateway, 브라우저 작업공간, 사용자 권한 Rust Agent로 구성된다. 신규 경로의 기준 결정은 [ADR 0013](./adr/0013-remote-outbound-e2ee-relay.md)이다. 기존 Cloudflare Mesh 경로는 [ADR 0012](./adr/0012-remote-private-mesh.md)에 rollback 기록으로 남긴다. + +## Repository boundary + +- `src/remote/` — 보호된 `remote.json`, GitHub device flow, E2EE vault 생성/변경, Agent lifecycle. +- `src/server/management/remote-routes.ts` — local `ocx gui`가 사용하는 same-origin management API. +- `platform/server/` — GitHub identity, workspace/device metadata, access session, outbound relay Gateway. +- `platform/web/` — 중앙 로그인/해제 화면과 wildcard domain의 xterm.js 작업공간. +- `platform/server/migrations/0003_outbound_e2ee_relay.sql` — E2EE envelope, relay device presence, terminal session schema. +- `remote-agent/` — Rust outbound WSS, E2EE handshake, fixed-profile portable PTY. + +중앙 플랫폼 source는 npm package에 포함하지 않는다. 일반 OCX release에는 플랫폼별로 빌드·서명된 최소 Agent binary만 `bin/remote-agent/-/`에 동봉해야 한다. 런타임 `cargo build`는 금지한다. + +## Signed multi-platform Agent bundle + +지원 bundle은 정확히 여섯 개다. + +| Package path | Rust target | GitHub runner | +| --- | --- | --- | +| `linux-x64` | `x86_64-unknown-linux-musl` | `ubuntu-24.04` | +| `linux-arm64` | `aarch64-unknown-linux-musl` | `ubuntu-24.04-arm` | +| `macos-x64` | `x86_64-apple-darwin` | `macos-15-intel` | +| `macos-arm64` | `aarch64-apple-darwin` | `macos-15` | +| `windows-x64` | `x86_64-pc-windows-msvc` | `windows-latest` | +| `windows-arm64` | `aarch64-pc-windows-msvc` | `windows-11-arm` | + +`.github/workflows/ci.yml`은 pinned Rust `1.97.1`로 각 native runner에서 fmt, clippy, test, release build를 수행한다. 수동 release workflow는 같은 matrix를 다시 build해 1일 보관 Actions artifact로 publish job에 넘긴다. 보호된 `remote-agent-release` Environment 승인을 통과한 실제 publish job만 Environment secret `REMOTE_AGENT_SIGNING_KEY`를 파일 mode `0600`으로 잠깐 materialize하고, `scripts/remote-agent-bundle.ts`가 다음을 생성한다. + +- npm payload: `bin/remote-agent//opencodex-remote-agent[.exe]` +- exact six-entry `remote-agent-manifest.json`: npm package version, source commit, release filename, SHA-256, raw-binary Ed25519 signature, canonical manifest signature +- GitHub Release assets: 각 native binary, raw detached `.sig`, 동일 manifest와 manifest `.sig`, public key PEM + +manifest signature는 domain-separated canonical payload 전체를 인증하므로 이전 binary/signature를 새 commit처럼 보이게 섞을 수 없다. package preparation과 런타임은 manifest의 package version이 독립적인 root `package.json` version과 같은지도 확인해 완전한 이전 bundle replay를 막는다. `scripts/prepare-package.ts`는 release에서 bundle 전체를 다시 검증하고 하나라도 없거나 다르면 publish 전에 실패한다. 런타임은 현재 OS/architecture artifact만 고정 public key로 검증한 직후 실행한다. bundle이 존재하지만 manifest, checksum, signature가 틀리면 unsigned development build로 fallback하지 않는다. 명시적인 absolute `OPENCODEX_REMOTE_AGENT_BIN`은 source checkout 개발용 override로만 남는다. + +실제 production private key는 `remote-agent-release` GitHub Environment secret에만 두고, 해당 environment 승인 뒤 실제 publish job에서만 읽는다. 기본 dry-run은 매번 임시 Ed25519 key를 생성해 동일한 조립·검증·packaging 경로를 시험하며 production key를 읽지 않는다. `expected-sha`는 모든 dispatch에서 필수이고 현재 `GITHUB_SHA`와 다르면 signing 전에 실패한다. + +[Decision Log] +- 목적과 의도: 설치 직후 Remote를 사용할 수 있게 하면서 사용자 PC의 컴파일과 변조 binary 실행을 모두 제거한다. +- 기존 구현 및 제약 조건: Rust Agent source와 local target fallback만 있었고 npm release에는 native artifact나 신뢰 기준이 없었다. +- 검토한 주요 대안: 런타임 cargo build, checksum-only bundle, 플랫폼별 optional npm package, 한 npm package에 서명된 6종 동봉. +- 선택한 방식: official native runner 6종 build, exact manifest, Ed25519 raw-binary signature, release-time bundle assembly를 사용한다. +- 다른 대안 대신 이 방식을 선택한 이유: 기존 `npm install -g` UX와 단일 버전을 유지하면서 cross-compilation 차이와 캐시/패키지 변조를 fail-closed로 처리한다. +- 장점, 단점 및 영향: Linux/macOS/Windows x64·arm64는 바로 실행되지만 release secret 보관과 6개 runner 성공이 새 release 필수 조건이다. + +## User flow + +```text +local ocx gui → Remote + → GitHub device approval + → first account device: local Argon2id E2EE password + encrypted vault envelope + → reserve one unique .opencodexpages.me workspace + → register this computer under a unique account-local name + → start the unprivileged prebuilt Agent + → Agent opens one outbound WSS to relay.opencodexpages.me + +another browser + → owning GitHub account + → local E2EE password derivation and vault unlock + → host-only workspace session + → choose an online computer and shell/codex/claude + → first authenticated resize/input frame starts the selected PTY + → browser ↔ Agent encrypted PTY frames through the opaque relay +``` + +기존 계정은 새 E2EE 비밀번호를 처음 설정할 때 기존 slug를 보존한 채 `mesh-tunnel`에서 `outbound-relay`로 전환한다. 기존 Cloudflare 리소스 삭제는 자동 전환과 분리된 운영 작업이다. + +## Trust boundaries + +1. GitHub는 계정 소유권만 증명한다. OAuth access/refresh/ID token은 DB에 저장하지 않는다. +2. Remote 비밀번호와 Argon2id root, vault wrapping key는 로컬 브라우저/OCX를 떠나지 않는다. +3. 서버가 받는 32-byte authentication secret은 password-equivalent이지만 HKDF 분리 때문에 vault envelope를 복호화할 수 없다. +4. 브라우저는 account root Ed25519로 handshake를 서명하고 Agent는 device Ed25519로 응답을 서명한다. +5. 세션별 ephemeral P-256 ECDH와 방향별 AES-256-GCM counter가 기밀성, endpoint binding, replay 방지를 제공한다. +6. Gateway는 연결 경계에서만 DB를 사용하고 terminal frame을 해석·저장·로그하지 않는다. +7. Agent는 `shell`, `codex`, `claude` 고정 profile만 실행하며 현재 OS 사용자 권한을 넘지 않는다. +8. Cloudflare는 public TLS/ingress와 패킷 전달 경계다. E2EE endpoint나 plaintext authority가 아니다. + +## Live data flow + +```text +Browser xterm.js + ↕ encrypted session frames (AES-GCM, signed ephemeral ECDH) +Cloudflare wildcard ingress + ↕ WSS +Gateway in-memory bounded relay + ↕ one outbound WSS per online computer +Rust Agent + portable-pty + ↕ current-user local process +shell / codex / claude +``` + +Gateway frame header는 protocol version, kind, 128-bit terminal session ID만 가진다. payload는 최대 64 KiB 암호문이다. 기기당 active session은 4개, socket buffered output은 1 MiB로 제한한다. Agent가 연결되면 workspace는 `online`, 마지막 Agent가 끊기면 `offline`이다. + +## Credential and key classes + +- `ocxr_device_`: 로컬 management API용 장치별 폐기 가능 token. `remote.json` 0600에만 저장. +- `ocxr_agent_`: 해당 컴퓨터의 outbound Relay 연결 token. DB에는 SHA-256만 저장. +- Instance session: 중앙 access code를 wildcard host-only HttpOnly cookie로 교환한 12시간 browser session. +- E2EE authentication secret: Remote password의 Argon2id root에서 auth 전용 HKDF info로 만든 32 bytes. 서버는 다시 SHA-256하여 저장. +- Vault wrapping key: 동일 root에서 별도 HKDF info로 만든 AES-256 key. 서버로 전송하지 않음. +- Account root Ed25519: private key는 encrypted vault 안, public key는 서버/Agent 검증 metadata. +- Device Ed25519: private key는 해당 컴퓨터 `remote.json`, public key는 workspace metadata. +- Session ECDH/AES keys: 브라우저와 Agent memory에만 존재하고 session 종료 시 폐기. + +## Runtime and release invariants + +- Agent는 root, `CAP_NET_ADMIN`, port forwarding, LAN listener를 요구하지 않는다. +- Relay token을 argv, URL query, browser storage, log에 넣지 않는다. +- 브라우저 vault handoff는 URL fragment로만 전달하고 wildcard origin 진입 즉시 `sessionStorage`로 옮긴 뒤 주소 표시줄/history에서 제거한다. +- suspend/delete PostgreSQL 알림만 active sockets를 강제 종료한다. online/offline presence 알림은 revocation으로 취급하지 않는다. +- Agent handshake 또는 frame 오류는 해당 terminal session만 닫아야 하며 다른 세션과 Agent WSS를 불필요하게 끊지 않는다. +- build/test는 운영 VPS에서 transient systemd unit의 `CPUQuota=50%`, low CPU weight, positive nice로 실행한다. Remote runtime 서비스에는 영구 CPUQuota를 두지 않는다. +- release 전 플랫폼별 signed Agent artifact, migration backup/rollback, real browser E2E, reconnect/backpressure, XSS/CSP 검증이 모두 필요하다. + +[Decision Log] +- 목적과 의도: 실제 구현과 문서가 하나의 현재 구조를 설명하고 기존 Mesh 기록은 rollback 근거로 보존한다. +- 기존 구현 및 제약 조건: 기존 문서는 인스턴스당 Mesh/Tunnel과 중앙 plaintext proxy를 현재형으로 설명해 새 다중 컴퓨터 E2EE 구조와 충돌했다. +- 검토한 주요 대안: 기존 문서에 부록만 추가, 완전 삭제, 현재 구조 재작성과 이전 ADR 보존. +- 선택한 방식: 현재 구조를 이 문서의 본문으로 만들고 기존 경로는 superseded ADR과 운영 문서로 남긴다. +- 다른 대안 대신 이 방식을 선택한 이유: 신규 개발자가 잘못된 root/Cloudflare lifecycle을 계속 구현하지 않으면서 검증된 rollback 지식도 잃지 않는다. +- 장점, 단점 및 영향: 현재 data flow가 명확해지지만 운영 배포가 전환되기 전까지 코드 경로 두 개를 구분해 유지해야 한다. diff --git a/structure/adr/0012-remote-private-mesh.md b/structure/adr/0012-remote-private-mesh.md new file mode 100644 index 000000000..399f9e6f5 --- /dev/null +++ b/structure/adr/0012-remote-private-mesh.md @@ -0,0 +1,36 @@ +# ADR 0012: Remote access through a central Gateway and private Mesh routes + +- Status: superseded for new Remote workspaces by ADR 0013; retained only as the deployed private-MVP rollback path +- Date: 2026-07-30 + +## Context + +OpenCodex Remote must expose a user's loopback OpenCodex GUI, management API, data API, SSE, and WebSocket without publishing the user's Tunnel hostname. User-controlled origins and Agents are untrusted. The public hostname must not reveal whether another user's instance exists. + +## Decision + +All instance wildcard DNS enters a central VPS Gateway. The Gateway resolves hostname ownership and lifecycle state from PostgreSQL, authenticates an instance session or `ocxr_` token, signs a 30-second Ed25519 assertion, and routes only to a random private hostname through a Linux Cloudflare Mesh node. Each user server runs one Cloudflare Tunnel into a Rust local ingress. The Agent and OpenCodex management API verify the assertion independently. + +Each private hostname has a DNS-only A record under `private.remote.opencodexpages.me` whose value is a unique address from `10.192.0.0/10`. The Agent assigns that `/32` to loopback and listens only on that address at port `10101`. The same `/32` is attached to the instance Tunnel as a narrow CIDR activation route because a hostname route alone did not enable `warp-routing` in the live Cloudflare account. The exact DNS record is not a public application route: Internet clients only receive an unroutable RFC1918 address, while enrolled Mesh traffic receives a synthetic `100.80.0.0/16` address and reaches the dedicated Tunnel. + +Cloudflare Access Applications are not used. No public hostname is attached to a user Tunnel. Mesh failure does not authorize a weaker automatic fallback. + +The onboarding control surface is the local `ocx gui`, not the central instance dashboard. A ten-minute device authorization binds the PC key to a GitHub identity without returning GitHub credentials. Users set an additional Argon2id Remote password and reserve one hostname. Browser entry to that hostname redirects only top-level HTML navigation to the central access page; API and WebSocket failures remain concealed. The central page requires both the owning GitHub session and the Remote password before issuing a short-lived hostname exchange code. + +## Consequences + +- Positive: one policy enforcement point, hidden instance existence, immediate DB-first suspension, no inbound user-server port. +- Positive: browser, `/api/*`, `/v1/*`, SSE, and WebSocket retain one public instance origin. +- Negative: Mesh/private hostname routing is Beta and must pass Phase 0 before MVP approval. +- Negative: Gateway and Agent must implement careful streaming, cancellation, replay, and header normalization. +- Negative: operating the service requires PostgreSQL, three Bun services, central ingress, Mesh, and instance Tunnel lifecycle reconciliation. +- Negative: provisioning and deletion now own four Cloudflare resources per instance: Tunnel, DNS record, `/32` activation route, and hostname route. +- Negative: the DNS record makes the random internal hostname observable only if guessed; its 160-bit random label and lack of a certificate keep it out of ordinary enumeration surfaces. + +[Decision Log] +- 목적과 의도: 사용자 Tunnel을 공개하지 않고 중앙 인증 정책을 강제한다. +- 기존 구현 및 제약 조건: OpenCodex GUI와 API는 동일 origin과 장시간 stream/WS를 사용한다. +- 검토한 주요 대안: 공개 Tunnel hostname, Cloudflare Access, Workers VPC, 자체 reverse tunnel. +- 선택한 방식: central Gateway + Cloudflare Mesh private hostname + DNS-backed RFC1918 loopback origin + per-instance Tunnel. +- 다른 대안 대신 이 방식을 선택한 이유: 확정된 private-only 경계와 전체 protocol surface를 동시에 만족하는 후보이기 때문이다. +- 장점, 단점 및 영향: 격리와 정지는 강해지지만 Beta dependency와 인스턴스당 네 Cloudflare 리소스의 lifecycle이 생긴다. 2026-07-30 live account에서 Mesh HTTP, SSE, WebSocket, 100 MiB 양방향 전송, 30분 stream, Rust Agent 경로와 활성 connector cleanup suspend를 통과했다. diff --git a/structure/adr/0013-remote-outbound-e2ee-relay.md b/structure/adr/0013-remote-outbound-e2ee-relay.md new file mode 100644 index 000000000..52334d7f6 --- /dev/null +++ b/structure/adr/0013-remote-outbound-e2ee-relay.md @@ -0,0 +1,52 @@ +# ADR 0013: Account workspace with outbound E2EE terminal relay + +- Status: accepted for implementation; not deployed to production +- Date: 2026-07-30 +- Supersedes: ADR 0012 for newly activated Remote workspaces + +## Context + +The Mesh private-MVP proved that a loopback OpenCodex GUI could be reached without a public user origin, but it requires four Cloudflare resources, a privileged loopback address, and a dedicated `cloudflared` connector per computer. The intended product is different: one GitHub account owns one stable Remote address, multiple named computers can attach to it, and a browser should run Shell, Codex CLI, or Claude Code on any online computer. Home users cannot be required to configure port forwarding. + +The central service must minimize CPU, database, and trust. It must not receive terminal plaintext, the user's Remote password, or a vault decryption key. + +## Decision + +An `instance` becomes an account workspace and keeps one unique `.opencodexpages.me` address. `remote_devices` are the named computers attached to that workspace. Every computer opens one outbound WSS connection to `relay.opencodexpages.me`; no inbound user port, private Mesh enrollment, per-device Tunnel, or root network preparation is required. + +The browser enters through the central GitHub page and derives two values locally from the separate Remote password with Argon2id: + +- an authentication secret sent over TLS after GitHub ownership is established; +- an independent AES-256-GCM wrapping key that never leaves the client. + +HKDF domain separation prevents the authentication secret from decrypting the vault envelope. The encrypted vault contains an account Ed25519 root private key and a random vault key. The server stores only the authentication-secret hash, KDF parameters, public root key, and encrypted envelope. + +For every terminal session, the browser creates an ephemeral P-256 key and signs the session transcript with the account root key. The Agent verifies that signature, creates its own ephemeral P-256 key, and signs the reply with the device Ed25519 key. Both sides derive directional AES-256-GCM keys and nonce prefixes with HKDF. Strict monotonically increasing counters are authenticated as AAD and reject replay or reordering. The central Gateway sees the device, session UUID, command profile, frame size, and connection timing, but relays only bounded ciphertext payloads. + +The Agent does not start the PTY while returning its signed handshake. It waits for the first authenticated browser application frame, which acts as the PTY-start signal. This ordering is required because a shell can emit a prompt before the browser has installed its encrypted-frame handler; losing counter zero would otherwise make the next legitimate frame indistinguishable from replay or reordering. + +The only command choices accepted by the Agent are fixed profiles: `shell`, `codex`, and `claude`. The browser cannot provide an arbitrary executable. Each device permits at most four active sessions. Relay payloads are limited to 64 KiB and each socket is closed when its buffered output exceeds 1 MiB. The Gateway performs database work only at authentication, session creation, and close boundaries; it does not query or persist per terminal frame. + +The Rust Agent runs as the current user with `portable-pty`, Tokio, and `tokio-tungstenite`. OCX may start only a prebuilt platform binary with explicit argv; it never compiles the Agent at runtime. Release remains blocked until signed binaries are packaged for each supported platform. + +Cloudflare remains the public TLS and wildcard ingress boundary. It is not the terminal encryption boundary and is not relied on to see or protect terminal plaintext. + +## Consequences + +- Positive: no port forwarding, root helper, private Mesh enrollment, or per-computer Cloudflare lifecycle. +- Positive: one account/domain naturally supports multiple computers and a future managed VPS as another device. +- Positive: the server can route live traffic without decrypting or storing terminal data and without database work per frame. +- Positive: ephemeral ECDH keys provide forward secrecy for terminal sessions; device and account signatures prevent an active relay from silently substituting an endpoint. +- Negative: metadata such as device identity, selected command profile, frame size, and timing remains visible to the service. +- Negative: the authentication secret is password-equivalent. TLS, attempt limits, session revocation, and GitHub ownership remain required; OPAQUE is a future hardening candidate. +- Negative: browser memory and session storage hold the unlocked vault for the current tab. CSP, same-origin delivery, dependency integrity, and immediate fragment removal are security-critical. +- Negative: signed multi-platform Agent packaging and a public operating E2E with real Codex/Claude CLI processes are still release blockers. The local PostgreSQL 17 + Gateway + Rust Agent + WebCrypto + PTY encrypted round trip is complete. +- Negative: old Mesh resources are not automatically deleted by the schema migration. Conversion preserves the existing slug and marks the workspace `outbound-relay`; resource cleanup requires a separate audited operation. + +[Decision Log] +- 목적과 의도: 서버 부하와 신뢰를 줄이면서 계정 주소 하나에서 여러 컴퓨터의 CLI 터미널을 포트포워딩 없이 제공한다. +- 기존 구현 및 제약 조건: 기존 Mesh MVP는 컴퓨터마다 Cloudflare 리소스 네 개와 root loopback 준비가 필요했고 중앙 proxy가 GUI 전체 평문을 처리했다. +- 검토한 주요 대안: 기존 Mesh 유지, 공개 user Tunnel, 중앙 PTY/VPS 실행, Tailscale형 overlay, outbound application relay와 브라우저-Agent E2EE. +- 선택한 방식: 한 컴퓨터당 outbound WSS 하나, 세션별 상호 서명 ephemeral P-256, 방향별 AES-GCM, 중앙의 bounded opaque relay를 사용한다. Agent handshake 응답 뒤 첫 인증된 browser frame을 PTY 시작 신호로 사용한다. +- 다른 대안 대신 이 방식을 선택한 이유: 일반 가정용 네트워크에서도 동작하고 서버가 터미널 평문이나 복호화 키를 보지 않으면서 계정당 여러 컴퓨터를 자연스럽게 지원한다. +- 장점, 단점 및 영향: 운영 리소스와 중앙 CPU는 줄지만 signed Agent 배포, 브라우저 crypto 상호운용, 메타데이터 노출 관리가 새 완료 조건이 된다. diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index f5b6374d6..f0ae1d93e 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -34,11 +34,13 @@ describe("GitHub Actions hardening", () => { // The cross-platform `test` job sits at 20 minutes: a green Windows run measured // 11.8 min against 4.6 on Linux, and the previous 12-minute ceiling left ~12s of // margin, so runner variance rather than the code decided the verdict (#717). - // `npm-global-smoke` stays at 8; it finishes in 1-2 minutes. + // `npm-global-smoke` stays at 8; the native six-platform Agent matrix is + // bounded separately at 25 because Rust release builds dominate it. expect(count(workflow, "timeout-minutes: 20")).toBe(1); expect(count(workflow, "timeout-minutes: 8")).toBe(1); - // Both jobs must stay bounded — an unbounded job can hang a queue for hours. - expect(count(workflow, "timeout-minutes:")).toBe(2); + // Every job family must stay bounded — an unbounded job can hang a queue for hours. + expect(count(workflow, "timeout-minutes: 25")).toBe(1); + expect(count(workflow, "timeout-minutes:")).toBe(3); expect(workflow).toContain("actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"); expect(workflow).toContain("oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6"); expect(workflow).toContain("actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e"); @@ -106,6 +108,8 @@ describe("GitHub Actions hardening", () => { "bun.lock", "gui/**", "package.json", + "platform/**", + "remote-agent/**", "scripts/**", "src/**", "tests/**", @@ -115,6 +119,15 @@ describe("GitHub Actions hardening", () => { // Push and pull_request have to cover the same surfaces, or a change lands // on dev having been checked on one trigger and not the other. expect([...(ci.on?.push?.paths ?? [])].sort()).toEqual(ciPaths); + + for (const packageName of [ + "linux-x64", "linux-arm64", "macos-x64", "macos-arm64", "windows-x64", "windows-arm64", + ]) { + expect(await readText(".github/workflows/ci.yml")).toContain(`package: ${packageName}`); + } + expect(await readText(".github/workflows/ci.yml")).toContain( + "- name: Install pinned Rust target\n working-directory: remote-agent", + ); }); test("cross-platform CI keeps the GUI lint and build gates", async () => { @@ -217,8 +230,28 @@ describe("GitHub Actions hardening", () => { expect(workflow).toContain("actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"); expect(workflow).toContain("oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6"); expect(workflow).toContain("actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e"); + expect(workflow).toContain("actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02"); + expect(workflow).toContain("actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093"); expect(workflow).not.toMatch(/uses:\s+\S+@(?:v\d+|main|master)\b/); + // Every supported native build must finish before the only secret-bearing + // publish job assembles an exact signed bundle. PR jobs never receive the key. + expect(workflow).toContain("needs: remote-agent-build"); + expect(workflow).toContain("name: ${{ inputs.dry-run && 'remote-agent-dry-run' || 'remote-agent-release' }}"); + expect(workflow).toContain("REMOTE_AGENT_SIGNING_KEY: ${{ secrets.REMOTE_AGENT_SIGNING_KEY }}"); + expect(workflow).toContain('OPENCODEX_REQUIRE_REMOTE_AGENT_BUNDLE: "1"'); + expect(workflow).toContain("shred -u \"$signing_key\""); + expect(workflow).toContain("openssl genpkey -algorithm ED25519"); + expect(workflow).toContain("OPENCODEX_REMOTE_AGENT_DRY_RUN_PUBLIC_KEY_FILE=$public_key"); + expect(workflow).toContain("expected-sha is required; refusing to release an unaudited branch tip"); + expect(workflow).toMatch(/expected-sha:[\s\S]*?required: true/); + expect(workflow).toContain("- name: Install pinned Rust target\n working-directory: remote-agent"); + for (const packageName of [ + "linux-x64", "linux-arm64", "macos-x64", "macos-arm64", "windows-x64", "windows-arm64", + ]) { + expect(workflow).toContain(`package: ${packageName}`); + } + // Workflow-dispatch inputs must reach shell code via env, never by direct // interpolation into run: source (script-injection hardening). const runBlocks = workflow.split(/\n {6,}- name: /).filter(block => block.includes("run: |")); diff --git a/tests/cli-headless-parity.test.ts b/tests/cli-headless-parity.test.ts index 6def96f08..f6ad15c47 100644 --- a/tests/cli-headless-parity.test.ts +++ b/tests/cli-headless-parity.test.ts @@ -9,6 +9,7 @@ import { handleConfigCommand } from "../src/cli/config-command"; import { handleGrokCommand } from "../src/cli/integrations"; import { handleModelsRuntimeCommand } from "../src/cli/models-runtime"; import { handleProviderRuntimeCommand } from "../src/cli/provider-runtime"; +import { handleRemoteCommand } from "../src/cli/remote-command"; type Recorded = { path: string; method: string; body: unknown }; const servers: Array> = []; @@ -66,6 +67,7 @@ describe("headless GUI parity CLI", () => { ["/api/oauth", "ocx account"], ["/api/providers/keys", "ocx account"], ["/api/providers", "ocx provider"], + ["/api/remote", "ocx remote"], ["/api/provider-", "ocx provider/models"], ["/api/selected-models", "ocx models"], ["/api/custom-models", "ocx models"], @@ -184,4 +186,16 @@ describe("headless GUI parity CLI", () => { rmSync(home, { recursive: true, force: true }); } }); + + test("remote activation and pairing reuse the local GUI management routes", async () => { + const runtime = fakeRuntime((req) => new URL(req.url).pathname === "/api/remote/pairing-code" + ? { code: "ABCD2345EFGH", expiresAt: "2030-01-01T00:00:00.000Z" } + : undefined); + expect(await handleRemoteCommand(["activate", "--name", "Home PC", "--slug", "home-pc", "--json"], runtime.deps)).toBe(0); + expect(await handleRemoteCommand(["pairing-code", "--json"], runtime.deps)).toBe(0); + expect(runtime.requests).toEqual([ + { path: "/api/remote/activate", method: "POST", body: { name: "Home PC", slug: "home-pc" } }, + { path: "/api/remote/pairing-code", method: "POST", body: null }, + ]); + }); }); diff --git a/tests/remote-agent-bundle.test.ts b/tests/remote-agent-bundle.test.ts new file mode 100644 index 000000000..c27ba7a4d --- /dev/null +++ b/tests/remote-agent-bundle.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { generateKeyPairSync } from "node:crypto"; +import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { mkdtempSync } from "node:fs"; +import { + REMOTE_AGENT_SPECS, + assembleRemoteAgentBundle, + remoteAgentPackageForRuntime, + verifyRemoteAgentArtifact, + verifyRemoteAgentBundle, +} from "../src/remote/agent-bundle"; + +const cleanup: string[] = []; + +afterEach(() => { + for (const path of cleanup.splice(0)) rmSync(path, { recursive: true, force: true }); +}); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "ocx-agent-bundle-")); + cleanup.push(root); + const inputRoot = join(root, "input"); + const bundleRoot = join(root, "bundle"); + const releaseAssetsRoot = join(root, "release"); + for (const spec of REMOTE_AGENT_SPECS) { + const artifactRoot = join(inputRoot, `remote-agent-${spec.package}`); + mkdirSync(artifactRoot, { recursive: true }); + writeFileSync(join(artifactRoot, spec.executable), `native-agent:${spec.package}\n`, { mode: 0o755 }); + } + const pair = generateKeyPairSync("ed25519"); + const privateKeyPem = pair.privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const publicKeyPem = pair.publicKey.export({ type: "spki", format: "pem" }).toString(); + const manifest = assembleRemoteAgentBundle({ + inputRoot, + bundleRoot, + releaseAssetsRoot, + privateKeyPem, + expectedPublicKeyPem: publicKeyPem, + sourceCommit: "a".repeat(40), + packageVersion: "9.8.7", + }); + return { root, bundleRoot, releaseAssetsRoot, privateKeyPem, publicKeyPem, manifest }; +} + +describe("Remote Agent release bundle", () => { + test("assembles and verifies the complete six-platform set", () => { + const built = fixture(); + expect(Object.keys(verifyRemoteAgentBundle(built.bundleRoot, built.publicKeyPem).artifacts)).toHaveLength(6); + expect(built.manifest.sourceCommit).toBe("a".repeat(40)); + expect(built.manifest.packageVersion).toBe("9.8.7"); + for (const spec of REMOTE_AGENT_SPECS) { + expect(readFileSync(join(built.releaseAssetsRoot, `${spec.releaseFile}.sig`))).toHaveLength(64); + expect(verifyRemoteAgentArtifact(built.bundleRoot, spec.package, built.publicKeyPem)).toEndWith(spec.executable); + } + }); + + test("fails closed when a packaged binary is modified", () => { + const built = fixture(); + const linuxBinary = join(built.bundleRoot, "linux-x64", "opencodex-remote-agent"); + writeFileSync(linuxBinary, "tampered\n"); + chmodSync(linuxBinary, 0o755); + expect(() => verifyRemoteAgentArtifact(built.bundleRoot, "linux-x64", built.publicKeyPem)) + .toThrow("checksum verification failed"); + }); + + test("rejects a missing platform and manifest path substitution", () => { + const missing = fixture(); + rmSync(join(missing.bundleRoot, "windows-arm64", "opencodex-remote-agent.exe")); + expect(() => verifyRemoteAgentBundle(missing.bundleRoot, missing.publicKeyPem)).toThrow("artifact is missing"); + + const substituted = fixture(); + const manifestPath = join(substituted.bundleRoot, "remote-agent-manifest.json"); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + manifest.artifacts["linux-x64"].path = "../opencodex-remote-agent"; + writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); + expect(() => verifyRemoteAgentBundle(substituted.bundleRoot, substituted.publicKeyPem)) + .toThrow("manifest path is invalid"); + }); + + test("authenticates source provenance and the installed package version", () => { + const built = fixture(); + const manifestPath = join(built.bundleRoot, "remote-agent-manifest.json"); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + manifest.sourceCommit = "b".repeat(40); + writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); + expect(() => verifyRemoteAgentBundle(built.bundleRoot, built.publicKeyPem, "9.8.7")) + .toThrow("manifest signature verification failed"); + + const versionBound = fixture(); + expect(() => verifyRemoteAgentBundle(versionBound.bundleRoot, versionBound.publicKeyPem, "9.8.8")) + .toThrow("does not match the installed package version"); + }); + + test("rejects a signing key that does not match the pinned public key", () => { + const built = fixture(); + const other = generateKeyPairSync("ed25519"); + const otherPrivateKey = other.privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + expect(() => assembleRemoteAgentBundle({ + inputRoot: join(built.root, "input"), + bundleRoot: join(built.root, "wrong-bundle"), + releaseAssetsRoot: join(built.root, "wrong-release"), + privateKeyPem: otherPrivateKey, + expectedPublicKeyPem: built.publicKeyPem, + sourceCommit: "b".repeat(40), + packageVersion: "9.8.7", + })).toThrow("does not match the pinned public key"); + }); + + test("maps only the supported runtime combinations", () => { + expect(remoteAgentPackageForRuntime("linux", "x64")).toBe("linux-x64"); + expect(remoteAgentPackageForRuntime("darwin", "arm64")).toBe("macos-arm64"); + expect(remoteAgentPackageForRuntime("win32", "x64")).toBe("windows-x64"); + expect(remoteAgentPackageForRuntime("freebsd", "x64")).toBeNull(); + expect(remoteAgentPackageForRuntime("linux", "riscv64")).toBeNull(); + }); +}); diff --git a/tests/remote-client.test.ts b/tests/remote-client.test.ts new file mode 100644 index 000000000..3cf693663 --- /dev/null +++ b/tests/remote-client.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + activateRemoteInstance, + createRemotePairingCode, + disconnectRemoteDevice, + getRemoteStatus, + setRemotePassword, + startRemoteLink, +} from "../src/remote/client"; + +const CONTROL = "http://127.0.0.1:19991"; +let temporaryHome = ""; +let previousHome: string | undefined; + +beforeEach(() => { + temporaryHome = mkdtempSync(join(tmpdir(), "ocx-remote-client-")); + previousHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = temporaryHome; +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(temporaryHome, { recursive: true, force: true }); +}); + +describe("local Remote device authorization", () => { + test("links a device without exposing polling or device secrets in the management DTO", async () => { + let pollCount = 0; + let passwordUpdated = false; + let instanceActivated = false; + let deviceRevoked = false; + let e2eeEnvelope: Record | null = null; + const deviceToken = `ocxr_device_${"d".repeat(43)}`; + const relayToken = `ocxr_agent_${"r".repeat(43)}`; + const pollSecret = `ocxr_device_${"p".repeat(43)}`; + const fetchImpl: typeof fetch = async (input, init) => { + const url = new URL(String(input)); + if (url.pathname === "/api/v1/device-links" && init?.method === "POST") { + return Response.json({ + id: "11111111-1111-4111-8111-111111111111", + pollSecret, + userCode: "ABCD2345", + authorizeUrl: `${CONTROL}/connect/11111111-1111-4111-8111-111111111111`, + expiresAt: new Date(Date.now() + 600_000).toISOString(), + }, { status: 201 }); + } + if (url.pathname === "/api/v1/device-links/11111111-1111-4111-8111-111111111111" && init?.method === "GET") { + pollCount += 1; + return pollCount === 1 + ? Response.json({ status: "pending" }) + : Response.json({ + status: "approved", + deviceId: "22222222-2222-4222-8222-222222222222", + deviceToken, + relayToken, + relayUrl: "wss://relay.opencodexpages.me/_ocxr/agent", + user: { name: "Octo User", email: "octo@example.test", githubNumericId: "12345" }, + }); + } + if (url.pathname.endsWith("/ack") && init?.method === "POST") return Response.json({ ok: true }); + if (url.pathname === "/api/v1/remote/profile" && init?.method === "GET") { + expect(init?.headers).toMatchObject({ authorization: `Bearer ${deviceToken}` }); + return Response.json({ profile: { + passwordSet: passwordUpdated, + canActivate: true, + e2ee: e2eeEnvelope, + devices: [], + instance: instanceActivated ? { + id: "33333333-3333-4333-8333-333333333333", + name: "Test PC", + slug: "test-pc", + status: "awaiting_agent", + publicUrl: "https://test-pc.opencodexpages.me", + } : null, + } }); + } + if (url.pathname === "/api/v1/remote/e2ee-profile" && init?.method === "PUT") { + expect(init?.headers).toMatchObject({ authorization: `Bearer ${deviceToken}` }); + const body = JSON.parse(String(init.body)) as { authSecret: string; envelope: Record }; + expect(body.authSecret).toHaveLength(43); + e2eeEnvelope = body.envelope; + passwordUpdated = true; + return Response.json({ profile: { + passwordSet: true, + canActivate: true, + e2ee: e2eeEnvelope, + devices: [], + instance: null, + } }); + } + if (url.pathname === "/api/v1/remote/activate" && init?.method === "POST") { + instanceActivated = true; + return Response.json({ profile: { + passwordSet: true, + canActivate: true, + e2ee: e2eeEnvelope, + devices: [], + instance: { + id: "33333333-3333-4333-8333-333333333333", + name: "Test PC", + slug: "test-pc", + status: "pending", + publicUrl: "https://test-pc.opencodexpages.me", + }, + } }, { status: 202 }); + } + if (url.pathname === "/api/v1/remote/pairing-code" && init?.method === "POST") { + return Response.json({ code: "ABCD2345EFGH", expiresAt: new Date(Date.now() + 600_000).toISOString() }, { status: 201 }); + } + if (url.pathname === "/api/v1/devices/current" && init?.method === "DELETE") { + deviceRevoked = true; + return Response.json({ ok: true }); + } + return Response.json({ error: "unexpected request" }, { status: 500 }); + }; + + const started = await startRemoteLink({ fetchImpl, controlPlaneUrl: CONTROL, deviceName: "Test PC", devicePlatform: "linux-x64" }); + expect(started).toMatchObject({ state: "pending", userCode: "ABCD2345", serviceReachable: true }); + expect(JSON.stringify(started)).not.toContain(pollSecret); + expect(readFileSync(join(temporaryHome, "remote.json"), "utf8")).toContain(pollSecret); + + expect(await getRemoteStatus({ fetchImpl })).toMatchObject({ state: "pending" }); + const connected = await getRemoteStatus({ fetchImpl }); + expect(connected).toMatchObject({ + state: "connected", + account: { name: "Octo User", githubNumericId: "12345" }, + passwordSet: false, + }); + expect(JSON.stringify(connected)).not.toContain(deviceToken); + expect(JSON.stringify(connected)).not.toContain(relayToken); + + expect(await setRemotePassword("a-strong-remote-password", { fetchImpl })).toMatchObject({ + state: "connected", + passwordSet: true, + }); + expect(passwordUpdated).toBe(true); + expect(e2eeEnvelope).not.toBeNull(); + expect(await activateRemoteInstance("Test PC", "test-pc", { fetchImpl })).toMatchObject({ + state: "connected", + instance: { slug: "test-pc", status: "pending" }, + }); + expect(await createRemotePairingCode({ fetchImpl })).toMatchObject({ code: "ABCD2345EFGH" }); + expect(await disconnectRemoteDevice({ fetchImpl })).toMatchObject({ state: "signed_out" }); + expect(deviceRevoked).toBe(true); + }); + + test("rejects an authorization URL on a different origin", async () => { + const fetchImpl: typeof fetch = async () => Response.json({ + id: "11111111-1111-4111-8111-111111111111", + pollSecret: `ocxr_device_${"p".repeat(43)}`, + userCode: "ABCD2345", + authorizeUrl: "https://attacker.example/connect/request", + expiresAt: new Date(Date.now() + 600_000).toISOString(), + }, { status: 201 }); + await expect(startRemoteLink({ fetchImpl, controlPlaneUrl: CONTROL })).rejects.toThrow("authorization origin mismatch"); + }); +}); diff --git a/tests/remote-e2ee.test.ts b/tests/remote-e2ee.test.ts new file mode 100644 index 000000000..3d4871a21 --- /dev/null +++ b/tests/remote-e2ee.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from "bun:test"; +import { + createRemoteE2eeProfile, + rewrapRemoteE2eeProfile, + unlockRemoteE2eeProfile, +} from "../src/remote/e2ee"; + +test("remote vault stays decryptable across a password rewrap without reusing auth material", async () => { + const initial = await createRemoteE2eeProfile("correct horse battery staple", "github:186453546"); + const unlocked = await unlockRemoteE2eeProfile("correct horse battery staple", "github:186453546", initial.envelope); + expect(Buffer.from(unlocked.vault.vaultKey, "base64url")).toHaveLength(32); + expect(unlocked.authSecret).toBe(initial.authSecret); + + const changed = await rewrapRemoteE2eeProfile( + "correct horse battery staple", + "new correct horse battery staple", + "github:186453546", + initial.envelope, + ); + expect(changed.oldAuthSecret).toBe(initial.authSecret); + expect(changed.newAuthSecret).not.toBe(initial.authSecret); + expect(changed.envelope.ciphertext).not.toBe(initial.envelope.ciphertext); + const after = await unlockRemoteE2eeProfile( + "new correct horse battery staple", + "github:186453546", + changed.envelope, + ); + expect(after.vault.vaultKey).toBe(unlocked.vault.vaultKey); +}, 60_000); + +test("remote vault rejects a wrong password and a different account binding", async () => { + const initial = await createRemoteE2eeProfile("correct horse battery staple", "github:186453546"); + await expect(unlockRemoteE2eeProfile("totally incorrect password", "github:186453546", initial.envelope)).rejects.toThrow(); + await expect(unlockRemoteE2eeProfile("correct horse battery staple", "github:999999", initial.envelope)).rejects.toThrow(); +}, 60_000); diff --git a/tests/server-management-auth.test.ts b/tests/server-management-auth.test.ts index 1484eb2eb..38e303634 100644 --- a/tests/server-management-auth.test.ts +++ b/tests/server-management-auth.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { generateKeyPairSync, randomUUID, sign } from "node:crypto"; import { saveConfig } from "../src/config"; import { startServer } from "../src/server"; import type { OcxConfig } from "../src/types"; @@ -12,6 +13,11 @@ import { issueGuiSession, requireManagementAuth, } from "../src/server/management-auth"; +import { + REMOTE_ASSERTION_AUDIENCE, + remoteAssertionPathHash, + resetRemoteAssertionReplayCacheForTest, +} from "../src/server/remote-assertion"; import { resetHardenedStateForTests, setIcaclsRunnerForTests, @@ -62,12 +68,95 @@ function websocketHandshakeOpens(url: URL, token: string): Promise { } beforeEach(() => { + resetRemoteAssertionReplayCacheForTest(); testHome = mkdtempSync(join(tmpdir(), "ocx-management-auth-")); process.env.OPENCODEX_HOME = testHome; process.env.OPENCODEX_API_AUTH_TOKEN = "data-secret"; process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "admin-secret"; }); +function remoteAssertionRequest(options: { + url?: string; + method?: string; + instanceId?: string; + issuer?: string; + expiresIn?: number; + issuedOffset?: number; + jti?: string; +} = {}): { request: Request; config: OcxConfig; assertion: string } { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const url = options.url ?? "http://127.0.0.1:10100/api/config?view=remote&z=2&a=1"; + const method = options.method ?? "GET"; + const now = Math.floor(Date.now() / 1000); + const iat = now + (options.issuedOffset ?? 0); + const header = Buffer.from(JSON.stringify({ alg: "EdDSA", typ: "JWT", kid: "gateway-1" })).toString("base64url"); + const payload = Buffer.from(JSON.stringify({ + iss: options.issuer ?? "opencodex-remote", + aud: REMOTE_ASSERTION_AUDIENCE, + instance_id: options.instanceId ?? "instance-1", + user_id: "user-1", + method, + path_sha256: remoteAssertionPathHash(url), + iat, + exp: iat + (options.expiresIn ?? 30), + jti: options.jti ?? randomUUID(), + })).toString("base64url"); + const signingInput = `${header}.${payload}`; + const assertion = `${signingInput}.${sign(null, Buffer.from(signingInput), privateKey).toString("base64url")}`; + const config: OcxConfig = { + ...remoteConfig(), + remoteAccess: { + enabled: true, + instanceId: "instance-1", + issuer: "opencodex-remote", + publicKeys: [{ + kid: "gateway-1", + publicKeyPem: publicKey.export({ type: "spki", format: "pem" }).toString(), + }], + }, + }; + return { + request: new Request(url, { method, headers: { "X-OpenCodex-Remote-Assertion": assertion } }), + config, + assertion, + }; +} + +describe("remote management assertions", () => { + test("accepts a valid Ed25519 assertion and rejects replay", () => { + const { request, config } = remoteAssertionRequest(); + const state = initializeManagementAuthState(config); + expect(requireManagementAuth(request, state, config)).toBeNull(); + expect(requireManagementAuth(request, state, config)?.status).toBe(401); + }); + + test("binds the assertion to instance, method, and normalized path", () => { + const valid = remoteAssertionRequest(); + const state = initializeManagementAuthState(valid.config); + const wrongMethod = new Request(valid.request.url, { + method: "POST", + headers: valid.request.headers, + }); + expect(requireManagementAuth(wrongMethod, state, valid.config)?.status).toBe(401); + + const wrongPath = new Request("http://127.0.0.1:10100/api/config?view=other", { + headers: valid.request.headers, + }); + expect(requireManagementAuth(wrongPath, state, valid.config)?.status).toBe(401); + + const wrongInstance = { ...valid.config, remoteAccess: { ...valid.config.remoteAccess!, instanceId: "instance-2" } }; + expect(requireManagementAuth(valid.request, state, wrongInstance)?.status).toBe(401); + }); + + test("rejects expired and overlong assertions", () => { + for (const options of [{ issuedOffset: -60, expiresIn: 30 }, { expiresIn: 31 }]) { + const { request, config } = remoteAssertionRequest(options); + const state = initializeManagementAuthState(config); + expect(requireManagementAuth(request, state, config)?.status).toBe(401); + } + }); +}); + afterEach(() => { setIcaclsRunnerForTests(null); setPlatformForTests(null);