diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 27b6051cf..eb59ac8a3 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -82,6 +82,8 @@ jobs: node-version-file: .node-version cache: npm - run: npm ci --ignore-scripts + - name: Build Remote Control protocol for UI checks + run: npm run build --workspace @codenomad/remote-control-protocol - name: Install browser for native pruning UI checks run: npx --workspace @codenomad/ui playwright install --with-deps chromium - name: Pack and install outside the checkout @@ -220,9 +222,23 @@ jobs: - name: Install dependencies run: npm ci + - name: Install Remote Control relay dependencies + run: npm ci --prefix packages/cloudflare + - name: Typecheck desktop clients run: npm run typecheck + - name: Typecheck server and Remote Control relay + run: >- + npm run typecheck --workspace @neuralnomads/codenomad && + npm run typecheck --prefix packages/cloudflare + + - name: Test Remote Control protocol and relay + run: >- + npm test --workspace @codenomad/remote-control-protocol && + npm test --prefix packages/cloudflare && + npm run test:e2e --prefix packages/cloudflare + - name: Build bundled automation integration run: npm run build:automation --workspace @neuralnomads/codenomad @@ -262,6 +278,10 @@ jobs: packages/ui/src/lib/model-visibility.test.ts packages/ui/src/lib/native/browser.test.ts packages/ui/src/lib/runtime-env.test.ts + packages/ui/src/lib/remote-control/bounded-body.test.ts + packages/ui/src/lib/remote-control/event-source.test.ts + packages/ui/src/lib/remote-control/tunnel.test.ts + packages/ui/src/lib/remote-control/web-socket.test.ts packages/ui/src/lib/server-meta.test.ts packages/ui/src/lib/theme-scheme.test.ts packages/ui/src/lib/trailing-resync.test.ts diff --git a/.opencode/skills/codenomad-architecture-guide/references/desktop-conventions.md b/.opencode/skills/codenomad-architecture-guide/references/desktop-conventions.md index cf03766fe..a6ac84bd9 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/desktop-conventions.md +++ b/.opencode/skills/codenomad-architecture-guide/references/desktop-conventions.md @@ -33,7 +33,7 @@ The backend delegates only the official `service start` command through its priv - Tauri adapter: `packages/ui/src/lib/native/tauri/functions.ts` - Desktop file drop: `packages/ui/src/lib/native/desktop-file-drop.ts` - Client state: `packages/ui/src/lib/native/client-state.ts` -- Remote windows: `packages/ui/src/lib/native/remote-window.ts` +- Remote Control lifecycle: `packages/server/src/remote-control/manager.ts` - Runtime detection: `packages/ui/src/lib/runtime-env.ts` Use these abstractions instead of importing host APIs into feature components. diff --git a/MIGRATION_V2.md b/MIGRATION_V2.md index ed27a9169..0084ea94d 100644 --- a/MIGRATION_V2.md +++ b/MIGRATION_V2.md @@ -177,7 +177,7 @@ Git status is hybrid: native `vcs.status` is augmented with CodeNomad server det - Translate host/WSL paths only after ownership validation. - Strip CodeNomad cookies, browser authorization, forwarding headers, and incoming `x-opencode-*` headers; inject shared-service authentication server-side. - Block upstream cookies and authentication challenges and avoid logging unredacted secret-bearing request bodies. -- Treat each unguessable preview token as a route-scoped capability. Loopback HTTP native previews use `.preview.localhost` so applications retain normal root paths; HTTPS, LAN, and web clients use the equivalent capability path. SideCar/browser previews remain opaque-origin sandboxes without `allow-same-origin`, and element comments cross a source-checked message bridge. +- Treat each unguessable preview token as a route-scoped capability. Loopback HTTP native previews use `.preview.localhost` so applications retain normal root paths; HTTPS and web clients use the equivalent capability path. SideCar/browser previews remain opaque-origin sandboxes without `allow-same-origin`, and element comments cross a source-checked message bridge. ## Desktop and Restore Restructuring diff --git a/README.md b/README.md index f7d0bb1ed..7f885955e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ CodeNomad transforms OpenCode from a terminal tool into a **premium desktop work ## Features - **๐Ÿš€ Multi-Instance Workspace** -- **๐ŸŒ Remote Access** +- **๐ŸŒ Remote Control** through a secure outbound relay, one-time pairing links, and revocable devices - **๐Ÿง  Session Management** - **๐ŸŽ™๏ธ Voice Input & Speech** - **๐ŸŒณ Git Worktrees** @@ -56,7 +56,7 @@ npx @neuralnomads/codenomad --password --launch > **Self-signed certificate:** On first launch with HTTPS enabled (the default), your browser will show a "Your connection is not private" warning. This is expected โ€” the server generates a local self-signed certificate automatically. Click **Advanced โ†’ Proceed to localhost** to continue. For local-only use without the warning, run with `--https=false --http=true`. -See [Server Documentation](packages/server/README.md) for flags, TLS, auth, and remote access. +See [Server Documentation](packages/server/README.md) for flags, TLS, authentication, and Remote Control. ### ๐Ÿงช Dev Releases @@ -72,7 +72,7 @@ npx @neuralnomads/codenomad-dev --password --launch SideCars let you open local web tools inside CodeNomad as tabs. -Previews use token-scoped URLs inside opaque-origin sandboxes. Loopback HTTP native previews receive a dedicated `.preview.localhost` origin so root routes, POSTs, and live reload behave normally; HTTPS, LAN, and web hosts use the capability path. Element comments use a source-checked message bridge. +Previews use token-scoped URLs inside opaque-origin sandboxes. Loopback HTTP native previews receive a dedicated `.preview.localhost` origin so root routes, POSTs, and live reload behave normally; HTTPS and web clients use the capability path. Element comments use a source-checked message bridge.
Configuration diff --git a/dev-docs/DEVELOPER_MODE.md b/dev-docs/DEVELOPER_MODE.md index ce7ad5f59..990526af7 100644 --- a/dev-docs/DEVELOPER_MODE.md +++ b/dev-docs/DEVELOPER_MODE.md @@ -38,8 +38,8 @@ Windows registrations are also discoverable by an OpenCode plugin running in WSL ## Target And Trust Boundaries -- The HTTP bridge and CDP endpoint use IPv4 loopback only. The bridge requires its random token. Raw CDP has no authentication and trusts local processes; default tool availability does not remove the bridge's authentication or its session/window selection checks. -- The native host selects the focused local window, or the most-recent local window when CodeNomad is not focused. A focused remote window is never selected. +- The HTTP bridge and CDP endpoint use IPv4 loopback only. The bridge requires its random token. Raw CDP has no authentication, can execute code in authenticated renderer pages, and therefore trusts local processes; default tool availability does not remove the bridge's authentication or its session/window selection checks. +- The native host selects the focused local window, or the most-recent local window when CodeNomad is not focused. Support windows such as Preferences and focused remote windows are never selected. - CDP evaluates a bounded set of page targets and requires exactly the native window UUID, visible `data-instance-id`, and active `data-session-id`. - The bridge resolves the OpenCode session through the shared service and verifies that the visible `data-instance-id` owns that session location. - Operations for one native run are serialized. Click and type revalidate context immediately before input; inspection and screenshot revalidate after capture. Accessibility refs are invalidated by navigation, target replacement, context change, and restart. diff --git a/dev-docs/architecture.md b/dev-docs/architecture.md index a270f27a8..0bbd09f89 100644 --- a/dev-docs/architecture.md +++ b/dev-docs/architecture.md @@ -9,6 +9,8 @@ Desktop host -> CodeNomad server -> one shared OpenCode service ^ | | +-> CodeNomad /api/* and /api/events +------ UI clients through /workspaces/:id/instance/api/* + +Paired browser -> Cloudflare Worker/Durable Object <- outbound WebSocket <- CodeNomad server ``` There is no `@opencode-ai/sdk` integration and no legacy `packages/opencode-plugin` package. The narrow bundled `codenomad.automation` plugin is documented in [DEVELOPER_MODE.md](DEVELOPER_MODE.md) and [BROWSER_AUTOMATION.md](BROWSER_AUTOMATION.md); it does not own the OpenCode daemon or restore the V1 compatibility runtime. @@ -36,6 +38,14 @@ OpenCode sessions and messages remain shared through the global daemon. Window m Previews use unguessable capabilities for HTTP and WebSocket traffic. Electron and Windows Tauri local windows open HTTP(S) pages in hardened native child webviews with isolated storage; other clients use the existing capability-scoped iframe proxy. SideCar/browser iframes remain opaque-origin sandboxes without `allow-same-origin`; preview element comments use a source-checked message bridge instead of parent DOM access. +## Remote Control + +CodeNomad listens only on `127.0.0.1`. Remote Control is an outbound-only connection from `packages/server/src/remote-control/` to the Cloudflare Worker and one `RemoteControlHost` Durable Object per random host ID. OpenCode is never exposed directly; relayed requests terminate at CodeNomad and continue through its existing authentication, folder, Git, Yolo, and proxy boundaries. + +The persistent host identity and P-256 key pair are stored in `remote-control.json` with restricted permissions where supported; legacy identities gain a key pair without changing their host ID or relay secret. The connector authenticates with a bearer secret, while browsers pair through a fragment-token link that expires after ten minutes and pins the host public key. The relay stores only token hashes, issues secure host-scoped device cookies for 30 days, and supports revocation. Remote credentials are stripped only after host-side decryption; the local connector injects a dedicated internal CodeNomad session instead. + +Protocol v2 carries HTTP streams and WebSocket messages inside an end-to-end encrypted browser-to-host tunnel. An ephemeral browser P-256 key, a fresh host challenge, ECDH, and HKDF-SHA-256 produce directional AES-256-GCM keys; authenticated counters reject tampering, reordering, and replay across the same or later tunnels. Cloudflare sees host/device routing, sizes, and timing, but receives neither application plaintext nor the host private key. This protects against an honest-but-curious relay and captured tunnel traffic, not an actively malicious Worker operator that replaces the browser bundle before it runs; reviewed releases and Cloudflare account security remain in the trust boundary. Host and remote-client sockets use the Durable Objects WebSocket Hibernation API, with attachment metadata sufficient to recover routing after an object is evicted. Connector heartbeats use Cloudflare's automatic WebSocket response path so idle hosts stay reachable without waking the object. The relay, browser, and connector bound clients, requests, sockets, devices, pairing links, bodies, frames, encrypted queues, unread response data, and outbound buffers; stream HTTP responses with idle timeouts; cancel abandoned work; and reject stale responses after a host reconnect. Electron and Tauri keep the backend alive after the final window closes only while Remote Control is enabled. + ## API Boundaries CodeNomad control APIs live under `/api/*`. Important routes include: @@ -44,6 +54,7 @@ CodeNomad control APIs live under `/api/*`. Important routes include: - `/api/workspaces/:id/worktrees/:slug/git-status|git-diff|git-stage|git-unstage|git-commit` - `/api/events` and `/api/client-connections/pong` - `/api/storage`, `/api/settings`, `/api/filesystem`, `/api/speech` +- `/api/remote-control/*`, restricted to local host UI requests - `/api/opencode-plugin/automation`, authenticated by a per-process loopback token and restricted to CodeNomad-owned locations Native OpenCode requests use `/workspaces/:id/instance/api/*`. The Fastify proxy exposes an explicit method/path allowlist, adds shared-service authorization, and rejects locations/directories outside the selected workspace or its worktrees. Session routes also verify `session.location.directory`. Upstream additions require an explicit proxy review and are not available automatically. @@ -73,12 +84,13 @@ Current native events include session lifecycle/output events (`session.created` | Browser SSE multiplexing | CodeNomad server | | Desktop inspection and CDP feedback | Current CodeNomad desktop host and bundled automation plugin, available at normal startup | | Autonomous browser previews | CodeNomad desktop browser controllers and the same bundled automation plugin, independent of Developer Mode | +| Remote Control relay, pairing, and device credentials | CodeNomad server plus Cloudflare Worker/Durable Object | Session Shell remains separate from background Shell and PTY management. The Status panel lists location-scoped native background Shells, refreshes on Shell events/reconnect, displays native metadata, and allows ownership-checked removal. Output requests preserve native cursor pagination. Interactive PTYs remain separate. `packages/opencode-plugin` and the server plugin/background-process paths remain deleted and must not be restored; the narrow bundled automation plugin and session-pruning RPC use native V2 discovery and backend presence. ## Persistence -CodeNomad configuration resolves through `packages/server/src/config/location.ts`: `config.yaml`, `state.yaml`, and `instances/` under `~/.config/codenomad/`. `config.json` is migration input only. +CodeNomad configuration resolves through `packages/server/src/config/location.ts`: `config.yaml`, `state.yaml`, `remote-control.json`, and `instances/` under `~/.config/codenomad/`. `config.json` is migration input only. ## Key Files @@ -90,6 +102,9 @@ CodeNomad configuration resolves through `packages/server/src/config/location.ts - `packages/server/src/workspaces/git-mutations.ts` - `packages/server/src/permissions/auto-accept-manager.ts` - `packages/server/src/opencode/automation-plugin.ts` +- `packages/server/src/remote-control/manager.ts` +- `packages/cloudflare/src/remote-control/host-object.ts` +- `packages/remote-control-protocol/src/index.ts` - `packages/ui/src/lib/sdk-manager.ts` - `packages/ui/src/lib/api-client.ts` - `packages/ui/src/stores/session-api.ts` diff --git a/package-lock.json b/package-lock.json index a22aa5cf1..6e3f99283 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,8 @@ "packages/server", "packages/ui", "packages/electron-app", - "packages/tauri-app" + "packages/tauri-app", + "packages/remote-control-protocol" ] } }, @@ -2079,6 +2080,10 @@ "dev": true, "license": "MIT" }, + "node_modules/@codenomad/remote-control-protocol": { + "resolved": "packages/remote-control-protocol", + "link": true + }, "node_modules/@codenomad/tauri-app": { "resolved": "packages/tauri-app", "link": true @@ -18552,11 +18557,21 @@ "dev": true, "license": "MIT" }, + "packages/remote-control-protocol": { + "name": "@codenomad/remote-control-protocol", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "tsx": "^4.20.6", + "typescript": "^5.6.3" + } + }, "packages/server": { "name": "@neuralnomads/codenomad", "version": "0.20.0-dev-v2", "license": "MIT", "dependencies": { + "@codenomad/remote-control-protocol": "0.1.0", "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", "@fastify/static": "^7.0.4", @@ -18614,6 +18629,7 @@ "version": "0.20.0-dev-v2", "license": "MIT", "dependencies": { + "@codenomad/remote-control-protocol": "0.1.0", "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", "@opencode/client": "2.0.4", diff --git a/package.json b/package.json index 18d8af1d2..4950e4b6b 100644 --- a/package.json +++ b/package.json @@ -9,19 +9,20 @@ "packages/server", "packages/ui", "packages/electron-app", - "packages/tauri-app" + "packages/tauri-app", + "packages/remote-control-protocol" ] }, "scripts": { "dev": "npm run dev --workspace @neuralnomads/codenomad-electron-app", "dev:electron": "npm run dev --workspace @neuralnomads/codenomad-electron-app", "dev:tauri": "npm run dev --workspace @codenomad/tauri-app", - "build": "npm run build --workspace @neuralnomads/codenomad-electron-app", + "build": "npm run build --workspace @codenomad/remote-control-protocol && npm run build --workspace @neuralnomads/codenomad-electron-app", "build:tauri": "npm run build --workspace @codenomad/tauri-app", "build:ui": "npm run build --workspace @codenomad/ui", "build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app", "build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app", - "typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app", + "typecheck": "npm run typecheck --workspace @codenomad/remote-control-protocol && npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app", "bumpVersion": "node ./scripts/bump-version.js" }, "dependencies": { diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md new file mode 100644 index 000000000..dac72d824 --- /dev/null +++ b/packages/cloudflare/README.md @@ -0,0 +1,78 @@ +# CodeNomad Cloudflare Worker + +This package serves the published web UI and the shared Remote Control relay. + +## Remote Control topology + +- The CodeNomad host opens one authenticated outbound WebSocket. +- A random 128-bit host ID selects one `RemoteControlHost` Durable Object. +- Remote browsers use a one-time pairing link and a host-scoped device cookie. +- The pairing fragment pins the host's persistent P-256 public key without + disclosing the one-time token to the relay in an HTTP request URL. +- The Worker authenticates and routes devices, but forwards application traffic + only as opaque encrypted tunnel frames. +- The local connector strips remote headers and injects its private CodeNomad + session only after decrypting a request on the host. +- OpenCode remains behind the loopback-only CodeNomad server. + +The browser uses an ephemeral P-256 key and the host contributes a fresh +challenge to every tunnel. HKDF-SHA-256 derives separate client-to-host and +host-to-client AES-256-GCM keys. Authenticated counters reject tampering, +reordering, same-tunnel replay, and replay into a later tunnel. The private host +key and plaintext never enter Worker storage or messages. The relay can still +observe routing metadata, frame sizes, and timing, and it serves the published +browser bundle. E2EE protects against an honest-but-curious relay, storage +disclosure, and captured tunnel traffic. It cannot protect a session from an +actively malicious Worker operator that replaces the browser JavaScript before +it runs; release review and Cloudflare account security remain part of the +trust boundary. + +Both host and browser WebSockets use the Durable Objects WebSocket Hibernation +API. Socket attachments contain the minimum routing metadata needed after an +object is evicted. The host heartbeat is handled with a WebSocket auto response, +so it does not wake an idle object. Hashed UI assets are public and immutable; +only HTML navigation, bootstrap discovery, pairing, management, and tunnel +operations consult the host object. + +## Resource limits + +Each host object limits browser tunnels, paired devices, unredeemed pairing +links, pairing bodies, and opaque frame sizes. The local connector separately +limits concurrent HTTP requests, local WebSockets, decrypted request bodies, +handshake frames, encrypted send/receive queues, relay buffers, and pre-open +socket queues. The browser bounds request-body reads, active requests and +sockets, encrypted queues, WebSocket sends, and unread HTTP response data. +HTTP streams have an idle timeout and abandoned requests are cancelled at the +host. Expired pairing and device records are removed by a Durable Object alarm. + +These limits contain abuse and memory use, but they are not a substitute for +Cloudflare account-level usage alerts and limits. + +## Validation + +```sh +npm install +npm run typecheck +npm test +npm run test:e2e +npx wrangler deploy --dry-run +``` + +The end-to-end test starts a local Wrangler relay plus a loopback host and +verifies one-time pairing, encrypted multiplexed HTTP streaming, header +isolation, connector recovery, pairing and client limits, WebSocket forwarding, +and live revocation. Use `npm run dev` for manual testing with a local CodeNomad +server. The protocol package is built automatically before either flow. + +## Deployment + +`wrangler.toml` requires: + +- the `REMOTE_HOSTS` Durable Object binding; +- an assets directory built into `dist`; +- the `ui.codenomad.neuralnomads.ai` and + `remote.codenomad.neuralnomads.ai` custom domains; +- a wildcard route for `*.remote.codenomad.neuralnomads.ai`. + +Deploy only from the Cloudflare account that owns the `neuralnomads.ai` zone. +Do not put host or device credentials in URLs, logs, or Worker variables. diff --git a/packages/cloudflare/package-lock.json b/packages/cloudflare/package-lock.json index 12b89dffe..5561a4adf 100644 --- a/packages/cloudflare/package-lock.json +++ b/packages/cloudflare/package-lock.json @@ -5,29 +5,46 @@ "packages": { "": { "name": "@codenomad/ui-host-worker", + "license": "MIT", + "dependencies": { + "@codenomad/remote-control-protocol": "file:../remote-control-protocol" + }, + "devDependencies": { + "@cloudflare/workers-types": "^5.20260903.1", + "tsx": "^4.20.6", + "typescript": "^5.6.3", + "undici": "^6.19.8", + "wrangler": "^4.129.0" + } + }, + "../remote-control-protocol": { + "name": "@codenomad/remote-control-protocol", + "version": "0.1.0", + "license": "MIT", "devDependencies": { - "wrangler": "^4.0.0" + "tsx": "^4.20.6", + "typescript": "^5.6.3" } }, "node_modules/@cloudflare/kv-asset-handler": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", - "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", "dev": true, "license": "MIT OR Apache-2.0", "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, "node_modules/@cloudflare/unenv-preset": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.11.0.tgz", - "integrity": "sha512-z3hxFajL765VniNPGV0JRStZolNz63gU3B3AktwoGdDlnQvz5nP+Ah4RL04PONlZQjwmDdGHowEStJ94+RsaJg==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", "dev": true, "license": "MIT OR Apache-2.0", "peerDependencies": { "unenv": "2.0.0-rc.24", - "workerd": "^1.20260115.0" + "workerd": ">1.20260305.0 <2.0.0-0" }, "peerDependenciesMeta": { "workerd": { @@ -36,9 +53,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260120.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260120.0.tgz", - "integrity": "sha512-JLHx3p5dpwz4wjVSis45YNReftttnI3ndhdMh5BUbbpdreN/g0jgxNt5Qp9tDFqEKl++N63qv+hxJiIIvSLR+Q==", + "version": "1.20260903.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260903.1.tgz", + "integrity": "sha512-FG+4mGxAXhKiL/1temH42alevIkumtYXNidhTa//3yULpzux6APw5UNVocI/vCQ1yG1YOEZRfbVy8lyuipM9MQ==", "cpu": [ "x64" ], @@ -53,9 +70,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260120.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260120.0.tgz", - "integrity": "sha512-1Md2tCRhZjwajsZNOiBeOVGiS3zbpLPzUDjHr4+XGTXWOA6FzzwScJwQZLa0Doc28Cp4Nr1n7xGL0Dwiz1XuOA==", + "version": "1.20260903.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260903.1.tgz", + "integrity": "sha512-o241VefnjG8eG+kGap5CjgV4zOT5UaAmS3OR1VZCpNj7vkXGxvp9KftKvtQgcCsIqJKaKx+7Xd7xq0L1DjkfPQ==", "cpu": [ "arm64" ], @@ -70,9 +87,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260120.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260120.0.tgz", - "integrity": "sha512-O0mIfJfvU7F8N5siCoRDaVDuI12wkz2xlG4zK6/Ct7U9c9FiE0ViXNFWXFQm5PPj+qbkNRyhjUwhP+GCKTk5EQ==", + "version": "1.20260903.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260903.1.tgz", + "integrity": "sha512-/VEvvtQ/XKf6HlBbg6FbvpwcpfYUcx6Fv6RkASY8DyEmUyuJ8rc7Qxil83ClRFoBzz/GY0BV94UZ6F+wers/hg==", "cpu": [ "x64" ], @@ -87,9 +104,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260120.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260120.0.tgz", - "integrity": "sha512-aRHO/7bjxVpjZEmVVcpmhbzpN6ITbFCxuLLZSW0H9O0C0w40cDCClWSi19T87Ax/PQcYjFNT22pTewKsupkckA==", + "version": "1.20260903.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260903.1.tgz", + "integrity": "sha512-OWhihGC6KoTXF4u2C1AonfpgXeM4/7p/1IXuALqXESmFUpLLP5gZhRzjSk/gWW+mrCZDfSrvnjifl+lRqselbA==", "cpu": [ "arm64" ], @@ -104,9 +121,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260120.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260120.0.tgz", - "integrity": "sha512-ASZIz1E8sqZQqQCgcfY1PJbBpUDrxPt8NZ+lqNil0qxnO4qX38hbCsdDF2/TDAuq0Txh7nu8ztgTelfNDlb4EA==", + "version": "1.20260903.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260903.1.tgz", + "integrity": "sha512-soPMF9/aMHlHKK7M0vq5HrRRicPbnZO1F6ZZ7JWN8EltxWJU5L7CEq7CxjO878DzyPx7gYvqBFy3SVgdZKZjMQ==", "cpu": [ "x64" ], @@ -120,6 +137,18 @@ "node": ">=16" } }, + "node_modules/@cloudflare/workers-types": { + "version": "5.20260903.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260903.1.tgz", + "integrity": "sha512-Dgm28XJqMYj3VCNK14Xwo9UPtSPWL8Izo0emiyeQLZCRXWuhc3GnlKENF4Pf3ot+3NaWosq+yq8OIa531dCr6g==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peer": true + }, + "node_modules/@codenomad/remote-control-protocol": { + "resolved": "../remote-control-protocol", + "link": true + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -134,9 +163,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "dev": true, "license": "MIT", "optional": true, @@ -145,9 +174,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", - "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -162,9 +191,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", - "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -179,9 +208,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", - "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -196,9 +225,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", - "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -213,9 +242,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", - "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -230,9 +259,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", - "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -247,9 +276,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", - "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -264,9 +293,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", - "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -281,9 +310,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", - "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -298,9 +327,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", - "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -315,9 +344,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", - "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -332,9 +361,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", - "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -349,9 +378,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", - "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -366,9 +395,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", - "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -383,9 +412,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", - "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -400,9 +429,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", - "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -417,9 +446,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", - "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -434,9 +463,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", - "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -451,9 +480,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", - "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -468,9 +497,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", - "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -485,9 +514,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", - "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -502,9 +531,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", - "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -519,9 +548,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", - "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -536,9 +565,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", - "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -553,9 +582,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", - "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -570,9 +599,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", - "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -587,9 +616,9 @@ } }, "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "dev": true, "license": "MIT", "engines": { @@ -597,9 +626,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", "cpu": [ "arm64" ], @@ -610,19 +639,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.1" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", "cpu": [ "x64" ], @@ -633,19 +662,39 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", "cpu": [ "arm64" ], @@ -660,9 +709,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", "cpu": [ "x64" ], @@ -677,9 +726,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", "cpu": [ "arm" ], @@ -694,9 +743,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", "cpu": [ "arm64" ], @@ -711,9 +760,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", "cpu": [ "ppc64" ], @@ -728,9 +777,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", "cpu": [ "riscv64" ], @@ -745,9 +794,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", "cpu": [ "s390x" ], @@ -762,9 +811,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", "cpu": [ "x64" ], @@ -779,9 +828,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", "cpu": [ "arm64" ], @@ -796,9 +845,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", "cpu": [ "x64" ], @@ -813,9 +862,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", "cpu": [ "arm" ], @@ -826,19 +875,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.1" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", "cpu": [ "arm64" ], @@ -849,19 +898,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.1" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", "cpu": [ "ppc64" ], @@ -872,19 +921,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.1" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", "cpu": [ "riscv64" ], @@ -895,19 +944,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.1" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", "cpu": [ "s390x" ], @@ -918,19 +967,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.1" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", "cpu": [ "x64" ], @@ -941,19 +990,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.1" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", "cpu": [ "arm64" ], @@ -964,19 +1013,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", "cpu": [ "x64" ], @@ -987,39 +1036,56 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.1" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", "cpu": [ "wasm32" ], "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@img/sharp-wasm32": "0.35.2" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", "cpu": [ "arm64" ], @@ -1030,16 +1096,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", "cpu": [ "ia32" ], @@ -1050,16 +1116,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", "cpu": [ "x64" ], @@ -1070,7 +1136,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -1087,9 +1153,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -1147,9 +1213,9 @@ } }, "node_modules/@speed-highlight/core": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.14.tgz", - "integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==", + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", "dev": true, "license": "CC0-1.0" }, @@ -1195,9 +1261,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", - "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1208,32 +1274,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.0", - "@esbuild/android-arm": "0.27.0", - "@esbuild/android-arm64": "0.27.0", - "@esbuild/android-x64": "0.27.0", - "@esbuild/darwin-arm64": "0.27.0", - "@esbuild/darwin-x64": "0.27.0", - "@esbuild/freebsd-arm64": "0.27.0", - "@esbuild/freebsd-x64": "0.27.0", - "@esbuild/linux-arm": "0.27.0", - "@esbuild/linux-arm64": "0.27.0", - "@esbuild/linux-ia32": "0.27.0", - "@esbuild/linux-loong64": "0.27.0", - "@esbuild/linux-mips64el": "0.27.0", - "@esbuild/linux-ppc64": "0.27.0", - "@esbuild/linux-riscv64": "0.27.0", - "@esbuild/linux-s390x": "0.27.0", - "@esbuild/linux-x64": "0.27.0", - "@esbuild/netbsd-arm64": "0.27.0", - "@esbuild/netbsd-x64": "0.27.0", - "@esbuild/openbsd-arm64": "0.27.0", - "@esbuild/openbsd-x64": "0.27.0", - "@esbuild/openharmony-arm64": "0.27.0", - "@esbuild/sunos-x64": "0.27.0", - "@esbuild/win32-arm64": "0.27.0", - "@esbuild/win32-ia32": "0.27.0", - "@esbuild/win32-x64": "0.27.0" + "@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" } }, "node_modules/fsevents": { @@ -1262,25 +1328,31 @@ } }, "node_modules/miniflare": { - "version": "4.20260120.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260120.0.tgz", - "integrity": "sha512-XXZyE2pDKMtP5OLuv0LPHEAzIYhov4jrYjcqrhhqtxGGtXneWOHvXIPo+eV8sqwqWd3R7j4DlEKcyb+87BR49Q==", + "version": "5.20260903.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260903.0-alpha.tgz", + "integrity": "sha512-VCZIFxOqFXeibRBJWwTlppqbv2lkeO10IX3QBRGK9j1QiMAfH/OSsCvbjp1MslBMhVZmy2VTRlVaVnsKnbDp6A==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", - "sharp": "^0.34.5", - "undici": "7.18.2", - "workerd": "1.20260120.0", - "ws": "8.18.0", - "youch": "4.1.0-beta.10", - "zod": "^3.25.76" - }, - "bin": { - "miniflare": "bootstrap.js" + "sharp": "0.35.2", + "undici": "7.29.0", + "workerd": "1.20260903.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" + } + }, + "node_modules/miniflare/node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" } }, "node_modules/path-to-regexp": { @@ -1298,9 +1370,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -1311,48 +1383,48 @@ } }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", "dev": true, - "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.4" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" } }, "node_modules/supports-color": { @@ -1376,14 +1448,531 @@ "license": "0BSD", "optional": true }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz", - "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==", + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "dev": true, "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=18.17" } }, "node_modules/unenv": { @@ -1398,9 +1987,9 @@ } }, "node_modules/workerd": { - "version": "1.20260120.0", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260120.0.tgz", - "integrity": "sha512-R6X/VQOkwLTBGLp4VRUwLQZZVxZ9T9J8pGiJ6GQUMaRkY7TVWrCSkVfoNMM1/YyFsY5UYhhPoQe5IehnhZ3Pdw==", + "version": "1.20260903.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260903.1.tgz", + "integrity": "sha512-xJzt2RnCy7ulOULmZy/4JLbEPg1uisp9lVoOUsEz+UVhDsTmrSQ0rBXZMGcXuhr2HCGKs89bg8nnbbzvBSX1Ig==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -1412,41 +2001,42 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260120.0", - "@cloudflare/workerd-darwin-arm64": "1.20260120.0", - "@cloudflare/workerd-linux-64": "1.20260120.0", - "@cloudflare/workerd-linux-arm64": "1.20260120.0", - "@cloudflare/workerd-windows-64": "1.20260120.0" + "@cloudflare/workerd-darwin-64": "1.20260903.1", + "@cloudflare/workerd-darwin-arm64": "1.20260903.1", + "@cloudflare/workerd-linux-64": "1.20260903.1", + "@cloudflare/workerd-linux-arm64": "1.20260903.1", + "@cloudflare/workerd-windows-64": "1.20260903.1" } }, "node_modules/wrangler": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.60.0.tgz", - "integrity": "sha512-n4kibm/xY0Qd5G2K/CbAQeVeOIlwPNVglmFjlDRCCYk3hZh8IggO/rg8AXt/vByK2Sxsugl5Z7yvgWxrUbmS6g==", + "version": "4.129.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.129.0.tgz", + "integrity": "sha512-PGPvs9UPoFrwxT0VogpESSZGvZIctAuTK3wGsLLPHtHsSgS85kNdvtpa2d14UzG8gwLWD64XUGFPGG9tOXG9VQ==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { - "@cloudflare/kv-asset-handler": "0.4.2", - "@cloudflare/unenv-preset": "2.11.0", + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", - "esbuild": "0.27.0", - "miniflare": "4.20260120.0", + "esbuild": "0.28.1", + "miniflare": "5.20260903.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260120.0" + "workerd": "1.20260903.1" }, "bin": { + "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "optionalDependencies": { - "fsevents": "~2.3.2" + "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20260120.0" + "@cloudflare/workers-types": "^5.20260903.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { @@ -1455,9 +2045,9 @@ } }, "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { @@ -1500,16 +2090,6 @@ "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } } } } diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index ec362fce5..03ec01f96 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -6,10 +6,24 @@ "scripts": { "build:manifest": "node ./scripts/build-manifest.mjs", "release:ui": "node ./scripts/release-ui.mjs", + "predev": "npm run build --prefix ../remote-control-protocol", "dev": "wrangler dev", - "deploy": "wrangler deploy" + "predeploy": "npm run build --prefix ../remote-control-protocol", + "deploy": "wrangler deploy", + "pretest": "npm run build --prefix ../remote-control-protocol", + "test": "node --import tsx --test src/**/*.test.ts", + "pretest:e2e": "node ./scripts/prepare-e2e-assets.mjs && npm run build --prefix ../remote-control-protocol", + "test:e2e": "node --import tsx --test scripts/remote-control-e2e.test.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@codenomad/remote-control-protocol": "file:../remote-control-protocol" }, "devDependencies": { - "wrangler": "^4.0.0" + "@cloudflare/workers-types": "^5.20260903.1", + "tsx": "^4.20.6", + "typescript": "^5.6.3", + "undici": "^6.19.8", + "wrangler": "^4.129.0" } } diff --git a/packages/cloudflare/scripts/prepare-e2e-assets.mjs b/packages/cloudflare/scripts/prepare-e2e-assets.mjs new file mode 100644 index 000000000..ae5866787 --- /dev/null +++ b/packages/cloudflare/scripts/prepare-e2e-assets.mjs @@ -0,0 +1,8 @@ +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") +const dist = path.join(packageRoot, "dist") +fs.mkdirSync(dist, { recursive: true }) +fs.writeFileSync(path.join(dist, "version.json"), "{}\n", "utf8") diff --git a/packages/cloudflare/scripts/release-ui.mjs b/packages/cloudflare/scripts/release-ui.mjs index bfa202bc7..ce0f09e26 100644 --- a/packages/cloudflare/scripts/release-ui.mjs +++ b/packages/cloudflare/scripts/release-ui.mjs @@ -27,6 +27,7 @@ if (!uiVersion) { } const uiBuildDir = path.join(repoRoot, "packages/ui/src/renderer/dist") +const workerAssetsDir = path.join(root, "dist") if (!fs.existsSync(uiBuildDir)) { console.error(`Missing UI build dir: ${uiBuildDir}. Run UI build first.`) process.exit(1) @@ -49,6 +50,10 @@ try { { cwd: root, stdio: "inherit" }, ) + // Rebuild Worker assets from scratch so obsolete hashed bundles are not + // retained across releases. + fs.rmSync(workerAssetsDir, { recursive: true, force: true }) + // Generate version.json into packages/cloudflare/dist console.log("[release-ui] Generating version.json") execFileSync( @@ -64,6 +69,10 @@ try { }, ) + // Remote Control loads the same reviewed UI bundle from the relay origin. + // API traffic then travels through the browser-to-host encrypted tunnel. + fs.cpSync(uiBuildDir, workerAssetsDir, { recursive: true }) + console.log("[release-ui] Deploying worker") execFileSync("npx", ["wrangler", "deploy"], { cwd: root, diff --git a/packages/cloudflare/scripts/remote-control-e2e.test.ts b/packages/cloudflare/scripts/remote-control-e2e.test.ts new file mode 100644 index 000000000..a5fa466a3 --- /dev/null +++ b/packages/cloudflare/scripts/remote-control-e2e.test.ts @@ -0,0 +1,409 @@ +import assert from "node:assert/strict" +import { createHash } from "node:crypto" +import { once } from "node:events" +import { mkdtempSync, rmSync } from "node:fs" +import { createServer, type IncomingMessage } from "node:http" +import { createServer as createNetServer, type Socket } from "node:net" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fileURLToPath } from "node:url" +import { spawn, spawnSync, type ChildProcess } from "node:child_process" +import test from "node:test" +import { createClientHandshake, decodeBase64, encodeBase64, type EncryptedChannel, type HostToClientMessage } from "@codenomad/remote-control-protocol" +import { WebSocket } from "undici" +import { loadOrCreateRemoteControlIdentity } from "../../server/src/remote-control/identity" +import { RemoteControlManager } from "../../server/src/remote-control/manager" + +const packageRoot = fileURLToPath(new URL("..", import.meta.url)) +const wranglerBin = join(packageRoot, "node_modules", "wrangler", "bin", "wrangler.js") + +test("hibernating relay carries opaque HTTP streams and WebSockets end to end", async () => { + const port = await availablePort() + const stateDirectory = mkdtempSync(join(tmpdir(), "codenomad-relay-state-")) + const identityDirectory = mkdtempSync(join(tmpdir(), "codenomad-relay-identity-")) + const local = createLocalServer() + local.server.listen(0, "127.0.0.1") + await once(local.server, "listening") + const address = local.server.address() + assert(address && typeof address !== "string") + const relay = startRelay(port, stateDirectory) + const identity = loadOrCreateRemoteControlIdentity(identityDirectory) + const manager = new RemoteControlManager({ + identity, + relayUrl: `http://localhost:${port}`, + localUrl: () => `http://127.0.0.1:${address.port}`, + localCookie: () => "local=session", + logger: silentLogger() as never, + }) + + try { + await waitForRelay(port, relay) + const started = await manager.start() + const routeHeaders = { "x-codenomad-relay-test-host": `${identity.hostId}.localhost` } + await replaceHostConnection(port, identity.hostId, identity.secret) + await waitForCondition(() => manager.status().state !== "connected", "Connector did not observe host replacement") + await waitForCondition(() => manager.status().state === "connected", "Connector did not reconnect") + + const stalePairing = decodePairing(started.pairing.url) + const staleExchange = await fetch(`http://127.0.0.1:${port}/__codenomad/pair`, { + method: "POST", + headers: { ...routeHeaders, "content-type": "application/json" }, + body: JSON.stringify({ token: stalePairing.token, name: "Stale pairing" }), + }) + assert.equal(staleExchange.status, 401) + const pairing = decodePairing((await manager.createPairing()).url) + const oversized = await fetch(`http://127.0.0.1:${port}/__codenomad/pair`, { + method: "POST", + headers: { ...routeHeaders, "content-type": "application/json" }, + body: JSON.stringify({ token: "x".repeat(5_000) }), + }) + assert.equal(oversized.status, 400) + + const paired = await fetch(`http://127.0.0.1:${port}/__codenomad/pair`, { + method: "POST", + headers: { ...routeHeaders, "content-type": "application/json" }, + body: JSON.stringify({ token: pairing.token, name: "E2E device" }), + }) + assert.equal(paired.status, 204) + const cookie = paired.headers.get("set-cookie") + assert(cookie) + const replayedPairing = await fetch(`http://127.0.0.1:${port}/__codenomad/pair`, { + method: "POST", + headers: { ...routeHeaders, "content-type": "application/json" }, + body: JSON.stringify({ token: pairing.token, name: "Replay" }), + }) + assert.equal(replayedPairing.status, 401) + + const directApi = await fetch(`http://127.0.0.1:${port}/api/workspaces`, { headers: { ...routeHeaders, cookie } }) + assert.equal(directApi.status, 426) + + const client = await EncryptedRelayClient.connect(port, routeHeaders, cookie, pairing.hostPublicKey) + const extraClients: EncryptedRelayClient[] = [] + try { + const streamId = crypto.randomUUID() + const fastId = crypto.randomUUID() + await client.send({ type: "http.request", id: streamId, method: "GET", path: "/api/stream", headers: [] }) + await client.send({ + type: "http.request", + id: fastId, + method: "POST", + path: "/api/fast?kept=1", + headers: [["authorization", "must-not-reach-local"]], + body: encodeBase64(new TextEncoder().encode("encrypted request body")), + }) + const responses = await client.collectHttp([streamId, fastId]) + assert.equal(responses.get(streamId), "stream-start-stream-end") + assert.deepEqual(client.httpCompletionOrder, [fastId, streamId]) + const fast = JSON.parse(responses.get(fastId) ?? "") as Record + assert.equal(fast.url, "/api/fast?kept=1") + assert.equal(fast.body, "encrypted request body") + assert.equal(fast.remote, "1") + assert.equal(fast.authorization, undefined) + assert.equal(fast.cookie, "local=session") + + const socketId = crypto.randomUUID() + await client.send({ type: "socket.open", id: socketId, path: "/workspaces/socket", headers: [["authorization", "strip-me"]], protocols: [] }) + await client.waitFor((message) => message.type === "socket.ready" && message.id === socketId) + await client.send({ type: "socket.message", id: socketId, data: encodeBase64(new TextEncoder().encode("hello")), binary: false }) + const socketReply = await client.waitFor((message) => message.type === "socket.message" && message.id === socketId) + assert.equal(new TextDecoder().decode(decodeBase64(socketReply.data)), "echo:hello") + assert.equal(local.socketHeaders.cookie, "local=session") + assert.equal(local.socketHeaders.authorization, undefined) + await client.send({ type: "socket.close", id: socketId, code: 1000, reason: "done" }) + + for (let index = 0; index < 15; index += 1) { + extraClients.push(await EncryptedRelayClient.connect(port, routeHeaders, cookie, pairing.hostPublicKey)) + } + await assert.rejects( + () => EncryptedRelayClient.connect(port, routeHeaders, cookie, pairing.hostPublicKey), + /failed|timed out/i, + ) + + const pairingResults = await Promise.allSettled( + Array.from({ length: 8 }, () => manager.createPairing()), + ) + assert.equal(pairingResults.filter((result) => result.status === "fulfilled").length, 8) + await assert.rejects(() => manager.createPairing(), /Too many active pairing links/) + + const devices = await manager.devices() + assert.equal(devices.length, 1) + await manager.revokeDevice(devices[0].id) + await client.waitForClose() + const revoked = await fetch(`http://127.0.0.1:${port}/__codenomad/bootstrap`, { headers: { ...routeHeaders, cookie } }) + assert.equal(revoked.status, 401) + } finally { + client.close() + for (const extra of extraClients) extra.close() + } + } finally { + await manager.shutdown() + local.server.close() + await once(local.server, "close") + stopRelay(relay) + rmSync(stateDirectory, { recursive: true, force: true }) + rmSync(identityDirectory, { recursive: true, force: true }) + } +}) + +class EncryptedRelayClient { + readonly httpCompletionOrder: string[] = [] + private readonly messages: HostToClientMessage[] = [] + private readonly waiters: Array<{ + predicate: (message: HostToClientMessage) => boolean + resolve: (message: HostToClientMessage) => void + timeout: ReturnType + }> = [] + private receiveQueue = Promise.resolve() + private sendQueue = Promise.resolve() + + private constructor( + private readonly socket: InstanceType, + private readonly channel: EncryptedChannel, + ) { + socket.addEventListener("message", (event) => { + if (!(event.data instanceof ArrayBuffer)) return + this.receiveQueue = this.receiveQueue.then(async () => { + const plaintext = await channel.decrypt(new Uint8Array(event.data)) + this.push(JSON.parse(new TextDecoder().decode(plaintext)) as HostToClientMessage) + }) + }) + } + + static async connect(port: number, routeHeaders: Record, cookie: string, hostPublicKey: JsonWebKey): Promise { + const handshake = await createClientHandshake(hostPublicKey) + const socket = new WebSocket(`ws://127.0.0.1:${port}/__codenomad/tunnel`, { headers: { ...routeHeaders, cookie } }) + socket.binaryType = "arraybuffer" + const channel = await new Promise((resolve, reject) => { + const fail = (error: Error) => { + clearTimeout(timeout) + if (socket.readyState < WebSocket.CLOSING) socket.close() + reject(error) + } + const timeout = setTimeout(() => fail(new Error("Encrypted relay handshake timed out")), 15_000) + socket.addEventListener("open", () => socket.send(handshake.hello), { once: true }) + socket.addEventListener("error", () => fail(new Error("Encrypted relay WebSocket failed")), { once: true }) + socket.addEventListener("close", () => fail(new Error("Encrypted relay WebSocket closed during handshake")), { once: true }) + socket.addEventListener("message", (event) => { + if (typeof event.data !== "string") return + void handshake.accept(event.data).then((value) => { + clearTimeout(timeout) + resolve(value) + }).catch(reject) + }, { once: true }) + }) + return new EncryptedRelayClient(socket, channel) + } + + send(message: unknown): Promise { + const plaintext = new TextEncoder().encode(JSON.stringify(message)) + this.sendQueue = this.sendQueue.then(async () => this.socket.send(await this.channel.encrypt(plaintext))) + return this.sendQueue + } + + async collectHttp(ids: string[]): Promise> { + const pending = new Set(ids) + const bodies = new Map(ids.map((id) => [id, ""])) + while (pending.size) { + const message = await this.waitFor((candidate) => "id" in candidate && pending.has(candidate.id)) + if (message.type === "http.chunk") { + bodies.set(message.id, `${bodies.get(message.id)}${new TextDecoder().decode(decodeBase64(message.data))}`) + } else if (message.type === "http.error") { + throw new Error(message.message) + } else if (message.type === "http.end") { + pending.delete(message.id) + } + } + return bodies + } + + waitFor(predicate: (message: HostToClientMessage) => boolean): Promise { + const index = this.messages.findIndex(predicate) + if (index >= 0) return Promise.resolve(this.messages.splice(index, 1)[0]) + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + const index = this.waiters.findIndex((waiter) => waiter.timeout === timeout) + if (index >= 0) this.waiters.splice(index, 1) + reject(new Error("Timed out waiting for encrypted relay response")) + }, 10_000) + this.waiters.push({ predicate, resolve, timeout }) + }) + } + + waitForClose(): Promise { + if (this.socket.readyState === WebSocket.CLOSED) return Promise.resolve() + return new Promise((resolve) => this.socket.addEventListener("close", () => resolve(), { once: true })) + } + + close(): void { + if (this.socket.readyState < WebSocket.CLOSING) this.socket.close() + } + + private push(message: HostToClientMessage): void { + if (message.type === "http.end") this.httpCompletionOrder.push(message.id) + const index = this.waiters.findIndex((waiter) => waiter.predicate(message)) + if (index < 0) this.messages.push(message) + else { + const waiter = this.waiters.splice(index, 1)[0] + clearTimeout(waiter.timeout) + waiter.resolve(message) + } + } +} + +function createLocalServer() { + const socketHeaders: Record = {} + const server = createServer((request, response) => { + if (request.headers.cookie !== "local=session") { + response.writeHead(401).end("missing internal session") + return + } + if (request.url === "/api/stream") { + response.write("stream-start-") + setTimeout(() => response.end("stream-end"), 150) + return + } + const chunks: Buffer[] = [] + request.on("data", (chunk) => chunks.push(Buffer.from(chunk))) + request.on("end", () => response.end(JSON.stringify({ + url: request.url, + body: Buffer.concat(chunks).toString(), + remote: request.headers["x-codenomad-remote-control"], + authorization: request.headers.authorization, + cookie: request.headers.cookie, + }))) + }) + server.on("upgrade", (request, socket) => { + socketHeaders.cookie = request.headers.cookie + socketHeaders.authorization = request.headers.authorization + acceptEchoSocket(request, socket) + }) + return { server, socketHeaders } +} + +function acceptEchoSocket(request: IncomingMessage, socket: Socket): void { + const key = request.headers["sec-websocket-key"] + if (request.url !== "/workspaces/socket" || typeof key !== "string" || request.headers.cookie !== "local=session") { + socket.destroy() + return + } + const accept = createHash("sha1").update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest("base64") + socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`) + socket.on("data", (frame) => { + if ((frame[0] & 0x0f) === 0x08) { + socket.end() + return + } + const payload = decodeClientFrame(frame) + socket.write(serverTextFrame(`echo:${payload}`)) + }) +} + +function decodeClientFrame(frame: Buffer): string { + const length = frame[1] & 0x7f + assert(length < 126) + const mask = frame.subarray(2, 6) + const payload = frame.subarray(6, 6 + length) + for (let index = 0; index < payload.length; index += 1) payload[index] ^= mask[index % 4] + return payload.toString() +} + +function serverTextFrame(value: string): Buffer { + const payload = Buffer.from(value) + assert(payload.length < 126) + return Buffer.concat([Buffer.from([0x81, payload.length]), payload]) +} + +function decodePairing(urlValue: string): { token: string; hostPublicKey: JsonWebKey } { + const fragment = decodeURIComponent(new URL(urlValue).hash.slice(1)) + const value = JSON.parse(new TextDecoder().decode(decodeBase64(fragment))) as Record + assert.equal(value.protocol, 2) + assert.equal(typeof value.token, "string") + assert.equal(typeof value.hostPublicKey, "object") + return value as { token: string; hostPublicKey: JsonWebKey } +} + +function startRelay(port: number, stateDirectory: string): ChildProcess { + return spawn(process.execPath, [ + wranglerBin, + "dev", + "--local", + "--port", + String(port), + "--persist-to", + stateDirectory, + "--var", + "REMOTE_BASE_HOST:localhost", + ], { + cwd: packageRoot, + env: { ...process.env, NO_COLOR: "1" }, + stdio: ["ignore", "pipe", "pipe"], + }) +} + +async function replaceHostConnection(port: number, hostId: string, secret: string): Promise { + const socket = new WebSocket(`ws://127.0.0.1:${port}/api/hosts/${hostId}/connect`, { + headers: { Authorization: `Bearer ${secret}` }, + }) + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Replacement host did not connect")), 10_000) + socket.addEventListener("open", () => socket.send(JSON.stringify({ type: "ready", protocol: 2 })), { once: true }) + socket.addEventListener("message", () => { + clearTimeout(timeout) + resolve() + }, { once: true }) + socket.addEventListener("error", () => reject(new Error("Replacement host failed")), { once: true }) + }) +} + +async function waitForCondition(condition: () => boolean, message: string): Promise { + const deadline = Date.now() + 10_000 + while (!condition()) { + if (Date.now() >= deadline) throw new Error(message) + await new Promise((resolve) => setTimeout(resolve, 25)) + } +} + +async function waitForRelay(port: number, relay: ChildProcess): Promise { + const output: Buffer[] = [] + relay.stdout?.on("data", (chunk) => output.push(Buffer.from(chunk))) + relay.stderr?.on("data", (chunk) => output.push(Buffer.from(chunk))) + const deadline = Date.now() + 20_000 + while (Date.now() < deadline) { + if (relay.exitCode !== null) throw new Error(`Wrangler exited early:\n${Buffer.concat(output).toString()}`) + try { + const response = await fetch(`http://127.0.0.1:${port}/version.json`) + if (response.status < 500) return + } catch { + // Wrangler is still starting. + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error(`Wrangler did not start:\n${Buffer.concat(output).toString()}`) +} + +function stopRelay(relay: ChildProcess): void { + if (!relay.pid || relay.exitCode !== null) return + if (process.platform === "win32") spawnSync("taskkill", ["/PID", String(relay.pid), "/T", "/F"], { stdio: "ignore" }) + else relay.kill("SIGTERM") +} + +async function availablePort(): Promise { + const server = createNetServer() + server.listen(0, "127.0.0.1") + await once(server, "listening") + const address = server.address() + assert(address && typeof address !== "string") + server.close() + await once(server, "close") + return address.port +} + +function silentLogger() { + return { + child() { return this }, + debug() {}, + error() {}, + info() {}, + warn() {}, + } +} diff --git a/packages/cloudflare/src/index.test.ts b/packages/cloudflare/src/index.test.ts new file mode 100644 index 000000000..9bfbe2c05 --- /dev/null +++ b/packages/cloudflare/src/index.test.ts @@ -0,0 +1,150 @@ +import assert from "node:assert/strict" +import test from "node:test" +import worker, { type Env } from "./index" + +const DEVICE_COOKIE = `codenomad_remote_device=${"d".repeat(43)}` + +function relayEnv( + onRequest: (request: Request) => Response | Promise, + onAsset: (request: Request) => Response | Promise = () => new Response("asset"), +): Env { + const stub = { fetch: onRequest } + return { + REMOTE_BASE_HOST: "remote.example.com", + REMOTE_HOSTS: { + idFromName: (name: string) => name, + get: () => stub, + } as unknown as DurableObjectNamespace, + ASSETS: { fetch: onAsset } as Fetcher, + } +} + +test("remote API paths require a paired session and encrypted tunnel", async () => { + let forwarded: Request | undefined + const env = relayEnv((request) => { + forwarded = request + return new Response(null, { status: 204 }) + }) + const hostId = "a".repeat(32) + const response = await worker.fetch(new Request(`https://${hostId}.remote.example.com/api/items?value=1`, { headers: { cookie: DEVICE_COOKIE } }), env) + assert.equal(response.status, 426) + assert.equal(forwarded!.headers.get("x-codenomad-relay-operation"), "session-check") + assert.match(await response.text(), /Encrypted Remote Control tunnel required/) +}) + +test("remote HTML is authenticated and receives the encrypted transport bootstrap", async () => { + const hostId = "b".repeat(32) + const response = await worker.fetch( + new Request(`https://${hostId}.remote.example.com/`, { headers: { cookie: DEVICE_COOKIE } }), + relayEnv( + () => new Response(null, { status: 204 }), + () => new Response("", { headers: { "content-type": "text/html" } }), + ), + ) + assert.equal(response.status, 200) + assert.match(await response.text(), /__CODENOMAD_REMOTE_CONTROL__/) + assert.equal(response.headers.get("cache-control"), "no-store") + assert.equal(response.headers.get("x-frame-options"), "DENY") +}) + +test("remote tunnel upgrades are routed directly to the host object", async () => { + let operation: string | null = null + const hostId = "c".repeat(32) + const response = await worker.fetch( + new Request(`https://${hostId}.remote.example.com/__codenomad/tunnel`, { headers: { cookie: DEVICE_COOKIE } }), + relayEnv((request) => { + operation = request.headers.get("x-codenomad-relay-operation") + return new Response("tunnel") + }), + ) + assert.equal(await response.text(), "tunnel") + assert.equal(operation, "tunnel-connect") +}) + +test("remote HTML is not served before device authentication", async () => { + const hostId = "0".repeat(32) + let assetRequests = 0 + const response = await worker.fetch( + new Request(`https://${hostId}.remote.example.com/`), + relayEnv( + () => Response.json({ error: "Remote device is not paired" }, { status: 401 }), + () => { + assetRequests += 1 + return new Response("should not be served") + }, + ), + ) + assert.equal(response.status, 401) + assert.equal(assetRequests, 0) +}) + +test("asset fallback HTML still requires device authentication", async () => { + const hostId = "2".repeat(32) + let hostRequests = 0 + const response = await worker.fetch( + new Request(`https://${hostId}.remote.example.com/missing-route`, { headers: { accept: "*/*", cookie: DEVICE_COOKIE } }), + relayEnv( + () => { + hostRequests += 1 + return Response.json({ error: "Remote device is not paired" }, { status: 401 }) + }, + () => new Response("", { headers: { "content-type": "text/html" } }), + ), + ) + assert.equal(response.status, 401) + assert.equal(hostRequests, 1) + assert.doesNotMatch(await response.text(), /doctype/) +}) + +test("immutable UI assets do not wake the host object", async () => { + const hostId = "1".repeat(32) + let hostRequests = 0 + const response = await worker.fetch( + new Request(`https://${hostId}.remote.example.com/assets/app.js`), + relayEnv( + () => { + hostRequests += 1 + return new Response(null, { status: 401 }) + }, + () => new Response("console.log('public bundle')", { headers: { "content-type": "text/javascript" } }), + ), + ) + assert.equal(response.status, 200) + assert.equal(hostRequests, 0) +}) + +test("remote bootstrap is authenticated and never cached", async () => { + const hostId = "e".repeat(32) + const response = await worker.fetch( + new Request(`https://${hostId}.remote.example.com/__codenomad/bootstrap`, { headers: { cookie: DEVICE_COOKIE } }), + relayEnv(() => new Response(null, { status: 204 })), + ) + assert.equal(response.status, 200) + assert.deepEqual(await response.json(), { tunnelPath: "/__codenomad/tunnel" }) + assert.equal(response.headers.get("cache-control"), "no-store") +}) + +test("host control endpoints are accepted only on the relay base origin", async () => { + const hostId = "f".repeat(32) + let forwarded = 0 + const env = relayEnv(() => { + forwarded += 1 + return new Response(null, { status: 204 }) + }) + const allowed = await worker.fetch(new Request(`https://remote.example.com/api/hosts/${hostId}/devices`), env) + assert.equal(allowed.status, 204) + const blocked = await worker.fetch(new Request(`https://ui.example.com/api/hosts/${hostId}/devices`), env) + assert.equal(blocked.status, 200) + assert.equal(forwarded, 1) +}) + +test("pairing page allows its same-origin exchange, stores the pinned host key, and blocks framing", async () => { + const hostId = "d".repeat(32) + const response = await worker.fetch(new Request(`https://${hostId}.remote.example.com/__codenomad/pair`), relayEnv(() => new Response())) + const policy = response.headers.get("content-security-policy") ?? "" + const page = await response.text() + assert.match(policy, /connect-src 'self'/) + assert.match(policy, /frame-ancestors 'none'/) + assert.match(page, /codenomad\.remote-control\.host-public-key/) + assert.match(page, /pairing\.protocol!==2/) +}) diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index dbe264587..3987d65e7 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -1,26 +1,217 @@ +import { RemoteControlHost } from "./remote-control/host-object" +import { HOST_ID_PATTERN, RELAY_TOKEN_PATTERN, clearDeviceCookie, cookieToken } from "./remote-control/security" + +export { RemoteControlHost } + export interface Env { - ASSETS: { fetch: (request: Request) => Promise } + ASSETS: Fetcher + REMOTE_HOSTS: DurableObjectNamespace + REMOTE_BASE_HOST: string } export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url) + const baseHost = env.REMOTE_BASE_HOST.toLowerCase() + const hostname = requestHostname(request, url, baseHost) + const hostId = remoteHostId(hostname, baseHost) + + if (hostId) return handleRemoteHost(request, env, hostId) + if ((hostname === baseHost || baseHost === "localhost") && url.pathname.startsWith("/api/hosts/")) { + return handleHostControl(request, env) + } if (url.pathname === "/version.json") { const response = await env.ASSETS.fetch(request) - - const newHeaders = new Headers(response.headers) - newHeaders.set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate") - newHeaders.set("Pragma", "no-cache") - newHeaders.set("Expires", "0") - - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers: newHeaders, - }) + const headers = new Headers(response.headers) + headers.set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate") + headers.set("Pragma", "no-cache") + headers.set("Expires", "0") + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }) } - return env.ASSETS.fetch(request) }, } + +function requestHostname(request: Request, url: URL, baseHost: string): string { + const hostname = url.hostname.toLowerCase() + if (baseHost === "localhost") { + const testHost = request.headers.get("x-codenomad-relay-test-host") + if (testHost) return testHost.toLowerCase() + } + if (baseHost !== "localhost" || (hostname !== "localhost" && !hostname.startsWith("127."))) return hostname + const presented = (request.headers.get("host") ?? hostname).split(":")[0].toLowerCase() + return presented === "localhost" || presented.startsWith("127.") ? baseHost : presented +} + +async function handleHostControl(request: Request, env: Env): Promise { + const url = new URL(request.url) + const match = url.pathname.match(/^\/api\/hosts\/([a-f0-9]{32})\/(connect|pair|devices)(?:\/([^/]+))?$/) + if (!match) return Response.json({ error: "Invalid host control path" }, { status: 404 }) + const [, hostId, action, resourceId] = match + const operation = action === "connect" + ? "host-connect" + : action === "pair" + ? "pair-create" + : resourceId + ? "device-revoke" + : "devices" + return hostStub(env, hostId).fetch(withOperation(request, operation, resourceId)) +} + +async function handleRemoteHost(request: Request, env: Env, hostId: string): Promise { + const url = new URL(request.url) + if (url.pathname === "/__codenomad/pair" && request.method === "POST") { + return hostStub(env, hostId).fetch(withOperation(request, "pair-exchange")) + } + + if (url.pathname === "/__codenomad/pair" && request.method === "GET") { + return new Response(pairingPage(request.headers.get("accept-language")), { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + "Content-Security-Policy": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + }, + }) + } + + if (url.pathname === "/__codenomad/pair") return new Response(null, { status: 405 }) + + if (url.pathname === "/__codenomad/tunnel") { + if (!validDeviceCookie(request)) return unpairedResponse() + return hostStub(env, hostId).fetch(withOperation(request, "tunnel-connect")) + } + + if (url.pathname === "/__codenomad/bootstrap") { + const authorized = await checkRemoteSession(request, env, hostId) + if (!authorized.ok) return authorized + return Response.json({ tunnelPath: "/__codenomad/tunnel" }, { + headers: { "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" }, + }) + } + if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/workspaces/")) { + const authorized = await checkRemoteSession(request, env, hostId) + if (!authorized.ok) return authorized + return Response.json({ error: "Encrypted Remote Control tunnel required" }, { status: 426 }) + } + const htmlNavigation = isHtmlNavigation(request, url) + if (htmlNavigation) { + const authorized = await checkRemoteSession(request, env, hostId) + if (!authorized.ok) return authorized + } + return remoteAsset(request, env, hostId, htmlNavigation) +} + +function checkRemoteSession(request: Request, env: Env, hostId: string): Promise { + if (!validDeviceCookie(request)) return Promise.resolve(unpairedResponse()) + return hostStub(env, hostId).fetch(withOperation(request, "session-check")) +} + +function validDeviceCookie(request: Request): boolean { + return RELAY_TOKEN_PATTERN.test(cookieToken(request) ?? "") +} + +function unpairedResponse(): Response { + return Response.json({ error: "Remote device is not paired" }, { + status: 401, + headers: { "Set-Cookie": clearDeviceCookie() }, + }) +} + +function isHtmlNavigation(request: Request, url: URL): boolean { + return url.pathname === "/" || url.pathname.endsWith(".html") + || request.headers.get("accept")?.includes("text/html") === true +} + +async function remoteAsset(request: Request, env: Env, hostId: string, authorized: boolean): Promise { + const response = await env.ASSETS.fetch(request) + const headers = new Headers(response.headers) + headers.set("Referrer-Policy", "no-referrer") + headers.set("X-Content-Type-Options", "nosniff") + headers.set("X-Frame-Options", "DENY") + const url = new URL(request.url) + const isHtml = response.headers.get("content-type")?.toLowerCase().includes("text/html") + || url.pathname === "/" + || url.pathname.endsWith(".html") + if (!isHtml) { + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }) + } + if (!authorized) { + const authorization = await checkRemoteSession(request, env, hostId) + if (!authorization.ok) return authorization + } + if (request.method === "HEAD") { + return new Response(null, { status: response.status, statusText: response.statusText, headers }) + } + const html = await response.text() + const bootstrap = "" + headers.set("Cache-Control", "no-store") + headers.delete("Content-Length") + headers.delete("Content-Encoding") + headers.delete("ETag") + const bootstrappedHtml = html.includes("") ? html.replace("", `${bootstrap}`) : `${bootstrap}${html}` + return new Response(bootstrappedHtml, { + status: response.status, + statusText: response.statusText, + headers, + }) +} + +function hostStub(env: Env, hostId: string): DurableObjectStub { + return env.REMOTE_HOSTS.get(env.REMOTE_HOSTS.idFromName(hostId)) +} + +function withOperation(request: Request, operation: string, resourceId?: string): Request { + const headers = new Headers(request.headers) + headers.set("X-CodeNomad-Relay-Operation", operation) + if (resourceId) headers.set("X-CodeNomad-Relay-Device-Id", resourceId) + return new Request(request, { headers }) +} + +function remoteHostId(hostname: string, baseHost: string): string | null { + const suffix = `.${baseHost}` + const normalized = hostname.toLowerCase() + if (!normalized.endsWith(suffix)) return null + const value = normalized.slice(0, -suffix.length) + return HOST_ID_PATTERN.test(value) ? value : null +} + +const pairingMessages = { + de: { connecting: "Dieses Gerรคt wird gekoppeltโ€ฆ", failed: "Kopplung fehlgeschlagen", mobile: "Mobilgerรคt", browser: "Webbrowser" }, + en: { connecting: "Pairing this deviceโ€ฆ", failed: "Pairing failed", mobile: "Mobile device", browser: "Web browser" }, + es: { connecting: "Vinculando este dispositivoโ€ฆ", failed: "Error de vinculaciรณn", mobile: "Dispositivo mรณvil", browser: "Navegador web" }, + fr: { connecting: "Appairage de cet appareilโ€ฆ", failed: "ร‰chec de lโ€™appairage", mobile: "Appareil mobile", browser: "Navigateur web" }, + he: { connecting: "ื”ืžื›ืฉื™ืจ ืžืฆื•ืžื“โ€ฆ", failed: "ื”ืฆื™ืžื•ื“ ื ื›ืฉืœ", mobile: "ืžื›ืฉื™ืจ ื ื™ื™ื“", browser: "ื“ืคื“ืคืŸ" }, + ja: { connecting: "ใ“ใฎ็ซฏๆœซใ‚’ใƒšใ‚ขใƒชใƒณใ‚ฐไธญโ€ฆ", failed: "ใƒšใ‚ขใƒชใƒณใ‚ฐใซๅคฑๆ•—ใ—ใพใ—ใŸ", mobile: "ใƒขใƒใ‚คใƒซ็ซฏๆœซ", browser: "ใ‚ฆใ‚งใƒ–ใƒ–ใƒฉใ‚ฆใ‚ถใƒผ" }, + ne: { connecting: "เคฏเฅ‹ เค‰เคชเค•เคฐเคฃ เคœเฅ‹เคกเฅ€ เคนเฅเคเคฆเฅˆเค›โ€ฆ", failed: "เคœเฅ‹เคกเฅ€ เค…เคธเคซเคฒ เคญเคฏเฅ‹", mobile: "เคฎเฅ‹เคฌเคพเค‡เคฒ เค‰เคชเค•เคฐเคฃ", browser: "เคตเฅ‡เคฌ เคฌเฅเคฐเคพเค‰เคœเคฐ" }, + ru: { connecting: "ะŸะพะดะบะปัŽั‡ะตะฝะธะต ัƒัั‚ั€ะพะนัั‚ะฒะฐโ€ฆ", failed: "ะะต ัƒะดะฐะปะพััŒ ะฒั‹ะฟะพะปะฝะธั‚ัŒ ัะพะฟั€ัะถะตะฝะธะต", mobile: "ะœะพะฑะธะปัŒะฝะพะต ัƒัั‚ั€ะพะนัั‚ะฒะพ", browser: "ะ’ะตะฑ-ะฑั€ะฐัƒะทะตั€" }, + tr: { connecting: "Bu cihaz eลŸleลŸtiriliyorโ€ฆ", failed: "EลŸleลŸtirme baลŸarฤฑsฤฑz", mobile: "Mobil cihaz", browser: "Web tarayฤฑcฤฑsฤฑ" }, + "zh-Hans": { connecting: "ๆญฃๅœจ้…ๅฏนๆญค่ฎพๅค‡โ€ฆ", failed: "้…ๅฏนๅคฑ่ดฅ", mobile: "็งปๅŠจ่ฎพๅค‡", browser: "็ฝ‘้กตๆต่งˆๅ™จ" }, +} as const + +function pairingPage(acceptLanguage: string | null): string { + const requested = (acceptLanguage ?? "").toLowerCase() + const locale = Object.keys(pairingMessages).find((candidate) => requested.startsWith(candidate.toLowerCase()) + || requested.includes(`,${candidate.toLowerCase()}`)) as keyof typeof pairingMessages | undefined + const messages = JSON.stringify(pairingMessages[locale ?? "en"]).replace(/CodeNomad
` +} diff --git a/packages/cloudflare/src/remote-control/host-object.ts b/packages/cloudflare/src/remote-control/host-object.ts new file mode 100644 index 000000000..5eb794cce --- /dev/null +++ b/packages/cloudflare/src/remote-control/host-object.ts @@ -0,0 +1,543 @@ +import { + REMOTE_CONTROL_HEARTBEAT_REQUEST, + REMOTE_CONTROL_HEARTBEAT_RESPONSE, + REMOTE_CONTROL_MAX_HANDSHAKE_BYTES, + REMOTE_CONTROL_MAX_PLAINTEXT_BYTES, + REMOTE_CONTROL_PROTOCOL_VERSION, + decodeBase64, + encodeBase64, + type RelayToHostMessage, +} from "@codenomad/remote-control-protocol" +import { base64ByteLength, parseHostMessage, readPairingInput, safeRelayCloseCode } from "./relay-messages" +import { HOST_SECRET_PATTERN, RELAY_TOKEN_PATTERN, bearerToken, clearDeviceCookie, cookieToken, deviceCookie, randomToken, tokenHash } from "./security" + +const PAIRING_TTL_MS = 10 * 60_000 +const DEVICE_TTL_MS = 30 * 24 * 60 * 60_000 +const HOST_SECRET_KEY = "host-secret" +const PAIRING_PREFIX = "pair:" +const DEVICE_PREFIX = "device:" +const HOST_TAG = "host" +const CLIENT_TAG = "client" +const MAX_ACTIVE_PAIRINGS = 8 +const MAX_CONNECTED_CLIENTS = 16 +const MAX_DEVICES = 64 +const MAX_PAIRING_BODY_BYTES = 4 * 1024 +const MAX_TUNNEL_FRAME_BYTES = REMOTE_CONTROL_MAX_PLAINTEXT_BYTES + 1024 +const MAX_HOST_MESSAGE_CHARS = Math.ceil(MAX_TUNNEL_FRAME_BYTES * 4 / 3) + 2 * 1024 + +interface PairingRecord { + expiresAt: number + connectionId: string +} +interface DeviceRecord { + id: string + name: string + createdAt: number + lastSeenAt: number + expiresAt: number +} + +interface HostSocketAttachment { + role: "host" + connectionId: string + ready: boolean + active: boolean +} + +interface ClientSocketAttachment { + role: "client" + id: string + deviceId: string + phase?: "hello" | "encrypted" +} + +type SocketAttachment = HostSocketAttachment | ClientSocketAttachment + +export class RemoteControlHost implements DurableObject { + constructor(private readonly state: DurableObjectState) { + state.setWebSocketAutoResponse(new WebSocketRequestResponsePair( + REMOTE_CONTROL_HEARTBEAT_REQUEST, + REMOTE_CONTROL_HEARTBEAT_RESPONSE, + )) + } + + async fetch(request: Request): Promise { + const operation = request.headers.get("x-codenomad-relay-operation") + if (operation === "host-connect") return this.connectHost(request) + if (operation === "pair-create") return this.createPairing(request) + if (operation === "pair-exchange") return this.exchangePairing(request) + if (operation === "devices") return this.devices(request) + if (operation === "device-revoke") return this.revokeDevice(request) + if (operation === "session-check") return this.checkDevice(request) + if (operation === "tunnel-connect") return this.connectTunnel(request) + return Response.json({ error: "Unknown remote-control operation" }, { status: 404 }) + } + + webSocketMessage(socket: WebSocket, payload: string | ArrayBuffer): void { + const attachment = socketAttachment(socket) + if (!attachment) { + socket.close(1008, "Missing relay socket identity") + return + } + if (attachment.role === "host") { + this.onHostMessage(socket, payload) + return + } + + const bytes = typeof payload === "string" ? new TextEncoder().encode(payload) : new Uint8Array(payload) + if ((attachment.phase === undefined && typeof payload !== "string") + || attachment.phase === "hello" + || (attachment.phase === "encrypted" && typeof payload === "string")) { + this.closeClient(attachment.id, 1002, "Invalid encrypted tunnel sequence") + return + } + if (bytes.byteLength > (attachment.phase === "encrypted" ? MAX_TUNNEL_FRAME_BYTES : REMOTE_CONTROL_MAX_HANDSHAKE_BYTES)) { + this.closeClient(attachment.id, 1009, attachment.phase === "encrypted" + ? "Encrypted tunnel frame is too large" + : "Encryption handshake is too large") + return + } + if (!this.sendHost({ + type: "tunnel.message", + id: attachment.id, + data: encodeBase64(bytes), + binary: typeof payload !== "string", + })) { + this.closeClient(attachment.id, 1013, "CodeNomad host disconnected") + } else if (attachment.phase !== "encrypted") { + attachment.phase = "hello" + socket.serializeAttachment(attachment) + } + } + + webSocketClose(socket: WebSocket, code: number, reason: string): void { + const attachment = socketAttachment(socket) + if (!attachment) return + if (attachment.role === "host") { + this.onHostClosed(socket) + return + } + this.sendHost({ type: "tunnel.close", id: attachment.id, code, reason }) + } + + webSocketError(socket: WebSocket): void { + const attachment = socketAttachment(socket) + if (!attachment) return + if (attachment.role === "host") { + this.onHostClosed(socket) + return + } + this.sendHost({ type: "tunnel.close", id: attachment.id, code: 1011, reason: "Remote tunnel failed" }) + } + + async alarm(): Promise { + const now = Date.now() + const expiredDeviceIds = await this.state.storage.transaction(async (transaction) => { + const pairings = await transaction.list({ prefix: PAIRING_PREFIX }) + const devices = await transaction.list({ prefix: DEVICE_PREFIX }) + const expiredDevices = Array.from(devices.entries()).filter(([, record]) => record.expiresAt <= now) + const expired = [ + ...Array.from(pairings.entries()).filter(([, record]) => record.expiresAt <= now).map(([key]) => key), + ...expiredDevices.map(([key]) => key), + ] + if (expired.length) await transaction.delete(expired) + const nextExpiration = [...pairings.values(), ...devices.values()] + .map((record) => record.expiresAt) + .filter((expiresAt) => expiresAt > now) + .sort((left, right) => left - right)[0] + if (nextExpiration) await transaction.setAlarm(nextExpiration) + else await transaction.deleteAlarm() + return expiredDevices.map(([, record]) => record.id) + }) + this.closeDeviceSockets(expiredDeviceIds, "Remote device expired") + } + + private async connectHost(request: Request): Promise { + if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { + return Response.json({ error: "WebSocket required" }, { status: 426 }) + } + if (!(await this.authorizeHost(request, true))) return Response.json({ error: "Unauthorized" }, { status: 401 }) + + const pair = new WebSocketPair() + const client = pair[0] + const server = pair[1] + const previous = this.hostConnection() + if (previous) { + previous.attachment.active = false + previous.socket.serializeAttachment(previous.attachment) + previous.socket.close(1012, "Host reconnected") + this.closeAllClients("CodeNomad host reconnected") + } + const attachment: HostSocketAttachment = { + role: "host", + connectionId: crypto.randomUUID(), + ready: false, + active: true, + } + server.serializeAttachment(attachment) + this.state.acceptWebSocket(server, [HOST_TAG]) + return new Response(null, { status: 101, webSocket: client }) + } + + private async connectTunnel(request: Request): Promise { + if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { + return Response.json({ error: "WebSocket required" }, { status: 426 }) + } + const device = await this.authorizeDevice(request) + if (!device) return this.unpairedResponse() + if (!this.isHostConnected()) return Response.json({ error: "CodeNomad host is offline" }, { status: 503 }) + if (this.state.getWebSockets(CLIENT_TAG).filter((socket) => socket.readyState === WebSocket.OPEN).length >= MAX_CONNECTED_CLIENTS) { + return Response.json({ error: "Too many active remote clients" }, { status: 429 }) + } + + const id = crypto.randomUUID() + const pair = new WebSocketPair() + const client = pair[0] + const server = pair[1] + const attachment: ClientSocketAttachment = { role: "client", id, deviceId: device.id } + server.serializeAttachment(attachment) + this.state.acceptWebSocket(server, [CLIENT_TAG, clientTag(id)]) + if (!this.sendHost({ type: "tunnel.open", id })) { + server.close(1013, "CodeNomad host disconnected") + return Response.json({ error: "CodeNomad host is offline" }, { status: 503 }) + } + return new Response(null, { status: 101, webSocket: client }) + } + + private async createPairing(request: Request): Promise { + if (request.method !== "POST") return new Response(null, { status: 405 }) + if (!(await this.authorizeHost(request))) return Response.json({ error: "Unauthorized" }, { status: 401 }) + if (!this.isHostConnected()) return Response.json({ error: "Host is offline" }, { status: 409 }) + + const now = Date.now() + const token = randomToken() + const key = `${PAIRING_PREFIX}${await tokenHash(token)}` + const expiresAt = now + PAIRING_TTL_MS + const host = this.hostConnection() + if (!host?.attachment.ready) return Response.json({ error: "Host reconnected while creating the pairing link" }, { status: 409 }) + const connectionId = host.attachment.connectionId + const created = await this.state.storage.transaction(async (transaction) => { + const pairings = await transaction.list({ prefix: PAIRING_PREFIX }) + const expired = Array.from(pairings.entries()) + .filter(([, record]) => record.expiresAt <= now) + .map(([pairingKey]) => pairingKey) + if (expired.length) await transaction.delete(expired) + const active = Array.from(pairings.values()) + .filter((record) => record.expiresAt > now && record.connectionId === connectionId) + if (active.length >= MAX_ACTIVE_PAIRINGS) return false + await transaction.put(key, { + expiresAt, + connectionId, + } satisfies PairingRecord) + await scheduleExpiration(transaction, expiresAt) + return true + }) + if (!created) { + return Response.json({ error: "Too many active pairing links" }, { status: 429 }) + } + if (!this.isCurrentHost(connectionId)) { + await this.state.storage.transaction(async (transaction) => { + const record = await transaction.get(key) + if (record?.connectionId === connectionId) await transaction.delete(key) + }) + return Response.json({ error: "Host reconnected while creating the pairing link" }, { status: 409 }) + } + return Response.json({ token, expiresAt: new Date(expiresAt).toISOString() }) + } + + private async exchangePairing(request: Request): Promise { + if (request.method !== "POST") return new Response(null, { status: 405 }) + const input = await readPairingInput(request, MAX_PAIRING_BODY_BYTES) + if (!input) return Response.json({ error: "Invalid or oversized pairing request" }, { status: 400 }) + const token = typeof input.token === "string" ? input.token.trim() : "" + if (!RELAY_TOKEN_PATTERN.test(token)) return Response.json({ error: "Valid pairing token required" }, { status: 400 }) + + const pairingKey = `${PAIRING_PREFIX}${await tokenHash(token)}` + const now = Date.now() + const deviceToken = randomToken() + const device: DeviceRecord = { + id: crypto.randomUUID(), + name: typeof input.name === "string" && input.name.trim() ? input.name.trim().slice(0, 80) : "Remote device", + createdAt: now, + lastSeenAt: now, + expiresAt: now + DEVICE_TTL_MS, + } + const deviceKey = `${DEVICE_PREFIX}${await tokenHash(deviceToken)}` + const host = this.hostConnection() + const connectionId = host?.attachment.ready ? host.attachment.connectionId : null + const result = await this.state.storage.transaction(async (transaction) => { + const record = await transaction.get(pairingKey) + if (record) await transaction.delete(pairingKey) + if (!record || record.expiresAt <= now || !connectionId || record.connectionId !== connectionId) { + return { status: "invalid" as const, expiredDeviceIds: [] as string[] } + } + const devices = await transaction.list({ prefix: DEVICE_PREFIX }) + const expiredEntries = Array.from(devices.entries()).filter(([, candidate]) => candidate.expiresAt <= now) + if (expiredEntries.length) await transaction.delete(expiredEntries.map(([key]) => key)) + const expiredDeviceIds = expiredEntries.map(([, candidate]) => candidate.id) + if (devices.size - expiredEntries.length >= MAX_DEVICES) { + return { status: "full" as const, expiredDeviceIds } + } + await transaction.put(deviceKey, device) + await scheduleExpiration(transaction, device.expiresAt) + return { status: "created" as const, expiredDeviceIds } + }) + this.closeDeviceSockets(result.expiredDeviceIds, "Remote device expired") + if (result.status === "invalid") { + return Response.json({ error: "Pairing link is invalid or expired" }, { status: 401 }) + } + if (result.status === "full") { + return Response.json({ error: "Too many paired devices" }, { status: 429 }) + } + if (!connectionId || !this.isCurrentHost(connectionId)) { + await this.state.storage.transaction(async (transaction) => { + const stored = await transaction.get(deviceKey) + if (stored?.id === device.id) await transaction.delete(deviceKey) + }) + return Response.json({ error: "Pairing link is invalid or expired" }, { status: 401 }) + } + + return new Response(null, { + status: 204, + headers: { "Set-Cookie": deviceCookie(deviceToken, Math.floor(DEVICE_TTL_MS / 1000)) }, + }) + } + + private async devices(request: Request): Promise { + if (!(await this.authorizeHost(request))) return Response.json({ error: "Unauthorized" }, { status: 401 }) + const now = Date.now() + const result = await this.state.storage.transaction(async (transaction) => { + const records = await transaction.list({ prefix: DEVICE_PREFIX }) + const expiredEntries = Array.from(records.entries()).filter(([, device]) => device.expiresAt <= now) + if (expiredEntries.length) await transaction.delete(expiredEntries.map(([key]) => key)) + return { + expiredDeviceIds: expiredEntries.map(([, device]) => device.id), + devices: Array.from(records.values()) + .filter((device) => device.expiresAt > now) + .map((device) => ({ + id: device.id, + name: device.name, + createdAt: new Date(device.createdAt).toISOString(), + lastSeenAt: new Date(device.lastSeenAt).toISOString(), + })), + } + }) + this.closeDeviceSockets(result.expiredDeviceIds, "Remote device expired") + return Response.json({ devices: result.devices }) + } + + private async revokeDevice(request: Request): Promise { + if (request.method !== "DELETE") return new Response(null, { status: 405 }) + if (!(await this.authorizeHost(request))) return Response.json({ error: "Unauthorized" }, { status: 401 }) + const deviceId = request.headers.get("x-codenomad-relay-device-id") + const entry = await this.state.storage.transaction(async (transaction) => { + const records = await transaction.list({ prefix: DEVICE_PREFIX }) + const match = Array.from(records.entries()).find(([, device]) => device.id === deviceId) + if (match) await transaction.delete(match[0]) + return match?.[1] ?? null + }) + if (entry) { + this.closeDeviceSockets([entry.id], "Remote device was revoked") + } + return new Response(null, { status: 204 }) + } + + private async checkDevice(request: Request): Promise { + return await this.authorizeDevice(request) ? new Response(null, { status: 204 }) : this.unpairedResponse() + } + + private onHostMessage(socket: WebSocket, payload: string | ArrayBuffer): void { + const current = this.hostConnection() + if (current?.socket !== socket) return + if (typeof payload !== "string") { + socket.close(1003, "Invalid binary host message") + return + } + if (payload.length > MAX_HOST_MESSAGE_CHARS) { + socket.close(1009, "Relay message is too large") + return + } + const message = parseHostMessage(payload) + if (!message) { + socket.close(1003, "Invalid relay message") + return + } + if (message.type === "ready") { + if (message.protocol !== REMOTE_CONTROL_PROTOCOL_VERSION) { + socket.close(1002, "Unsupported protocol") + return + } + current.attachment.ready = true + socket.serializeAttachment(current.attachment) + this.sendHost({ type: "ready", protocol: REMOTE_CONTROL_PROTOCOL_VERSION }) + return + } + if (!current.attachment.ready) { + socket.close(1002, "Relay handshake required") + return + } + if (message.type === "tunnel.close") { + this.closeClient(message.id, message.code ?? 1011, message.reason ?? "CodeNomad closed the tunnel") + return + } + const client = this.clientSocket(message.id) + if (!client) return + const attachment = socketAttachment(client) + if (!attachment || attachment.role !== "client") return + const phase = attachment.phase ?? "hello" + if ((phase === "hello" && message.binary) || (phase === "encrypted" && !message.binary)) { + this.closeClient(message.id, 1002, "Invalid encrypted tunnel sequence") + return + } + const limit = phase === "hello" ? REMOTE_CONTROL_MAX_HANDSHAKE_BYTES : MAX_TUNNEL_FRAME_BYTES + if (base64ByteLength(message.data) > limit) { + this.closeClient(message.id, 1009, phase === "hello" ? "Encryption handshake is too large" : "Encrypted tunnel frame is too large") + return + } + try { + const bytes = decodeBase64(message.data) + client.send(message.binary ? bytes.buffer : new TextDecoder().decode(bytes)) + if (phase === "hello") { + attachment.phase = "encrypted" + client.serializeAttachment(attachment) + } + } catch { + this.closeClient(message.id, 1003, "Invalid tunnel frame") + } + } + + private onHostClosed(socket: WebSocket): void { + const attachment = socketAttachment(socket) + if (!attachment || attachment.role !== "host" || !attachment.active) return + attachment.active = false + socket.serializeAttachment(attachment) + this.closeAllClients("CodeNomad host disconnected") + } + + private closeAllClients(reason: string): void { + for (const socket of this.state.getWebSockets(CLIENT_TAG)) { + const attachment = socketAttachment(socket) + if (attachment?.role === "client") this.closeClient(attachment.id, 1012, reason) + } + } + + private closeClient(id: string, code: number, reason: string): void { + this.clientSocket(id)?.close(safeRelayCloseCode(code), boundedCloseReason(reason)) + } + + private isHostConnected(): boolean { + return this.hostConnection()?.attachment.ready === true + } + + private isCurrentHost(connectionId: string): boolean { + const host = this.hostConnection() + return host?.attachment.ready === true && host.attachment.connectionId === connectionId + } + + private sendHost(message: RelayToHostMessage): boolean { + const host = this.hostConnection() + if (!host?.attachment.ready && message.type !== "ready") return false + if (!host) return false + try { + host.socket.send(JSON.stringify(message)) + return true + } catch { + return false + } + } + + private hostConnection(): { socket: WebSocket; attachment: HostSocketAttachment } | null { + for (const socket of this.state.getWebSockets(HOST_TAG)) { + const attachment = socketAttachment(socket) + if (socket.readyState === WebSocket.OPEN && attachment?.role === "host" && attachment.active) return { socket, attachment } + } + return null + } + + private clientSocket(id: string): WebSocket | null { + return this.state.getWebSockets(clientTag(id)).find((socket) => socket.readyState === WebSocket.OPEN) ?? null + } + + private async authorizeHost(request: Request, allowRegistration = false): Promise { + const token = bearerToken(request) + if (!token || !HOST_SECRET_PATTERN.test(token)) return false + const presented = await tokenHash(token) + if (!allowRegistration) return await this.state.storage.get(HOST_SECRET_KEY) === presented + return this.state.storage.transaction(async (transaction) => { + const stored = await transaction.get(HOST_SECRET_KEY) + if (stored) return stored === presented + await transaction.put(HOST_SECRET_KEY, presented) + return true + }) + } + + private async authorizeDevice(request: Request): Promise { + const token = cookieToken(request) + if (!token || !RELAY_TOKEN_PATTERN.test(token)) return null + const key = `${DEVICE_PREFIX}${await tokenHash(token)}` + const now = Date.now() + const result = await this.state.storage.transaction(async (transaction) => { + const device = await transaction.get(key) + if (!device) return { device: null, expiredDeviceId: null } + if (device.expiresAt <= now) { + await transaction.delete(key) + return { device: null, expiredDeviceId: device.id } + } + if (now - device.lastSeenAt > 60_000) { + device.lastSeenAt = now + await transaction.put(key, device) + } + return { device, expiredDeviceId: null } + }) + if (result.expiredDeviceId) this.closeDeviceSockets([result.expiredDeviceId], "Remote device expired") + return result.device + } + + private unpairedResponse(): Response { + return Response.json({ error: "Remote device is not paired" }, { + status: 401, + headers: { "Set-Cookie": clearDeviceCookie() }, + }) + } + + private closeDeviceSockets(deviceIds: Iterable, reason: string): void { + const ids = new Set(deviceIds) + if (!ids.size) return + for (const socket of this.state.getWebSockets(CLIENT_TAG)) { + const attachment = socketAttachment(socket) + if (attachment?.role === "client" && ids.has(attachment.deviceId)) { + this.closeClient(attachment.id, 1008, reason) + } + } + } +} + +async function scheduleExpiration( + transaction: DurableObjectTransaction, + expiresAt: number, +): Promise { + const scheduled = await transaction.getAlarm() + if (scheduled === null || expiresAt < scheduled) await transaction.setAlarm(expiresAt) +} + +function clientTag(id: string): string { + return `${CLIENT_TAG}:${id}` +} + +function socketAttachment(socket: WebSocket): SocketAttachment | null { + const value = socket.deserializeAttachment() as Partial | null + if (!value || (value.role !== "host" && value.role !== "client")) return null + return value as SocketAttachment +} + +function boundedCloseReason(value: string): string { + let result = "" + let bytes = 0 + for (const character of value) { + const next = new TextEncoder().encode(character).byteLength + if (bytes + next > 123) break + result += character + bytes += next + } + return result +} diff --git a/packages/cloudflare/src/remote-control/relay-messages.test.ts b/packages/cloudflare/src/remote-control/relay-messages.test.ts new file mode 100644 index 000000000..90c5afc9e --- /dev/null +++ b/packages/cloudflare/src/remote-control/relay-messages.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { parseHostMessage, readPairingInput } from "./relay-messages" + +const id = "123e4567-e89b-42d3-a456-426614174000" + +test("host relay messages require bounded identifiers and close metadata", () => { + assert.ok(parseHostMessage(JSON.stringify({ type: "tunnel.message", id, data: "", binary: true }))) + assert.equal(parseHostMessage(JSON.stringify({ type: "tunnel.message", id: "not-a-uuid", data: "", binary: true })), null) + assert.equal(parseHostMessage(JSON.stringify({ type: "tunnel.close", id, reason: 42 })), null) + assert.equal(parseHostMessage(JSON.stringify({ type: "tunnel.close", id, code: 1.5 })), null) + assert.equal(parseHostMessage(JSON.stringify({ type: "tunnel.close", id, reason: "๐Ÿ™‚".repeat(31) })), null) +}) + +test("pairing input rejects excessive stream fragmentation", async () => { + let cancelled = false + const stream = new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array()) + }, + cancel() { + cancelled = true + }, + }) + + assert.equal(await readPairingInput(new Request("https://relay.example", { method: "POST", body: stream, duplex: "half" } as RequestInit), 4096), null) + assert.equal(cancelled, true) +}) diff --git a/packages/cloudflare/src/remote-control/relay-messages.ts b/packages/cloudflare/src/remote-control/relay-messages.ts new file mode 100644 index 000000000..e9429a542 --- /dev/null +++ b/packages/cloudflare/src/remote-control/relay-messages.ts @@ -0,0 +1,74 @@ +import type { HostToRelayMessage } from "@codenomad/remote-control-protocol" + +const MESSAGE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const MAX_PAIRING_BODY_CHUNKS = 256 +const MAX_CLOSE_REASON_CHARS = 120 + +export async function readPairingInput(request: Request, maxBytes: number): Promise<{ token?: unknown; name?: unknown } | null> { + if (!request.body) return null + const reader = request.body.getReader() + const chunks: Uint8Array[] = [] + let size = 0 + let reads = 0 + while (true) { + const { done, value } = await reader.read() + if (done) break + reads += 1 + size += value.byteLength + if (size > maxBytes || reads > MAX_PAIRING_BODY_CHUNKS) { + await reader.cancel().catch(() => undefined) + return null + } + chunks.push(value) + } + const body = new Uint8Array(size) + let offset = 0 + for (const chunk of chunks) { + body.set(chunk, offset) + offset += chunk.byteLength + } + try { + const value = JSON.parse(new TextDecoder().decode(body)) as unknown + return typeof value === "object" && value !== null ? value : null + } catch { + return null + } +} + +export function parseHostMessage(value: string): HostToRelayMessage | null { + try { + const message = JSON.parse(value) as Partial + if (message.type === "ready" && typeof message.protocol === "number") return message as HostToRelayMessage + if (!validMessageId((message as { id?: unknown }).id)) return null + if (message.type === "tunnel.close" && validCloseMetadata(message.code, message.reason)) return message as HostToRelayMessage + if (message.type === "tunnel.message" && typeof message.data === "string" && typeof message.binary === "boolean") { + return message as HostToRelayMessage + } + return null + } catch { + return null + } +} + +export function base64ByteLength(value: string): number { + if (!value) return 0 + const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0 + return Math.floor(value.length * 3 / 4) - padding +} + +export function safeRelayCloseCode(value: number): number { + if (value === 1000 || value === 1001 || value === 1002 || value === 1003 + || (Number.isSafeInteger(value) && value >= 1007 && value <= 1014) + || (Number.isSafeInteger(value) && value >= 3000 && value <= 4999)) return value + return 1011 +} + +function validMessageId(value: unknown): value is string { + return typeof value === "string" && MESSAGE_ID_PATTERN.test(value) +} + +function validCloseMetadata(code: unknown, reason: unknown): boolean { + return (code === undefined || (typeof code === "number" && Number.isSafeInteger(code) && code >= 0 && code <= 0xffff)) + && (reason === undefined || (typeof reason === "string" && reason.length <= MAX_CLOSE_REASON_CHARS + && new TextEncoder().encode(reason).byteLength <= 123 && !/[\0\r\n]/.test(reason))) +} diff --git a/packages/cloudflare/src/remote-control/security.test.ts b/packages/cloudflare/src/remote-control/security.test.ts new file mode 100644 index 000000000..5d0ea3f99 --- /dev/null +++ b/packages/cloudflare/src/remote-control/security.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { DEVICE_COOKIE, bearerToken, clearDeviceCookie, cookieToken, deviceCookie, tokenHash } from "./security" + +test("host bearer tokens are read only from the authorization header", () => { + assert.equal(bearerToken(new Request("https://relay.example/?secret=query", { headers: { Authorization: "Bearer host-secret" } })), "host-secret") + assert.equal(bearerToken(new Request("https://relay.example/?secret=query")), null) +}) + +test("device credentials use secure host-scoped cookies", () => { + const cookie = deviceCookie("device token", 60) + assert.match(cookie, new RegExp(`^${DEVICE_COOKIE}=`)) + assert.match(cookie, /HttpOnly; Secure; SameSite=Strict; Path=\/; Max-Age=60/) + assert.equal(cookieToken(new Request("https://host.relay.example", { headers: { Cookie: cookie } })), "device token") + assert.match(clearDeviceCookie(), /Max-Age=0/) + assert.equal(cookieToken(new Request("https://host.relay.example", { headers: { Cookie: `${DEVICE_COOKIE}=%GG` } })), null) +}) + +test("stored credentials are deterministic hashes rather than raw tokens", async () => { + const hash = await tokenHash("secret") + assert.match(hash, /^[a-f0-9]{64}$/) + assert.equal(hash, await tokenHash("secret")) + assert.notEqual(hash, "secret") +}) diff --git a/packages/cloudflare/src/remote-control/security.ts b/packages/cloudflare/src/remote-control/security.ts new file mode 100644 index 000000000..7cc3d50ca --- /dev/null +++ b/packages/cloudflare/src/remote-control/security.ts @@ -0,0 +1,47 @@ +const encoder = new TextEncoder() + +export const HOST_ID_PATTERN = /^[a-f0-9]{32}$/ +export const DEVICE_COOKIE = "codenomad_remote_device" +export const RELAY_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/ +export const HOST_SECRET_PATTERN = /^[A-Za-z0-9_-]{40,128}$/ + +export function randomToken(byteLength = 32): string { + const bytes = crypto.getRandomValues(new Uint8Array(byteLength)) + let binary = "" + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") +} + +export async function tokenHash(token: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", encoder.encode(token)) + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("") +} + +export function bearerToken(request: Request): string | null { + const value = request.headers.get("authorization") ?? "" + const match = value.match(/^Bearer\s+(.+)$/i) + return match?.[1]?.trim() || null +} + +export function cookieToken(request: Request): string | null { + const cookies = request.headers.get("cookie") ?? "" + for (const entry of cookies.split(";")) { + const [name, ...parts] = entry.trim().split("=") + if (name === DEVICE_COOKIE) { + try { + return decodeURIComponent(parts.join("=")) + } catch { + return null + } + } + } + return null +} + +export function deviceCookie(token: string, maxAgeSeconds: number): string { + return `${DEVICE_COOKIE}=${encodeURIComponent(token)}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${maxAgeSeconds}` +} + +export function clearDeviceCookie(): string { + return `${DEVICE_COOKIE}=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0` +} diff --git a/packages/cloudflare/tsconfig.json b/packages/cloudflare/tsconfig.json new file mode 100644 index 000000000..1a304045e --- /dev/null +++ b/packages/cloudflare/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "WebWorker"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/cloudflare/wrangler.toml b/packages/cloudflare/wrangler.toml index e9e6e3b08..143a2fee0 100644 --- a/packages/cloudflare/wrangler.toml +++ b/packages/cloudflare/wrangler.toml @@ -8,7 +8,27 @@ compatibility_date = "2026-01-22" pattern = "ui.codenomad.neuralnomads.ai" custom_domain = true +[[routes]] +pattern = "remote.codenomad.neuralnomads.ai" +custom_domain = true + +[[routes]] +pattern = "*.remote.codenomad.neuralnomads.ai/*" +zone_name = "neuralnomads.ai" + +[vars] +REMOTE_BASE_HOST = "remote.codenomad.neuralnomads.ai" + +[[durable_objects.bindings]] +name = "REMOTE_HOSTS" +class_name = "RemoteControlHost" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["RemoteControlHost"] + [assets] directory = "./dist" binding = "ASSETS" not_found_handling = "404-page" +run_worker_first = true diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index 690aecdf9..8aac16c6e 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -149,6 +149,7 @@ function runPrimary(firstIntent: LaunchIntent) { }, removeWindowState: (id) => clientState.removeWindow(id), getAllowedRendererOrigins: getAllowedOrigins, isTrustedRendererOrigin: isAllowedRendererOrigin, + shouldKeepBackendAlive: () => isRemoteControlEnabled(backendUrl, cli), navigationLifecycle, }) const bindClientState = setupClientStateIPC(ipcMain, clientState, (sender) => registry.resolve(sender), getAllowedOrigins) @@ -451,7 +452,7 @@ function runPrimary(firstIntent: LaunchIntent) { window.setTitle(title) window.webContents.on("page-title-updated", (event) => { event.preventDefault(); window.setTitle(title) }) setupNavigationGuards(window, undefined, getAllowedOrigins, getLoadingUrl) - lifecycle.attachRemote(window) + lifecycle.attachSupportWindow(window) window.on("closed", () => { remoteOrigins.delete(nativeWindowId); insecureOrigins.delete(webContentsId) }) try { await navigateRemoteWindow(window, target, allowedOrigins, remoteOrigins, insecureOrigins, payload.skipTlsVerify) } catch (error) { console.warn("[electron] failed to load remote window; showing loading screen", error) @@ -505,7 +506,7 @@ function runPrimary(firstIntent: LaunchIntent) { lifecycle: navigationLifecycle, }) setupNavigationGuards(window, preferencesNavigation, getAllowedOrigins, getLoadingUrl) - lifecycle.attachRemote(window) + lifecycle.attachSupportWindow(window) window.webContents.on("page-title-updated", (event) => { event.preventDefault(); window.setTitle("Preferences") }) window.on("closed", () => { remoteOrigins.delete(nativeWindowId) @@ -622,4 +623,50 @@ async function exchangeBootstrapToken(baseUrl: string, token: string, cli: CliPr return true } +async function isRemoteControlEnabled(baseUrl: string | null, cli: CliProcessManager): Promise { + if (!baseUrl) return false + const cookie = (await session.defaultSession.cookies.get({ url: baseUrl, name: cli.getAuthCookieName() }))[0] + if (!cookie) return false + const target = new URL("/api/remote-control/status", baseUrl) + const transport = target.protocol === "https:" ? https : http + return new Promise((resolve) => { + let settled = false + const finish = (enabled: boolean) => { + if (settled) return + settled = true + resolve(enabled) + } + const request = transport.request(target, { + method: "GET", + headers: { Cookie: `${cookie.name}=${cookie.value}` }, + timeout: 2_000, + }, (response) => { + const chunks: Buffer[] = [] + let bytes = 0 + response.on("data", (chunk) => { + const value = Buffer.from(chunk) + bytes += value.byteLength + if (bytes > 64 * 1024) { + response.destroy() + finish(false) + return + } + chunks.push(value) + }) + response.on("end", () => { + try { + const payload = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { enabled?: unknown } + finish(response.statusCode === 200 && payload.enabled === true) + } catch { + finish(false) + } + }) + response.on("error", () => finish(false)) + }) + request.on("timeout", () => request.destroy()) + request.on("error", () => finish(false)) + request.end() + }) +} + if (isMac) app.commandLine.appendSwitch("disable-spell-checking") diff --git a/packages/electron-app/electron/main/multiwindow-lifecycle.test.ts b/packages/electron-app/electron/main/multiwindow-lifecycle.test.ts index febab5563..dfae00c06 100644 --- a/packages/electron-app/electron/main/multiwindow-lifecycle.test.ts +++ b/packages/electron-app/electron/main/multiwindow-lifecycle.test.ts @@ -31,7 +31,7 @@ test("closing one local window removes only its V3 record and leaves backend run assert.deepEqual(calls, ["prevent", "renderer:one", "native:one", "remove:one", "close:one"]) }) -test("closing the sole local window while a remote remains removes its V3 record", async () => { +test("closing the sole local window while a support window remains removes its V3 record", async () => { const calls: string[] = [] const local = windowRecord("local", calls) const remote = { isDestroyed: () => false } @@ -47,7 +47,7 @@ test("closing the sole local window while a remote remains removes its V3 record assert.deepEqual(calls, ["prevent", "renderer:local", "native:local", "remove:local", "close:local"]) }) -test("Preferences does not turn the final local close into a destructive window removal", () => { +test("Preferences does not turn the final local close into a destructive window removal", async () => { const calls: string[] = [] const local = windowRecord("local", calls) const preferences = { isDestroyed: () => false } @@ -60,10 +60,11 @@ test("Preferences does not turn the final local close into a destructive window }) lifecycle.attach(local) local.events.get("close")?.({ preventDefault: () => calls.push("prevent") }) + await tick() assert.deepEqual(calls, ["prevent", "quit"]) }) -test("Preferences does not keep the final remote window alive", () => { +test("Preferences does not keep the final support window alive", async () => { const calls: string[] = [] const remote = windowRecord("remote", calls) const preferences = { isDestroyed: () => false } @@ -74,11 +75,94 @@ test("Preferences does not keep the final remote window alive", () => { isSupportWindow: (window) => window === preferences, removeWindowState: async () => true, getAllowedRendererOrigins: () => ["http://localhost"], isTrustedRendererOrigin: () => true, }) - lifecycle.attachRemote(remote.window) + lifecycle.attachSupportWindow(remote.window) remote.events.get("close")?.({ defaultPrevented: false, preventDefault: () => calls.push("prevent") }) + await tick() assert.deepEqual(calls, ["prevent", "quit"]) }) +test("Remote Control keeps the backend alive after the last local window closes", async () => { + const calls: string[] = [] + const events = new Map() + const local = windowRecord("local", calls) + const lifecycle = new MultiwindowLifecycle({ + app: { on: (name: string, handler: Function) => events.set(name, handler), quit: () => calls.push("quit"), exit: () => calls.push("exit") } as never, + clientStateManager: { isPrimary: true } as never, + cliManager: { shutdown: async () => calls.push("stop") } as never, + getLocalWindows: () => [local], + getAllWindows: () => [local.window], + removeWindowState: async (id) => { calls.push(`remove:${id}`); return true }, + getAllowedRendererOrigins: () => ["http://localhost"], + isTrustedRendererOrigin: () => true, + shouldKeepBackendAlive: async () => true, + }) + lifecycle.attach(local) + lifecycle.registerAppEvents() + + local.events.get("close")?.({ preventDefault: () => calls.push("prevent") }) + await tick() + events.get("window-all-closed")?.() + + assert.deepEqual(calls, ["prevent", "renderer:local", "native:local", "remove:local", "close:local"]) +}) + +test("a window opened during the Remote Control check cancels stale whole-app shutdown", async () => { + const calls: string[] = [] + const first = windowRecord("one", calls) + const second = windowRecord("two", calls) + const local = [first] + let resolveKeepAlive!: (value: boolean) => void + const keepAlive = new Promise((resolve) => { resolveKeepAlive = resolve }) + const lifecycle = new MultiwindowLifecycle({ + app: { on: () => {}, quit: () => calls.push("quit"), exit: () => calls.push("exit") } as never, + clientStateManager: { isPrimary: true } as never, + cliManager: { shutdown: async () => calls.push("stop") } as never, + getLocalWindows: () => local, + getAllWindows: () => local.map((record) => record.window), + removeWindowState: async (id) => { calls.push(`remove:${id}`); return true }, + getAllowedRendererOrigins: () => ["http://localhost"], + isTrustedRendererOrigin: () => true, + shouldKeepBackendAlive: () => keepAlive, + }) + lifecycle.attach(first) + + first.events.get("close")?.({ preventDefault: () => calls.push("prevent") }) + local.push(second) + resolveKeepAlive(false) + await tick() + await tick() + + assert.deepEqual(calls, ["prevent", "renderer:one", "native:one", "remove:one", "close:one"]) +}) + +test("simultaneous local closes retain one record for whole-app shutdown", async () => { + const calls: string[] = [] + const first = windowRecord("one", calls) + const second = windowRecord("two", calls) + const local = [first, second] + const lifecycle = new MultiwindowLifecycle({ + app: { on: () => {}, quit: () => calls.push("quit"), exit: () => calls.push("exit") } as never, + clientStateManager: { isPrimary: true } as never, + cliManager: { shutdown: async () => calls.push("stop") } as never, + getLocalWindows: () => local, + getAllWindows: () => local.map((record) => record.window), + removeWindowState: async (id) => { calls.push(`remove:${id}`); return true }, + getAllowedRendererOrigins: () => ["http://localhost"], + isTrustedRendererOrigin: () => true, + shouldKeepBackendAlive: async () => false, + }) + lifecycle.attach(first) + lifecycle.attach(second) + + first.events.get("close")?.({ preventDefault: () => calls.push("prevent:one") }) + second.events.get("close")?.({ preventDefault: () => calls.push("prevent:two") }) + await tick() + await tick() + + assert(calls.includes("quit")) + assert.equal(calls.includes("remove:two"), false) +}) + test("persisted local close waits for confirmed removal and remains retryable", async () => { const calls: string[] = [] const first = windowRecord("one", calls) @@ -179,6 +263,7 @@ test("final close retains its record and shutdown stops/releases once", async () }) lifecycle.attach(first); lifecycle.registerAppEvents() first.events.get("close")?.({ preventDefault: () => calls.push("prevent") }) + await tick() assert.deepEqual(calls, ["prevent", "quit"]) events.get("before-quit")?.({ preventDefault: () => calls.push("prevent-quit") }) events.get("before-quit")?.({ preventDefault: () => calls.push("prevent-quit") }) @@ -210,7 +295,7 @@ test("Windows query preflush leaves the app alive until session end is confirmed assert.deepEqual(calls, ["renderer:one", "native:one", "aggregate", "stop", "release", "exit"]) }) -test("remote windows receive session-end cleanup without local close semantics", async () => { +test("support windows receive session-end cleanup without local close semantics", async () => { const calls: string[] = [] const events = new Map() const remote = { on: (name: string, handler: Function) => events.set(name, handler), isDestroyed: () => false } @@ -363,7 +448,7 @@ test("final remote close stays restorable by routing through app shutdown", asyn getLocalWindows: () => [], getAllWindows: () => [remote.window], removeWindowState: async () => true, getAllowedRendererOrigins: () => [], isTrustedRendererOrigin: () => false, }) - lifecycle.attachRemote(remote.window); lifecycle.registerAppEvents() + lifecycle.attachSupportWindow(remote.window); lifecycle.registerAppEvents() remote.events.get("close")?.({ preventDefault: () => calls.push("prevent-close") }) await tick(); await tick() diff --git a/packages/electron-app/electron/main/multiwindow-lifecycle.ts b/packages/electron-app/electron/main/multiwindow-lifecycle.ts index 59995dfd1..8963258e8 100644 --- a/packages/electron-app/electron/main/multiwindow-lifecycle.ts +++ b/packages/electron-app/electron/main/multiwindow-lifecycle.ts @@ -23,6 +23,7 @@ interface Dependencies { removeWindowState(id: string): Promise getAllowedRendererOrigins(window: BrowserWindow): string[] isTrustedRendererOrigin(url: string, allowedOrigins: string[]): boolean + shouldKeepBackendAlive?(): Promise rendererFlushTimeoutMs?: number sessionEndCleanupTimeoutMs?: number isWindows?: boolean @@ -37,34 +38,45 @@ export class MultiwindowLifecycle { private release: Promise | null = null private exitAllowed = false private relaunchRequested = false + private keepAliveWithoutWindows = false private readonly sessionEndWindows = new WeakSet() + private readonly pendingWindowCloses = new Set() constructor(private readonly dependencies: Dependencies) {} attach(record: LifecycleWindow): void { let approved = false let closing = false + record.window.on("closed", () => this.pendingWindowCloses.delete(record.window)) record.window.on("close", (event) => { if (approved || this.exitAllowed) return event.preventDefault() if (closing || this.shutdown) return - const otherLocal = this.dependencies.getLocalWindows().some((candidate) => candidate.id !== record.id && !candidate.window.isDestroyed()) - const otherWindow = this.dependencies.getAllWindows().some((candidate) => candidate !== record.window - && !candidate.isDestroyed() && !this.dependencies.isSupportWindow?.(candidate)) - if (!otherLocal && !otherWindow) { - this.dependencies.app.quit() - return - } closing = true - void this.flushWindow(record).then(async () => { + this.pendingWindowCloses.add(record.window) + void (async () => { + if (!this.hasOtherApplicationWindow(record.window, record.id)) { + const keepAlive = await this.dependencies.shouldKeepBackendAlive?.().catch(() => false) ?? false + const stillFinal = !this.hasOtherApplicationWindow(record.window, record.id) + if (!keepAlive && stillFinal) { + closing = false + this.pendingWindowCloses.delete(record.window) + this.dependencies.app.quit() + return + } + if (keepAlive && stillFinal) this.keepAliveWithoutWindows = true + } + await this.flushWindow(record) if (record.persisted !== false && !await this.dependencies.removeWindowState(record.id)) { closing = false + this.pendingWindowCloses.delete(record.window) return } approved = true record.window.close() - }).catch((error) => { + })().catch((error) => { closing = false + this.pendingWindowCloses.delete(record.window) console.warn("[client-state] local window close failed", error) }) }) @@ -72,12 +84,26 @@ export class MultiwindowLifecycle { this.attachSessionEnd(record.window) } - attachRemote(window: BrowserWindow): void { + attachSupportWindow(window: BrowserWindow): void { + let approved = false + let closing = false window.on("close", (event) => { - if (event.defaultPrevented || this.exitAllowed || this.dependencies.getAllWindows().some((candidate) => candidate !== window + if (approved || event.defaultPrevented || this.exitAllowed || this.dependencies.getAllWindows().some((candidate) => candidate !== window && !candidate.isDestroyed() && !this.dependencies.isSupportWindow?.(candidate))) return event.preventDefault() - if (!this.shutdown) this.dependencies.app.quit() + if (closing || this.shutdown) return + closing = true + void (this.dependencies.shouldKeepBackendAlive?.().catch(() => false) ?? Promise.resolve(false)).then((keepAlive) => { + const stillFinal = !this.hasOtherApplicationWindow(window) + if (!keepAlive && stillFinal) { + closing = false + this.dependencies.app.quit() + return + } + if (keepAlive && stillFinal) this.keepAliveWithoutWindows = true + approved = true + window.close() + }) }) this.attachSessionEnd(window) } @@ -97,15 +123,19 @@ export class MultiwindowLifecycle { this.dependencies.app.on("before-quit", (event) => { if (this.exitAllowed) return event.preventDefault() + this.keepAliveWithoutWindows = false const visibleWindows = this.dependencies.getAllWindows().filter((window) => !window.isDestroyed() && window.isVisible()) for (const window of visibleWindows) window.hide() void this.startShutdown().then(() => this.exit(), (error) => { this.relaunchRequested = false + this.pendingWindowCloses.clear() for (const window of visibleWindows) if (!window.isDestroyed()) window.show() console.warn("[client-state] shutdown remains pending", error) }) }) - this.dependencies.app.on("window-all-closed", () => this.dependencies.app.quit()) + this.dependencies.app.on("window-all-closed", () => { + if (!this.keepAliveWithoutWindows) this.dependencies.app.quit() + }) } requestRelaunch(): void { @@ -114,6 +144,14 @@ export class MultiwindowLifecycle { this.dependencies.app.quit() } + private hasOtherApplicationWindow(window: BrowserWindow, localId?: string): boolean { + return (localId !== undefined && this.dependencies.getLocalWindows().some((candidate) => candidate.id !== localId + && !candidate.window.isDestroyed() && !this.pendingWindowCloses.has(candidate.window))) + || this.dependencies.getAllWindows().some((candidate) => candidate !== window + && !candidate.isDestroyed() && !this.pendingWindowCloses.has(candidate) + && !this.dependencies.isSupportWindow?.(candidate)) + } + private startShutdown(preparedFlush?: Promise): Promise { if (this.shutdown) return this.shutdown const cleanup = async () => { diff --git a/packages/electron-app/electron/main/window-navigation.ts b/packages/electron-app/electron/main/window-navigation.ts new file mode 100644 index 000000000..9f6e14c7f --- /dev/null +++ b/packages/electron-app/electron/main/window-navigation.ts @@ -0,0 +1,37 @@ +import type { BrowserWindow } from "electron" + +interface NavigationAuthority { + generation: number + trustedOrigins: Set +} + +const navigationAuthorities = new WeakMap() + +export async function navigateTrustedWindow( + window: BrowserWindow, + target: URL, + nextOrigins: ReadonlySet, + trustedOrigins: Map>, +): Promise { + let authority = navigationAuthorities.get(window) + if (!authority) { + authority = { generation: 0, trustedOrigins: new Set(trustedOrigins.get(window.id)) } + navigationAuthorities.set(window, authority) + } + const generation = ++authority.generation + const committedOrigins = new Set(nextOrigins) + trustedOrigins.set(window.id, new Set([...authority.trustedOrigins, ...committedOrigins])) + + try { + await window.loadURL(target.toString()) + } catch (error) { + if (authority.generation !== generation) return + if (authority.trustedOrigins.size) trustedOrigins.set(window.id, new Set(authority.trustedOrigins)) + else trustedOrigins.delete(window.id) + throw error + } + + if (authority.generation !== generation) return + authority.trustedOrigins = committedOrigins + trustedOrigins.set(window.id, committedOrigins) +} diff --git a/packages/remote-control-protocol/package.json b/packages/remote-control-protocol/package.json new file mode 100644 index 000000000..5a371260c --- /dev/null +++ b/packages/remote-control-protocol/package.json @@ -0,0 +1,23 @@ +{ + "name": "@codenomad/remote-control-protocol", + "version": "0.1.0", + "private": true, + "license": "MIT", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "prepare": "npm run build", + "test": "node --import tsx --test src/**/*.test.ts", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "devDependencies": { + "tsx": "^4.20.6", + "typescript": "^5.6.3" + } +} diff --git a/packages/remote-control-protocol/src/crypto.ts b/packages/remote-control-protocol/src/crypto.ts new file mode 100644 index 000000000..89341e646 --- /dev/null +++ b/packages/remote-control-protocol/src/crypto.ts @@ -0,0 +1,249 @@ +import { REMOTE_CONTROL_MAX_PLAINTEXT_BYTES, REMOTE_CONTROL_PROTOCOL_VERSION, decodeBase64, encodeBase64 } from "./messages" + +const ECDH_ALGORITHM = { name: "ECDH", namedCurve: "P-256" } as const +const FRAME_VERSION = 1 +const IV_PREFIX_BYTES = 4 +const IV_COUNTER_BYTES = 8 +const MAX_COUNTER = (1n << BigInt(IV_COUNTER_BYTES * 8)) - 1n +const IV_BYTES = IV_PREFIX_BYTES + IV_COUNTER_BYTES +const TAG_BYTES = 16 +const SESSION_KEY_BYTES = 32 +const HANDSHAKE_NONCE_BYTES = 16 +const KEY_INFO = new TextEncoder().encode("codenomad-remote-control-e2ee-v2") +const FRAME_AAD = new TextEncoder().encode("codenomad-remote-control-frame-v2") +const READY_PROOF = new TextEncoder().encode("codenomad-remote-control-ready-v2") + +export interface EncryptedChannel { + encrypt(plaintext: Uint8Array): Promise + decrypt(frame: Uint8Array): Promise +} + +interface DirectionalKeys { + clientToHost: CryptoKey + hostToClient: CryptoKey +} + +interface HelloMessage { + type: "e2ee.hello" + protocol: typeof REMOTE_CONTROL_PROTOCOL_VERSION + publicKey: JsonWebKey + nonce: string +} + +interface ReadyMessage { + type: "e2ee.ready" + protocol: typeof REMOTE_CONTROL_PROTOCOL_VERSION + nonce: string + proof: string +} + +export interface ClientHandshake { + hello: string + accept(message: string): Promise +} + +export interface HostHandshake { + accept(message: string): Promise<{ ready: string; channel: EncryptedChannel }> +} + +export async function createClientHandshake(hostPublicJwk: JsonWebKey): Promise { + const hostPublicKey = await importPublicKey(hostPublicJwk) + const ephemeral = await crypto.subtle.generateKey(ECDH_ALGORITHM, true, ["deriveBits"]) as CryptoKeyPair + const nonce = crypto.getRandomValues(new Uint8Array(HANDSHAKE_NONCE_BYTES)) + const publicKey = await crypto.subtle.exportKey("jwk", ephemeral.publicKey) + const hello: HelloMessage = { + type: "e2ee.hello", + protocol: REMOTE_CONTROL_PROTOCOL_VERSION, + publicKey: publicJwk(publicKey), + nonce: encodeBase64(nonce), + } + let accepted = false + return { + hello: JSON.stringify(hello), + async accept(message: string) { + if (accepted) throw new Error("Remote Control handshake was already accepted") + accepted = true + const ready = parseReady(message) + if (!ready) throw new Error("Invalid Remote Control host handshake") + const hostNonce = decodeNonce(ready.nonce) + const keys = await deriveKeys(ephemeral.privateKey, hostPublicKey, nonce, hostNonce) + const channel = createChannel(keys.clientToHost, keys.hostToClient) + const proof = await channel.decrypt(decodeBase64(ready.proof)) + if (!equalBytes(proof, READY_PROOF)) throw new Error("Remote Control host handshake authentication failed") + return channel + }, + } +} + +export async function createHostHandshake(hostPrivateJwk: JsonWebKey): Promise { + const hostPrivateKey = await importPrivateKey(hostPrivateJwk) + let accepted = false + return { + async accept(message: string) { + if (accepted) throw new Error("Remote Control handshake was already accepted") + accepted = true + const hello = parseHello(message) + if (!hello) throw new Error("Invalid Remote Control client handshake") + const clientNonce = decodeNonce(hello.nonce) + const hostNonce = crypto.getRandomValues(new Uint8Array(HANDSHAKE_NONCE_BYTES)) + const clientPublicKey = await importPublicKey(hello.publicKey) + const keys = await deriveKeys(hostPrivateKey, clientPublicKey, clientNonce, hostNonce) + const channel = createChannel(keys.hostToClient, keys.clientToHost) + const proof = await channel.encrypt(READY_PROOF) + const ready: ReadyMessage = { + type: "e2ee.ready", + protocol: REMOTE_CONTROL_PROTOCOL_VERSION, + nonce: encodeBase64(hostNonce), + proof: encodeBase64(proof), + } + return { + ready: JSON.stringify(ready), + channel, + } + }, + } +} + +function createChannel(encryptionKey: CryptoKey, decryptionKey: CryptoKey): EncryptedChannel { + const prefix = crypto.getRandomValues(new Uint8Array(IV_PREFIX_BYTES)) + let sendCounter = 0n + let receiveCounter = 0n + return { + async encrypt(plaintext) { + if (plaintext.byteLength > REMOTE_CONTROL_MAX_PLAINTEXT_BYTES) throw new Error("Remote Control encrypted frame is too large") + if (sendCounter >= MAX_COUNTER) throw new Error("Remote Control encrypted channel counter is exhausted") + sendCounter += 1n + const iv = new Uint8Array(IV_BYTES) + iv.set(prefix) + writeCounter(iv, IV_PREFIX_BYTES, sendCounter) + const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ + name: "AES-GCM", + iv, + additionalData: FRAME_AAD, + }, encryptionKey, arrayBuffer(plaintext))) + const frame = new Uint8Array(1 + IV_BYTES + ciphertext.byteLength) + frame[0] = FRAME_VERSION + frame.set(iv, 1) + frame.set(ciphertext, 1 + IV_BYTES) + return frame + }, + async decrypt(frame) { + if (frame.byteLength < 1 + IV_BYTES + TAG_BYTES || frame.byteLength > 1 + IV_BYTES + TAG_BYTES + REMOTE_CONTROL_MAX_PLAINTEXT_BYTES) { + throw new Error("Invalid Remote Control encrypted frame size") + } + if (frame[0] !== FRAME_VERSION) throw new Error("Unsupported Remote Control encrypted frame") + const iv = frame.slice(1, 1 + IV_BYTES) + const counter = readCounter(iv, IV_PREFIX_BYTES) + if (counter !== receiveCounter + 1n) throw new Error("Remote Control encrypted frame was replayed or arrived out of order") + receiveCounter = counter + let plaintext: ArrayBuffer + try { + plaintext = await crypto.subtle.decrypt({ + name: "AES-GCM", + iv, + additionalData: FRAME_AAD, + }, decryptionKey, arrayBuffer(frame.slice(1 + IV_BYTES))) + } catch { + throw new Error("Remote Control encrypted frame authentication failed") + } + return new Uint8Array(plaintext) + }, + } +} + +async function deriveKeys( + privateKey: CryptoKey, + publicKey: CryptoKey, + clientNonce: Uint8Array, + hostNonce: Uint8Array, +): Promise { + const shared = await crypto.subtle.deriveBits({ name: "ECDH", public: publicKey }, privateKey, 256) + const material = await crypto.subtle.importKey("raw", shared, "HKDF", false, ["deriveBits"]) + const salt = new Uint8Array(clientNonce.byteLength + hostNonce.byteLength) + salt.set(clientNonce) + salt.set(hostNonce, clientNonce.byteLength) + const bits = new Uint8Array(await crypto.subtle.deriveBits({ + name: "HKDF", + hash: "SHA-256", + salt: arrayBuffer(salt), + info: KEY_INFO, + }, material, SESSION_KEY_BYTES * 2 * 8)) + return { + clientToHost: await importAesKey(bits.slice(0, SESSION_KEY_BYTES)), + hostToClient: await importAesKey(bits.slice(SESSION_KEY_BYTES)), + } +} + +function importAesKey(bytes: Uint8Array): Promise { + return crypto.subtle.importKey("raw", arrayBuffer(bytes), "AES-GCM", false, ["encrypt", "decrypt"]) +} + +function importPublicKey(jwk: JsonWebKey): Promise { + const normalized = publicJwk(jwk) + return crypto.subtle.importKey("jwk", normalized, ECDH_ALGORITHM, false, []) +} + +function importPrivateKey(jwk: JsonWebKey): Promise { + if (typeof jwk.d !== "string") return Promise.reject(new Error("Invalid Remote Control private key")) + return crypto.subtle.importKey("jwk", { ...publicJwk(jwk), d: jwk.d }, ECDH_ALGORITHM, false, ["deriveBits"]) +} + +function publicJwk(jwk: JsonWebKey): JsonWebKey { + if (jwk.kty !== "EC" || jwk.crv !== "P-256" || typeof jwk.x !== "string" || typeof jwk.y !== "string") { + throw new Error("Invalid Remote Control public key") + } + return { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y, ext: true } +} + +function parseHello(value: string): HelloMessage | null { + const parsed = parseObject(value) + if (parsed?.type !== "e2ee.hello" || parsed.protocol !== REMOTE_CONTROL_PROTOCOL_VERSION) return null + if (typeof parsed.publicKey !== "object" || parsed.publicKey === null || typeof parsed.nonce !== "string") return null + return parsed as unknown as HelloMessage +} + +function parseReady(value: string): ReadyMessage | null { + const parsed = parseObject(value) + if (parsed?.type !== "e2ee.ready" || parsed.protocol !== REMOTE_CONTROL_PROTOCOL_VERSION) return null + if (typeof parsed.nonce !== "string" || typeof parsed.proof !== "string") return null + return parsed as unknown as ReadyMessage +} + +function parseObject(value: string): Record | null { + try { + const parsed = JSON.parse(value) + return typeof parsed === "object" && parsed !== null ? parsed as Record : null + } catch { + return null + } +} + +function writeCounter(target: Uint8Array, offset: number, value: bigint): void { + for (let index = IV_COUNTER_BYTES - 1; index >= 0; index -= 1) { + target[offset + index] = Number(value & 0xffn) + value >>= 8n + } +} + +function readCounter(source: Uint8Array, offset: number): bigint { + let value = 0n + for (let index = 0; index < IV_COUNTER_BYTES; index += 1) value = (value << 8n) | BigInt(source[offset + index]) + return value +} + +function arrayBuffer(bytes: Uint8Array): ArrayBuffer { + return (bytes.buffer as ArrayBuffer).slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) +} + +function decodeNonce(value: string): Uint8Array { + const nonce = decodeBase64(value) + if (nonce.byteLength !== HANDSHAKE_NONCE_BYTES) throw new Error("Invalid Remote Control handshake nonce") + return nonce +} + +function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false + let difference = 0 + for (let index = 0; index < left.byteLength; index += 1) difference |= left[index] ^ right[index] + return difference === 0 +} diff --git a/packages/remote-control-protocol/src/frame-budget.test.ts b/packages/remote-control-protocol/src/frame-budget.test.ts new file mode 100644 index 000000000..9d100bbaf --- /dev/null +++ b/packages/remote-control-protocol/src/frame-budget.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { FrameBudget } from "./frame-budget" + +test("bounds queued frames by count and aggregate bytes", () => { + const budget = new FrameBudget(2, 10) + const releaseFirst = budget.reserve(6) + const releaseSecond = budget.reserve(4) + assert.ok(releaseFirst) + assert.ok(releaseSecond) + assert.equal(budget.reserve(0), null) + assert.equal(budget.reserve(1), null) + assert.deepEqual(budget.usage(), { frames: 2, bytes: 10 }) + + releaseFirst() + const releaseReplacement = budget.reserve(5) + assert.ok(releaseReplacement) + assert.deepEqual(budget.usage(), { frames: 2, bytes: 9 }) + releaseFirst() + releaseSecond() + releaseReplacement() + assert.deepEqual(budget.usage(), { frames: 0, bytes: 0 }) +}) + +test("rejects invalid budget limits and frame sizes", () => { + assert.throws(() => new FrameBudget(0, 1), RangeError) + assert.throws(() => new FrameBudget(1, Number.POSITIVE_INFINITY), RangeError) + const budget = new FrameBudget(1, 1) + assert.throws(() => budget.reserve(-1), RangeError) + assert.throws(() => budget.reserve(1.5), RangeError) +}) diff --git a/packages/remote-control-protocol/src/frame-budget.ts b/packages/remote-control-protocol/src/frame-budget.ts new file mode 100644 index 000000000..fb11bcd07 --- /dev/null +++ b/packages/remote-control-protocol/src/frame-budget.ts @@ -0,0 +1,33 @@ +export class FrameBudget { + private frames = 0 + private bytes = 0 + + constructor( + private readonly maxFrames: number, + private readonly maxBytes: number, + ) { + if (!Number.isSafeInteger(maxFrames) || maxFrames < 1 || !Number.isSafeInteger(maxBytes) || maxBytes < 1) { + throw new RangeError("Remote Control frame budget limits must be positive integers") + } + } + + reserve(byteLength: number): (() => void) | null { + if (!Number.isSafeInteger(byteLength) || byteLength < 0) { + throw new RangeError("Remote Control frame size must be a non-negative integer") + } + if (this.frames >= this.maxFrames || byteLength > this.maxBytes - this.bytes) return null + this.frames += 1 + this.bytes += byteLength + let active = true + return () => { + if (!active) return + active = false + this.frames -= 1 + this.bytes -= byteLength + } + } + + usage(): { frames: number; bytes: number } { + return { frames: this.frames, bytes: this.bytes } + } +} diff --git a/packages/remote-control-protocol/src/index.test.ts b/packages/remote-control-protocol/src/index.test.ts new file mode 100644 index 000000000..449a20b45 --- /dev/null +++ b/packages/remote-control-protocol/src/index.test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { createClientHandshake, createHostHandshake, decodeBase64, encodeBase64 } from "./index" + +test("binary relay payloads round-trip without truncation", () => { + const input = Uint8Array.from({ length: 100_000 }, (_, index) => index % 251) + assert.deepEqual(decodeBase64(encodeBase64(input)), input) +}) + +test("client and host establish authenticated directional encryption", async () => { + const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]) as CryptoKeyPair + const publicKey = await crypto.subtle.exportKey("jwk", pair.publicKey) + const privateKey = await crypto.subtle.exportKey("jwk", pair.privateKey) + const client = await createClientHandshake(publicKey) + const host = await createHostHandshake(privateKey) + const accepted = await host.accept(client.hello) + const clientChannel = await client.accept(accepted.ready) + const plaintext = new TextEncoder().encode("opaque remote payload") + + const toHost = await clientChannel.encrypt(plaintext) + assert.notDeepEqual(toHost, plaintext) + assert.deepEqual(await accepted.channel.decrypt(toHost), plaintext) + + const toClient = await accepted.channel.encrypt(plaintext) + assert.deepEqual(await clientChannel.decrypt(toClient), plaintext) + await assert.rejects(() => clientChannel.decrypt(toClient), /replayed/) +}) + +test("encrypted frames fail closed after tampering", async () => { + const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]) as CryptoKeyPair + const client = await createClientHandshake(await crypto.subtle.exportKey("jwk", pair.publicKey)) + const host = await createHostHandshake(await crypto.subtle.exportKey("jwk", pair.privateKey)) + const accepted = await host.accept(client.hello) + const clientChannel = await client.accept(accepted.ready) + const frame = await clientChannel.encrypt(new TextEncoder().encode("secret")) + frame[frame.length - 1] ^= 1 + await assert.rejects(() => accepted.channel.decrypt(frame), /authentication failed/) +}) + +test("encrypted channels reject a skipped counter before exposing later plaintext", async () => { + const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]) as CryptoKeyPair + const client = await createClientHandshake(await crypto.subtle.exportKey("jwk", pair.publicKey)) + const host = await createHostHandshake(await crypto.subtle.exportKey("jwk", pair.privateKey)) + const accepted = await host.accept(client.hello) + const clientChannel = await client.accept(accepted.ready) + const first = await clientChannel.encrypt(new TextEncoder().encode("first")) + const second = await clientChannel.encrypt(new TextEncoder().encode("second")) + + await assert.rejects(() => accepted.channel.decrypt(second), /out of order/) + assert.equal(new TextDecoder().decode(await accepted.channel.decrypt(first)), "first") +}) + +test("encrypted channels reject concurrent replay before duplicate plaintext is exposed", async () => { + const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]) as CryptoKeyPair + const client = await createClientHandshake(await crypto.subtle.exportKey("jwk", pair.publicKey)) + const host = await createHostHandshake(await crypto.subtle.exportKey("jwk", pair.privateKey)) + const accepted = await host.accept(client.hello) + const clientChannel = await client.accept(accepted.ready) + const frame = await clientChannel.encrypt(new TextEncoder().encode("once")) + const results = await Promise.allSettled([ + accepted.channel.decrypt(frame), + accepted.channel.decrypt(frame), + ]) + + assert.equal(results.filter((result) => result.status === "fulfilled").length, 1) + assert.equal(results.filter((result) => result.status === "rejected").length, 1) +}) + +test("handshake objects admit only one concurrent acceptance", async () => { + const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]) as CryptoKeyPair + const publicKey = await crypto.subtle.exportKey("jwk", pair.publicKey) + const privateKey = await crypto.subtle.exportKey("jwk", pair.privateKey) + const client = await createClientHandshake(publicKey) + const host = await createHostHandshake(privateKey) + const hostAcceptance = host.accept(client.hello) + await assert.rejects(() => host.accept(client.hello), /already accepted/) + const accepted = await hostAcceptance + const clientAcceptance = client.accept(accepted.ready) + await assert.rejects(() => client.accept(accepted.ready), /already accepted/) + await clientAcceptance +}) + +test("host challenge prevents encrypted requests from being replayed into a new tunnel", async () => { + const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]) as CryptoKeyPair + const publicKey = await crypto.subtle.exportKey("jwk", pair.publicKey) + const privateKey = await crypto.subtle.exportKey("jwk", pair.privateKey) + const client = await createClientHandshake(publicKey) + const firstHost = await createHostHandshake(privateKey) + const firstAccepted = await firstHost.accept(client.hello) + const clientChannel = await client.accept(firstAccepted.ready) + const captured = await clientChannel.encrypt(new TextEncoder().encode("state-changing request")) + + const secondHost = await createHostHandshake(privateKey) + const secondAccepted = await secondHost.accept(client.hello) + await assert.rejects(() => secondAccepted.channel.decrypt(captured), /authentication failed/) +}) + +test("client authenticates the host before establishing a channel", async () => { + const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]) as CryptoKeyPair + const client = await createClientHandshake(await crypto.subtle.exportKey("jwk", pair.publicKey)) + const host = await createHostHandshake(await crypto.subtle.exportKey("jwk", pair.privateKey)) + const accepted = await host.accept(client.hello) + const ready = JSON.parse(accepted.ready) as { proof: string } + const proof = decodeBase64(ready.proof) + proof[proof.length - 1] ^= 1 + ready.proof = encodeBase64(proof) + + await assert.rejects(() => client.accept(JSON.stringify(ready)), /authentication failed/) +}) diff --git a/packages/remote-control-protocol/src/index.ts b/packages/remote-control-protocol/src/index.ts new file mode 100644 index 000000000..3db8a63f7 --- /dev/null +++ b/packages/remote-control-protocol/src/index.ts @@ -0,0 +1,4 @@ +export * from "./crypto" +export * from "./frame-budget" +export * from "./messages" +export * from "./websocket-close" diff --git a/packages/remote-control-protocol/src/messages.ts b/packages/remote-control-protocol/src/messages.ts new file mode 100644 index 000000000..9aec122ad --- /dev/null +++ b/packages/remote-control-protocol/src/messages.ts @@ -0,0 +1,100 @@ +export const REMOTE_CONTROL_PROTOCOL_VERSION = 2 as const +export const REMOTE_CONTROL_HEARTBEAT_REQUEST = "codenomad.remote-control.ping.v2" +export const REMOTE_CONTROL_HEARTBEAT_RESPONSE = "codenomad.remote-control.pong.v2" +export const REMOTE_CONTROL_MAX_HANDSHAKE_BYTES = 4 * 1024 +export const REMOTE_CONTROL_MAX_HTTP_BODY_BYTES = 12 * 1024 * 1024 +export const REMOTE_CONTROL_MAX_SOCKET_MESSAGE_BYTES = 12 * 1024 * 1024 +export const REMOTE_CONTROL_MAX_PLAINTEXT_BYTES = 20 * 1024 * 1024 + +export type HeaderEntries = Array<[string, string]> + +export type RelayToHostMessage = + | { type: "ready"; protocol: typeof REMOTE_CONTROL_PROTOCOL_VERSION } + | { type: "tunnel.open"; id: string } + | { type: "tunnel.message"; id: string; data: string; binary: boolean } + | { type: "tunnel.close"; id: string; code?: number; reason?: string } + +export type HostToRelayMessage = + | { type: "ready"; protocol: typeof REMOTE_CONTROL_PROTOCOL_VERSION } + | { type: "tunnel.message"; id: string; data: string; binary: boolean } + | { type: "tunnel.close"; id: string; code?: number; reason?: string } + +export type ClientToHostMessage = + | { + type: "http.request" + id: string + method: string + path: string + headers: HeaderEntries + body?: string + } + | { type: "http.cancel"; id: string } + | { + type: "socket.open" + id: string + path: string + headers: HeaderEntries + protocols: string[] + } + | { type: "socket.message"; id: string; data: string; binary: boolean } + | { type: "socket.close"; id: string; code?: number; reason?: string } + +export type HostToClientMessage = + | { + type: "http.start" + id: string + status: number + headers: HeaderEntries + } + | { type: "http.chunk"; id: string; data: string } + | { type: "http.end"; id: string } + | { type: "http.error"; id: string; message: string } + | { type: "socket.ready"; id: string; protocol?: string } + | { type: "socket.message"; id: string; data: string; binary: boolean } + | { type: "socket.close"; id: string; code?: number; reason?: string } + | { type: "socket.error"; id: string; message: string } + +export interface RemoteControlStatus { + manageable: boolean + enabled: boolean + state: "stopped" | "connecting" | "connected" | "reconnecting" | "error" + hostId: string + relayUrl: string + remoteUrl: string + pairedDevices: number + lastConnectedAt?: string + error?: string +} + +export interface RemoteControlPairing { + url: string + expiresAt: string +} + +export interface RemoteControlDevice { + id: string + name: string + createdAt: string + lastSeenAt: string +} + +export interface RemoteControlStartResponse { + status: RemoteControlStatus + pairing: RemoteControlPairing +} + +export function encodeBase64(bytes: Uint8Array): string { + let binary = "" + const chunkSize = 0x8000 + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)) + } + return btoa(binary) +} + +export function decodeBase64(value: string): Uint8Array { + const binary = atob(value) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index) + return bytes +} diff --git a/packages/remote-control-protocol/src/websocket-close.test.ts b/packages/remote-control-protocol/src/websocket-close.test.ts new file mode 100644 index 000000000..b94edff8b --- /dev/null +++ b/packages/remote-control-protocol/src/websocket-close.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { clientWebSocketCloseCode } from "./websocket-close" + +test("client close codes preserve permitted values and normalize reserved codes", () => { + for (const code of [undefined, 1000, 3000, 4001, 4999]) { + assert.equal(clientWebSocketCloseCode(code), code) + } + for (const code of [1002, 1003, 1008, 1009, 1012, 1013, 5000, 3000.5, NaN]) { + assert.equal(clientWebSocketCloseCode(code), 4000) + } +}) diff --git a/packages/remote-control-protocol/src/websocket-close.ts b/packages/remote-control-protocol/src/websocket-close.ts new file mode 100644 index 000000000..b83da9d72 --- /dev/null +++ b/packages/remote-control-protocol/src/websocket-close.ts @@ -0,0 +1,6 @@ +// Browser and Undici WebSockets cannot send reserved protocol close codes. +// Preserve application codes and use a private-use failure code otherwise. +export function clientWebSocketCloseCode(code: number | undefined): number | undefined { + if (code === undefined || code === 1000) return code + return Number.isInteger(code) && code >= 3000 && code <= 4999 ? code : 4000 +} diff --git a/packages/remote-control-protocol/tsconfig.json b/packages/remote-control-protocol/tsconfig.json new file mode 100644 index 000000000..93e161fad --- /dev/null +++ b/packages/remote-control-protocol/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true + }, + "include": ["src/index.ts"] +} diff --git a/packages/server/README.md b/packages/server/README.md index 3efe4013f..e11158520 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -1,15 +1,15 @@ # CodeNomad Server -**CodeNomad Server** is the high-performance engine behind the CodeNomad cockpit. It transforms your machine into a robust development host, managing the lifecycle of multiple OpenCode instances and providing the low-latency data streams that long-haul builders demand. It bridges your local filesystem with the UI, ensuring that whether you are on localhost or a remote tunnel, you have the speed, clarity, and control of a native workspace. +**CodeNomad Server** is the engine behind the CodeNomad cockpit. It manages local workspace access and connects to OpenCode's shared global daemon. The server listens only on loopback; Remote Control reaches it through an authenticated outbound relay connection. ## Features & Capabilities -### ๐ŸŒ Deployment Freedom +### ๐ŸŒ Remote Control -- **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop. -- **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling. -- **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal. -- **Always-On**: Run as a background service so your sessions are always ready when you connect. +- **No inbound exposure**: CodeNomad remains on `127.0.0.1`; no LAN port, VPN, NAT rule, or public OpenCode endpoint is needed. +- **Outbound relay**: The host establishes one persistent WebSocket connection to the configured Cloudflare relay. +- **Secure pairing**: One-time links and QR codes expire after ten minutes and issue revocable 30-day device credentials. +- **Multi-device**: Continue the same CodeNomad sessions from a paired browser, tablet, or laptop. ### โšก๏ธ Workspace Power @@ -45,10 +45,7 @@ To list all CLI options: npx @neuralnomads/codenomad --help ``` -On startup, CodeNomad prints two URLs: - -- `Local Connection URL : ...` (used by desktop shells) -- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled) +On startup, CodeNomad prints its loopback `Local Connection URL`. Enable Remote Control from Settings to create a pairing link. ### Install Globally @@ -84,7 +81,6 @@ You can configure the server using flags or environment variables: | `--tls-cert ` | `CLI_TLS_CERT` | TLS certificate (PEM). Requires `--tls-key`. | | `--tls-ca ` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) | | `--tlsSANs ` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) | -| `--host ` | `CLI_HOST` | Interface to bind (default 127.0.0.1) | | `--workspace-root ` | `CLI_WORKSPACE_ROOT` | Restricts the root path where new workspaces can be opened. Git worktrees are created in `.codenomad/worktrees` inside the project folder. | | `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing | | `--config ` | `CLI_CONFIG` | Config file location | @@ -101,6 +97,8 @@ You can configure the server using flags or environment variables: | `--ui-auto-update ` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (`true`) | | `--ui-manifest-url ` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL | +Remote Control uses `https://remote.codenomad.neuralnomads.ai` by default. Set `CODENOMAD_REMOTE_CONTROL_RELAY_URL` to use another compatible relay. The shared relay uses Cloudflare Durable Objects WebSocket Hibernation: idle host connections remain reachable without keeping an object active, and application heartbeats are answered without waking it. Protocol v2 encrypts application traffic end to end with directional AES-256-GCM keys derived from an ephemeral browser key, the pinned persistent host key, and fresh per-tunnel challenges; the relay routes ciphertext without receiving the decryption keys. + ### Dev Releases (Advanced) If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package: @@ -125,19 +123,15 @@ These environment variables control how CodeNomad checks for dev updates: codenomad --https=false --http=true ``` -- To run both HTTPS (for remote) and HTTP loopback (for desktop): +- To run both HTTPS and HTTP on loopback: ```sh codenomad --https=true --http=true ``` -### Remote Access Binding Rules +### Remote Control Network Model -- When remote access is enabled (bind host is non-loopback, e.g. `--host 0.0.0.0`): - - HTTP listens on `127.0.0.1` only. - - HTTPS listens on `--host` (LAN/all interfaces). -- When remote access is disabled (bind host is loopback, e.g. `--host 127.0.0.1`): - - Both HTTP and HTTPS listen on `127.0.0.1`. +Both HTTP and HTTPS listeners bind to `127.0.0.1`. Remote Control opens only an outbound host WebSocket and requires no inbound port. It never forwards its device cookie or remote authorization headers to the local server: after host-side decryption, the connector injects a dedicated internal CodeNomad session. OpenCode remains behind CodeNomad's existing authorization, workspace, Git, Yolo, and proxy boundaries. ### Self-Signed Certificates @@ -158,14 +152,12 @@ codenomad --tlsSANs "localhost,127.0.0.1,my-hostname,192.168.1.10" > 2. **Firefox:** Click **Advanced** โ†’ **Accept the Risk and Continue** > 3. **Alternative:** For local-only development without the warning, run with `--https=false --http=true` > -> **Note:** Only accept self-signed certificates for localhost/127.0.0.1 that you control. For remote hosts, use proper TLS certificates. +> Remote Control does not expose this certificate or require remote devices to trust it; relay traffic uses normal public HTTPS. ### Authentication - Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser. -- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated. - Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.). - If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API. +- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats loopback requests as authenticated. Use it only for isolated local development. #### Setting a password @@ -205,7 +197,7 @@ Manual creation of this file is not recommended unless you have a helper to gene ### Progressive Web App (PWA) -When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead. +CodeNomad can be installed as a PWA from a supported browser, including from a paired Remote Control URL. 1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.). 2. Click the install icon in the address bar, or use the browser menu โ†’ "Install CodeNomad". @@ -213,7 +205,7 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo > **TLS requirement** > Browsers require a secure (`https://`) connection for PWA installation. -> If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA). +> Paired Remote Control URLs use the relay's public HTTPS certificate. ### Data Storage @@ -223,6 +215,7 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo - **CodeNomad instance data**: `~/.config/codenomad/instances/` - **OpenCode V2 sessions, messages, and service registration**: OpenCode's platform-default global locations. - **Desktop restore state**: `~/.codenomad/client-state/v2/` +- **Remote Control host identity**: `~/.config/codenomad/remote-control.json` (random host ID, relay secret, and P-256 private key; keep private) CodeNomad owns no private OpenCode port, database, service registration, or daemon PID. Configured allowed `server.environmentVariables` and the current `NODE_EXTRA_CA_CERTS` apply only when CodeNomad starts a missing daemon. Existing daemons are unchanged; legacy `OPENCODE_DB` and `XDG_STATE_HOME` ownership variables are ignored. WSL lifecycle commands run inside Linux and never inspect or signal Linux PIDs from Windows. diff --git a/packages/server/package.json b/packages/server/package.json index c78de894c..858304719 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -17,6 +17,7 @@ "codenomad": "dist/bin.js" }, "scripts": { + "prebuild": "npm run build --workspace @codenomad/remote-control-protocol", "build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run build:pruning && npm run build:automation", "build:pruning": "node ./scripts/build-session-pruning.mjs", "build:automation": "node ./scripts/build-automation-plugin.mjs", @@ -26,6 +27,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@codenomad/remote-control-protocol": "0.1.0", "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", "@fastify/static": "^7.0.4", diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index cf4190ffb..1e8789e99 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -463,6 +463,13 @@ export interface RemoteProxySessionCreateResponse { windowUrl: string } +export type { + RemoteControlDevice, + RemoteControlPairing, + RemoteControlStartResponse, + RemoteControlStatus, +} from "@codenomad/remote-control-protocol" + export type WorkspaceEventType = | "workspace.created" | "workspace.started" @@ -530,7 +537,7 @@ export interface SupportMeta { export interface ServerMeta { /** URL desktop apps should use to connect (prefers loopback HTTP when enabled). */ localUrl: string - /** URL remote clients should use (prefers HTTPS when enabled). */ + /** URL direct remote clients should use (prefers HTTPS when enabled). */ remoteUrl?: string /** SSE endpoint advertised to clients (`/api/events` by default). */ eventsUrl: string @@ -540,13 +547,13 @@ export interface ServerMeta { listeningMode: "local" | "all" /** Actual local port in use after binding. */ localPort: number - /** Actual remote port in use after binding (when remoteUrl is set). */ + /** Actual direct remote port in use after binding (when remoteUrl is set). */ remotePort?: number /** Display label for the host (e.g., hostname or friendly name). */ hostLabel: string /** Absolute path of the filesystem root exposed to clients. */ workspaceRoot: string - /** Reachable addresses for this server, external first. */ + /** Reachable direct-access addresses for this server, external first. */ addresses: NetworkAddress[] serverVersion?: string ui?: UiMeta diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index fa98420ed..738d58b48 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -41,6 +41,8 @@ import { PruningLifecycle } from "./opencode/pruning-lifecycle" import { DesktopPluginLifecycle, prepareDesktopPluginPresence } from "./opencode/desktop-plugin-lifecycle" import { resolveDesktopPluginPaths } from "./opencode/desktop-plugin-paths" import { AUTOMATION_BRIDGE_PATH, createAutomationBridgeRegistration, publishAutomationBridge } from "./opencode/automation-plugin" +import { loadOrCreateRemoteControlIdentity } from "./remote-control/identity" +import { RemoteControlManager } from "./remote-control/manager" const require = createRequire(import.meta.url) @@ -48,6 +50,7 @@ const packageJson = require("../package.json") as { version: string } const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public") +const DEFAULT_REMOTE_CONTROL_RELAY_URL = "https://remote.codenomad.neuralnomads.ai" interface CliOptions { host: string @@ -483,6 +486,14 @@ async function main() { logger: logger.child({ component: "remote-proxy" }), httpsOptions: tlsResolution?.httpsOptions, }) + const remoteControlSession = authManager.createSession(options.authUsername) + const remoteControlManager = new RemoteControlManager({ + identity: loadOrCreateRemoteControlIdentity(configDir), + relayUrl: process.env.CODENOMAD_REMOTE_CONTROL_RELAY_URL ?? DEFAULT_REMOTE_CONTROL_RELAY_URL, + localUrl: () => serverMeta.localUrl, + localCookie: () => `${authManager.getCookieName()}=${encodeURIComponent(remoteControlSession.id)}`, + logger: logger.child({ component: "remote-control" }), + }) const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT) const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT) @@ -515,6 +526,7 @@ async function main() { authManager, clientConnectionManager, remoteProxySessionManager, + remoteControlManager, yoloManager, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiDevServerUrl: uiResolution.uiDevServerUrl, @@ -543,6 +555,7 @@ async function main() { authManager, clientConnectionManager, remoteProxySessionManager, + remoteControlManager, yoloManager, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiDevServerUrl: undefined, @@ -658,6 +671,7 @@ async function main() { stopSidecars: () => sidecarManager.shutdown(), stopClientConnections: () => clientConnectionManager.shutdown(), stopRemoteProxySessions: () => remoteProxySessionManager.shutdown(), + stopRemoteControl: () => remoteControlManager.shutdown(), stopWorkspaces: () => workspaceManager.shutdown(), stopHttpServers: async () => { await pruningLifecycle.stop() diff --git a/packages/server/src/remote-control/connector-protocol.ts b/packages/server/src/remote-control/connector-protocol.ts new file mode 100644 index 000000000..d7cc73367 --- /dev/null +++ b/packages/server/src/remote-control/connector-protocol.ts @@ -0,0 +1,186 @@ +import type { ClientToHostMessage, HeaderEntries, RelayToHostMessage } from "@codenomad/remote-control-protocol" +import { isIP } from "node:net" + +const RESPONSE_HEADER_BLOCKLIST = new Set(["connection", "content-encoding", "content-length", "set-cookie", "transfer-encoding", "upgrade"]) +const REQUEST_HEADER_BLOCKLIST = new Set([ + "authorization", + "cf-connecting-ip", + "client-ip", + "connection", + "content-length", + "cookie", + "forwarded", + "host", + "keep-alive", + "origin", + "proxy-authorization", + "referer", + "te", + "trailer", + "transfer-encoding", + "true-client-ip", + "upgrade", + "via", + "x-real-ip", +]) + +export const ALLOWED_REMOTE_METHODS = new Set(["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]) +const ALLOWED_REMOTE_PATH_PREFIXES = ["/api/", "/workspaces/"] +const MESSAGE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/ +const PROTOCOL_PATTERN = /^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/ +const MAX_PATH_CHARS = 8 * 1024 +const MAX_HEADER_ENTRIES = 256 +const MAX_HEADER_NAME_CHARS = 256 +const MAX_HEADER_VALUE_BYTES = 16 * 1024 +const MAX_HEADER_BYTES = 64 * 1024 +const MAX_PROTOCOLS = 16 +const MAX_PROTOCOL_CHARS = 128 +const MAX_CLOSE_REASON_CHARS = 120 + +export function relaySocketUrl(relayUrl: string, hostId: string): URL { + const url = new URL(`/api/hosts/${hostId}/connect`, normalizedRelayUrl(relayUrl)) + url.protocol = url.protocol === "https:" ? "wss:" : "ws:" + return url +} + +export function normalizedRelayUrl(value: string): URL { + const url = new URL(value) + if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname(url.hostname))) { + throw new Error("Remote Control relay must use HTTPS") + } + url.pathname = "/" + url.search = "" + url.hash = "" + return url +} + +export function allowedRemotePath(path: string): boolean { + return ALLOWED_REMOTE_PATH_PREFIXES.some((prefix) => path.startsWith(prefix)) +} + +export function localHeaders(entries: HeaderEntries, cookie: string): Headers { + const headers = new Headers() + for (const [name, value] of entries) if (!blockedRequestHeader(name)) headers.append(name, value) + headers.set("Cookie", cookie) + headers.set("X-CodeNomad-Remote-Control", "1") + return headers +} + +export function responseHeaders(headers: Headers): HeaderEntries { + const entries: HeaderEntries = [] + headers.forEach((value, name) => { + if (!RESPONSE_HEADER_BLOCKLIST.has(name.toLowerCase())) entries.push([name, value]) + }) + return entries +} + +export function parseRelayMessage(value: string): RelayToHostMessage | null { + try { + const message = JSON.parse(value) as Partial + if (message.type === "ready" && typeof message.protocol === "number") return message as RelayToHostMessage + if (!validMessageId((message as { id?: unknown }).id)) return null + if (message.type === "tunnel.open") return message as RelayToHostMessage + if (message.type === "tunnel.close" && validCloseMetadata(message.code, message.reason)) return message as RelayToHostMessage + if (message.type === "tunnel.message" && typeof message.data === "string" && typeof message.binary === "boolean") { + return message as RelayToHostMessage + } + return null + } catch { + return null + } +} + +export function parseClientMessage(value: string): ClientToHostMessage | null { + try { + const message = JSON.parse(value) as Partial + if (!validMessageId(message.id) || typeof message.type !== "string") return null + if (message.type === "http.cancel") return message as ClientToHostMessage + if (message.type === "socket.close" && validClientCloseMetadata(message.code, message.reason)) return message as ClientToHostMessage + if (message.type === "socket.message" && typeof message.data === "string" && typeof message.binary === "boolean") return message as ClientToHostMessage + if (message.type === "http.request" && validMethod(message.method) && validPath(message.path) && validHeaders(message.headers)) { + if (message.body !== undefined && typeof message.body !== "string") return null + return message as ClientToHostMessage + } + if (message.type === "socket.open" && validPath(message.path) && validHeaders(message.headers) && validProtocols(message.protocols)) { + return message as ClientToHostMessage + } + return null + } catch { + return null + } +} + +export function base64ByteLength(value: string): number { + if (!value) return 0 + const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0 + return Math.floor(value.length * 3 / 4) - padding +} + +export function validCloseCode(value: number | undefined): value is number { + return value === 1000 || (typeof value === "number" && Number.isSafeInteger(value) && value >= 3000 && value <= 4999) +} + +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase() + return normalized === "localhost" || normalized === "::1" || normalized === "[::1]" + || (isIP(normalized) === 4 && normalized.startsWith("127.")) +} + +function validHeaders(value: unknown): value is HeaderEntries { + if (!Array.isArray(value) || value.length > MAX_HEADER_ENTRIES) return false + let bytes = 0 + return value.every((entry) => { + if (!Array.isArray(entry) || entry.length !== 2) return false + const [name, headerValue] = entry as unknown[] + if (typeof name !== "string" || typeof headerValue !== "string" + || !name || name.length > MAX_HEADER_NAME_CHARS || !HEADER_NAME_PATTERN.test(name) + || /[\0\r\n]/.test(headerValue)) return false + const valueBytes = new TextEncoder().encode(headerValue).byteLength + if (valueBytes > MAX_HEADER_VALUE_BYTES) return false + bytes += name.length + valueBytes + return bytes <= MAX_HEADER_BYTES + }) +} + +function blockedRequestHeader(name: string): boolean { + const normalized = name.toLowerCase() + return REQUEST_HEADER_BLOCKLIST.has(normalized) + || normalized.startsWith("proxy-") + || normalized.startsWith("sec-websocket-") + || normalized.startsWith("x-codenomad-") + || normalized.startsWith("x-forwarded-") +} + +function validMessageId(value: unknown): value is string { + return typeof value === "string" && MESSAGE_ID_PATTERN.test(value) +} + +function validMethod(value: unknown): value is string { + return typeof value === "string" && value.length <= 16 && /^[A-Za-z]+$/.test(value) +} + +function validPath(value: unknown): value is string { + return typeof value === "string" && value.length > 1 && value.length <= MAX_PATH_CHARS && value.startsWith("/") +} + +function validProtocols(value: unknown): value is string[] { + return Array.isArray(value) && value.length <= MAX_PROTOCOLS + && new Set(value).size === value.length + && value.every((protocol) => typeof protocol === "string" && protocol.length <= MAX_PROTOCOL_CHARS && PROTOCOL_PATTERN.test(protocol)) +} + +function validClientCloseMetadata(code: unknown, reason: unknown): boolean { + return (code === undefined || validCloseCode(typeof code === "number" ? code : undefined)) + && validCloseReason(reason) +} + +function validCloseMetadata(code: unknown, reason: unknown): boolean { + return (code === undefined || (typeof code === "number" && Number.isSafeInteger(code) && code >= 0 && code <= 0xffff)) + && validCloseReason(reason) +} + +function validCloseReason(reason: unknown): boolean { + return reason === undefined || (typeof reason === "string" && reason.length <= MAX_CLOSE_REASON_CHARS + && new TextEncoder().encode(reason).byteLength <= 123 && !/[\0\r\n]/.test(reason)) +} diff --git a/packages/server/src/remote-control/connector.test.ts b/packages/server/src/remote-control/connector.test.ts new file mode 100644 index 000000000..a350bd2b3 --- /dev/null +++ b/packages/server/src/remote-control/connector.test.ts @@ -0,0 +1,84 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { clientWebSocketCloseCode } from "@codenomad/remote-control-protocol" +import { WebSocket } from "undici" +import { normalizedRelayUrl } from "./connector" +import { allowedRemotePath, localHeaders, parseClientMessage, parseRelayMessage } from "./connector-protocol" + +test("Remote Control relays require HTTPS except for loopback development", () => { + assert.equal(normalizedRelayUrl("https://relay.example/path?ignored=1").href, "https://relay.example/") + assert.equal(normalizedRelayUrl("http://127.0.0.1:8787").href, "http://127.0.0.1:8787/") + assert.equal(normalizedRelayUrl("http://localhost:8787").href, "http://localhost:8787/") + assert.throws(() => normalizedRelayUrl("http://relay.example"), /must use HTTPS/) + assert.throws(() => normalizedRelayUrl("file:///relay"), /must use HTTPS/) + for (const host of ["127.attacker.example", "127.0.0.1.attacker.example", "128.0.0.1"]) { + assert.throws(() => normalizedRelayUrl(`http://${host}`), /must use HTTPS/) + } + assert.equal(normalizedRelayUrl("http://127.1.2.3:8787").hostname, "127.1.2.3") + assert.equal(normalizedRelayUrl("http://[::1]:8787").hostname, "[::1]") +}) + +test("Remote Control replaces remote credentials and forwarding metadata with host-local identity", () => { + const headers = localHeaders([ + ["authorization", "Bearer remote"], + ["cookie", "remote=session"], + ["origin", "https://remote.example"], + ["content-length", "999"], + ["cf-connecting-ip", "203.0.113.1"], + ["x-real-ip", "203.0.113.2"], + ["x-forwarded-host", "attacker.example"], + ["x-codenomad-remote-control", "0"], + ["content-type", "application/json"], + ], "local=session") + assert.equal(headers.get("authorization"), null) + assert.equal(headers.get("origin"), null) + assert.equal(headers.get("content-length"), null) + assert.equal(headers.get("cf-connecting-ip"), null) + assert.equal(headers.get("x-real-ip"), null) + assert.equal(headers.get("x-forwarded-host"), null) + assert.equal(headers.get("cookie"), "local=session") + assert.equal(headers.get("x-codenomad-remote-control"), "1") + assert.equal(headers.get("content-type"), "application/json") +}) + +test("Remote Control reaches only API and workspace namespaces", () => { + assert.equal(allowedRemotePath("/api/events"), true) + assert.equal(allowedRemotePath("/workspaces/example/instance/api/session"), true) + assert.equal(allowedRemotePath("/api"), false) + assert.equal(allowedRemotePath("/assets/app.js"), false) + assert.equal(allowedRemotePath("//attacker.example/api/events"), false) +}) + +test("Undici accepts normalized security and reconnect close codes", async () => { + for (const code of [1002, 1003, 1008, 1009, 1012, 1013]) { + const socket = new WebSocket("ws://127.0.0.1:1") + const finished = new Promise((resolve) => { + socket.addEventListener("error", () => resolve(), { once: true }) + socket.addEventListener("close", () => resolve(), { once: true }) + }) + assert.doesNotThrow(() => socket.close(clientWebSocketCloseCode(code), "Security teardown")) + await finished + } +}) + +test("Remote Control accepts only bounded UUID-addressed tunnel contracts", () => { + const id = "123e4567-e89b-42d3-a456-426614174000" + assert.ok(parseRelayMessage(JSON.stringify({ type: "tunnel.open", id }))) + assert.equal(parseRelayMessage(JSON.stringify({ type: "tunnel.open", id: "x".repeat(10_000) })), null) + assert.ok(parseClientMessage(JSON.stringify({ + type: "http.request", id, method: "POST", path: "/api/items", headers: [["content-type", "application/json"]], + }))) + assert.equal(parseClientMessage(JSON.stringify({ + type: "http.request", id, method: "POST", path: `/api/${"x".repeat(9_000)}`, headers: [], + })), null) + assert.equal(parseClientMessage(JSON.stringify({ + type: "http.request", id, method: "POST", path: "/api/items", headers: [["x-large", "x".repeat(17_000)]], + })), null) + assert.equal(parseClientMessage(JSON.stringify({ + type: "socket.open", id, path: "/api/socket", headers: [], protocols: Array.from({ length: 17 }, () => "v1"), + })), null) + assert.equal(parseClientMessage(JSON.stringify({ + type: "socket.open", id, path: "/api/socket", headers: [], protocols: ["v1", "v1"], + })), null) + assert.equal(parseClientMessage(JSON.stringify({ type: "socket.close", id, code: 1006 })), null) +}) diff --git a/packages/server/src/remote-control/connector.ts b/packages/server/src/remote-control/connector.ts new file mode 100644 index 000000000..6ca0834e8 --- /dev/null +++ b/packages/server/src/remote-control/connector.ts @@ -0,0 +1,517 @@ +import { + clientWebSocketCloseCode, + REMOTE_CONTROL_HEARTBEAT_REQUEST, + REMOTE_CONTROL_HEARTBEAT_RESPONSE, + REMOTE_CONTROL_MAX_HANDSHAKE_BYTES, + REMOTE_CONTROL_MAX_HTTP_BODY_BYTES, + REMOTE_CONTROL_MAX_PLAINTEXT_BYTES, + REMOTE_CONTROL_MAX_SOCKET_MESSAGE_BYTES, + REMOTE_CONTROL_PROTOCOL_VERSION, + FrameBudget, + createHostHandshake, + decodeBase64, + encodeBase64, + type ClientToHostMessage, + type EncryptedChannel, + type HostToClientMessage, + type HostToRelayMessage, +} from "@codenomad/remote-control-protocol" +import { Agent, fetch, WebSocket } from "undici" +import type { Logger } from "../logger" +import { + ALLOWED_REMOTE_METHODS, + allowedRemotePath, + base64ByteLength, + localHeaders, + normalizedRelayUrl, + parseClientMessage, + parseRelayMessage, + relaySocketUrl, + responseHeaders, +} from "./connector-protocol" + +export { normalizedRelayUrl } from "./connector-protocol" + +const INITIAL_RECONNECT_MS = 1_000 +const MAX_RECONNECT_MS = 30_000 +const MAX_ACTIVE_HTTP_REQUESTS = 32 +const MAX_LOCAL_SOCKETS = 16 +const MAX_QUEUED_SOCKET_MESSAGES = 64 +const MAX_QUEUED_SOCKET_BYTES = 1024 * 1024 +const MAX_PENDING_TUNNEL_FRAMES = 128 +const MAX_PENDING_TUNNEL_BYTES = 24 * 1024 * 1024 +const MAX_LOCAL_SOCKET_BUFFERED_BYTES = 24 * 1024 * 1024 +const MAX_RELAY_BUFFERED_BYTES = 32 * 1024 * 1024 +const MAX_RELAY_MESSAGE_BYTES = Math.ceil((REMOTE_CONTROL_MAX_PLAINTEXT_BYTES + 1024) * 4 / 3) + 2 * 1024 +const RELAY_HANDSHAKE_TIMEOUT_MS = 15_000 +const HEARTBEAT_INTERVAL_MS = 30_000 +const HEARTBEAT_TIMEOUT_MS = 70_000 + +export type ConnectorState = "stopped" | "connecting" | "connected" | "reconnecting" | "error" + +interface ConnectorOptions { + relayUrl: string + hostId: string + secret: string + encryptionPrivateKey: JsonWebKey + localUrl: () => string + localCookie: () => string + logger: Logger + onState: (state: ConnectorState, error?: string) => void +} + +interface TunnelState { + handshake: ReturnType + channel?: EncryptedChannel + receiveQueue: Promise + sendQueue: Promise + receiveBudget: FrameBudget + sendBudget: FrameBudget + httpRequests: Map + localSockets: Map> + localSocketQueues: Map> +} + +export class RemoteControlConnector { + private socket: InstanceType | null = null + private reconnectTimer: NodeJS.Timeout | null = null + private handshakeTimer: NodeJS.Timeout | null = null + private heartbeatTimer: NodeJS.Timeout | null = null + private lastHeartbeatAt = 0 + private ready = false + private desired = false + private reconnectDelay = INITIAL_RECONNECT_MS + private readonly tunnels = new Map() + private readonly localDispatcher = new Agent({ connect: { rejectUnauthorized: false } }) + + constructor(private readonly options: ConnectorOptions) {} + + start(): void { + if (this.desired) return + this.desired = true + this.reconnectDelay = INITIAL_RECONNECT_MS + this.connect("connecting") + } + + stop(): void { + this.desired = false + if (this.reconnectTimer) clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + if (this.handshakeTimer) clearTimeout(this.handshakeTimer) + this.handshakeTimer = null + this.stopHeartbeat() + this.socket?.close(1000, "Remote Control stopped") + this.socket = null + this.ready = false + this.closeAllTunnels() + this.options.onState("stopped") + } + + async shutdown(): Promise { + this.stop() + await this.localDispatcher.close().catch(() => undefined) + } + + isConnected(): boolean { + return this.ready && this.socket?.readyState === WebSocket.OPEN + } + + private connect(state: "connecting" | "reconnecting"): void { + if (!this.desired || this.socket) return + this.options.onState(state) + const socket = new WebSocket(relaySocketUrl(this.options.relayUrl, this.options.hostId), { + headers: { Authorization: `Bearer ${this.options.secret}` }, + }) + this.socket = socket + this.ready = false + socket.addEventListener("open", () => { + if (this.socket !== socket) return + this.sendRelay({ type: "ready", protocol: REMOTE_CONTROL_PROTOCOL_VERSION }) + this.handshakeTimer = setTimeout(() => socket.close(clientWebSocketCloseCode(1002), "Remote Control relay handshake timed out"), RELAY_HANDSHAKE_TIMEOUT_MS) + this.handshakeTimer.unref() + }) + socket.addEventListener("message", (event) => this.onRelayMessage(socket, event.data)) + socket.addEventListener("close", () => this.onClosed(socket)) + socket.addEventListener("error", () => this.onClosed(socket, "Unable to connect to the Remote Control relay")) + } + + private onClosed(socket: InstanceType, error?: string): void { + if (this.socket !== socket) return + this.socket = null + this.ready = false + if (this.handshakeTimer) clearTimeout(this.handshakeTimer) + this.handshakeTimer = null + this.stopHeartbeat() + this.closeAllTunnels() + if (!this.desired) { + this.options.onState("stopped") + return + } + this.options.onState(error ? "error" : "reconnecting", error) + if (this.reconnectTimer) clearTimeout(this.reconnectTimer) + const delay = this.reconnectDelay + this.reconnectDelay = Math.min(MAX_RECONNECT_MS, this.reconnectDelay * 2) + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null + this.connect("reconnecting") + }, delay) + this.reconnectTimer.unref() + } + + private onRelayMessage(socket: InstanceType, data: unknown): void { + if (this.socket !== socket) return + const byteLength = typeof data === "string" ? data.length : data instanceof ArrayBuffer ? data.byteLength : 0 + if (byteLength > MAX_RELAY_MESSAGE_BYTES) { + socket.close(clientWebSocketCloseCode(1009), "Remote Control relay message is too large") + return + } + const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? new TextDecoder().decode(data) : "" + if (!text) return + if (text === REMOTE_CONTROL_HEARTBEAT_RESPONSE) { + this.lastHeartbeatAt = Date.now() + return + } + const message = parseRelayMessage(text) + if (!message) { + this.socket?.close(clientWebSocketCloseCode(1003), "Invalid Remote Control relay message") + return + } + if (message.type === "ready") { + if (message.protocol !== REMOTE_CONTROL_PROTOCOL_VERSION) { + this.socket?.close(clientWebSocketCloseCode(1002), "Unsupported Remote Control protocol") + return + } + if (this.handshakeTimer) clearTimeout(this.handshakeTimer) + this.handshakeTimer = null + this.ready = true + this.reconnectDelay = INITIAL_RECONNECT_MS + this.startHeartbeat() + this.options.onState("connected") + return + } + if (!this.ready) { + this.socket?.close(clientWebSocketCloseCode(1002), "Remote Control relay handshake required") + return + } + if (message.type === "tunnel.open") { + this.openTunnel(message.id) + return + } + if (message.type === "tunnel.close") { + this.closeTunnel(message.id, message.code, message.reason) + return + } + const tunnel = this.tunnels.get(message.id) + if (!tunnel) return + const release = tunnel.receiveBudget.reserve(Math.max(0, base64ByteLength(message.data))) + if (!release) { + this.failTunnel(message.id, new Error("Remote Control receive queue exceeded its safety limit")) + return + } + tunnel.receiveQueue = tunnel.receiveQueue + .then(() => this.handleTunnelFrame(message.id, message.data, message.binary)) + .finally(release) + .catch((error) => this.failTunnel(message.id, error)) + } + + private openTunnel(id: string): void { + if (this.tunnels.has(id)) { + this.failTunnel(id, new Error("Duplicate Remote Control tunnel")) + return + } + this.tunnels.set(id, { + handshake: createHostHandshake(this.options.encryptionPrivateKey), + receiveQueue: Promise.resolve(), + sendQueue: Promise.resolve(), + receiveBudget: new FrameBudget(MAX_PENDING_TUNNEL_FRAMES, MAX_PENDING_TUNNEL_BYTES), + sendBudget: new FrameBudget(MAX_PENDING_TUNNEL_FRAMES, MAX_PENDING_TUNNEL_BYTES), + httpRequests: new Map(), + localSockets: new Map(), + localSocketQueues: new Map(), + }) + } + + private async handleTunnelFrame(tunnelId: string, encoded: string, binary: boolean): Promise { + const tunnel = this.tunnels.get(tunnelId) + if (!tunnel) return + const bytes = decodeBase64(encoded) + if (!binary) { + if (tunnel.channel) throw new Error("Plaintext received after Remote Control encryption was established") + if (bytes.byteLength > REMOTE_CONTROL_MAX_HANDSHAKE_BYTES) throw new Error("Remote Control handshake is too large") + const accepted = await (await tunnel.handshake).accept(new TextDecoder().decode(bytes)) + tunnel.channel = accepted.channel + this.sendTunnelFrame(tunnelId, new TextEncoder().encode(accepted.ready), false) + return + } + if (!tunnel.channel) throw new Error("Encrypted Remote Control frame arrived before its handshake") + const plaintext = await tunnel.channel.decrypt(bytes) + const message = parseClientMessage(new TextDecoder().decode(plaintext)) + if (!message) throw new Error("Invalid encrypted Remote Control message") + if (message.type === "http.request") void this.handleHttp(tunnelId, tunnel, message).catch((error) => this.failTunnel(tunnelId, error)) + else if (message.type === "http.cancel") this.cancelHttp(tunnel, message.id) + else if (message.type === "socket.open") this.openLocalSocket(tunnelId, tunnel, message) + else if (message.type === "socket.message") this.forwardSocketMessage(tunnelId, tunnel, message) + else if (message.type === "socket.close") this.closeLocalSocket(tunnel, message.id, message.code, message.reason) + } + + private async handleHttp(tunnelId: string, tunnel: TunnelState, message: Extract): Promise { + if (tunnel.httpRequests.has(message.id)) { + await this.sendClient(tunnelId, { type: "http.error", id: message.id, message: "Duplicate remote request identifier" }) + return + } + if (tunnel.httpRequests.size >= MAX_ACTIVE_HTTP_REQUESTS) { + await this.sendClient(tunnelId, { type: "http.error", id: message.id, message: "Too many active remote requests" }) + return + } + if (!ALLOWED_REMOTE_METHODS.has(message.method.toUpperCase())) { + await this.sendClient(tunnelId, { type: "http.error", id: message.id, message: "Remote HTTP method is not allowed" }) + return + } + if (message.body && base64ByteLength(message.body) > REMOTE_CONTROL_MAX_HTTP_BODY_BYTES) { + await this.sendClient(tunnelId, { type: "http.error", id: message.id, message: "Remote request body is too large" }) + return + } + const controller = new AbortController() + tunnel.httpRequests.set(message.id, controller) + try { + const response = await fetch(this.localTarget(message.path), { + method: message.method, + headers: localHeaders(message.headers, this.options.localCookie()), + body: message.body ? decodeBase64(message.body) : undefined, + dispatcher: this.localDispatcher, + signal: controller.signal, + redirect: "manual", + }) + await this.sendClient(tunnelId, { + type: "http.start", + id: message.id, + status: response.status, + headers: responseHeaders(response.headers), + }) + if (response.body && message.method !== "HEAD") { + const reader = response.body.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + if (value.byteLength) await this.sendClient(tunnelId, { type: "http.chunk", id: message.id, data: encodeBase64(value) }) + } + } else if (response.body) { + await response.body.cancel() + } + await this.sendClient(tunnelId, { type: "http.end", id: message.id }) + } catch (error) { + if (!controller.signal.aborted) { + await this.sendClient(tunnelId, { + type: "http.error", + id: message.id, + message: error instanceof Error ? error.message : "Local request failed", + }) + } + } finally { + tunnel.httpRequests.delete(message.id) + } + } + + private cancelHttp(tunnel: TunnelState, id: string): void { + tunnel.httpRequests.get(id)?.abort() + tunnel.httpRequests.delete(id) + } + + private openLocalSocket(tunnelId: string, tunnel: TunnelState, message: Extract): void { + try { + if (tunnel.localSockets.has(message.id)) throw new Error("Duplicate remote WebSocket identifier") + if (tunnel.localSockets.size >= MAX_LOCAL_SOCKETS) throw new Error("Too many active remote WebSockets") + const target = this.localTarget(message.path) + target.protocol = target.protocol === "https:" ? "wss:" : "ws:" + const socket = new WebSocket(target, { + protocols: message.protocols, + dispatcher: this.localDispatcher, + headers: localHeaders(message.headers, this.options.localCookie()), + }) + socket.binaryType = "arraybuffer" + tunnel.localSockets.set(message.id, socket) + tunnel.localSocketQueues.set(message.id, []) + socket.addEventListener("open", () => { + void this.sendClient(tunnelId, { type: "socket.ready", id: message.id, ...(socket.protocol ? { protocol: socket.protocol } : {}) }) + const queued = tunnel.localSocketQueues.get(message.id) ?? [] + tunnel.localSocketQueues.delete(message.id) + for (const entry of queued) this.sendLocalSocket(tunnelId, tunnel, message.id, socket, entry.data, entry.binary) + }) + socket.addEventListener("message", (event) => { + const binary = typeof event.data !== "string" + const bytes = binary ? new Uint8Array(event.data as ArrayBuffer) : new TextEncoder().encode(event.data as string) + if (bytes.byteLength > REMOTE_CONTROL_MAX_SOCKET_MESSAGE_BYTES) { + this.closeLocalSocket(tunnel, message.id, 1009, "Local WebSocket message exceeded the remote safety limit") + return + } + void this.sendClient(tunnelId, { type: "socket.message", id: message.id, data: encodeBase64(bytes), binary }) + }) + socket.addEventListener("close", (event) => { + tunnel.localSockets.delete(message.id) + tunnel.localSocketQueues.delete(message.id) + void this.sendClient(tunnelId, { type: "socket.close", id: message.id, code: event.code, reason: event.reason }) + }) + socket.addEventListener("error", () => { + tunnel.localSockets.delete(message.id) + tunnel.localSocketQueues.delete(message.id) + void this.sendClient(tunnelId, { type: "socket.error", id: message.id, message: "Local WebSocket failed" }) + }) + } catch (error) { + void this.sendClient(tunnelId, { + type: "socket.error", + id: message.id, + message: error instanceof Error ? error.message : "Local WebSocket failed", + }) + } + } + + private forwardSocketMessage(tunnelId: string, tunnel: TunnelState, message: Extract): void { + const socket = tunnel.localSockets.get(message.id) + if (!socket) return + if (base64ByteLength(message.data) > REMOTE_CONTROL_MAX_SOCKET_MESSAGE_BYTES) { + this.closeLocalSocket(tunnel, message.id, 1009, "Remote WebSocket message exceeded its safety limit") + return + } + if (socket.readyState === WebSocket.CONNECTING) { + const queued = tunnel.localSocketQueues.get(message.id) + const queuedBytes = queued?.reduce((total, entry) => total + base64ByteLength(entry.data), 0) ?? 0 + if (!queued || queued.length >= MAX_QUEUED_SOCKET_MESSAGES + || queuedBytes + base64ByteLength(message.data) > MAX_QUEUED_SOCKET_BYTES) { + this.closeLocalSocket(tunnel, message.id, 1009, "Too many queued Remote Control messages") + } else queued.push({ data: message.data, binary: message.binary }) + return + } + if (socket.readyState === WebSocket.OPEN) this.sendLocalSocket(tunnelId, tunnel, message.id, socket, message.data, message.binary) + } + + private sendLocalSocket( + tunnelId: string, + tunnel: TunnelState, + socketId: string, + socket: InstanceType, + data: string, + binary: boolean, + ): void { + try { + const bytes = decodeBase64(data) + if (socket.bufferedAmount + bytes.byteLength > MAX_LOCAL_SOCKET_BUFFERED_BYTES) { + this.closeLocalSocket(tunnel, socketId, 1009, "Remote WebSocket buffer exceeded its safety limit") + return + } + socket.send(binary ? bytes : new TextDecoder().decode(bytes)) + } catch (error) { + this.failTunnel(tunnelId, error) + } + } + + private closeLocalSocket(tunnel: TunnelState, id: string, code?: number, reason?: string): void { + const socket = tunnel.localSockets.get(id) + tunnel.localSockets.delete(id) + tunnel.localSocketQueues.delete(id) + socket?.close(clientWebSocketCloseCode(code), boundedCloseReason(reason)) + } + + private sendClient(tunnelId: string, message: HostToClientMessage): Promise { + const tunnel = this.tunnels.get(tunnelId) + if (!tunnel?.channel) return Promise.resolve() + const plaintext = new TextEncoder().encode(JSON.stringify(message)) + const release = tunnel.sendBudget.reserve(plaintext.byteLength) + if (!release) { + this.failTunnel(tunnelId, new Error("Remote Control send queue exceeded its safety limit")) + return Promise.resolve() + } + tunnel.sendQueue = tunnel.sendQueue.then(async () => { + const channel = tunnel.channel + if (!channel || this.tunnels.get(tunnelId) !== tunnel) return + const frame = await channel.encrypt(plaintext) + if (this.tunnels.get(tunnelId) !== tunnel) return + this.sendTunnelFrame(tunnelId, frame, true) + }).finally(release).catch((error) => this.failTunnel(tunnelId, error)) + return tunnel.sendQueue + } + + private sendTunnelFrame(tunnelId: string, bytes: Uint8Array, binary: boolean): void { + this.sendRelay({ type: "tunnel.message", id: tunnelId, data: encodeBase64(bytes), binary }) + } + + private failTunnel(id: string, error: unknown): void { + if (!this.tunnels.has(id)) return + const reason = error instanceof Error ? error.message : "Encrypted Remote Control tunnel failed" + const closeReason = boundedCloseReason(reason) + this.options.logger.warn({ err: error, tunnelId: id }, "Remote Control encrypted tunnel failed") + this.sendRelay({ type: "tunnel.close", id, code: 1008, reason: closeReason }) + this.closeTunnel(id, 1008, closeReason) + } + + private closeTunnel(id: string, code?: number, reason?: string): void { + const tunnel = this.tunnels.get(id) + if (!tunnel) return + this.tunnels.delete(id) + for (const controller of tunnel.httpRequests.values()) controller.abort() + for (const socket of tunnel.localSockets.values()) { + socket.close(clientWebSocketCloseCode(code), boundedCloseReason(reason)) + } + tunnel.httpRequests.clear() + tunnel.localSockets.clear() + tunnel.localSocketQueues.clear() + } + + private closeAllTunnels(): void { + for (const id of Array.from(this.tunnels.keys())) this.closeTunnel(id, 1012, "Remote Control reconnecting") + } + + private sendRelay(message: HostToRelayMessage): void { + const socket = this.socket + if (socket?.readyState !== WebSocket.OPEN) return + const payload = JSON.stringify(message) + if (socket.bufferedAmount + payload.length > MAX_RELAY_BUFFERED_BYTES) { + socket.close(clientWebSocketCloseCode(1013), "Remote Control relay send buffer exceeded its safety limit") + this.onClosed(socket, "Remote Control relay send buffer exceeded its safety limit") + return + } + socket.send(payload) + } + + private startHeartbeat(): void { + this.stopHeartbeat() + this.lastHeartbeatAt = Date.now() + this.heartbeatTimer = setInterval(() => { + const socket = this.socket + if (!this.ready || socket?.readyState !== WebSocket.OPEN) return + if (Date.now() - this.lastHeartbeatAt > HEARTBEAT_TIMEOUT_MS) { + socket.close(clientWebSocketCloseCode(1012), "Remote Control relay heartbeat timed out") + this.onClosed(socket, "Remote Control relay stopped responding") + return + } + socket.send(REMOTE_CONTROL_HEARTBEAT_REQUEST) + }, HEARTBEAT_INTERVAL_MS) + this.heartbeatTimer.unref() + } + + private stopHeartbeat(): void { + if (this.heartbeatTimer) clearInterval(this.heartbeatTimer) + this.heartbeatTimer = null + this.lastHeartbeatAt = 0 + } + + private localTarget(path: string): URL { + if (!allowedRemotePath(path) || path.startsWith("//") || path.includes("\\")) throw new Error("Invalid remote request path") + const base = new URL(this.options.localUrl()) + const target = new URL(path, base) + if (target.origin !== base.origin || !allowedRemotePath(target.pathname)) throw new Error("Remote request escaped the local server") + return target + } +} + +function boundedCloseReason(value: string | undefined): string | undefined { + if (value === undefined) return undefined + let result = "" + let bytes = 0 + for (const character of value) { + const next = new TextEncoder().encode(character).byteLength + if (bytes + next > 123) break + result += character + bytes += next + } + return result +} diff --git a/packages/server/src/remote-control/identity.test.ts b/packages/server/src/remote-control/identity.test.ts new file mode 100644 index 000000000..8fdc27ad4 --- /dev/null +++ b/packages/server/src/remote-control/identity.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict" +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import test from "node:test" +import { loadOrCreateRemoteControlIdentity } from "./identity" + +test("Remote Control identity is random, persistent, and never stores malformed input", () => { + const directory = mkdtempSync(join(tmpdir(), "codenomad-remote-control-")) + try { + const first = loadOrCreateRemoteControlIdentity(directory) + assert.match(first.hostId, /^[a-f0-9]{32}$/) + assert.match(first.secret, /^[A-Za-z0-9_-]{40,}$/) + assert.equal(first.encryptionPublicKey.crv, "P-256") + assert.equal(first.encryptionPrivateKey.crv, "P-256") + assert.equal(typeof first.encryptionPrivateKey.d, "string") + assert.deepEqual(loadOrCreateRemoteControlIdentity(directory), first) + + const stored = JSON.parse(readFileSync(join(directory, "remote-control.json"), "utf8")) + assert.deepEqual(stored, first) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + +test("legacy relay identities gain encryption keys without changing their address or secret", () => { + const directory = mkdtempSync(join(tmpdir(), "codenomad-remote-control-")) + const legacy = { hostId: "a".repeat(32), secret: "b".repeat(48) } + try { + writeFileSync(join(directory, "remote-control.json"), JSON.stringify(legacy)) + const identity = loadOrCreateRemoteControlIdentity(directory) + assert.equal(identity.hostId, legacy.hostId) + assert.equal(identity.secret, legacy.secret) + assert.equal(identity.encryptionPublicKey.crv, "P-256") + assert.equal(typeof identity.encryptionPrivateKey.d, "string") + assert.deepEqual(loadOrCreateRemoteControlIdentity(directory), identity) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + +test("mismatched encryption keys are replaced without changing the relay identity", () => { + const directory = mkdtempSync(join(tmpdir(), "codenomad-remote-control-")) + try { + const first = loadOrCreateRemoteControlIdentity(directory) + const otherDirectory = mkdtempSync(join(tmpdir(), "codenomad-remote-control-other-")) + try { + const other = loadOrCreateRemoteControlIdentity(otherDirectory) + writeFileSync(join(directory, "remote-control.json"), JSON.stringify({ + ...first, + encryptionPublicKey: other.encryptionPublicKey, + })) + const repaired = loadOrCreateRemoteControlIdentity(directory) + assert.equal(repaired.hostId, first.hostId) + assert.equal(repaired.secret, first.secret) + assert.notDeepEqual(repaired.encryptionPrivateKey, first.encryptionPrivateKey) + assert.notDeepEqual(repaired.encryptionPublicKey, other.encryptionPublicKey) + } finally { + rmSync(otherDirectory, { recursive: true, force: true }) + } + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + +test("malformed existing identities are replaced atomically", () => { + const directory = mkdtempSync(join(tmpdir(), "codenomad-remote-control-")) + try { + writeFileSync(join(directory, "remote-control.json"), JSON.stringify({ hostId: "predictable", secret: "short" })) + const identity = loadOrCreateRemoteControlIdentity(directory) + assert.notEqual(identity.hostId, "predictable") + assert.notEqual(identity.secret, "short") + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) diff --git a/packages/server/src/remote-control/identity.ts b/packages/server/src/remote-control/identity.ts new file mode 100644 index 000000000..fca66f70e --- /dev/null +++ b/packages/server/src/remote-control/identity.ts @@ -0,0 +1,89 @@ +import { createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, type JsonWebKey as NodeJsonWebKey } from "crypto" +import fs from "fs" +import path from "path" + +export interface RemoteControlIdentity { + hostId: string + secret: string + encryptionPrivateKey: JsonWebKey + encryptionPublicKey: JsonWebKey +} + +const HOST_ID_PATTERN = /^[a-f0-9]{32}$/ +const SECRET_PATTERN = /^[A-Za-z0-9_-]{40,128}$/ + +export function loadOrCreateRemoteControlIdentity(configDir: string): RemoteControlIdentity { + const filePath = path.join(configDir, "remote-control.json") + const existing = readIdentity(filePath) + if (existing) return existing + + const encryption = createEncryptionKeyPair() + const identity = { + hostId: randomBytes(16).toString("hex"), + secret: randomBytes(32).toString("base64url"), + ...encryption, + } + writeIdentity(filePath, identity) + return identity +} + +function writeIdentity(filePath: string, identity: RemoteControlIdentity): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp` + fs.writeFileSync(temporary, `${JSON.stringify(identity, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }) + fs.renameSync(temporary, filePath) + try { + fs.chmodSync(filePath, 0o600) + } catch { + // Windows ACLs and some network filesystems do not implement POSIX modes. + } +} + +function readIdentity(filePath: string): RemoteControlIdentity | null { + try { + const value = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial + if (!HOST_ID_PATTERN.test(value.hostId ?? "") || !SECRET_PATTERN.test(value.secret ?? "")) return null + if (isPrivateKey(value.encryptionPrivateKey) && isPublicKey(value.encryptionPublicKey) + && isMatchingKeyPair(value.encryptionPrivateKey, value.encryptionPublicKey)) { + return { + hostId: value.hostId!, + secret: value.secret!, + encryptionPrivateKey: value.encryptionPrivateKey, + encryptionPublicKey: value.encryptionPublicKey, + } + } + const migrated = { hostId: value.hostId!, secret: value.secret!, ...createEncryptionKeyPair() } + writeIdentity(filePath, migrated) + return migrated + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null + throw error + } +} + +function createEncryptionKeyPair(): Pick { + const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }) + return { + encryptionPrivateKey: privateKey.export({ format: "jwk" }), + encryptionPublicKey: publicKey.export({ format: "jwk" }), + } +} + +function isPublicKey(value: JsonWebKey | undefined): value is JsonWebKey { + return value?.kty === "EC" && value.crv === "P-256" + && typeof value.x === "string" && typeof value.y === "string" +} + +function isPrivateKey(value: JsonWebKey | undefined): value is JsonWebKey { + return isPublicKey(value) && typeof value.d === "string" +} + +function isMatchingKeyPair(privateKey: JsonWebKey, publicKey: JsonWebKey): boolean { + try { + const derived = createPublicKey(createPrivateKey({ key: privateKey as NodeJsonWebKey, format: "jwk" })).export({ format: "jwk" }) + return derived.kty === publicKey.kty && derived.crv === publicKey.crv + && derived.x === publicKey.x && derived.y === publicKey.y + } catch { + return false + } +} diff --git a/packages/server/src/remote-control/manager.ts b/packages/server/src/remote-control/manager.ts new file mode 100644 index 000000000..5ca8e7b9e --- /dev/null +++ b/packages/server/src/remote-control/manager.ts @@ -0,0 +1,153 @@ +import type { + RemoteControlDevice, + RemoteControlPairing, + RemoteControlStartResponse, + RemoteControlStatus, +} from "@codenomad/remote-control-protocol" +import { encodeBase64, REMOTE_CONTROL_PROTOCOL_VERSION } from "@codenomad/remote-control-protocol" +import { fetch } from "undici" +import type { Logger } from "../logger" +import { RemoteControlConnector, normalizedRelayUrl, type ConnectorState } from "./connector" +import type { RemoteControlIdentity } from "./identity" +import { parseRelayDevices, parseRelayPairing, readRelayJson, type RelayResponse } from "./relay-response" + +interface ManagerOptions { + identity: RemoteControlIdentity + relayUrl: string + localUrl: () => string + localCookie: () => string + logger: Logger +} + +export class RemoteControlManager { + private state: ConnectorState = "stopped" + private error: string | undefined + private lastConnectedAt: string | undefined + private enabled = false + private pairedDevices = 0 + private readonly connector: RemoteControlConnector + + constructor(private readonly options: ManagerOptions) { + normalizedRelayUrl(options.relayUrl) + this.connector = new RemoteControlConnector({ + relayUrl: options.relayUrl, + hostId: options.identity.hostId, + secret: options.identity.secret, + encryptionPrivateKey: options.identity.encryptionPrivateKey, + localUrl: options.localUrl, + localCookie: options.localCookie, + logger: options.logger, + onState: (state, error) => { + this.state = state + this.error = error + if (state === "connected") this.lastConnectedAt = new Date().toISOString() + }, + }) + } + + status(): RemoteControlStatus { + const relay = normalizedRelayUrl(this.options.relayUrl) + return { + manageable: true, + enabled: this.enabled, + state: this.state, + hostId: this.options.identity.hostId, + relayUrl: relay.origin, + remoteUrl: remoteOrigin(relay, this.options.identity.hostId), + pairedDevices: this.pairedDevices, + ...(this.lastConnectedAt ? { lastConnectedAt: this.lastConnectedAt } : {}), + ...(this.error ? { error: this.error } : {}), + } + } + + async start(): Promise { + this.enabled = true + this.connector.start() + await this.waitForConnection() + const pairing = await this.createPairing() + return { status: this.status(), pairing } + } + + stop(): RemoteControlStatus { + this.enabled = false + this.connector.stop() + return this.status() + } + + async createPairing(): Promise { + if (!this.connector.isConnected()) throw new Error("Remote Control is not connected") + const relay = normalizedRelayUrl(this.options.relayUrl) + const response = await fetch(new URL(`/api/hosts/${this.options.identity.hostId}/pair`, relay), { + method: "POST", + headers: { Authorization: `Bearer ${this.options.identity.secret}` }, + signal: AbortSignal.timeout(10_000), + }) + if (!response.ok) throw new Error(await relayError(response, "Could not create a pairing link")) + const payload = parseRelayPairing(await readRelayJson(response)) + if (!payload) throw new Error("Relay returned an invalid pairing link") + const origin = remoteOrigin(relay, this.options.identity.hostId) + const pairingFragment = encodeBase64(new TextEncoder().encode(JSON.stringify({ + protocol: REMOTE_CONTROL_PROTOCOL_VERSION, + token: payload.token, + hostPublicKey: this.options.identity.encryptionPublicKey, + }))) + return { url: `${origin}/__codenomad/pair#${encodeURIComponent(pairingFragment)}`, expiresAt: payload.expiresAt } + } + + async devices(): Promise { + const response = await this.hostRequest("devices") + const devices = parseRelayDevices(await readRelayJson(response)) + if (!devices) throw new Error("Relay returned an invalid remote device list") + this.pairedDevices = devices.length + return devices + } + + async revokeDevice(deviceId: string): Promise { + const response = await this.hostRequest(`devices/${encodeURIComponent(deviceId)}`, "DELETE") + if (!response.ok) throw new Error(await relayError(response, "Could not revoke the remote device")) + this.pairedDevices = Math.max(0, this.pairedDevices - 1) + } + + shutdown(): Promise { + this.enabled = false + return this.connector.shutdown() + } + + private async hostRequest(path: string, method = "GET") { + const relay = normalizedRelayUrl(this.options.relayUrl) + const response = await fetch(new URL(`/api/hosts/${this.options.identity.hostId}/${path}`, relay), { + method, + headers: { Authorization: `Bearer ${this.options.identity.secret}` }, + signal: AbortSignal.timeout(10_000), + }) + if (!response.ok) throw new Error(await relayError(response, "Remote Control relay request failed")) + return response + } + + private waitForConnection(timeoutMs = 10_000): Promise { + if (this.connector.isConnected()) return Promise.resolve() + const started = Date.now() + return new Promise((resolve, reject) => { + const timer = setInterval(() => { + if (this.connector.isConnected()) { + clearInterval(timer) + resolve() + } else if (Date.now() - started >= timeoutMs) { + clearInterval(timer) + reject(new Error(this.error ?? "Timed out connecting to the Remote Control relay")) + } + }, 50) + timer.unref() + }) + } +} + +function remoteOrigin(relay: URL, hostId: string): string { + return `${relay.protocol}//${hostId}.${relay.host}` +} + +async function relayError(response: RelayResponse, fallback: string): Promise { + const payload = await readRelayJson(response).catch(() => null) + const error = typeof payload === "object" && payload !== null ? (payload as { error?: unknown }).error : undefined + return typeof error === "string" && error.length <= 512 ? error : `${fallback} (HTTP ${response.status})` +} diff --git a/packages/server/src/remote-control/relay-response.test.ts b/packages/server/src/remote-control/relay-response.test.ts new file mode 100644 index 000000000..069d32200 --- /dev/null +++ b/packages/server/src/remote-control/relay-response.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { parseRelayDevices, parseRelayPairing, readRelayJson } from "./relay-response" + +test("Remote Control bounds relay control responses before parsing JSON", async () => { + const oversized = new Response("{}", { headers: { "content-length": String(129 * 1024) } }) + await assert.rejects(() => readRelayJson(oversized), /too large/) + + const streamed = new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(128 * 1024)) + controller.enqueue(Uint8Array.of(1)) + }, + })) + await assert.rejects(() => readRelayJson(streamed), /too large/) +}) + +test("Remote Control validates pairing and device control metadata", () => { + const id = "123e4567-e89b-42d3-a456-426614174000" + assert.deepEqual(parseRelayPairing({ token: "a".repeat(43), expiresAt: "2026-09-05T12:00:00.000Z" }), { + token: "a".repeat(43), + expiresAt: "2026-09-05T12:00:00.000Z", + }) + assert.equal(parseRelayPairing({ token: "short", expiresAt: "later" }), null) + assert.deepEqual(parseRelayDevices({ devices: [{ + id, + name: "Browser", + createdAt: "2026-09-05T12:00:00.000Z", + lastSeenAt: "2026-09-05T12:01:00.000Z", + }] })?.map((device) => device.id), [id]) + assert.equal(parseRelayDevices({ devices: Array.from({ length: 65 }, () => ({ + id, + name: "Browser", + createdAt: "2026-09-05T12:00:00.000Z", + lastSeenAt: "2026-09-05T12:01:00.000Z", + })) }), null) +}) diff --git a/packages/server/src/remote-control/relay-response.ts b/packages/server/src/remote-control/relay-response.ts new file mode 100644 index 000000000..15cffaa29 --- /dev/null +++ b/packages/server/src/remote-control/relay-response.ts @@ -0,0 +1,86 @@ +import type { RemoteControlDevice } from "@codenomad/remote-control-protocol" + +const MAX_CONTROL_RESPONSE_BYTES = 128 * 1024 +const MAX_CONTROL_RESPONSE_CHUNKS = 1024 +const MAX_DEVICES = 64 +const DEVICE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/ + +export interface RelayPairingMetadata { + token: string + expiresAt: string +} + +export interface RelayResponse { + status: number + headers: { get(name: string): string | null } + body: { + cancel(): Promise + getReader(): { + read(): Promise<{ done: boolean; value?: Uint8Array }> + cancel(): Promise + } + } | null +} + +export async function readRelayJson(response: RelayResponse): Promise { + const declaredLength = Number(response.headers.get("content-length")) + if (Number.isFinite(declaredLength) && declaredLength > MAX_CONTROL_RESPONSE_BYTES) { + await response.body?.cancel().catch(() => undefined) + throw new Error("Remote Control relay response is too large") + } + if (!response.body) throw new Error("Remote Control relay returned an empty response") + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let size = 0 + while (true) { + const { done, value } = await reader.read() + if (done || !value) break + if (!value.byteLength) continue + size += value.byteLength + if (size > MAX_CONTROL_RESPONSE_BYTES || chunks.length >= MAX_CONTROL_RESPONSE_CHUNKS) { + await reader.cancel().catch(() => undefined) + throw new Error("Remote Control relay response is too large") + } + chunks.push(value) + } + const bytes = new Uint8Array(size) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown +} + +export function parseRelayPairing(value: unknown): RelayPairingMetadata | null { + if (!isRecord(value) || typeof value.token !== "string" || !TOKEN_PATTERN.test(value.token) + || typeof value.expiresAt !== "string" || value.expiresAt.length > 64 + || !Number.isFinite(Date.parse(value.expiresAt))) return null + return { token: value.token, expiresAt: value.expiresAt } +} + +export function parseRelayDevices(value: unknown): RemoteControlDevice[] | null { + if (!isRecord(value) || !Array.isArray(value.devices) || value.devices.length > MAX_DEVICES) return null + const ids = new Set() + const devices: RemoteControlDevice[] = [] + for (const candidate of value.devices) { + if (!isRecord(candidate) || typeof candidate.id !== "string" || !DEVICE_ID_PATTERN.test(candidate.id) + || ids.has(candidate.id) || typeof candidate.name !== "string" || !candidate.name.trim() + || candidate.name.length > 80 || typeof candidate.createdAt !== "string" || candidate.createdAt.length > 64 + || typeof candidate.lastSeenAt !== "string" || candidate.lastSeenAt.length > 64 + || !Number.isFinite(Date.parse(candidate.createdAt)) || !Number.isFinite(Date.parse(candidate.lastSeenAt))) return null + ids.add(candidate.id) + devices.push({ + id: candidate.id, + name: candidate.name, + createdAt: candidate.createdAt, + lastSeenAt: candidate.lastSeenAt, + }) + } + return devices +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 031063855..5c3999000 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -27,6 +27,7 @@ import { registerSessionPruningRoutes } from "./routes/session-pruning" import { registerWorktreeRoutes } from "./routes/worktrees" import { registerSpeechRoutes } from "./routes/speech" import { registerOpenCodeUpdateRoutes } from "./routes/opencode-update" +import { registerRemoteControlRoutes } from "./routes/remote-control" import { registerRemoteServerRoutes } from "./routes/remote-servers" import { registerRemoteProxyRoutes } from "./routes/remote-proxy" import { registerSideCarRoutes } from "./routes/sidecars" @@ -42,6 +43,7 @@ import type { SpeechService } from "../speech/service" import { ClientConnectionManager } from "../clients/connection-manager" import type { SideCarManager } from "../sidecars/manager" import type { PreviewManager } from "../previews/manager" +import type { RemoteControlManager } from "../remote-control/manager" import { buildPreviewRuntimeBridge, rewritePreviewImportMap, rewritePreviewJavaScriptImports } from "../previews/runtime-bridge" import { forwardRuntimeRequest } from "../opencode/compatibility/proxy" import { contractProfile, runtimeIdentity, type ContractProfile } from "../opencode/compatibility/runtime" @@ -71,6 +73,7 @@ interface HttpServerDeps { speechService: SpeechService sidecarManager: SideCarManager previewManager: PreviewManager + remoteControlManager: RemoteControlManager authManager: AuthManager clientConnectionManager: ClientConnectionManager remoteProxySessionManager: RemoteProxySessionManager @@ -320,6 +323,7 @@ export function createHttpServer(deps: HttpServerDeps) { }) registerRemoteServerRoutes(app, { logger: apiLogger }) registerRemoteProxyRoutes(app, { logger: proxyLogger, sessionManager: deps.remoteProxySessionManager }) + registerRemoteControlRoutes(app, { manager: deps.remoteControlManager }) registerSpeechRoutes(app, { speechService: deps.speechService }) registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager }) registerPreviewRoutes(app, { previewManager: deps.previewManager }) diff --git a/packages/server/src/server/routes/meta.ts b/packages/server/src/server/routes/meta.ts index e65405172..22fc4cd93 100644 --- a/packages/server/src/server/routes/meta.ts +++ b/packages/server/src/server/routes/meta.ts @@ -1,7 +1,6 @@ import { FastifyInstance } from "fastify" import { ServerMeta } from "../../api-types" import { isLoopbackHost, isWildcardHost } from "../network-host" - interface RouteDeps { serverMeta: ServerMeta diff --git a/packages/server/src/server/routes/remote-control.test.ts b/packages/server/src/server/routes/remote-control.test.ts new file mode 100644 index 000000000..c5f91f346 --- /dev/null +++ b/packages/server/src/server/routes/remote-control.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict" +import test from "node:test" +import Fastify from "fastify" +import type { RemoteControlManager } from "../../remote-control/manager" +import { registerRemoteControlRoutes } from "./remote-control" + +function manager() { + return { + status: () => ({ manageable: true, enabled: false, state: "stopped", hostId: "a".repeat(32), relayUrl: "https://relay.example", remoteUrl: "https://host.relay.example", pairedDevices: 0 }), + start: async () => { throw new Error("not used") }, + stop: () => ({ manageable: true, enabled: false, state: "stopped", hostId: "a".repeat(32), relayUrl: "https://relay.example", remoteUrl: "https://host.relay.example", pairedDevices: 0 }), + createPairing: async () => { throw new Error("not used") }, + devices: async () => [], + revokeDevice: async () => undefined, + } as unknown as RemoteControlManager +} + +test("Remote Control status is readable remotely but management remains local-only", async () => { + const app = Fastify() + registerRemoteControlRoutes(app, { manager: manager() }) + const local = await app.inject({ method: "GET", url: "/api/remote-control/status" }) + assert.equal(local.statusCode, 200) + + const relayed = await app.inject({ + method: "GET", + url: "/api/remote-control/status", + headers: { "x-codenomad-remote-control": "1" }, + }) + assert.equal(relayed.statusCode, 200) + assert.equal(relayed.json().manageable, false) + const blocked = await app.inject({ + method: "DELETE", + url: "/api/remote-control", + headers: { "x-codenomad-remote-control": "1" }, + }) + assert.equal(blocked.statusCode, 403) + await app.close() +}) + +test("device revocation validates UUIDs before reaching the relay", async () => { + const app = Fastify() + registerRemoteControlRoutes(app, { manager: manager() }) + const response = await app.inject({ method: "DELETE", url: "/api/remote-control/devices/not-a-uuid" }) + assert.equal(response.statusCode, 400) + await app.close() +}) diff --git a/packages/server/src/server/routes/remote-control.ts b/packages/server/src/server/routes/remote-control.ts new file mode 100644 index 000000000..62b157e1f --- /dev/null +++ b/packages/server/src/server/routes/remote-control.ts @@ -0,0 +1,79 @@ +import type { FastifyInstance, FastifyRequest } from "fastify" +import { z } from "zod" +import type { RemoteControlManager } from "../../remote-control/manager" + +interface RouteDeps { + manager: RemoteControlManager +} + +const DeviceParams = z.object({ id: z.string().uuid() }) + +export function registerRemoteControlRoutes(app: FastifyInstance, deps: RouteDeps) { + app.get("/api/remote-control/status", async (request, reply) => { + const status = deps.manager.status() + return isRemoteControlRequest(request) ? { ...status, manageable: false } : status + }) + + app.post("/api/remote-control/start", async (request, reply) => { + if (!requireLocalControl(request, reply)) return + try { + return await deps.manager.start() + } catch (error) { + reply.code(502) + return { error: error instanceof Error ? error.message : "Remote Control failed to start" } + } + }) + + app.post("/api/remote-control/pairings", async (request, reply) => { + if (!requireLocalControl(request, reply)) return + try { + return await deps.manager.createPairing() + } catch (error) { + reply.code(502) + return { error: error instanceof Error ? error.message : "Pairing link creation failed" } + } + }) + + app.delete("/api/remote-control", async (request, reply) => { + if (!requireLocalControl(request, reply)) return + return deps.manager.stop() + }) + + app.get("/api/remote-control/devices", async (request, reply) => { + if (!requireLocalControl(request, reply)) return + try { + return { devices: await deps.manager.devices() } + } catch (error) { + reply.code(502) + return { error: error instanceof Error ? error.message : "Could not load remote devices" } + } + }) + + app.delete("/api/remote-control/devices/:id", async (request, reply) => { + if (!requireLocalControl(request, reply)) return + const parsed = DeviceParams.safeParse(request.params) + if (!parsed.success) { + reply.code(400) + return { error: parsed.error.message } + } + try { + await deps.manager.revokeDevice(parsed.data.id) + reply.code(204).send() + } catch (error) { + reply.code(502) + return { error: error instanceof Error ? error.message : "Could not revoke remote device" } + } + }) +} + +function requireLocalControl(request: FastifyRequest, reply: { code: (status: number) => { send: (body: unknown) => unknown } }): boolean { + if (isRemoteControlRequest(request)) { + reply.code(403).send({ error: "Remote Control settings are available on the host only" }) + return false + } + return true +} + +function isRemoteControlRequest(request: FastifyRequest): boolean { + return request.headers["x-codenomad-remote-control"] === "1" +} diff --git a/packages/server/src/shutdown.test.ts b/packages/server/src/shutdown.test.ts index 446e77918..d96063fdc 100644 --- a/packages/server/src/shutdown.test.ts +++ b/packages/server/src/shutdown.test.ts @@ -11,7 +11,7 @@ import { const logger = { info() {}, warn() {}, error() {} } const operations = (overrides: Partial = {}): ServerShutdownOperations => ({ stopInstanceEventBridge() {}, stopSidecars() {}, stopClientConnections() {}, - stopRemoteProxySessions() {}, stopWorkspaces() {}, stopHttpServers() {}, stopReleaseMonitor() {}, + stopRemoteControl() {}, stopRemoteProxySessions() {}, stopWorkspaces() {}, stopHttpServers() {}, stopReleaseMonitor() {}, ...overrides, }) @@ -20,11 +20,12 @@ describe("server shutdown orchestration", () => { const calls: string[] = [] let attempts = 0 await orchestrateServerShutdown(operations({ + stopRemoteControl: () => { calls.push("remote-control") }, stopRemoteProxySessions: () => { calls.push("remote-proxy") }, stopWorkspaces: () => { calls.push(`workspaces-${++attempts}`); if (attempts === 1) throw new Error("still alive") }, stopHttpServers: () => { calls.push("http") }, }), logger) - assert.deepEqual(calls, ["workspaces-1", "remote-proxy", "workspaces-2", "http"]) + assert.deepEqual(calls, ["workspaces-1", "remote-control", "remote-proxy", "workspaces-2", "http"]) }) it("closes remaining resources and aggregates the concrete current error", async () => { @@ -50,7 +51,7 @@ describe("server shutdown orchestration", () => { const preliminary = new Promise((resolve) => { releasePreliminary = resolve }) let workspaceStarted = false const shutdown = orchestrateServerShutdown(operations({ - stopRemoteProxySessions: () => preliminary, + stopRemoteControl: () => preliminary, stopWorkspaces: () => { workspaceStarted = true }, }), logger) diff --git a/packages/server/src/shutdown.ts b/packages/server/src/shutdown.ts index 3324aa255..bdeae2fb2 100644 --- a/packages/server/src/shutdown.ts +++ b/packages/server/src/shutdown.ts @@ -6,7 +6,7 @@ export const SERVER_SHUTDOWN_COMPLETE = "CODENOMAD_SHUTDOWN_STATUS:complete" export const SERVER_SHUTDOWN_INCOMPLETE = "CODENOMAD_SHUTDOWN_STATUS:incomplete" export type ServerShutdownOperations = Record< - "stopInstanceEventBridge" | "stopSidecars" | "stopClientConnections" | "stopRemoteProxySessions" | "stopWorkspaces" | + "stopInstanceEventBridge" | "stopSidecars" | "stopClientConnections" | "stopRemoteControl" | "stopRemoteProxySessions" | "stopWorkspaces" | "stopHttpServers" | "stopReleaseMonitor", ShutdownOperation > @@ -92,7 +92,8 @@ export async function orchestrateServerShutdown( await Promise.all([ settle([ ["stopInstanceEventBridge", operations.stopInstanceEventBridge], ["stopSidecars", operations.stopSidecars], - ["stopClientConnections", operations.stopClientConnections], ["stopRemoteProxySessions", operations.stopRemoteProxySessions], + ["stopClientConnections", operations.stopClientConnections], ["stopRemoteControl", operations.stopRemoteControl], + ["stopRemoteProxySessions", operations.stopRemoteProxySessions], ]), workspaceShutdown, ]) diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index 1bdb4f9e4..b3095c5f7 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -27,7 +27,7 @@ use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; #[cfg(any(windows, test))] use std::future::Future; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::sync::Mutex; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -71,12 +71,14 @@ pub struct AppState { remote_profiles: Mutex>, remote_window_operations: RemoteWindowOperationLocks, remote_proxy_cleanup_claims: Mutex>, + approved_auxiliary_closes: Mutex>, pub remote_tls_handlers: Mutex>, pub remote_zoom_levels: Mutex>, pub workspace_menu_items: Mutex>, pub webview_data_directory: std::path::PathBuf, pub developer_browser_arguments: Option, pub scoped_profile: bool, + keep_alive_for_remote_control: AtomicBool, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -1477,6 +1479,125 @@ fn is_final_application_window<'a>( !labels.any(|label| label != closing_label && label != preferences_window::LABEL) } +async fn remote_control_enabled(app: &AppHandle) -> bool { + let Some(access) = app.state::().manager.local_cli_access() else { + return false; + }; + let Ok(mut url) = Url::parse(&access.base_url) else { + return false; + }; + url.set_path("/api/remote-control/status"); + url.set_query(None); + url.set_fragment(None); + + let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(2)); + if url.scheme() == "https" { + let Ok(local_cert) = cert_manager::ensure_local_cert() else { + return false; + }; + let Ok(ca_cert) = reqwest::Certificate::from_der(&local_cert.ca_cert_der) else { + return false; + }; + builder = builder.add_root_certificate(ca_cert); + } + let Ok(client) = builder.build() else { + return false; + }; + let Ok(response) = client + .get(url) + .header( + reqwest::header::COOKIE, + format!("{}={}", access.cookie_name, access.session_cookie), + ) + .send() + .await + else { + return false; + }; + if !response.status().is_success() { + return false; + } + response + .json::() + .await + .ok() + .and_then(|value| value.get("enabled").and_then(serde_json::Value::as_bool)) + .unwrap_or(false) +} + +fn consume_approved_auxiliary_close(app: &AppHandle, label: &str) -> bool { + app.state::() + .approved_auxiliary_closes + .lock() + .ok() + .is_some_and(|mut labels| labels.remove(label)) +} + +fn dispatch_approved_auxiliary_close(app: &AppHandle, label: &str) { + let approved = app + .state::() + .approved_auxiliary_closes + .lock() + .ok() + .is_some_and(|mut labels| labels.insert(label.to_string())); + if !approved { + return; + } + let dispatched = app + .get_webview_window(label) + .is_some_and(|window| window.close().is_ok()); + if !dispatched { + let _ = consume_approved_auxiliary_close(app, label); + } +} + +fn request_final_application_window_close(app: AppHandle, label: String, local_window: bool) { + tauri::async_runtime::spawn(async move { + let keep_alive = remote_control_enabled(&app).await; + app.state::() + .keep_alive_for_remote_control + .store(keep_alive, Ordering::SeqCst); + let windows = app.webview_windows(); + let still_final = is_final_application_window( + &label, + windows + .keys() + .filter(|candidate| !shutdown::local_window_close_in_flight(&app, candidate)) + .map(String::as_str), + ); + if should_shutdown_after_last_window(keep_alive, still_final) { + shutdown::request(app); + } else if local_window { + shutdown::request_local_window_close(app, label); + } else { + dispatch_approved_auxiliary_close(&app, &label); + } + }); +} + +fn should_shutdown_after_last_window( + keep_alive_for_remote_control: bool, + is_final_window: bool, +) -> bool { + is_final_window && !keep_alive_for_remote_control +} + +#[derive(Debug, PartialEq, Eq)] +enum LocalCloseEventAction { + Allow, + Prevent, + Continue, +} + +fn local_close_event_action(consumed: Option, in_flight: bool) -> LocalCloseEventAction { + match consumed { + Some(true) => LocalCloseEventAction::Allow, + Some(false) => LocalCloseEventAction::Prevent, + None if in_flight => LocalCloseEventAction::Prevent, + None => LocalCloseEventAction::Continue, + } +} + fn main() { #[cfg(windows)] if let Some(code) = cli_manager::run_windows_cli_launcher_if_requested() { @@ -1575,12 +1696,14 @@ fn main() { remote_profiles: Mutex::new(HashMap::new()), remote_window_operations: RemoteWindowOperationLocks::default(), remote_proxy_cleanup_claims: Mutex::new(HashSet::new()), + approved_auxiliary_closes: Mutex::new(HashSet::new()), remote_tls_handlers: Mutex::new(HashMap::new()), remote_zoom_levels: Mutex::new(HashMap::new()), workspace_menu_items: Mutex::new(None), webview_data_directory, developer_browser_arguments, scoped_profile: setup_scope.scoped, + keep_alive_for_remote_control: AtomicBool::new(false), }) .on_page_load(|webview, payload| { if payload.event() == PageLoadEvent::Started { @@ -1810,6 +1933,14 @@ fn main() { return; } api.prevent_exit(); + if app_handle.webview_windows().is_empty() + && app_handle + .state::() + .keep_alive_for_remote_control + .load(Ordering::SeqCst) + { + return; + } shutdown::request(app_handle.clone()); } tauri::RunEvent::WindowEvent { @@ -1874,21 +2005,25 @@ fn main() { } let local_window = identity::local_window_id(&label).is_ok(); if local_window { - match shutdown::consume_local_window_close(&app_handle, &label) { - Some(true) => return, - Some(false) => { + let consumed = shutdown::consume_local_window_close(&app_handle, &label); + let in_flight = shutdown::local_window_close_in_flight(&app_handle, &label); + match local_close_event_action(consumed, in_flight) { + LocalCloseEventAction::Allow => return, + LocalCloseEventAction::Prevent => { api.prevent_close(); return; } - None => {} + LocalCloseEventAction::Continue => {} } + } else if consume_approved_auxiliary_close(&app_handle, &label) { + return; } let windows = app_handle.windows(); let final_window = is_final_application_window(&label, windows.keys().map(String::as_str)); if final_window { api.prevent_close(); - shutdown::request(app_handle.clone()); + request_final_application_window_close(app_handle.clone(), label, local_window); return; } if local_window { @@ -1901,6 +2036,8 @@ fn main() { event: tauri::WindowEvent::Destroyed, .. } => { + shutdown::local_window_destroyed(&app_handle, &label); + let _ = consume_approved_auxiliary_close(&app_handle, &label); app_handle .state::() .browser_controller @@ -1928,6 +2065,16 @@ fn main() { return; } + if !should_shutdown_after_last_window( + app_handle + .state::() + .keep_alive_for_remote_control + .load(Ordering::SeqCst), + true, + ) { + return; + } + // Stop the CLI only when the final window is gone and the app is // truly exiting. shutdown::request(app_handle.clone()); @@ -2242,9 +2389,10 @@ fn build_about_metadata(version: &str, include_update_link: bool) -> AboutMetada mod menu_tests { use super::{ build_about_metadata, claim_unowned_remote_proxy_session, clear_remote_tls_handler, - is_allowed_local_origin, is_final_application_window, require_http_url, - rollback_remote_window_metadata, run_update_with_fallback, should_allow_registered_origin, - should_open_external_url, should_recreate_remote_window, titlebar_menu_id, + is_allowed_local_origin, is_final_application_window, local_close_event_action, + require_http_url, rollback_remote_window_metadata, run_update_with_fallback, + should_allow_registered_origin, should_open_external_url, should_recreate_remote_window, + should_shutdown_after_last_window, titlebar_menu_id, LocalCloseEventAction, RemoteProfileIdentity, RemoteWindowMetadata, RemoteWindowOperationLocks, WakeLockState, RELEASES_URL, REMOTE_WINDOW_CONTEXT_SCRIPT, }; @@ -2268,6 +2416,29 @@ mod menu_tests { )); } + #[test] + fn remote_control_keeps_the_backend_alive_without_application_windows() { + assert!(!should_shutdown_after_last_window(true, true)); + assert!(should_shutdown_after_last_window(false, true)); + assert!(!should_shutdown_after_last_window(false, false)); + } + + #[test] + fn approved_local_close_wins_over_its_in_flight_marker() { + assert_eq!( + local_close_event_action(Some(true), true), + LocalCloseEventAction::Allow + ); + assert_eq!( + local_close_event_action(None, true), + LocalCloseEventAction::Prevent + ); + assert_eq!( + local_close_event_action(None, false), + LocalCloseEventAction::Continue + ); + } + #[test] fn titlebar_menu_ids_are_restricted_to_application_submenus() { assert_eq!(titlebar_menu_id("file"), Some("menu-file")); diff --git a/packages/tauri-app/src-tauri/src/shutdown.rs b/packages/tauri-app/src-tauri/src/shutdown.rs index 996897957..0844831dc 100644 --- a/packages/tauri-app/src-tauri/src/shutdown.rs +++ b/packages/tauri-app/src-tauri/src/shutdown.rs @@ -1,5 +1,5 @@ use crate::{client_state, local_windows::LocalWindows, AppState}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Mutex; use std::time::{Duration, Instant}; #[cfg(windows)] @@ -37,6 +37,7 @@ struct ShutdownState { next_generation: u64, local_closes: HashMap, committed_local_closes: HashMap, + approved_local_closes: HashSet, global_pending: HashMap, global_requests: HashMap, shutdown_started: bool, @@ -66,6 +67,7 @@ impl ShutdownCoordinator { if state.shutdown_started || state.local_closes.contains_key(&label) || state.committed_local_closes.contains_key(&label) + || state.approved_local_closes.contains(&label) { return None; } @@ -214,6 +216,30 @@ impl ShutdownCoordinator { self.state.lock().ok()?.committed_local_closes.remove(label) } + fn local_close_in_flight(&self, label: &str) -> bool { + self.state.lock().map_or(true, |state| { + state.local_closes.contains_key(label) + || state.committed_local_closes.contains_key(label) + || state.approved_local_closes.contains(label) + }) + } + + fn approve_local_close(&self, label: String) { + self.state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .approved_local_closes + .insert(label); + } + + fn local_window_destroyed(&self, label: &str) { + self.state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .approved_local_closes + .remove(label); + } + fn exit_allowed(&self) -> bool { self.state .lock() @@ -617,10 +643,21 @@ pub(crate) fn with_navigation_authority( } pub(crate) fn consume_local_window_close(app: &AppHandle, label: &str) -> Option { + let coordinator = app.state::(); + if coordinator + .state + .lock() + .ok() + .is_some_and(|state| state.approved_local_closes.contains(label)) + { + return Some(true); + } let pending = app .state::() .take_committed_local_close(label)?; if !pending.persisted { + app.state::() + .approve_local_close(label.to_string()); return Some(true); } let result = app @@ -636,9 +673,21 @@ pub(crate) fn consume_local_window_close(app: &AppHandle, label: &str) -> Option emit_flush_cancelled(app, label, pending.generation); return Some(false); } + app.state::() + .approve_local_close(label.to_string()); Some(true) } +pub(crate) fn local_window_close_in_flight(app: &AppHandle, label: &str) -> bool { + app.state::() + .local_close_in_flight(label) +} + +pub(crate) fn local_window_destroyed(app: &AppHandle, label: &str) { + app.state::() + .local_window_destroyed(label); +} + pub(crate) fn exit_allowed(app: &AppHandle) -> bool { app.state::().exit_allowed() } diff --git a/packages/tauri-app/src-tauri/src/shutdown_tests.rs b/packages/tauri-app/src-tauri/src/shutdown_tests.rs index ae731910b..976274b24 100644 --- a/packages/tauri-app/src-tauri/src/shutdown_tests.rs +++ b/packages/tauri-app/src-tauri/src/shutdown_tests.rs @@ -110,6 +110,25 @@ fn committed_local_close_retains_exact_authority_until_consumed() { assert!(coordinator.take_committed_local_close("local-a").is_none()); } +#[test] +fn pending_and_approved_local_closes_remain_visible_to_final_window_decisions() { + let coordinator = ShutdownCoordinator::default(); + let generation = coordinator + .begin_local_close("local-a".into(), "window-a".into(), true) + .unwrap(); + assert!(coordinator.local_close_in_flight("local-a")); + let pending = coordinator + .acknowledge_local("local-a", "window-a", generation) + .unwrap(); + assert!(coordinator.commit_local_close("local-a".into(), pending)); + assert!(coordinator.local_close_in_flight("local-a")); + coordinator.take_committed_local_close("local-a").unwrap(); + coordinator.approve_local_close("local-a".into()); + assert!(coordinator.local_close_in_flight("local-a")); + coordinator.local_window_destroyed("local-a"); + assert!(!coordinator.local_close_in_flight("local-a")); +} + #[test] fn local_close_dispatch_rollback_reopens_close_authority() { let coordinator = ShutdownCoordinator::default(); diff --git a/packages/ui/package.json b/packages/ui/package.json index eab450d9c..52520c6c5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -6,12 +6,14 @@ "type": "module", "scripts": { "dev": "vite dev", + "prebuild": "npm run build --workspace @codenomad/remote-control-protocol", "build": "vite build", "preview": "vite preview", "test:browser": "node --import tsx --test --test-concurrency=1 tests/browser/*.test.ts", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@codenomad/remote-control-protocol": "0.1.0", "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", "@opencode/client": "2.0.4", diff --git a/packages/ui/src/bootstrap.ts b/packages/ui/src/bootstrap.ts new file mode 100644 index 000000000..fd5118ccf --- /dev/null +++ b/packages/ui/src/bootstrap.ts @@ -0,0 +1,6 @@ +import { installRemoteControlTransport } from "./lib/remote-control/tunnel" + +void (async () => { + await installRemoteControlTransport() + await import("./main") +})() diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index 03b76f91b..cb218b845 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -525,17 +525,15 @@ const FolderSelectionView: Component = (props) => { > - - - + + + +
+
+ + +
+ void start()} disabled={loading()}> + + {t("remoteControl.start")} + + } + > + + + +
+
+ + {(value) =>
{value()}
}
+ + + + {(value) => ( +
+
+

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

+

{t("remoteControl.pairing.description")}

+
+ }> + {(source) => {t("remoteControl.pairing.qrAlt")}} + + {value().url} + +

{t("remoteControl.pairing.expires", { time: new Date(value().expiresAt).toLocaleTimeString() })}

+
+ )} +
+ + +
+
+
+ +
+

{t("remoteControl.devices.title")}

+

{t("remoteControl.devices.description")}

+
+
+
+ 0} fallback={
{t("remoteControl.devices.empty")}
}> +
+ + {(device) => ( +
+
+ {device.name} +

{t("remoteControl.devices.lastSeen", { time: new Date(device.lastSeenAt).toLocaleString() })}

+
+ +
+ )} +
+
+
+
+
+ + ) +} + +function message(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause) +} diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index b875f2b1c..3c8782493 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -23,6 +23,10 @@ import type { RemoteProxySessionCreateResponse, RemoteServerProbeRequest, RemoteServerProbeResponse, + RemoteControlDevice, + RemoteControlPairing, + RemoteControlStartResponse, + RemoteControlStatus, YoloStateResponse, WorkspaceCloneRequest, WorkspaceCloneResponse, @@ -294,6 +298,24 @@ export const serverApi = { deleteRemoteProxySession(id: string): Promise { return request(`/api/remote-proxy/sessions/${encodeURIComponent(id)}`, { method: "DELETE" }) }, + fetchRemoteControlStatus(): Promise { + return request("/api/remote-control/status") + }, + startRemoteControl(): Promise { + return request("/api/remote-control/start", { method: "POST" }) + }, + stopRemoteControl(): Promise { + return request("/api/remote-control", { method: "DELETE" }) + }, + createRemoteControlPairing(): Promise { + return request("/api/remote-control/pairings", { method: "POST" }) + }, + fetchRemoteControlDevices(): Promise<{ devices: RemoteControlDevice[] }> { + return request<{ devices: RemoteControlDevice[] }>("/api/remote-control/devices") + }, + revokeRemoteControlDevice(id: string): Promise { + return request(`/api/remote-control/devices/${encodeURIComponent(id)}`, { method: "DELETE" }) + }, fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> { return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status") }, diff --git a/packages/ui/src/lib/i18n/messages/de/index.ts b/packages/ui/src/lib/i18n/messages/de/index.ts index b6d4e4c1e..741bb7846 100644 --- a/packages/ui/src/lib/i18n/messages/de/index.ts +++ b/packages/ui/src/lib/i18n/messages/de/index.ts @@ -10,6 +10,7 @@ import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" import { remoteAccessMessages } from "./remoteAccess" +import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" import { timeMessages } from "./time" @@ -32,5 +33,6 @@ export const deMessages = mergeMessageParts( markdownMessages, settingsMessages, remoteAccessMessages, + remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/de/remoteControl.ts b/packages/ui/src/lib/i18n/messages/de/remoteControl.ts new file mode 100644 index 000000000..2b7d0536d --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/de/remoteControl.ts @@ -0,0 +1,26 @@ +export const remoteControlMessages = { + "remoteControl.title": "Fernsteuerung", + "remoteControl.description": "Lokale CodeNomad-Sitzungen ohne offene Netzwerkports auf einem anderen Gerรคt fortsetzen.", + "remoteControl.refresh": "Aktualisieren", + "remoteControl.state.stopped": "Fernsteuerung ist aus", + "remoteControl.state.connecting": "Verbindung zum Relayโ€ฆ", + "remoteControl.state.connected": "Bereit fรผr Remote-Verbindungen", + "remoteControl.state.reconnecting": "Verbindung wird wiederhergestelltโ€ฆ", + "remoteControl.state.error": "Relay-Verbindung fehlgeschlagen", + "remoteControl.status.localOnly": "CodeNomad ist nur auf diesem Gerรคt verfรผgbar.", + "remoteControl.status.hostOnly": "Verwalte Remote Control auf dem CodeNomad-Host.", + "remoteControl.start": "Fernsteuerung starten", + "remoteControl.newLink": "Neuen Kopplungslink erstellen", + "remoteControl.stop": "Fernsteuerung beenden", + "remoteControl.pairing.title": "Weiteres Gerรคt verbinden", + "remoteControl.pairing.description": "QR-Code scannen oder den einmaligen Link auf dem anderen Gerรคt รถffnen.", + "remoteControl.pairing.qrAlt": "QR-Code zur Kopplung der Fernsteuerung", + "remoteControl.pairing.copy": "Link kopieren", + "remoteControl.pairing.copied": "Kopiert", + "remoteControl.pairing.expires": "Dieser Link lรคuft um {time} ab.", + "remoteControl.devices.title": "Gekoppelte Gerรคte", + "remoteControl.devices.description": "Diese Gerรคte kรถnnen auf diesen CodeNomad-Host zugreifen, solange die Fernsteuerung lรคuft.", + "remoteControl.devices.empty": "Noch keine Gerรคte gekoppelt.", + "remoteControl.devices.lastSeen": "Zuletzt gesehen: {time}", + "remoteControl.devices.revoke": "Widerrufen", +} as const diff --git a/packages/ui/src/lib/i18n/messages/en/index.ts b/packages/ui/src/lib/i18n/messages/en/index.ts index a7b1ef6ae..ca45d199d 100644 --- a/packages/ui/src/lib/i18n/messages/en/index.ts +++ b/packages/ui/src/lib/i18n/messages/en/index.ts @@ -10,6 +10,7 @@ import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" import { remoteAccessMessages } from "./remoteAccess" +import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" import { timeMessages } from "./time" @@ -32,5 +33,6 @@ export const enMessages = mergeMessageParts( markdownMessages, settingsMessages, remoteAccessMessages, + remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/en/remoteControl.ts b/packages/ui/src/lib/i18n/messages/en/remoteControl.ts new file mode 100644 index 000000000..bc710f58c --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/en/remoteControl.ts @@ -0,0 +1,26 @@ +export const remoteControlMessages = { + "remoteControl.title": "Remote Control", + "remoteControl.description": "Continue your local CodeNomad sessions from another device without opening network ports.", + "remoteControl.refresh": "Refresh", + "remoteControl.state.stopped": "Remote Control is off", + "remoteControl.state.connecting": "Connecting to the relayโ€ฆ", + "remoteControl.state.connected": "Ready for remote connections", + "remoteControl.state.reconnecting": "Reconnectingโ€ฆ", + "remoteControl.state.error": "Relay connection failed", + "remoteControl.status.localOnly": "CodeNomad is available on this device only.", + "remoteControl.status.hostOnly": "Manage Remote Control from the CodeNomad host.", + "remoteControl.start": "Start Remote Control", + "remoteControl.newLink": "Create new pairing link", + "remoteControl.stop": "Stop Remote Control", + "remoteControl.pairing.title": "Connect another device", + "remoteControl.pairing.description": "Scan this QR code or open the one-time link on your other device.", + "remoteControl.pairing.qrAlt": "Remote Control pairing QR code", + "remoteControl.pairing.copy": "Copy link", + "remoteControl.pairing.copied": "Copied", + "remoteControl.pairing.expires": "This link expires at {time}.", + "remoteControl.devices.title": "Paired devices", + "remoteControl.devices.description": "These devices can access this CodeNomad host while Remote Control is running.", + "remoteControl.devices.empty": "No devices are paired yet.", + "remoteControl.devices.lastSeen": "Last seen {time}", + "remoteControl.devices.revoke": "Revoke", +} as const diff --git a/packages/ui/src/lib/i18n/messages/es/index.ts b/packages/ui/src/lib/i18n/messages/es/index.ts index 9bf3d8c1d..ebe445a99 100644 --- a/packages/ui/src/lib/i18n/messages/es/index.ts +++ b/packages/ui/src/lib/i18n/messages/es/index.ts @@ -10,6 +10,7 @@ import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" import { remoteAccessMessages } from "./remoteAccess" +import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" import { timeMessages } from "./time" @@ -32,5 +33,6 @@ export const esMessages = mergeMessageParts( markdownMessages, settingsMessages, remoteAccessMessages, + remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/es/remoteControl.ts b/packages/ui/src/lib/i18n/messages/es/remoteControl.ts new file mode 100644 index 000000000..2e4d74af6 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/es/remoteControl.ts @@ -0,0 +1,26 @@ +export const remoteControlMessages = { + "remoteControl.title": "Control remoto", + "remoteControl.description": "Continรบa tus sesiones locales de CodeNomad desde otro dispositivo sin abrir puertos de red.", + "remoteControl.refresh": "Actualizar", + "remoteControl.state.stopped": "El control remoto estรก desactivado", + "remoteControl.state.connecting": "Conectando al relรฉโ€ฆ", + "remoteControl.state.connected": "Listo para conexiones remotas", + "remoteControl.state.reconnecting": "Reconectandoโ€ฆ", + "remoteControl.state.error": "Fallรณ la conexiรณn al relรฉ", + "remoteControl.status.localOnly": "CodeNomad solo estรก disponible en este dispositivo.", + "remoteControl.status.hostOnly": "Gestiona Remote Control desde el equipo anfitriรณn de CodeNomad.", + "remoteControl.start": "Iniciar control remoto", + "remoteControl.newLink": "Crear nuevo enlace de emparejamiento", + "remoteControl.stop": "Detener control remoto", + "remoteControl.pairing.title": "Conectar otro dispositivo", + "remoteControl.pairing.description": "Escanea este cรณdigo QR o abre el enlace de un solo uso en el otro dispositivo.", + "remoteControl.pairing.qrAlt": "Cรณdigo QR de emparejamiento del control remoto", + "remoteControl.pairing.copy": "Copiar enlace", + "remoteControl.pairing.copied": "Copiado", + "remoteControl.pairing.expires": "Este enlace caduca a las {time}.", + "remoteControl.devices.title": "Dispositivos emparejados", + "remoteControl.devices.description": "Estos dispositivos pueden acceder a este host mientras el control remoto estรก activo.", + "remoteControl.devices.empty": "Todavรญa no hay dispositivos emparejados.", + "remoteControl.devices.lastSeen": "รšltima actividad: {time}", + "remoteControl.devices.revoke": "Revocar", +} as const diff --git a/packages/ui/src/lib/i18n/messages/fr/index.ts b/packages/ui/src/lib/i18n/messages/fr/index.ts index ac9d1c6e2..682354322 100644 --- a/packages/ui/src/lib/i18n/messages/fr/index.ts +++ b/packages/ui/src/lib/i18n/messages/fr/index.ts @@ -10,6 +10,7 @@ import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" import { remoteAccessMessages } from "./remoteAccess" +import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" import { timeMessages } from "./time" @@ -32,5 +33,6 @@ export const frMessages = mergeMessageParts( markdownMessages, settingsMessages, remoteAccessMessages, + remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/fr/remoteControl.ts b/packages/ui/src/lib/i18n/messages/fr/remoteControl.ts new file mode 100644 index 000000000..087fba782 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/fr/remoteControl.ts @@ -0,0 +1,26 @@ +export const remoteControlMessages = { + "remoteControl.title": "Contrรดle ร  distance", + "remoteControl.description": "Continuez vos sessions CodeNomad locales depuis un autre appareil sans ouvrir de port rรฉseau.", + "remoteControl.refresh": "Actualiser", + "remoteControl.state.stopped": "Le contrรดle ร  distance est dรฉsactivรฉ", + "remoteControl.state.connecting": "Connexion au relaisโ€ฆ", + "remoteControl.state.connected": "Prรชt pour les connexions distantes", + "remoteControl.state.reconnecting": "Reconnexionโ€ฆ", + "remoteControl.state.error": "ร‰chec de la connexion au relais", + "remoteControl.status.localOnly": "CodeNomad est disponible uniquement sur cet appareil.", + "remoteControl.status.hostOnly": "Gรจre Remote Control depuis lโ€™hรดte CodeNomad.", + "remoteControl.start": "Dรฉmarrer le contrรดle ร  distance", + "remoteControl.newLink": "Crรฉer un nouveau lien dโ€™appairage", + "remoteControl.stop": "Arrรชter le contrรดle ร  distance", + "remoteControl.pairing.title": "Connecter un autre appareil", + "remoteControl.pairing.description": "Scannez ce QR code ou ouvrez le lien ร  usage unique sur lโ€™autre appareil.", + "remoteControl.pairing.qrAlt": "QR code dโ€™appairage du contrรดle ร  distance", + "remoteControl.pairing.copy": "Copier le lien", + "remoteControl.pairing.copied": "Copiรฉ", + "remoteControl.pairing.expires": "Ce lien expire ร  {time}.", + "remoteControl.devices.title": "Appareils appairรฉs", + "remoteControl.devices.description": "Ces appareils peuvent accรฉder ร  cet hรดte CodeNomad pendant le contrรดle ร  distance.", + "remoteControl.devices.empty": "Aucun appareil nโ€™est encore appairรฉ.", + "remoteControl.devices.lastSeen": "Derniรจre activitรฉ : {time}", + "remoteControl.devices.revoke": "Rรฉvoquer", +} as const diff --git a/packages/ui/src/lib/i18n/messages/he/index.ts b/packages/ui/src/lib/i18n/messages/he/index.ts index cee41090a..ed799d3e8 100644 --- a/packages/ui/src/lib/i18n/messages/he/index.ts +++ b/packages/ui/src/lib/i18n/messages/he/index.ts @@ -10,6 +10,7 @@ import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" import { remoteAccessMessages } from "./remoteAccess" +import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" import { timeMessages } from "./time" @@ -32,5 +33,6 @@ export const heMessages = mergeMessageParts( markdownMessages, settingsMessages, remoteAccessMessages, + remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/he/remoteControl.ts b/packages/ui/src/lib/i18n/messages/he/remoteControl.ts new file mode 100644 index 000000000..48fe8f583 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/he/remoteControl.ts @@ -0,0 +1,26 @@ +export const remoteControlMessages = { + "remoteControl.title": "ืฉืœื™ื˜ื” ืžืจื—ื•ืง", + "remoteControl.description": "ื”ืžืฉืš ื”ืคืขืœื•ืช CodeNomad ืžืงื•ืžื™ื•ืช ืžืžื›ืฉื™ืจ ืื—ืจ ื‘ืœื™ ืœืคืชื•ื— ื™ืฆื™ืื•ืช ืจืฉืช.", + "remoteControl.refresh": "ืจืขื ื•ืŸ", + "remoteControl.state.stopped": "ื”ืฉืœื™ื˜ื” ืžืจื—ื•ืง ื›ื‘ื•ื™ื”", + "remoteControl.state.connecting": "ืžืชื—ื‘ืจ ืœืžืžืกืจโ€ฆ", + "remoteControl.state.connected": "ืžื•ื›ืŸ ืœื—ื™ื‘ื•ืจื™ื ืžืจื—ื•ืง", + "remoteControl.state.reconnecting": "ืžืชื—ื‘ืจ ืžื—ื“ืฉโ€ฆ", + "remoteControl.state.error": "ื”ื—ื™ื‘ื•ืจ ืœืžืžืกืจ ื ื›ืฉืœ", + "remoteControl.status.localOnly": "CodeNomad ื–ืžื™ืŸ ืจืง ื‘ืžื›ืฉื™ืจ ื–ื”.", + "remoteControl.status.hostOnly": "ื™ืฉ ืœื ื”ืœ ืืช Remote Control ืžื”ืžื—ืฉื‘ ื”ืžืืจื— ืฉืœ CodeNomad.", + "remoteControl.start": "ื”ืคืขืœ ืฉืœื™ื˜ื” ืžืจื—ื•ืง", + "remoteControl.newLink": "ืฆื•ืจ ืงื™ืฉื•ืจ ืฆื™ืžื•ื“ ื—ื“ืฉ", + "remoteControl.stop": "ืขืฆื•ืจ ืฉืœื™ื˜ื” ืžืจื—ื•ืง", + "remoteControl.pairing.title": "ื—ื™ื‘ื•ืจ ืžื›ืฉื™ืจ ื ื•ืกืฃ", + "remoteControl.pairing.description": "ืกืจื•ืง ืืช ืงื•ื“ ื”-QR ืื• ืคืชื— ืืช ื”ืงื™ืฉื•ืจ ื”ื—ื“-ืคืขืžื™ ื‘ืžื›ืฉื™ืจ ื”ืื—ืจ.", + "remoteControl.pairing.qrAlt": "ืงื•ื“ QR ืœืฆื™ืžื•ื“ ืฉืœื™ื˜ื” ืžืจื—ื•ืง", + "remoteControl.pairing.copy": "ื”ืขืชืง ืงื™ืฉื•ืจ", + "remoteControl.pairing.copied": "ื”ื•ืขืชืง", + "remoteControl.pairing.expires": "ื”ืงื™ืฉื•ืจ ื™ืคื•ื’ ื‘ืฉืขื” {time}.", + "remoteControl.devices.title": "ืžื›ืฉื™ืจื™ื ืžืฆื•ืžื“ื™ื", + "remoteControl.devices.description": "ืžื›ืฉื™ืจื™ื ืืœื” ื™ื›ื•ืœื™ื ืœื’ืฉืช ืœืžืืจื— ื›ืืฉืจ ื”ืฉืœื™ื˜ื” ืžืจื—ื•ืง ืคืขื™ืœื”.", + "remoteControl.devices.empty": "ืขื“ื™ื™ืŸ ืื™ืŸ ืžื›ืฉื™ืจื™ื ืžืฆื•ืžื“ื™ื.", + "remoteControl.devices.lastSeen": "ื ืจืื” ืœืื—ืจื•ื ื”: {time}", + "remoteControl.devices.revoke": "ื‘ื˜ืœ ื’ื™ืฉื”", +} as const diff --git a/packages/ui/src/lib/i18n/messages/ja/index.ts b/packages/ui/src/lib/i18n/messages/ja/index.ts index 407f06976..f792b5e27 100644 --- a/packages/ui/src/lib/i18n/messages/ja/index.ts +++ b/packages/ui/src/lib/i18n/messages/ja/index.ts @@ -10,6 +10,7 @@ import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" import { remoteAccessMessages } from "./remoteAccess" +import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" import { timeMessages } from "./time" @@ -32,5 +33,6 @@ export const jaMessages = mergeMessageParts( markdownMessages, settingsMessages, remoteAccessMessages, + remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/ja/remoteControl.ts b/packages/ui/src/lib/i18n/messages/ja/remoteControl.ts new file mode 100644 index 000000000..0e3c113c2 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/ja/remoteControl.ts @@ -0,0 +1,26 @@ +export const remoteControlMessages = { + "remoteControl.title": "ใƒชใƒขใƒผใƒˆใ‚ณใƒณใƒˆใƒญใƒผใƒซ", + "remoteControl.description": "ใƒใƒƒใƒˆใƒฏใƒผใ‚ฏใƒใƒผใƒˆใ‚’้–‹ใ‹ใšใซใ€ๅˆฅใฎ็ซฏๆœซใ‹ใ‚‰ใƒญใƒผใ‚ซใƒซใฎ CodeNomad ใ‚ปใƒƒใ‚ทใƒงใƒณใ‚’็ถšใ‘ใพใ™ใ€‚", + "remoteControl.refresh": "ๆ›ดๆ–ฐ", + "remoteControl.state.stopped": "ใƒชใƒขใƒผใƒˆใ‚ณใƒณใƒˆใƒญใƒผใƒซใฏใ‚ชใƒ•ใงใ™", + "remoteControl.state.connecting": "ใƒชใƒฌใƒผใซๆŽฅ็ถšไธญโ€ฆ", + "remoteControl.state.connected": "ใƒชใƒขใƒผใƒˆๆŽฅ็ถšใฎๆบ–ๅ‚™ใŒใงใใพใ—ใŸ", + "remoteControl.state.reconnecting": "ๅ†ๆŽฅ็ถšไธญโ€ฆ", + "remoteControl.state.error": "ใƒชใƒฌใƒผใธใฎๆŽฅ็ถšใซๅคฑๆ•—ใ—ใพใ—ใŸ", + "remoteControl.status.localOnly": "CodeNomad ใฏใ“ใฎ็ซฏๆœซใงใฎใฟๅˆฉ็”จใงใใพใ™ใ€‚", + "remoteControl.status.hostOnly": "Remote Control ใฏ CodeNomad ใƒ›ใ‚นใƒˆใง็ฎก็†ใ—ใฆใใ ใ•ใ„ใ€‚", + "remoteControl.start": "ใƒชใƒขใƒผใƒˆใ‚ณใƒณใƒˆใƒญใƒผใƒซใ‚’้–‹ๅง‹", + "remoteControl.newLink": "ๆ–ฐใ—ใ„ใƒšใ‚ขใƒชใƒณใ‚ฐใƒชใƒณใ‚ฏใ‚’ไฝœๆˆ", + "remoteControl.stop": "ใƒชใƒขใƒผใƒˆใ‚ณใƒณใƒˆใƒญใƒผใƒซใ‚’ๅœๆญข", + "remoteControl.pairing.title": "ๅˆฅใฎ็ซฏๆœซใ‚’ๆŽฅ็ถš", + "remoteControl.pairing.description": "QR ใ‚ณใƒผใƒ‰ใ‚’ใ‚นใ‚ญใƒฃใƒณใ™ใ‚‹ใ‹ใ€ๅˆฅใฎ็ซฏๆœซใงไธ€ๅบฆ้™ใ‚Šใฎใƒชใƒณใ‚ฏใ‚’้–‹ใ„ใฆใใ ใ•ใ„ใ€‚", + "remoteControl.pairing.qrAlt": "ใƒชใƒขใƒผใƒˆใ‚ณใƒณใƒˆใƒญใƒผใƒซใฎใƒšใ‚ขใƒชใƒณใ‚ฐ QR ใ‚ณใƒผใƒ‰", + "remoteControl.pairing.copy": "ใƒชใƒณใ‚ฏใ‚’ใ‚ณใƒ”ใƒผ", + "remoteControl.pairing.copied": "ใ‚ณใƒ”ใƒผใ—ใพใ—ใŸ", + "remoteControl.pairing.expires": "ใ“ใฎใƒชใƒณใ‚ฏใฏ {time} ใซๆœŸ้™ๅˆ‡ใ‚Œใซใชใ‚Šใพใ™ใ€‚", + "remoteControl.devices.title": "ใƒšใ‚ขใƒชใƒณใ‚ฐๆธˆใฟ็ซฏๆœซ", + "remoteControl.devices.description": "ใƒชใƒขใƒผใƒˆใ‚ณใƒณใƒˆใƒญใƒผใƒซใฎๅฎŸ่กŒไธญใ€ใ“ใ‚Œใ‚‰ใฎ็ซฏๆœซใ‹ใ‚‰ใ“ใฎใƒ›ใ‚นใƒˆใซใ‚ขใ‚ฏใ‚ปใ‚นใงใใพใ™ใ€‚", + "remoteControl.devices.empty": "ใƒšใ‚ขใƒชใƒณใ‚ฐๆธˆใฟ็ซฏๆœซใฏใ‚ใ‚Šใพใ›ใ‚“ใ€‚", + "remoteControl.devices.lastSeen": "ๆœ€็ต‚ใ‚ขใ‚ฏใ‚ปใ‚น: {time}", + "remoteControl.devices.revoke": "ๅ–ใ‚Šๆถˆใ™", +} as const diff --git a/packages/ui/src/lib/i18n/messages/ne/index.ts b/packages/ui/src/lib/i18n/messages/ne/index.ts index 634928295..18593389d 100644 --- a/packages/ui/src/lib/i18n/messages/ne/index.ts +++ b/packages/ui/src/lib/i18n/messages/ne/index.ts @@ -10,6 +10,7 @@ import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" import { remoteAccessMessages } from "./remoteAccess" +import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" import { timeMessages } from "./time" @@ -32,5 +33,6 @@ export const neMessages = mergeMessageParts( markdownMessages, settingsMessages, remoteAccessMessages, + remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/ne/remoteControl.ts b/packages/ui/src/lib/i18n/messages/ne/remoteControl.ts new file mode 100644 index 000000000..2a95a45b7 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/ne/remoteControl.ts @@ -0,0 +1,26 @@ +export const remoteControlMessages = { + "remoteControl.title": "เคฐเคฟเคฎเฅ‹เคŸ เค•เคจเฅเคŸเฅเคฐเฅ‹เคฒ", + "remoteControl.description": "เคจเฅ‡เคŸเคตเคฐเฅเค• เคชเฅ‹เคฐเฅเคŸ เคจเค–เฅ‹เคฒเฅ€ เค…เคฐเฅเค•เฅ‹ เค‰เคชเค•เคฐเคฃเคฌเคพเคŸ เคธเฅเคฅเคพเคจเฅ€เคฏ CodeNomad เคธเคคเฅเคฐเคนเคฐเฅ‚ เคœเคพเคฐเฅ€ เคฐเคพเค–เฅเคจเฅเคนเฅ‹เคธเฅเฅค", + "remoteControl.refresh": "เคฐเคฟเคซเฅเคฐเฅ‡เคธ", + "remoteControl.state.stopped": "เคฐเคฟเคฎเฅ‹เคŸ เค•เคจเฅเคŸเฅเคฐเฅ‹เคฒ เคฌเคจเฅเคฆ เค›", + "remoteControl.state.connecting": "เคฐเคฟเคฒเฅ‡เคธเคเค— เคœเคกเคพเคจ เคนเฅเคเคฆเฅˆเค›โ€ฆ", + "remoteControl.state.connected": "เคฐเคฟเคฎเฅ‹เคŸ เคœเคกเคพเคจเค•เคพ เคฒเคพเค—เคฟ เคคเคฏเคพเคฐ", + "remoteControl.state.reconnecting": "เคชเฅเคจเคƒ เคœเคกเคพเคจ เคนเฅเคเคฆเฅˆเค›โ€ฆ", + "remoteControl.state.error": "เคฐเคฟเคฒเฅ‡ เคœเคกเคพเคจ เค…เคธเคซเคฒ เคญเคฏเฅ‹", + "remoteControl.status.localOnly": "CodeNomad เคฏเคธ เค‰เคชเค•เคฐเคฃเคฎเคพ เคฎเคพเคคเฅเคฐ เค‰เคชเคฒเคฌเฅเคง เค›เฅค", + "remoteControl.status.hostOnly": "Remote Control เคฒเคพเคˆ CodeNomad เคนเฅ‹เคธเฅเคŸเคฌเคพเคŸ เคตเฅเคฏเคตเคธเฅเคฅเคพเคชเคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค", + "remoteControl.start": "เคฐเคฟเคฎเฅ‹เคŸ เค•เคจเฅเคŸเฅเคฐเฅ‹เคฒ เคธเฅเคฐเฅ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ", + "remoteControl.newLink": "เคจเคฏเคพเค เคœเฅ‹เคกเฅ€ เคฒเคฟเค‚เค• เคฌเคจเคพเค‰เคจเฅเคนเฅ‹เคธเฅ", + "remoteControl.stop": "เคฐเคฟเคฎเฅ‹เคŸ เค•เคจเฅเคŸเฅเคฐเฅ‹เคฒ เคฐเฅ‹เค•เฅเคจเฅเคนเฅ‹เคธเฅ", + "remoteControl.pairing.title": "เค…เคฐเฅเค•เฅ‹ เค‰เคชเค•เคฐเคฃ เคœเคกเคพเคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ", + "remoteControl.pairing.description": "QR เค•เฅ‹เคก เคธเฅเค•เฅเคฏเคพเคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ เคตเคพ เค…เคฐเฅเค•เฅ‹ เค‰เคชเค•เคฐเคฃเคฎเคพ เคเค•เคชเคŸเค• เคชเฅเคฐเคฏเฅ‹เค— เคนเฅเคจเฅ‡ เคฒเคฟเค‚เค• เค–เฅ‹เคฒเฅเคจเฅเคนเฅ‹เคธเฅเฅค", + "remoteControl.pairing.qrAlt": "เคฐเคฟเคฎเฅ‹เคŸ เค•เคจเฅเคŸเฅเคฐเฅ‹เคฒ เคœเฅ‹เคกเฅ€ QR เค•เฅ‹เคก", + "remoteControl.pairing.copy": "เคฒเคฟเค‚เค• เคชเฅเคฐเคคเคฟเคฒเคฟเคชเคฟ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ", + "remoteControl.pairing.copied": "เคชเฅเคฐเคคเคฟเคฒเคฟเคชเคฟ เคญเคฏเฅ‹", + "remoteControl.pairing.expires": "เคฏเฅ‹ เคฒเคฟเค‚เค• {time} เคฎเคพ เคธเคฎเคพเคชเฅเคค เคนเฅเคจเฅเค›เฅค", + "remoteControl.devices.title": "เคœเฅ‹เคกเคฟเคเค•เคพ เค‰เคชเค•เคฐเคฃเคนเคฐเฅ‚", + "remoteControl.devices.description": "เคฐเคฟเคฎเฅ‹เคŸ เค•เคจเฅเคŸเฅเคฐเฅ‹เคฒ เคšเคฒเฅเคฆเคพ เคฏเฅ€ เค‰เคชเค•เคฐเคฃเคนเคฐเฅ‚เคฒเฅ‡ CodeNomad เคนเฅ‹เคธเฅเคŸ เคชเคนเฅเคเคš เค—เคฐเฅเคจ เคธเค•เฅเค›เคจเฅเฅค", + "remoteControl.devices.empty": "เค…เคนเคฟเคฒเฅ‡เคธเคฎเฅเคฎ เค•เฅเคจเฅˆ เค‰เคชเค•เคฐเคฃ เคœเฅ‹เคกเคฟเคเค•เฅ‹ เค›เฅˆเคจเฅค", + "remoteControl.devices.lastSeen": "เค…เคจเฅเคคเคฟเคฎ เคชเคŸเค•: {time}", + "remoteControl.devices.revoke": "เคนเคŸเคพเค‰เคจเฅเคนเฅ‹เคธเฅ", +} as const diff --git a/packages/ui/src/lib/i18n/messages/ru/index.ts b/packages/ui/src/lib/i18n/messages/ru/index.ts index 4188ca557..838748067 100644 --- a/packages/ui/src/lib/i18n/messages/ru/index.ts +++ b/packages/ui/src/lib/i18n/messages/ru/index.ts @@ -10,6 +10,7 @@ import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" import { remoteAccessMessages } from "./remoteAccess" +import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" import { timeMessages } from "./time" @@ -32,5 +33,6 @@ export const ruMessages = mergeMessageParts( markdownMessages, settingsMessages, remoteAccessMessages, + remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/ru/remoteControl.ts b/packages/ui/src/lib/i18n/messages/ru/remoteControl.ts new file mode 100644 index 000000000..6b6d27c63 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/ru/remoteControl.ts @@ -0,0 +1,26 @@ +export const remoteControlMessages = { + "remoteControl.title": "ะฃะดะฐะปั‘ะฝะฝะพะต ัƒะฟั€ะฐะฒะปะตะฝะธะต", + "remoteControl.description": "ะŸั€ะพะดะพะปะถะฐะนั‚ะต ะปะพะบะฐะปัŒะฝั‹ะต ัะตะฐะฝัั‹ CodeNomad ั ะดั€ัƒะณะพะณะพ ัƒัั‚ั€ะพะนัั‚ะฒะฐ, ะฝะต ะพั‚ะบั€ั‹ะฒะฐั ัะตั‚ะตะฒั‹ะต ะฟะพั€ั‚ั‹.", + "remoteControl.refresh": "ะžะฑะฝะพะฒะธั‚ัŒ", + "remoteControl.state.stopped": "ะฃะดะฐะปั‘ะฝะฝะพะต ัƒะฟั€ะฐะฒะปะตะฝะธะต ะฒั‹ะบะปัŽั‡ะตะฝะพ", + "remoteControl.state.connecting": "ะŸะพะดะบะปัŽั‡ะตะฝะธะต ะบ ั€ะตั‚ั€ะฐะฝัะปัั‚ะพั€ัƒโ€ฆ", + "remoteControl.state.connected": "ะ“ะพั‚ะพะฒะพ ะบ ัƒะดะฐะปั‘ะฝะฝั‹ะผ ะฟะพะดะบะปัŽั‡ะตะฝะธัะผ", + "remoteControl.state.reconnecting": "ะŸะพะฒั‚ะพั€ะฝะพะต ะฟะพะดะบะปัŽั‡ะตะฝะธะตโ€ฆ", + "remoteControl.state.error": "ะะต ัƒะดะฐะปะพััŒ ะฟะพะดะบะปัŽั‡ะธั‚ัŒัั ะบ ั€ะตั‚ั€ะฐะฝัะปัั‚ะพั€ัƒ", + "remoteControl.status.localOnly": "CodeNomad ะดะพัั‚ัƒะฟะตะฝ ั‚ะพะปัŒะบะพ ะฝะฐ ัั‚ะพะผ ัƒัั‚ั€ะพะนัั‚ะฒะต.", + "remoteControl.status.hostOnly": "ะฃะฟั€ะฐะฒะปัะนั‚ะต Remote Control ะฝะฐ ะพัะฝะพะฒะฝะพะผ ัƒัั‚ั€ะพะนัั‚ะฒะต CodeNomad.", + "remoteControl.start": "ะ—ะฐะฟัƒัั‚ะธั‚ัŒ ัƒะดะฐะปั‘ะฝะฝะพะต ัƒะฟั€ะฐะฒะปะตะฝะธะต", + "remoteControl.newLink": "ะกะพะทะดะฐั‚ัŒ ะฝะพะฒัƒัŽ ััั‹ะปะบัƒ ัะพะฟั€ัะถะตะฝะธั", + "remoteControl.stop": "ะžัั‚ะฐะฝะพะฒะธั‚ัŒ ัƒะดะฐะปั‘ะฝะฝะพะต ัƒะฟั€ะฐะฒะปะตะฝะธะต", + "remoteControl.pairing.title": "ะŸะพะดะบะปัŽั‡ะธั‚ัŒ ะดั€ัƒะณะพะต ัƒัั‚ั€ะพะนัั‚ะฒะพ", + "remoteControl.pairing.description": "ะžั‚ัะบะฐะฝะธั€ัƒะนั‚ะต QR-ะบะพะด ะธะปะธ ะพั‚ะบั€ะพะนั‚ะต ะพะดะฝะพั€ะฐะทะพะฒัƒัŽ ััั‹ะปะบัƒ ะฝะฐ ะดั€ัƒะณะพะผ ัƒัั‚ั€ะพะนัั‚ะฒะต.", + "remoteControl.pairing.qrAlt": "QR-ะบะพะด ัะพะฟั€ัะถะตะฝะธั ัƒะดะฐะปั‘ะฝะฝะพะณะพ ัƒะฟั€ะฐะฒะปะตะฝะธั", + "remoteControl.pairing.copy": "ะšะพะฟะธั€ะพะฒะฐั‚ัŒ ััั‹ะปะบัƒ", + "remoteControl.pairing.copied": "ะกะบะพะฟะธั€ะพะฒะฐะฝะพ", + "remoteControl.pairing.expires": "ะกัั‹ะปะบะฐ ะธัั‚ะตะบะฐะตั‚ ะฒ {time}.", + "remoteControl.devices.title": "ะกะพะฟั€ัะถั‘ะฝะฝั‹ะต ัƒัั‚ั€ะพะนัั‚ะฒะฐ", + "remoteControl.devices.description": "ะญั‚ะธ ัƒัั‚ั€ะพะนัั‚ะฒะฐ ะผะพะณัƒั‚ ะพะฑั€ะฐั‰ะฐั‚ัŒัั ะบ ั…ะพัั‚ัƒ, ะฟะพะบะฐ ัƒะดะฐะปั‘ะฝะฝะพะต ัƒะฟั€ะฐะฒะปะตะฝะธะต ะทะฐะฟัƒั‰ะตะฝะพ.", + "remoteControl.devices.empty": "ะกะพะฟั€ัะถั‘ะฝะฝั‹ั… ัƒัั‚ั€ะพะนัั‚ะฒ ะฟะพะบะฐ ะฝะตั‚.", + "remoteControl.devices.lastSeen": "ะŸะพัะปะตะดะฝัั ะฐะบั‚ะธะฒะฝะพัั‚ัŒ: {time}", + "remoteControl.devices.revoke": "ะžั‚ะพะทะฒะฐั‚ัŒ", +} as const diff --git a/packages/ui/src/lib/i18n/messages/tr/index.ts b/packages/ui/src/lib/i18n/messages/tr/index.ts index c1ed1fe7e..8bfb26c08 100644 --- a/packages/ui/src/lib/i18n/messages/tr/index.ts +++ b/packages/ui/src/lib/i18n/messages/tr/index.ts @@ -11,6 +11,7 @@ import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" import { remoteAccessMessages } from "./remoteAccess" +import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" import { timeMessages } from "./time" @@ -29,6 +30,7 @@ export const trMessages = mergeMessageParts( markdownMessages, messagingMessages, remoteAccessMessages, + remoteControlMessages, sessionMessages, settingsMessages, timeMessages, diff --git a/packages/ui/src/lib/i18n/messages/tr/remoteControl.ts b/packages/ui/src/lib/i18n/messages/tr/remoteControl.ts new file mode 100644 index 000000000..d0329357b --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/tr/remoteControl.ts @@ -0,0 +1,26 @@ +export const remoteControlMessages = { + "remoteControl.title": "Uzaktan Kontrol", + "remoteControl.description": "AฤŸ portlarฤฑnฤฑ aรงmadan yerel CodeNomad oturumlarฤฑnฤฑ baลŸka bir cihazdan sรผrdรผrรผn.", + "remoteControl.refresh": "Yenile", + "remoteControl.state.stopped": "Uzaktan Kontrol kapalฤฑ", + "remoteControl.state.connecting": "Aktarฤฑcฤฑya baฤŸlanฤฑlฤฑyorโ€ฆ", + "remoteControl.state.connected": "Uzak baฤŸlantฤฑlar iรงin hazฤฑr", + "remoteControl.state.reconnecting": "Yeniden baฤŸlanฤฑlฤฑyorโ€ฆ", + "remoteControl.state.error": "Aktarฤฑcฤฑ baฤŸlantฤฑsฤฑ baลŸarฤฑsฤฑz", + "remoteControl.status.localOnly": "CodeNomad yalnฤฑzca bu cihazda kullanฤฑlabilir.", + "remoteControl.status.hostOnly": "Remote Controlโ€™รผ CodeNomad ana cihazฤฑndan yรถnetin.", + "remoteControl.start": "Uzaktan Kontrolรผ baลŸlat", + "remoteControl.newLink": "Yeni eลŸleลŸtirme baฤŸlantฤฑsฤฑ oluลŸtur", + "remoteControl.stop": "Uzaktan Kontrolรผ durdur", + "remoteControl.pairing.title": "BaลŸka bir cihaz baฤŸla", + "remoteControl.pairing.description": "QR kodunu tarayฤฑn veya tek kullanฤฑmlฤฑk baฤŸlantฤฑyฤฑ diฤŸer cihazda aรงฤฑn.", + "remoteControl.pairing.qrAlt": "Uzaktan Kontrol eลŸleลŸtirme QR kodu", + "remoteControl.pairing.copy": "BaฤŸlantฤฑyฤฑ kopyala", + "remoteControl.pairing.copied": "Kopyalandฤฑ", + "remoteControl.pairing.expires": "Bu baฤŸlantฤฑ {time} saatinde sona erer.", + "remoteControl.devices.title": "EลŸleลŸtirilmiลŸ cihazlar", + "remoteControl.devices.description": "Uzaktan Kontrol รงalฤฑลŸฤฑrken bu cihazlar CodeNomad ana makinesine eriลŸebilir.", + "remoteControl.devices.empty": "Henรผz eลŸleลŸtirilmiลŸ cihaz yok.", + "remoteControl.devices.lastSeen": "Son gรถrรผlme: {time}", + "remoteControl.devices.revoke": "ฤฐptal et", +} as const diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/index.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/index.ts index 5da404da6..63b269f7a 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/index.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/index.ts @@ -10,6 +10,7 @@ import { logMessages } from "./logs" import { markdownMessages } from "./markdown" import { messagingMessages } from "./messaging" import { remoteAccessMessages } from "./remoteAccess" +import { remoteControlMessages } from "./remoteControl" import { sessionMessages } from "./session" import { settingsMessages } from "./settings" import { timeMessages } from "./time" @@ -32,5 +33,6 @@ export const zhHansMessages = mergeMessageParts( markdownMessages, settingsMessages, remoteAccessMessages, + remoteControlMessages, commandMessages, ) diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/remoteControl.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/remoteControl.ts new file mode 100644 index 000000000..015a6c785 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/remoteControl.ts @@ -0,0 +1,26 @@ +export const remoteControlMessages = { + "remoteControl.title": "่ฟœ็จ‹ๆŽงๅˆถ", + "remoteControl.description": "ๆ— ้œ€ๅผ€ๆ”พ็ฝ‘็ปœ็ซฏๅฃ๏ผŒๅณๅฏไปŽๅ…ถไป–่ฎพๅค‡็ปง็ปญๆœฌๆœบ CodeNomad ไผš่ฏใ€‚", + "remoteControl.refresh": "ๅˆทๆ–ฐ", + "remoteControl.state.stopped": "่ฟœ็จ‹ๆŽงๅˆถๅทฒๅ…ณ้—ญ", + "remoteControl.state.connecting": "ๆญฃๅœจ่ฟžๆŽฅไธญ็ปงโ€ฆ", + "remoteControl.state.connected": "ๅทฒๅ‡†ๅค‡ๅฅฝ่ฟœ็จ‹่ฟžๆŽฅ", + "remoteControl.state.reconnecting": "ๆญฃๅœจ้‡ๆ–ฐ่ฟžๆŽฅโ€ฆ", + "remoteControl.state.error": "ไธญ็ปง่ฟžๆŽฅๅคฑ่ดฅ", + "remoteControl.status.localOnly": "CodeNomad ็›ฎๅ‰ไป…ๅฏๅœจๆญค่ฎพๅค‡ไธŠไฝฟ็”จใ€‚", + "remoteControl.status.hostOnly": "่ฏทๅœจ CodeNomad ไธปๆœบไธŠ็ฎก็†่ฟœ็จ‹ๆŽงๅˆถใ€‚", + "remoteControl.start": "ๅฏๅŠจ่ฟœ็จ‹ๆŽงๅˆถ", + "remoteControl.newLink": "ๅˆ›ๅปบๆ–ฐ็š„้…ๅฏน้“พๆŽฅ", + "remoteControl.stop": "ๅœๆญข่ฟœ็จ‹ๆŽงๅˆถ", + "remoteControl.pairing.title": "่ฟžๆŽฅๅ…ถไป–่ฎพๅค‡", + "remoteControl.pairing.description": "ๆ‰ซๆไบŒ็ปด็ ๏ผŒๆˆ–ๅœจๅ…ถไป–่ฎพๅค‡ไธŠๆ‰“ๅผ€ไธ€ๆฌกๆ€ง้“พๆŽฅใ€‚", + "remoteControl.pairing.qrAlt": "่ฟœ็จ‹ๆŽงๅˆถ้…ๅฏนไบŒ็ปด็ ", + "remoteControl.pairing.copy": "ๅคๅˆถ้“พๆŽฅ", + "remoteControl.pairing.copied": "ๅทฒๅคๅˆถ", + "remoteControl.pairing.expires": "ๆญค้“พๆŽฅๅฐ†ๅœจ {time} ่ฟ‡ๆœŸใ€‚", + "remoteControl.devices.title": "ๅทฒ้…ๅฏน่ฎพๅค‡", + "remoteControl.devices.description": "่ฟœ็จ‹ๆŽงๅˆถ่ฟ่กŒๆ—ถ๏ผŒ่ฟ™ไบ›่ฎพๅค‡ๅฏไปฅ่ฎฟ้—ฎๆญค CodeNomad ไธปๆœบใ€‚", + "remoteControl.devices.empty": "ๅฐšๆœช้…ๅฏนไปปไฝ•่ฎพๅค‡ใ€‚", + "remoteControl.devices.lastSeen": "ไธŠๆฌกๆดปๅŠจ๏ผš{time}", + "remoteControl.devices.revoke": "ๆ’ค้”€", +} as const diff --git a/packages/ui/src/lib/remote-control/bounded-body.test.ts b/packages/ui/src/lib/remote-control/bounded-body.test.ts new file mode 100644 index 000000000..47a7ede38 --- /dev/null +++ b/packages/ui/src/lib/remote-control/bounded-body.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { readBoundedBody } from "./bounded-body" + +test("reads request bodies only up to the transport limit", async () => { + const source = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.of(1, 2)) + controller.enqueue(Uint8Array.of(3)) + controller.close() + }, + }) + assert.deepEqual(await readBoundedBody(source, 3), Uint8Array.of(1, 2, 3)) +}) + +test("cancels request body reads before retaining oversized input", async () => { + let cancelled = false + const source = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(4)) + controller.enqueue(new Uint8Array(4)) + }, + cancel() { + cancelled = true + }, + }) + assert.equal(await readBoundedBody(source, 4), null) + assert.equal(cancelled, true) +}) diff --git a/packages/ui/src/lib/remote-control/bounded-body.ts b/packages/ui/src/lib/remote-control/bounded-body.ts new file mode 100644 index 000000000..3ae2d072d --- /dev/null +++ b/packages/ui/src/lib/remote-control/bounded-body.ts @@ -0,0 +1,23 @@ +export async function readBoundedBody(stream: ReadableStream | null, maxBytes: number): Promise { + if (!stream) return new Uint8Array() + const reader = stream.getReader() + const chunks: Uint8Array[] = [] + let size = 0 + while (true) { + const { done, value } = await reader.read() + if (done) break + size += value.byteLength + if (size > maxBytes) { + await reader.cancel() + return null + } + chunks.push(value) + } + const result = new Uint8Array(size) + let offset = 0 + for (const chunk of chunks) { + result.set(chunk, offset) + offset += chunk.byteLength + } + return result +} diff --git a/packages/ui/src/lib/remote-control/event-source.test.ts b/packages/ui/src/lib/remote-control/event-source.test.ts new file mode 100644 index 000000000..aee5cc923 --- /dev/null +++ b/packages/ui/src/lib/remote-control/event-source.test.ts @@ -0,0 +1,102 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { TunnelEventSource } from "./event-source" + +test("tunneled EventSource parses named and multiline SSE events", async () => { + const originalFetch = globalThis.fetch + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window") + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { href: "https://host.remote.example/" } }, + }) + globalThis.fetch = async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("id: 7\nevent: codenomad.client.ping\ndata: {\"ts\":1}\n\n")) + controller.enqueue(new TextEncoder().encode("data: first\ndata: second\n\n")) + controller.close() + }, + }), { status: 200 }) + + try { + const source = new TunnelEventSource("/api/events", { withCredentials: true }) + const named = new Promise((resolve) => source.addEventListener("codenomad.client.ping", (event) => resolve(event as MessageEvent), { once: true })) + const message = new Promise((resolve) => { source.onmessage = (event) => resolve(event) }) + assert.equal((await named).data, "{\"ts\":1}") + assert.equal((await message).data, "first\nsecond") + assert.equal(source.withCredentials, true) + source.close() + assert.equal(source.readyState, TunnelEventSource.CLOSED) + } finally { + globalThis.fetch = originalFetch + if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow) + else delete (globalThis as { window?: unknown }).window + } +}) + +test("tunneled EventSource reconnects with the last event identifier", async () => { + const originalFetch = globalThis.fetch + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window") + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { href: "https://host.remote.example/" } }, + }) + const requests: RequestInit[] = [] + globalThis.fetch = async (_input, init) => { + requests.push(init ?? {}) + const body = requests.length === 1 ? "retry: 1\nid: event-7\ndata: first\n\n" : "data: second\n\n" + return new Response(body, { status: 200 }) + } + + try { + const source = new TunnelEventSource("/api/events") + const messages: string[] = [] + await new Promise((resolve) => { + source.onmessage = (event) => { + messages.push(String(event.data)) + if (messages.length === 2) resolve() + } + }) + source.close() + assert.deepEqual(messages, ["first", "second"]) + assert.equal(new Headers(requests[1].headers).get("last-event-id"), "event-7") + } finally { + globalThis.fetch = originalFetch + if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow) + else delete (globalThis as { window?: unknown }).window + } +}) + +test("tunneled EventSource rejects an unbounded event line", async () => { + const originalFetch = globalThis.fetch + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window") + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { href: "https://host.remote.example/" } }, + }) + let sent = false + let cancelled = false + globalThis.fetch = async () => new Response(new ReadableStream({ + pull(controller) { + if (sent) return + sent = true + controller.enqueue(new TextEncoder().encode(`data: ${"x".repeat(1024 * 1024)}`)) + }, + cancel() { + cancelled = true + }, + })) + + try { + const source = new TunnelEventSource("/api/events") + let messages = 0 + source.onmessage = () => { messages += 1 } + await new Promise((resolve) => { source.onerror = () => resolve() }) + source.close() + assert.equal(messages, 0) + assert.equal(cancelled, true) + } finally { + globalThis.fetch = originalFetch + if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow) + else delete (globalThis as { window?: unknown }).window + } +}) diff --git a/packages/ui/src/lib/remote-control/event-source.ts b/packages/ui/src/lib/remote-control/event-source.ts new file mode 100644 index 000000000..38e2a7e22 --- /dev/null +++ b/packages/ui/src/lib/remote-control/event-source.ts @@ -0,0 +1,152 @@ +type EventHandler = ((event: Event) => unknown) | null + +const MAX_SSE_LINE_CHARS = 1024 * 1024 +const MAX_SSE_EVENT_CHARS = 4 * 1024 * 1024 +const MAX_SSE_EVENT_LINES = 4096 +const MAX_SSE_ID_CHARS = 8 * 1024 +const MIN_RECONNECT_DELAY_MS = 100 +const MAX_RECONNECT_DELAY_MS = 60_000 + +export class TunnelEventSource extends EventTarget { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSED = 2 + readonly CONNECTING = 0 + readonly OPEN = 1 + readonly CLOSED = 2 + readonly withCredentials: boolean + readonly url: string + readyState = TunnelEventSource.CONNECTING + onopen: EventHandler = null + onmessage: ((event: MessageEvent) => unknown) | null = null + onerror: EventHandler = null + private controller: AbortController | null = null + private reconnectDelay = 3_000 + private lastEventId = "" + + constructor(url: string | URL, options?: EventSourceInit) { + super() + this.url = new URL(url, window.location.href).toString() + this.withCredentials = options?.withCredentials === true + void this.connect() + } + + close(): void { + if (this.readyState === TunnelEventSource.CLOSED) return + this.readyState = TunnelEventSource.CLOSED + this.controller?.abort() + this.controller = null + const event = new Event("close") + this.dispatchEvent(event) + ;(this as EventSource & { onclose?: EventHandler }).onclose?.(event) + } + + private async connect(): Promise { + while (this.readyState !== TunnelEventSource.CLOSED) { + this.controller = new AbortController() + try { + const headers = new Headers({ Accept: "text/event-stream" }) + if (this.lastEventId) headers.set("Last-Event-ID", this.lastEventId) + const response = await fetch(this.url, { + headers, + credentials: this.withCredentials ? "include" : "same-origin", + signal: this.controller.signal, + cache: "no-store", + }) + if (response.status === 204) { + this.readyState = TunnelEventSource.CLOSED + return + } + if (!response.ok || !response.body) throw new Error(`Event stream failed with HTTP ${response.status}`) + this.readyState = TunnelEventSource.OPEN + const open = new Event("open") + this.dispatchEvent(open) + this.onopen?.(open) + await this.read(response.body) + } catch { + if (this.readyState === TunnelEventSource.CLOSED) return + } + if (this.readyState === TunnelEventSource.CLOSED) return + this.readyState = TunnelEventSource.CONNECTING + const error = new Event("error") + this.dispatchEvent(error) + this.onerror?.(error) + await new Promise((resolve) => setTimeout(resolve, this.reconnectDelay)) + } + } + + private async read(stream: ReadableStream): Promise { + const reader = stream.getReader() + const decoder = new TextDecoder() + let buffer = "" + let eventName = "message" + let eventData: string[] = [] + let eventCharacters = 0 + let eventLines = 0 + let eventId = this.lastEventId + + const dispatch = () => { + if (!eventData.length) { + eventName = "message" + return + } + this.lastEventId = eventId + const event = new MessageEvent(eventName, { data: eventData.join("\n"), lastEventId: eventId }) + this.dispatchEvent(event) + if (eventName === "message") this.onmessage?.(event) + eventName = "message" + eventData = [] + eventCharacters = 0 + eventLines = 0 + } + + let complete = false + try { + while (!this.controller?.signal.aborted) { + const { done, value } = await reader.read() + buffer += decoder.decode(value, { stream: !done }) + let newline = buffer.indexOf("\n") + while (newline >= 0) { + if (newline > MAX_SSE_LINE_CHARS) throw new Error("Remote event stream line exceeded its safety limit") + let line = buffer.slice(0, newline) + buffer = buffer.slice(newline + 1) + if (line.endsWith("\r")) line = line.slice(0, -1) + if (!line) dispatch() + else if (!line.startsWith(":")) { + const separator = line.indexOf(":") + const field = separator < 0 ? line : line.slice(0, separator) + let data = separator < 0 ? "" : line.slice(separator + 1) + if (data.startsWith(" ")) data = data.slice(1) + if (field === "event") eventName = data || "message" + else if (field === "data") { + eventCharacters += data.length + 1 + eventLines += 1 + if (eventCharacters > MAX_SSE_EVENT_CHARS || eventLines > MAX_SSE_EVENT_LINES) { + throw new Error("Remote event exceeded its safety limit") + } + eventData.push(data) + } + else if (field === "id" && !data.includes("\0")) { + if (data.length > MAX_SSE_ID_CHARS) throw new Error("Remote event identifier exceeded its safety limit") + eventId = data + } + else if (field === "retry" && /^\d+$/.test(data)) { + this.reconnectDelay = Math.max(MIN_RECONNECT_DELAY_MS, Math.min(MAX_RECONNECT_DELAY_MS, Number(data))) + } + } + newline = buffer.indexOf("\n") + } + if (buffer.length > MAX_SSE_LINE_CHARS) throw new Error("Remote event stream line exceeded its safety limit") + if (done) { + dispatch() + complete = true + break + } + } + } finally { + if (!complete) await reader.cancel().catch(() => undefined) + reader.releaseLock() + } + } + +} diff --git a/packages/ui/src/lib/remote-control/tunnel.test.ts b/packages/ui/src/lib/remote-control/tunnel.test.ts new file mode 100644 index 000000000..65504037a --- /dev/null +++ b/packages/ui/src/lib/remote-control/tunnel.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { createHostHandshake } from "@codenomad/remote-control-protocol" +import { RemoteControlTunnel } from "./tunnel" + +async function fixture(t: test.TestContext) { + const keys = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]) as CryptoKeyPair + const host = await createHostHandshake(await crypto.subtle.exportKey("jwk", keys.privateKey)) + let socket!: StrictSocket + let sent!: () => void + const requestSent = new Promise((resolve) => { sent = resolve }) + class StrictSocket extends EventTarget { + static OPEN = 1 + static CLOSING = 2 + readyState = 1 + bufferedAmount = 0 + binaryType = "" + closedWith?: number + encryptedSends = 0 + constructor(_url: URL) { + super() + socket = this + queueMicrotask(() => this.dispatchEvent(new Event("open"))) + } + send(data: string | Uint8Array) { + if (typeof data === "string") { + void host.accept(data).then(({ ready }) => { + this.dispatchEvent(new MessageEvent("message", { data: ready })) + }) + } else { + this.encryptedSends += 1 + sent() + } + } + close(code?: number) { + if (code !== undefined && code !== 1000 && !(Number.isInteger(code) && code >= 3000 && code <= 4999)) { + throw new DOMException("Invalid close code", "InvalidAccessError") + } + this.closedWith = code + this.readyState = 3 + queueMicrotask(() => this.dispatchEvent(new Event("close"))) + } + } + const oldWindow = Object.getOwnPropertyDescriptor(globalThis, "window") + const oldSocket = Object.getOwnPropertyDescriptor(globalThis, "WebSocket") + Object.defineProperty(globalThis, "window", { configurable: true, value: { location: { href: "https://relay.example/" } } }) + Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: StrictSocket }) + t.after(() => { + if (oldWindow) Object.defineProperty(globalThis, "window", oldWindow) + else Reflect.deleteProperty(globalThis, "window") + if (oldSocket) Object.defineProperty(globalThis, "WebSocket", oldSocket) + else Reflect.deleteProperty(globalThis, "WebSocket") + }) + const tunnel = new RemoteControlTunnel( + { tunnelPath: "/__codenomad/tunnel" }, + await crypto.subtle.exportKey("jwk", keys.publicKey), + StrictSocket as unknown as typeof WebSocket, + ) + return { tunnel, requestSent, socket: () => socket } +} + +test("security close uses a permitted code and rejects pending HTTP immediately", async (t) => { + const f = await fixture(t) + const rejected = assert.rejects(f.tunnel.fetch("/api/events"), /tunnel disconnected/) + await f.requestSent + f.socket().dispatchEvent(new MessageEvent("message", { data: "unexpected plaintext" })) + await rejected + assert.equal(f.socket().closedWith, 4000) +}) + +test("a stalled browser network buffer closes the tunnel without sending or replaying the next request", async (t) => { + const f = await fixture(t) + const rejected = assert.rejects(f.tunnel.fetch("/api/events"), /tunnel disconnected/) + await f.requestSent + f.socket().bufferedAmount = 24 * 1024 * 1024 + await assert.rejects(f.tunnel.fetch("/api/items", { method: "POST", body: "mutation" }), /tunnel disconnected/) + await rejected + assert.equal(f.socket().encryptedSends, 1) + assert.equal(f.socket().closedWith, 4000) +}) diff --git a/packages/ui/src/lib/remote-control/tunnel.ts b/packages/ui/src/lib/remote-control/tunnel.ts new file mode 100644 index 000000000..903c6782c --- /dev/null +++ b/packages/ui/src/lib/remote-control/tunnel.ts @@ -0,0 +1,551 @@ +import { + clientWebSocketCloseCode, + createClientHandshake, + decodeBase64, + encodeBase64, + FrameBudget, + REMOTE_CONTROL_MAX_HTTP_BODY_BYTES, + REMOTE_CONTROL_MAX_SOCKET_MESSAGE_BYTES, + type ClientToHostMessage, + type EncryptedChannel, + type HeaderEntries, + type HostToClientMessage, +} from "@codenomad/remote-control-protocol" +import { readBoundedBody } from "./bounded-body" +import { TunnelEventSource } from "./event-source" +import { + TunnelWebSocket, + tunnelAwareWebSocket, + type RemoteSocketBridge, + type RemoteSocketData, +} from "./web-socket" + +const HOST_KEY_STORAGE = "codenomad.remote-control.host-public-key" +const HTTP_IDLE_TIMEOUT_MS = 30_000 +const FETCH_PATH_PREFIXES = ["/api/", "/workspaces/"] +const MAX_ACTIVE_HTTP_REQUESTS = 32 +const MAX_PENDING_SOCKETS = 16 +const MAX_PENDING_FRAMES = 128 +const MAX_PENDING_FRAME_BYTES = 24 * 1024 * 1024 +const MAX_BUFFERED_HTTP_CHUNKS = 512 +const MAX_BUFFERED_HTTP_BYTES = 24 * 1024 * 1024 +const SOCKET_CLOSE_TIMEOUT_MS = 10_000 + +interface RemoteControlBootstrap { + tunnelPath: string +} + +interface PendingHttp { + resolve: (response: Response) => void + reject: (error: Error) => void + controller?: ReadableStreamDefaultController + queued: Array<{ bytes: Uint8Array; release: () => void }> + inFlightRelease?: () => void + ended: boolean + timeout: ReturnType + cleanup: () => void + releaseAdmission: () => void + method: string +} + +interface PendingSocket { + socket: TunnelWebSocket + opening: Promise + transmission: Promise + closeTimer?: ReturnType +} + +export async function installRemoteControlTransport(): Promise { + const bootstrap = window.__CODENOMAD_REMOTE_CONTROL__ ?? await discoverRemoteControl() + if (!bootstrap) return + window.__CODENOMAD_REMOTE_CONTROL__ = bootstrap + const hostPublicKey = loadHostPublicKey() + const NativeWebSocket = window.WebSocket + const tunnel = new RemoteControlTunnel(bootstrap, hostPublicKey, NativeWebSocket) + const nativeFetch = globalThis.fetch.bind(globalThis) + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = requestUrl(input) + if (url.origin === window.location.origin && FETCH_PATH_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) { + return tunnel.fetch(input, init) + } + return nativeFetch(input, init) + }) as typeof globalThis.fetch + window.EventSource = TunnelEventSource as unknown as typeof EventSource + window.WebSocket = tunnelAwareWebSocket(NativeWebSocket, tunnel, shouldTunnelUrl) +} + +async function discoverRemoteControl(): Promise { + try { + const response = await globalThis.fetch("/__codenomad/bootstrap", { credentials: "include", cache: "no-store" }) + if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return null + const value = await response.json() as Partial + return typeof value.tunnelPath === "string" && value.tunnelPath.startsWith("/") && !value.tunnelPath.startsWith("//") + ? { tunnelPath: value.tunnelPath } + : null + } catch { + return null + } +} + +export class RemoteControlTunnel implements RemoteSocketBridge { + private socket: WebSocket | null = null + private channel: EncryptedChannel | null = null + private connection: Promise | null = null + private sendQueue = Promise.resolve() + private receiveQueue = Promise.resolve() + private readonly sendBudget = new FrameBudget(MAX_PENDING_FRAMES, MAX_PENDING_FRAME_BYTES) + private readonly receiveBudget = new FrameBudget(MAX_PENDING_FRAMES, MAX_PENDING_FRAME_BYTES) + private readonly socketBudget = new FrameBudget(MAX_PENDING_FRAMES, MAX_PENDING_FRAME_BYTES) + private readonly httpBufferBudget = new FrameBudget(MAX_BUFFERED_HTTP_CHUNKS, MAX_BUFFERED_HTTP_BYTES) + private readonly pendingHttp = new Map() + private readonly pendingSockets = new Map() + private activeHttpRequests = 0 + + constructor( + private readonly bootstrap: RemoteControlBootstrap, + private readonly hostPublicKey: JsonWebKey, + private readonly NativeWebSocket: typeof WebSocket, + ) {} + + async fetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const source = input instanceof Request ? input : new Request(requestUrl(input), init) + const request = input instanceof Request && init ? new Request(input, init) : source + const id = crypto.randomUUID() + if (request.signal.aborted) throw new DOMException("The operation was aborted", "AbortError") + if (this.activeHttpRequests >= MAX_ACTIVE_HTTP_REQUESTS) { + return Response.json({ error: "Too many active remote requests" }, { status: 429 }) + } + this.activeHttpRequests += 1 + let admissionActive = true + const releaseAdmission = () => { + if (!admissionActive) return + admissionActive = false + this.activeHttpRequests -= 1 + } + let bodyBytes: Uint8Array | null + try { + bodyBytes = request.method === "GET" || request.method === "HEAD" + ? new Uint8Array() + : await readBoundedBody(request.body, REMOTE_CONTROL_MAX_HTTP_BODY_BYTES) + if (request.signal.aborted) throw new DOMException("The operation was aborted", "AbortError") + if (!bodyBytes) { + releaseAdmission() + return Response.json({ error: "Remote request body is too large" }, { status: 413 }) + } + await this.ensureConnected() + } catch (error) { + releaseAdmission() + throw error + } + const abort = () => { + void this.send({ type: "http.cancel", id }).catch(() => undefined) + this.failHttp(id, new DOMException("The operation was aborted", "AbortError")) + } + const response = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + void this.send({ type: "http.cancel", id }).catch(() => undefined) + this.failHttp(id, new Error("Remote request timed out")) + }, HTTP_IDLE_TIMEOUT_MS) + this.pendingHttp.set(id, { + resolve, + reject, + queued: [], + ended: false, + timeout, + cleanup: () => request.signal.removeEventListener("abort", abort), + releaseAdmission, + method: request.method, + }) + }) + request.signal.addEventListener("abort", abort, { once: true }) + if (request.signal.aborted) abort() + if (request.signal.aborted) return response + await this.send({ + type: "http.request", + id, + method: request.method, + path: `${requestUrl(request).pathname}${requestUrl(request).search}`, + headers: requestHeaders(request.headers), + ...(bodyBytes.byteLength ? { body: encodeBase64(bodyBytes) } : {}), + }).catch((error) => this.failHttp(id, error instanceof Error ? error : new Error("Remote request failed"))) + return response + } + + connectSocket(socket: TunnelWebSocket, url: URL, protocols: string[]): void { + if (this.pendingSockets.size >= MAX_PENDING_SOCKETS) { + queueMicrotask(() => { + socket.fail() + socket.finish(1008, "Too many active remote WebSockets", false) + }) + return + } + const opening = this.ensureConnected() + .then(() => this.send({ + type: "socket.open", + id: socket.id, + path: `${url.pathname}${url.search}`, + headers: [], + protocols, + })) + .catch(() => this.failSocket(socket.id)) + this.pendingSockets.set(socket.id, { socket, opening, transmission: opening }) + } + + transmitSocket(socket: TunnelWebSocket, data: RemoteSocketData): void { + const pending = this.pendingSockets.get(socket.id) + if (!pending || pending.socket !== socket) return + const byteLength = socketPayloadByteLength(data) + if (byteLength > REMOTE_CONTROL_MAX_SOCKET_MESSAGE_BYTES) { + this.failSocket(socket.id) + return + } + const release = this.socketBudget.reserve(byteLength) + if (!release) { + this.failSocket(socket.id) + return + } + socket.buffer(byteLength) + const transmission = pending.transmission.then(async () => { + const payload = await socketPayload(data) + await this.send({ type: "socket.message", id: socket.id, data: encodeBase64(payload.bytes), binary: payload.binary }) + }).finally(() => { + release() + socket.flush(byteLength) + }) + pending.transmission = transmission.catch(() => undefined) + void transmission.catch(() => this.failSocket(socket.id)) + } + + disconnectSocket(socket: TunnelWebSocket, code?: number, reason?: string): void { + const pending = this.pendingSockets.get(socket.id) + if (!pending || pending.socket !== socket) { + socket.finish(code, reason) + return + } + void pending.transmission + .then(() => this.send({ type: "socket.close", id: socket.id, code, reason })) + .then(() => { + if (this.pendingSockets.get(socket.id) !== pending) return + pending.closeTimer = setTimeout(() => { + pending.socket.fail() + this.finishSocket(socket.id, 1006, "Remote WebSocket close timed out", false) + }, SOCKET_CLOSE_TIMEOUT_MS) + }) + .catch(() => { + pending.socket.fail() + this.finishSocket(socket.id, 1006, "Remote WebSocket close failed", false) + }) + } + + private ensureConnected(): Promise { + if (this.channel && this.socket?.readyState === WebSocket.OPEN) return Promise.resolve() + if (this.connection) return this.connection + this.connection = this.connect().finally(() => { + this.connection = null + }) + return this.connection + } + + private async connect(): Promise { + const handshake = await createClientHandshake(this.hostPublicKey) + const url = new URL(this.bootstrap.tunnelPath, window.location.href) + url.protocol = url.protocol === "https:" ? "wss:" : "ws:" + const socket = new this.NativeWebSocket(url) + socket.binaryType = "arraybuffer" + this.socket = socket + this.channel = null + try { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Remote Control encryption handshake timed out")), 15_000) + socket.addEventListener("open", () => socket.send(handshake.hello), { once: true }) + socket.addEventListener("message", (event) => { + if (typeof event.data !== "string" || this.channel) return + void handshake.accept(event.data).then((channel) => { + clearTimeout(timeout) + if (this.socket !== socket) return + this.channel = channel + resolve() + }).catch(reject) + }) + socket.addEventListener("close", () => { + clearTimeout(timeout) + reject(new Error("Remote Control tunnel closed during encryption handshake")) + }, { once: true }) + socket.addEventListener("error", () => reject(new Error("Remote Control tunnel connection failed")), { once: true }) + }) + } catch (error) { + if (this.socket === socket) { + this.socket = null + this.channel = null + } + if (socket.readyState < WebSocket.CLOSING) socket.close() + throw error + } + this.sendQueue = Promise.resolve() + this.receiveQueue = Promise.resolve() + const channel = this.channel + if (!channel) throw new Error("Remote Control encrypted channel is unavailable") + socket.addEventListener("message", (event) => { + if (this.socket !== socket || this.channel !== channel) return + if (!(event.data instanceof ArrayBuffer)) { + this.close(1002, "Plaintext received after encryption handshake") + return + } + const frame = new Uint8Array(event.data) + const release = this.receiveBudget.reserve(frame.byteLength) + if (!release) { + this.close(1009, "Remote Control receive queue exceeded its safety limit") + return + } + const receive = this.receiveQueue.then(async () => { + if (this.socket !== socket || this.channel !== channel) return + await this.receive(channel, frame) + }).finally(release) + this.receiveQueue = receive.catch(() => { + if (this.socket === socket) this.close(1008, "Encrypted Remote Control frame failed") + }) + }) + socket.addEventListener("close", () => this.onSocketClosed(socket)) + socket.addEventListener("error", () => this.onSocketClosed(socket)) + } + + private send(message: ClientToHostMessage): Promise { + const plaintext = new TextEncoder().encode(JSON.stringify(message)) + const release = this.sendBudget.reserve(plaintext.byteLength) + if (!release) { + this.close(1009, "Remote Control send queue exceeded its safety limit") + return Promise.reject(new Error("Remote Control send queue exceeded its safety limit")) + } + const socket = this.socket + const channel = this.channel + const send = this.sendQueue.then(async () => { + if (!channel || !socket || this.channel !== channel || this.socket !== socket || socket.readyState !== WebSocket.OPEN) { + throw new Error("Remote Control tunnel is disconnected") + } + const frame = await channel.encrypt(plaintext) + if (this.channel !== channel || this.socket !== socket || socket.readyState !== WebSocket.OPEN) { + throw new Error("Remote Control tunnel is disconnected") + } + if (socket.bufferedAmount + frame.byteLength > MAX_PENDING_FRAME_BYTES) { + this.close(1009, "Remote Control network send buffer exceeded its safety limit") + throw new Error("Remote Control network send buffer exceeded its safety limit") + } + socket.send(frame) + }).finally(release) + this.sendQueue = send.catch(() => undefined) + return send + } + + private async receive(channel: EncryptedChannel, frame: Uint8Array): Promise { + const message = parseHostMessage(new TextDecoder().decode(await channel.decrypt(frame))) + if (!message) throw new Error("Invalid encrypted Remote Control response") + if (message.type === "http.start") this.startHttp(message) + else if (message.type === "http.chunk") this.chunkHttp(message.id, decodeBase64(message.data)) + else if (message.type === "http.end") this.endHttp(message.id) + else if (message.type === "http.error") this.failHttp(message.id, new Error(message.message)) + else if (message.type === "socket.ready") this.pendingSockets.get(message.id)?.socket.accept(message.protocol) + else if (message.type === "socket.message") { + if (base64ByteLength(message.data) > REMOTE_CONTROL_MAX_SOCKET_MESSAGE_BYTES) { + this.failSocket(message.id) + } else this.pendingSockets.get(message.id)?.socket.receive(decodeBase64(message.data), message.binary) + } + else if (message.type === "socket.error") this.failSocket(message.id) + else if (message.type === "socket.close") this.finishSocket(message.id, message.code, message.reason) + } + + private startHttp(message: Extract): void { + const pending = this.pendingHttp.get(message.id) + if (!pending) return + this.refreshTimeout(message.id) + const bodyAllowed = pending.method !== "HEAD" && !responseMustNotHaveBody(message.status) + const stream = bodyAllowed ? new ReadableStream({ + start: (controller) => { + pending.controller = controller + this.pumpHttp(message.id, pending) + }, + pull: () => { + pending.inFlightRelease?.() + pending.inFlightRelease = undefined + this.refreshTimeout(message.id) + this.pumpHttp(message.id, pending) + }, + cancel: () => { + void this.send({ type: "http.cancel", id: message.id }).catch(() => undefined) + this.releaseHttp(message.id, pending) + }, + }, { highWaterMark: 1 }) : null + pending.resolve(new Response(stream, { + status: message.status, + headers: message.headers, + })) + } + + private chunkHttp(id: string, chunk: Uint8Array): void { + const pending = this.pendingHttp.get(id) + if (!pending || pending.ended) return + this.refreshTimeout(id) + const release = this.httpBufferBudget.reserve(chunk.byteLength) + if (!release) { + void this.send({ type: "http.cancel", id }).catch(() => undefined) + this.failHttp(id, new Error("Remote response buffer exceeded its safety limit")) + return + } + pending.queued.push({ bytes: chunk, release }) + this.pumpHttp(id, pending) + } + + private endHttp(id: string): void { + const pending = this.pendingHttp.get(id) + if (!pending) return + pending.ended = true + pending.releaseAdmission() + this.refreshTimeout(id) + this.pumpHttp(id, pending) + } + + private failHttp(id: string, error: Error): void { + const pending = this.pendingHttp.get(id) + if (!pending) return + pending.reject(error) + pending.controller?.error(error) + this.releaseHttp(id, pending) + } + + private refreshTimeout(id: string): void { + const pending = this.pendingHttp.get(id) + if (!pending) return + clearTimeout(pending.timeout) + pending.timeout = setTimeout(() => { + void this.send({ type: "http.cancel", id }).catch(() => undefined) + this.failHttp(id, new Error("Remote response timed out")) + }, HTTP_IDLE_TIMEOUT_MS) + } + + private pumpHttp(id: string, pending: PendingHttp): void { + if (this.pendingHttp.get(id) !== pending || pending.inFlightRelease) return + if (!pending.controller) { + if (pending.ended) this.releaseHttp(id, pending) + return + } + const next = pending.queued.shift() + if (next) { + pending.inFlightRelease = next.release + pending.controller.enqueue(next.bytes) + return + } + if (!pending.ended) return + pending.controller.close() + this.releaseHttp(id, pending) + } + + private releaseHttp(id: string, pending: PendingHttp): void { + if (this.pendingHttp.get(id) !== pending) return + clearTimeout(pending.timeout) + pending.cleanup() + pending.releaseAdmission() + pending.inFlightRelease?.() + for (const queued of pending.queued) queued.release() + pending.queued.length = 0 + this.pendingHttp.delete(id) + } + + private close(code?: number, reason?: string): void { + const socket = this.socket + this.socket = null + this.channel = null + if (socket && socket.readyState < WebSocket.CLOSING) socket.close(clientWebSocketCloseCode(code), reason) + for (const id of Array.from(this.pendingHttp.keys())) this.failHttp(id, new Error("Remote Control tunnel disconnected")) + for (const id of Array.from(this.pendingSockets.keys())) this.finishSocket(id, 1006, "Remote Control tunnel disconnected", false) + } + + private onSocketClosed(socket: WebSocket): void { + if (this.socket === socket) this.close() + } + + private failSocket(id: string): void { + const pending = this.pendingSockets.get(id) + if (!pending) return + pending.socket.fail() + void this.send({ type: "socket.close", id, code: 1000, reason: "Remote WebSocket failed" }).catch(() => undefined) + this.finishSocket(id, 1006, "Remote WebSocket failed", false) + } + + private finishSocket(id: string, code?: number, reason?: string, wasClean = true): void { + const pending = this.pendingSockets.get(id) + if (!pending) return + this.pendingSockets.delete(id) + if (pending.closeTimer) clearTimeout(pending.closeTimer) + pending.socket.finish(code, reason, wasClean) + } +} + +function loadHostPublicKey(): JsonWebKey { + const raw = localStorage.getItem(HOST_KEY_STORAGE) + if (!raw) throw new Error("Remote Control encryption identity is missing") + const value = JSON.parse(raw) as JsonWebKey + if (value.kty !== "EC" || value.crv !== "P-256" || typeof value.x !== "string" || typeof value.y !== "string") { + throw new Error("Remote Control encryption identity is invalid") + } + return value +} + +function requestUrl(input: RequestInfo | URL): URL { + if (input instanceof Request) return new URL(input.url) + return new URL(input, window.location.href) +} + +function shouldTunnelUrl(url: URL): boolean { + const protocol = url.protocol === "ws:" ? "http:" : url.protocol === "wss:" ? "https:" : url.protocol + const comparable = new URL(url) + comparable.protocol = protocol + return comparable.origin === window.location.origin + && FETCH_PATH_PREFIXES.some((prefix) => comparable.pathname.startsWith(prefix)) +} + +async function socketPayload(data: RemoteSocketData): Promise<{ bytes: Uint8Array; binary: boolean }> { + if (typeof data === "string") return { bytes: new TextEncoder().encode(data), binary: false } + if (data instanceof Blob) return { bytes: new Uint8Array(await data.arrayBuffer()), binary: true } + if (ArrayBuffer.isView(data)) { + return { bytes: new Uint8Array(data.buffer, data.byteOffset, data.byteLength), binary: true } + } + return { bytes: new Uint8Array(data), binary: true } +} + +function socketPayloadByteLength(data: RemoteSocketData): number { + if (typeof data === "string") return new TextEncoder().encode(data).byteLength + if (data instanceof Blob) return data.size + return data.byteLength +} + +function requestHeaders(headers: Headers): HeaderEntries { + const result: HeaderEntries = [] + headers.forEach((value, name) => result.push([name, value])) + return result +} + +function parseHostMessage(value: string): HostToClientMessage | null { + try { + const message = JSON.parse(value) as Partial + if (typeof message.id !== "string" || typeof message.type !== "string") return null + if (message.type === "http.end") return message as HostToClientMessage + if (message.type === "http.chunk" && typeof message.data === "string") return message as HostToClientMessage + if (message.type === "http.error" && typeof message.message === "string") return message as HostToClientMessage + if (message.type === "http.start" && typeof message.status === "number" && Array.isArray(message.headers)) return message as HostToClientMessage + if (message.type === "socket.ready" || message.type === "socket.close") return message as HostToClientMessage + if (message.type === "socket.message" && typeof message.data === "string" && typeof message.binary === "boolean") return message as HostToClientMessage + if (message.type === "socket.error" && typeof message.message === "string") return message as HostToClientMessage + return null + } catch { + return null + } +} + +function base64ByteLength(value: string): number { + if (!value) return 0 + const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0 + return Math.floor(value.length * 3 / 4) - padding +} + +function responseMustNotHaveBody(status: number): boolean { + return status === 204 || status === 205 || status === 304 +} diff --git a/packages/ui/src/lib/remote-control/web-socket.test.ts b/packages/ui/src/lib/remote-control/web-socket.test.ts new file mode 100644 index 000000000..8a8d18ee3 --- /dev/null +++ b/packages/ui/src/lib/remote-control/web-socket.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { TunnelWebSocket, type RemoteSocketBridge } from "./web-socket" + +test("tunneled WebSocket preserves text, binary, protocol, and close events", () => { + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window") + const originalCloseEvent = Object.getOwnPropertyDescriptor(globalThis, "CloseEvent") + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { href: "https://host.remote.example/" } }, + }) + if (typeof CloseEvent === "undefined") { + Object.defineProperty(globalThis, "CloseEvent", { + configurable: true, + value: class extends Event { + readonly code: number + readonly reason: string + readonly wasClean: boolean + constructor(type: string, init: CloseEventInit) { + super(type) + this.code = init.code ?? 0 + this.reason = init.reason ?? "" + this.wasClean = init.wasClean ?? false + } + }, + }) + } + const calls: string[] = [] + const bridge: RemoteSocketBridge = { + connectSocket: (_socket, url, protocols) => calls.push(`open:${url.pathname}:${protocols.join(",")}`), + transmitSocket: (_socket, data) => calls.push(`send:${typeof data === "string" ? data : "binary"}`), + disconnectSocket: (_socket, code, reason) => calls.push(`close:${code}:${reason}`), + } + + try { + const socket = new TunnelWebSocket("/workspaces/demo/socket", ["v2"], bridge) + const events: string[] = [] + socket.onopen = () => events.push("open") + socket.onmessage = (event) => events.push(`message:${event.data}`) + socket.onclose = (event) => events.push(`close:${event.code}:${event.wasClean}`) + socket.buffer(12) + assert.equal(socket.bufferedAmount, 12) + socket.flush(5) + assert.equal(socket.bufferedAmount, 7) + socket.accept("v2") + socket.receive(new TextEncoder().encode("hello"), false) + socket.send("request") + socket.close(1000, "done") + socket.finish(1000, "done") + + assert.equal(socket.url, "wss://host.remote.example/workspaces/demo/socket") + assert.equal(socket.protocol, "v2") + assert.equal(socket.readyState, TunnelWebSocket.CLOSED) + assert.deepEqual(calls, ["open:/workspaces/demo/socket:v2", "send:request", "close:1000:done"]) + assert.deepEqual(events, ["open", "message:hello", "close:1000:true"]) + } finally { + if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow) + else delete (globalThis as { window?: unknown }).window + if (originalCloseEvent) Object.defineProperty(globalThis, "CloseEvent", originalCloseEvent) + else delete (globalThis as { CloseEvent?: unknown }).CloseEvent + } +}) + +test("tunneled WebSocket rejects transport metadata outside its bounded contract", () => { + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window") + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { href: "https://host.remote.example/" } }, + }) + const bridge: RemoteSocketBridge = { + connectSocket: () => assert.fail("invalid socket must not connect"), + transmitSocket: () => {}, + disconnectSocket: () => {}, + } + try { + assert.throws( + () => new TunnelWebSocket("/api/socket", Array.from({ length: 17 }, () => crypto.randomUUID()), bridge), + /Invalid WebSocket protocol/, + ) + assert.throws( + () => new TunnelWebSocket("/api/socket", [1] as unknown as string[], bridge), + /Invalid WebSocket protocol/, + ) + const socket = new TunnelWebSocket("/api/socket", undefined, { + ...bridge, + connectSocket: () => {}, + }) + assert.throws(() => socket.close(Number.NaN), /Invalid WebSocket close code/) + assert.throws(() => socket.close(3000.5), /Invalid WebSocket close code/) + } finally { + if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow) + else delete (globalThis as { window?: unknown }).window + } +}) diff --git a/packages/ui/src/lib/remote-control/web-socket.ts b/packages/ui/src/lib/remote-control/web-socket.ts new file mode 100644 index 000000000..9b6337644 --- /dev/null +++ b/packages/ui/src/lib/remote-control/web-socket.ts @@ -0,0 +1,150 @@ +export type RemoteSocketData = string | ArrayBufferLike | ArrayBufferView | Blob + +export interface RemoteSocketBridge { + connectSocket(socket: TunnelWebSocket, url: URL, protocols: string[]): void + transmitSocket(socket: TunnelWebSocket, data: RemoteSocketData): void + disconnectSocket(socket: TunnelWebSocket, code?: number, reason?: string): void +} + +type OpenHandler = ((event: Event) => unknown) | null +type MessageHandler = ((event: MessageEvent) => unknown) | null +type CloseHandler = ((event: CloseEvent) => unknown) | null + +const MAX_PROTOCOLS = 16 +const MAX_PROTOCOL_CHARS = 128 + +export class TunnelWebSocket extends EventTarget { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSING = 2 + static readonly CLOSED = 3 + + readonly CONNECTING = TunnelWebSocket.CONNECTING + readonly OPEN = TunnelWebSocket.OPEN + readonly CLOSING = TunnelWebSocket.CLOSING + readonly CLOSED = TunnelWebSocket.CLOSED + readonly id = crypto.randomUUID() + readonly url: string + readonly extensions = "" + binaryType: BinaryType = "blob" + private bufferedBytes = 0 + protocol = "" + readyState = TunnelWebSocket.CONNECTING + onopen: OpenHandler = null + onmessage: MessageHandler = null + onerror: OpenHandler = null + onclose: CloseHandler = null + + get bufferedAmount(): number { + return this.bufferedBytes + } + + constructor(url: string | URL, protocols: string | string[] | undefined, private readonly bridge: RemoteSocketBridge) { + super() + const target = new URL(url, window.location.href) + if (target.protocol === "http:") target.protocol = "ws:" + else if (target.protocol === "https:") target.protocol = "wss:" + if ((target.protocol !== "ws:" && target.protocol !== "wss:") || target.hash) { + throw new DOMException("Invalid WebSocket URL", "SyntaxError") + } + this.url = target.toString() + bridge.connectSocket(this, target, normalizeProtocols(protocols)) + } + + send(data: RemoteSocketData): void { + if (this.readyState !== TunnelWebSocket.OPEN) throw new DOMException("WebSocket is not open", "InvalidStateError") + this.bridge.transmitSocket(this, data) + } + + close(code?: number, reason = ""): void { + validateClose(code, reason) + if (this.readyState === TunnelWebSocket.CLOSING || this.readyState === TunnelWebSocket.CLOSED) return + this.readyState = TunnelWebSocket.CLOSING + this.bridge.disconnectSocket(this, code, reason) + } + + accept(protocol?: string): void { + if (this.readyState !== TunnelWebSocket.CONNECTING) return + this.protocol = protocol ?? "" + this.readyState = TunnelWebSocket.OPEN + const event = new Event("open") + this.dispatchEvent(event) + this.onopen?.(event) + } + + buffer(byteLength: number): void { + this.bufferedBytes += byteLength + } + + flush(byteLength: number): void { + this.bufferedBytes = Math.max(0, this.bufferedBytes - byteLength) + } + + receive(data: Uint8Array, binary: boolean): void { + if (this.readyState !== TunnelWebSocket.OPEN) return + const payload = binary + ? this.binaryType === "arraybuffer" + ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) + : new Blob([new Uint8Array(data).buffer]) + : new TextDecoder().decode(data) + const event = new MessageEvent("message", { data: payload, origin: new URL(this.url).origin }) + this.dispatchEvent(event) + this.onmessage?.(event) + } + + fail(): void { + if (this.readyState === TunnelWebSocket.CLOSED) return + const event = new Event("error") + this.dispatchEvent(event) + this.onerror?.(event) + } + + finish(code = 1005, reason = "", wasClean = true): void { + if (this.readyState === TunnelWebSocket.CLOSED) return + this.readyState = TunnelWebSocket.CLOSED + this.bufferedBytes = 0 + const event = new CloseEvent("close", { code, reason, wasClean }) + this.dispatchEvent(event) + this.onclose?.(event) + } +} + +export function tunnelAwareWebSocket( + NativeWebSocket: typeof WebSocket, + bridge: RemoteSocketBridge, + shouldTunnel: (url: URL) => boolean, +): typeof WebSocket { + return new Proxy(NativeWebSocket, { + get(target, property, receiver) { + if (property === Symbol.hasInstance) { + return (value: unknown) => value instanceof TunnelWebSocket || value instanceof target + } + return Reflect.get(target, property, receiver) + }, + construct(target, args, newTarget) { + if (!args.length) return Reflect.construct(target, args, newTarget) + const url = new URL(args[0] as string | URL, window.location.href) + if (shouldTunnel(url)) return new TunnelWebSocket(url, args[1] as string | string[] | undefined, bridge) + return Reflect.construct(target, args, newTarget) + }, + }) +} + +function normalizeProtocols(value: string | string[] | undefined): string[] { + const protocols = value === undefined ? [] : typeof value === "string" ? [value] : [...value] + if (protocols.length > MAX_PROTOCOLS || new Set(protocols).size !== protocols.length + || protocols.some((protocol) => typeof protocol !== "string" || protocol.length > MAX_PROTOCOL_CHARS + || !/^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/.test(protocol))) { + throw new DOMException("Invalid WebSocket protocol", "SyntaxError") + } + return protocols +} + +function validateClose(code: number | undefined, reason: string): void { + if (code !== undefined && (!Number.isInteger(code) || (code !== 1000 && (code < 3000 || code > 4999)))) { + throw new DOMException("Invalid WebSocket close code", "InvalidAccessError") + } + if (new TextEncoder().encode(reason).byteLength > 123) { + throw new DOMException("WebSocket close reason is too long", "SyntaxError") + } +} diff --git a/packages/ui/src/renderer/index.html b/packages/ui/src/renderer/index.html index dd372bace..e156885dc 100644 --- a/packages/ui/src/renderer/index.html +++ b/packages/ui/src/renderer/index.html @@ -27,6 +27,6 @@
- + diff --git a/packages/ui/src/styles/components/remote-control.css b/packages/ui/src/styles/components/remote-control.css new file mode 100644 index 000000000..bec7ff0df --- /dev/null +++ b/packages/ui/src/styles/components/remote-control.css @@ -0,0 +1,104 @@ +.remote-control-status { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border: 1px solid var(--border-base); + background: var(--surface-secondary); +} + +.remote-control-status strong, +.remote-control-device strong { + color: var(--text-primary); +} + +.remote-control-status p, +.remote-control-device p { + margin: 2px 0 0; + color: var(--text-secondary); + font-size: 12px; +} + +.remote-control-status-dot { + width: 10px; + height: 10px; + flex: 0 0 auto; + background: var(--text-subtle); +} + +.remote-control-status[data-state="connected"] .remote-control-status-dot { + background: var(--status-success, #3fb950); +} + +.remote-control-status[data-state="connecting"] .remote-control-status-dot, +.remote-control-status[data-state="reconnecting"] .remote-control-status-dot { + background: var(--status-warning, #d29922); +} + +.remote-control-status[data-state="error"] .remote-control-status-dot { + background: var(--status-danger, #f85149); +} + +.remote-control-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.remote-control-pairing { + align-items: flex-start; +} + +.remote-control-qr, +.remote-control-qr-loading { + width: 180px; + height: 180px; + align-self: center; + border: 1px solid var(--border-base); + background: #fff; + image-rendering: pixelated; +} + +.remote-control-qr-loading { + padding: 72px; + color: var(--text-secondary); + background: var(--surface-secondary); +} + +.remote-control-link { + display: block; + width: 100%; + padding: 10px; + overflow-wrap: anywhere; + border: 1px solid var(--border-base); + background: var(--surface-primary); + color: var(--text-secondary); + font-size: 11px; +} + +.remote-control-devices { + display: flex; + flex-direction: column; + border: 1px solid var(--border-base); +} + +.remote-control-device { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px; + border-bottom: 1px solid var(--border-base); +} + +.remote-control-device:last-child { + border-bottom: 0; +} + +.remote-spin { + animation: remote-spin 1s linear infinite; +} + +@keyframes remote-spin { + to { transform: rotate(360deg); } +} diff --git a/packages/ui/src/styles/controls.css b/packages/ui/src/styles/controls.css index 74ae3a091..e326a5662 100644 --- a/packages/ui/src/styles/controls.css +++ b/packages/ui/src/styles/controls.css @@ -11,6 +11,7 @@ @import "./components/env-vars.css"; @import "./components/directory-browser.css"; @import "./components/remote-access.css"; +@import "./components/remote-control.css"; @import "./components/permission-notification.css"; @import "./components/form-request.css"; @import "./components/form-request-options.css"; diff --git a/packages/ui/src/types/global.d.ts b/packages/ui/src/types/global.d.ts index fb314aa1c..93d251aec 100644 --- a/packages/ui/src/types/global.d.ts +++ b/packages/ui/src/types/global.d.ts @@ -75,7 +75,7 @@ declare global { releaseBrowserOpen?: (requestID: string) => Promise onBrowserOpenRequest?: (callback: (payload: { sessionID: string; url: string; requestID: string }) => void) => () => void - showNotification?: (payload: { title: string; body: string }) => Promise<{ ok: boolean; reason?: string }> + showNotification?: (payload: { title: string; body: string }) => Promise<{ ok: boolean; reason?: string }> openRemoteWindow?: (payload: { id: string name: string @@ -123,6 +123,7 @@ declare global { interface Window { __CODENOMAD_API_BASE__?: string __CODENOMAD_EVENTS_URL__?: string + __CODENOMAD_REMOTE_CONTROL__?: { tunnelPath: string } __CODENOMAD_RUNTIME_HOST__?: "electron" | "tauri" | "web" __CODENOMAD_WINDOW_CONTEXT__?: "local" | "remote" | "preferences" readonly __CODENOMAD_WINDOW_ID__?: string | null diff --git a/scripts/desktop-server-resources.cjs b/scripts/desktop-server-resources.cjs index 20684fdf0..2beeafc70 100644 --- a/scripts/desktop-server-resources.cjs +++ b/scripts/desktop-server-resources.cjs @@ -49,7 +49,11 @@ function validateServerProductionLock(lock) { visited.add(packagePath) const pkg = packages[packagePath] if (!pkg) throw new Error(`Root package-lock.json is missing ${packagePath}`) - if (packagePath !== "packages/server" && (!pkg.version || !pkg.resolved || !pkg.integrity)) { + if (pkg.link && typeof pkg.resolved === "string") { + pending.push(pkg.resolved) + continue + } + if (!packagePath.startsWith("packages/") && (!pkg.version || !pkg.resolved || !pkg.integrity)) { throw new Error(`Root package-lock.json does not integrity-pin ${packagePath}`) } const dependencies = { ...pkg.dependencies, ...pkg.optionalDependencies } @@ -62,19 +66,58 @@ function validateServerProductionLock(lock) { return visited } +function stagePrebuiltWorkspacePackage(source, destination) { + const sourceManifest = path.join(source, "package.json") + const sourceDist = path.join(source, "dist") + if (!fs.existsSync(sourceDist)) { + throw new Error(`Missing prebuilt workspace artifact: ${sourceDist}`) + } + + const manifest = JSON.parse(fs.readFileSync(sourceManifest, "utf8")) + delete manifest.scripts + fs.mkdirSync(destination, { recursive: true }) + fs.writeFileSync(path.join(destination, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`) + fs.cpSync(sourceDist, path.join(destination, "dist"), { recursive: true }) +} + +function materializePrebuiltWorkspacePackage(source, nodeModulesRoot) { + const manifest = JSON.parse(fs.readFileSync(path.join(source, "package.json"), "utf8")) + const packageName = manifest.name + const packageParts = typeof packageName === "string" ? packageName.split("/") : [] + const validShape = packageParts.length === 1 + ? !packageParts[0].startsWith("@") + : packageParts.length === 2 && packageParts[0].startsWith("@") + if (!validShape || packageParts.some((part) => !part || part === "." || part === ".." || part.includes("\\"))) { + throw new Error(`Invalid workspace package name in ${source}`) + } + + const destination = path.join(nodeModulesRoot, ...packageParts) + fs.rmSync(destination, { recursive: true, force: true }) + stagePrebuiltWorkspacePackage(source, destination) + return destination +} + function stagePackagedServer(options) { const { workspaceRoot, serverRoot, log = () => {}, env = process.env } = options const npmTarget = resolveNpmTarget(options.target || env.CODENOMAD_NODE_TARGET) const lockPath = path.join(workspaceRoot, "package-lock.json") - validateServerProductionLock(JSON.parse(fs.readFileSync(lockPath, "utf8"))) + const productionClosure = validateServerProductionLock(JSON.parse(fs.readFileSync(lockPath, "utf8"))) const stagingRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-server-")) const stagedServerRoot = path.join(stagingRoot, "packages", "server") + const workspacePackageSources = [] try { fs.mkdirSync(stagedServerRoot, { recursive: true }) fs.copyFileSync(path.join(workspaceRoot, "package.json"), path.join(stagingRoot, "package.json")) fs.copyFileSync(lockPath, path.join(stagingRoot, "package-lock.json")) fs.copyFileSync(path.join(serverRoot, "package.json"), path.join(stagedServerRoot, "package.json")) + for (const packagePath of productionClosure) { + if (!packagePath.startsWith("packages/") || packagePath === "packages/server" || packagePath.includes("/node_modules/")) continue + const source = path.join(workspaceRoot, packagePath) + const destination = path.join(stagingRoot, packagePath) + stagePrebuiltWorkspacePackage(source, destination) + workspacePackageSources.push(source) + } log(`installing production server dependencies from the workspace lock for ${npmTarget.target}`) const npmArgs = [ @@ -105,6 +148,14 @@ function stagePackagedServer(options) { fs.rmSync(path.join(rootModules, "@neuralnomads", "codenomad"), { recursive: true, force: true }) fs.cpSync(rootModules, serverModules, { recursive: true, dereference: true }) if (fs.existsSync(serverOverrides)) fs.cpSync(serverOverrides, serverModules, { recursive: true, dereference: true }) + // Recursive copies retain nested npm workspace links on POSIX. Replace them + // explicitly so desktop packagers never receive links back into the checkout. + for (const source of workspacePackageSources) { + materializePrebuiltWorkspacePackage(source, serverModules) + } + if (workspacePackageSources.length > 0) { + log(`materialized ${workspacePackageSources.length} prebuilt workspace package(s)`) + } for (const artifact of ["public", "dist"]) { fs.cpSync(path.join(serverRoot, artifact), path.join(stagedServerRoot, artifact), { recursive: true }) } @@ -321,7 +372,9 @@ function pruneKnownServerDependencies(root, log) { module.exports = { copyPackagedServerResources, + materializePrebuiltWorkspacePackage, resolveNpmTarget, + stagePrebuiltWorkspacePackage, stagePackagedServer, validateServerProductionLock, } diff --git a/scripts/desktop-server-resources.test.cjs b/scripts/desktop-server-resources.test.cjs index 9651b7b5a..fb21e8ebe 100644 --- a/scripts/desktop-server-resources.test.cjs +++ b/scripts/desktop-server-resources.test.cjs @@ -3,7 +3,12 @@ const fs = require("node:fs") const os = require("node:os") const path = require("node:path") const test = require("node:test") -const { resolveNpmTarget, validateServerProductionLock } = require("./desktop-server-resources.cjs") +const { + materializePrebuiltWorkspacePackage, + resolveNpmTarget, + stagePrebuiltWorkspacePackage, + validateServerProductionLock, +} = require("./desktop-server-resources.cjs") const { resolveEsbuildExecutable } = require("../packages/tauri-app/scripts/prebuild.js") const { copyPackagedServerResources } = require("./desktop-server-resources.cjs") @@ -27,9 +32,59 @@ test("integrity-pins the full server production closure in the root lock", () => assert.equal(lock.packages["node_modules/undici"].version, "6.28.1") assert.equal(lock.packages["packages/server/node_modules/commander"].version, "12.1.0") assert.equal(lock.packages["packages/server/node_modules/fuzzysort"].version, "2.0.4") + assert.ok(closure.has("packages/remote-control-protocol")) assert.equal(closure.has("node_modules/@opencode/plugin"), false, "the opt-in pruning plugin API is not a server production dependency") }) +test("stages prebuilt workspace packages without install lifecycle scripts", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-workspace-stage-")) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const source = path.join(root, "source") + const destination = path.join(root, "destination") + fs.mkdirSync(path.join(source, "dist"), { recursive: true }) + fs.writeFileSync(path.join(source, "package.json"), JSON.stringify({ + name: "@codenomad/example", + version: "1.0.0", + scripts: { prepare: "npm run build" }, + })) + fs.writeFileSync(path.join(source, "dist", "index.js"), "export {}\n") + + stagePrebuiltWorkspacePackage(source, destination) + + const manifest = JSON.parse(fs.readFileSync(path.join(destination, "package.json"), "utf8")) + assert.equal(manifest.scripts, undefined) + assert.equal(fs.readFileSync(path.join(destination, "dist", "index.js"), "utf8"), "export {}\n") +}) + +test("materializes workspace packages instead of retaining install links", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-workspace-materialize-")) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const source = path.join(root, "source") + const linkedSource = path.join(root, "linked-source") + const nodeModules = path.join(root, "node_modules") + const destination = path.join(nodeModules, "@codenomad", "example") + fs.mkdirSync(path.join(source, "dist"), { recursive: true }) + fs.mkdirSync(linkedSource, { recursive: true }) + fs.mkdirSync(path.dirname(destination), { recursive: true }) + fs.writeFileSync(path.join(source, "package.json"), JSON.stringify({ + name: "@codenomad/example", + version: "1.0.0", + scripts: { prepare: "npm run build" }, + })) + fs.writeFileSync(path.join(source, "dist", "index.js"), "export const value = 'packaged'\n") + fs.writeFileSync(path.join(linkedSource, "sentinel"), "keep\n") + fs.symlinkSync(linkedSource, destination, process.platform === "win32" ? "junction" : "dir") + + assert.equal(fs.lstatSync(destination).isSymbolicLink(), true) + assert.equal(materializePrebuiltWorkspacePackage(source, nodeModules), destination) + + assert.equal(fs.lstatSync(destination).isSymbolicLink(), false) + assert.equal(fs.readFileSync(path.join(destination, "dist", "index.js"), "utf8"), "export const value = 'packaged'\n") + assert.equal(fs.readFileSync(path.join(linkedSource, "sentinel"), "utf8"), "keep\n") + const manifest = JSON.parse(fs.readFileSync(path.join(destination, "package.json"), "utf8")) + assert.equal(manifest.scripts, undefined) +}) + test("rejects an unpinned production dependency despite an otherwise valid lock", () => { const root = path.resolve(__dirname, "..") const lock = JSON.parse(fs.readFileSync(path.join(root, "package-lock.json"), "utf8")) diff --git a/scripts/smoke-packaged-resources.cjs b/scripts/smoke-packaged-resources.cjs index a1aa97818..5ed30ec1c 100644 --- a/scripts/smoke-packaged-resources.cjs +++ b/scripts/smoke-packaged-resources.cjs @@ -2,9 +2,11 @@ const fs = require("fs") const path = require("path") const { spawnSync } = require("child_process") +const { pathToFileURL } = require("url") const { MANAGED_NODE_VERSION } = require("./prepare-node-runtime.cjs") const requiredPackages = [ + "@codenomad/remote-control-protocol", "yaml", "fastify", "@fastify/static", @@ -17,6 +19,7 @@ const requiredPackages = [ "zod", "node-forge", ] +const materializedWorkspacePackages = new Set(["@codenomad/remote-control-protocol"]) function parseArgs(argv) { const options = {} @@ -75,6 +78,9 @@ function smokeServer(resourcesRoot, target) { for (const packageName of requiredPackages) { const packageRoot = path.join(serverRoot, "node_modules", ...packageName.split("/")) if (!fs.existsSync(packageRoot)) throw new Error(`Missing packaged dependency: ${packageName}`) + if (materializedWorkspacePackages.has(packageName) && fs.lstatSync(packageRoot).isSymbolicLink()) { + throw new Error(`Packaged workspace dependency is still a symbolic link: ${packageName}`) + } } console.log(`packaged server static checks ok for ${target}`) @@ -95,9 +101,12 @@ function smokeServer(resourcesRoot, target) { `for (const name of ${JSON.stringify(requiredPackages)}) await import(name);`, "console.log('packaged dependency imports ok');", ].join(" ") + const loaderFileUrl = pathToFileURL(path.join(serverRoot, "dist", "loader.js")).href + const registerScript = `import { register } from "node:module"; import { pathToFileURL } from "node:url"; register(${JSON.stringify(loaderFileUrl)}, pathToFileURL("./"));` + const loaderArg = `data:text/javascript,${encodeURIComponent(registerScript)}` - // Resolve from the packaged server, not the build checkout. The V2 client is ESM-only. - run(node, ["--input-type=module", "-e", importScript], { cwd: serverRoot }) + // Resolve from the packaged server with the same loader used by its entrypoint. + run(node, ["--import", loaderArg, "--input-type=module", "-e", importScript], { cwd: serverRoot }) } function smokeLoadingAssets(loadingRoot) {