From dd42d4675330e2f007d15490e05c8fe8cda3a008 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:09:14 +0400 Subject: [PATCH] refactor: cleanup --- .github/workflows/publish-sidecars.yml | 87 +- AGENTS.md | 20 +- _typos.toml | 4 + donut-sync/src/sync/sync.service.ts | 3 +- e2e/README.md | 24 +- e2e/app/Cargo.lock | 397 +- e2e/app/Cargo.toml | 2 +- e2e/coverage-map.mjs | 55 + e2e/lib/app.mjs | 113 +- e2e/lib/fixtures.mjs | 211 +- e2e/lib/limits.mjs | 21 + e2e/lib/webdriver.mjs | 81 +- e2e/run.mjs | 102 +- e2e/tests/browser.test.mjs | 659 +- e2e/tests/coverage.test.mjs | 9 +- e2e/tests/entities.test.mjs | 661 ++ e2e/tests/integrations.test.mjs | 954 ++- e2e/tests/motion.test.mjs | 1886 +++++ e2e/tests/network.test.mjs | 175 +- e2e/tests/smoke.test.mjs | 113 + e2e/tests/sync.test.mjs | 50 +- e2e/tests/ui.test.mjs | 1032 ++- package.json | 11 +- pnpm-lock.yaml | 17 +- pnpm-workspace.yaml | 2 +- scripts/redact-sensitive-text.mjs | 8 +- sdk/.gitignore | 11 + sdk/README.md | 285 + sdk/api-paths.json | 363 + sdk/node/package.json | 41 + sdk/node/src/client.mts | 1083 +++ sdk/node/src/coverage.mts | 104 + sdk/node/src/errors.mts | 211 + sdk/node/src/index.mts | 41 + sdk/node/src/types.mts | 634 ++ sdk/node/test/configuration.test.mts | 109 + sdk/node/test/coverage.test.mts | 97 + sdk/node/test/errors.test.mts | 219 + sdk/node/test/fake-donut.mts | 136 + sdk/node/test/requests.test.mts | 768 ++ sdk/node/test/session.test.mts | 134 + sdk/node/test/support.mts | 22 + sdk/node/tsconfig.json | 24 + sdk/python/README.md | 37 + sdk/python/pyproject.toml | 40 + sdk/python/src/donutbrowser/__init__.py | 59 + sdk/python/src/donutbrowser/client.py | 1183 +++ sdk/python/src/donutbrowser/coverage.py | 113 + sdk/python/src/donutbrowser/errors.py | 240 + sdk/python/src/donutbrowser/models.py | 763 ++ sdk/python/src/donutbrowser/py.typed | 0 sdk/python/tests/conftest.py | 31 + sdk/python/tests/fake_donut.py | 144 + sdk/python/tests/test_configuration.py | 83 + sdk/python/tests/test_coverage.py | 73 + sdk/python/tests/test_errors.py | 168 + sdk/python/tests/test_requests.py | 721 ++ sdk/python/tests/test_session.py | 92 + sdk/tools/extract-api-paths.py | 145 + src-tauri/Cargo.lock | 281 +- src-tauri/Cargo.toml | 21 +- src-tauri/src/agent.rs | 1229 ++++ src-tauri/src/api_server.rs | 993 ++- src-tauri/src/app_dirs.rs | 328 +- src-tauri/src/auto_updater.rs | 14 + src-tauri/src/bin/proxy_server.rs | 2 +- src-tauri/src/browser.rs | 1 + src-tauri/src/browser_runner.rs | 114 +- src-tauri/src/cdp_target.rs | 204 +- src-tauri/src/cloud_auth.rs | 656 +- src-tauri/src/cloud_errors.rs | 20 +- src-tauri/src/cookie_bot.rs | 194 +- src-tauri/src/data_root.rs | 1036 +++ src-tauri/src/downloaded_browsers_registry.rs | 238 +- src-tauri/src/downloader.rs | 29 + src-tauri/src/ephemeral_dirs.rs | 1 + src-tauri/src/extension_fetch.rs | 600 ++ src-tauri/src/extension_manager.rs | 41 +- src-tauri/src/fingerprint_consistency.rs | 422 +- src-tauri/src/geoip_downloader.rs | 83 +- src-tauri/src/geolocation.rs | 78 + src-tauri/src/group_bookmarks.rs | 1175 +++ src-tauri/src/group_manager.rs | 52 + src-tauri/src/launch_gate.rs | 149 +- src-tauri/src/lib.rs | 1141 ++- src-tauri/src/log_redaction.rs | 37 +- src-tauri/src/mcp_integrations.rs | 2062 +++++- src-tauri/src/mcp_remote.rs | 2341 ++++++ src-tauri/src/mcp_server.rs | 6383 ++++++++++++++++- src-tauri/src/platform_browser.rs | 105 +- src-tauri/src/profile/clear_on_close.rs | 43 +- src-tauri/src/profile/manager.rs | 468 +- src-tauri/src/profile/mod.rs | 2 + src-tauri/src/profile/password.rs | 69 +- src-tauri/src/profile/portable.rs | 765 ++ src-tauri/src/profile/trash.rs | 917 +++ src-tauri/src/profile/types.rs | 7 + src-tauri/src/profile_import/os_crypt.rs | 21 +- src-tauri/src/profile_importer.rs | 2 + src-tauri/src/proxy_distribution.rs | 442 ++ src-tauri/src/proxy_manager.rs | 521 +- src-tauri/src/proxy_server.rs | 758 +- src-tauri/src/proxy_storage.rs | 70 + src-tauri/src/proxy_udp.rs | 431 ++ src-tauri/src/recorder.rs | 560 ++ src-tauri/src/remote_exit.rs | 147 +- src-tauri/src/remote_handoff.rs | 63 +- src-tauri/src/remote_session.rs | 274 +- src-tauri/src/settings_manager.rs | 688 +- src-tauri/src/socks5_local.rs | 3 +- src-tauri/src/sync/encryption.rs | 111 +- src-tauri/src/sync/engine.rs | 310 +- src-tauri/src/sync/manifest.rs | 37 +- src-tauri/src/sync/preflight.rs | 2 +- src-tauri/src/sync/scheduler.rs | 49 +- src-tauri/src/synchronizer.rs | 912 ++- src-tauri/src/team_lock.rs | 9 +- src-tauri/src/vpn/config.rs | 63 + src-tauri/src/vpn/storage.rs | 58 + src-tauri/src/vpn_extension_detect/rules.rs | 33 +- src-tauri/src/vpn_worker_runner.rs | 237 +- src-tauri/src/vpn_worker_storage.rs | 6 + src-tauri/src/wayfern_cdp.rs | 2145 ++++++ src-tauri/src/wayfern_manager.rs | 2547 ++++++- src-tauri/src/wayfern_persona.rs | 393 + src-tauri/src/wayfern_terms.rs | 4 + src-tauri/src/xray/model.rs | 45 +- src-tauri/src/xray/uri.rs | 37 +- src/app/page.tsx | 1317 ++-- src/components/about-dialog.tsx | 100 +- src/components/account-page.tsx | 23 +- src/components/agent-page.tsx | 248 + src/components/agent-recipe-recorder.tsx | 205 + src/components/agent-recipe-steps.tsx | 423 ++ src/components/agent-recipes.tsx | 308 + src/components/agent-run-form.tsx | 491 ++ src/components/agent-run-history.tsx | 244 + src/components/agent-run-view.tsx | 276 + src/components/agent-shared.tsx | 215 + src/components/assignment-impact.tsx | 88 + src/components/client-providers.tsx | 16 +- src/components/command-palette.tsx | 4 + src/components/cookie-bot-activity.tsx | 148 +- src/components/cookie-bot-enrol-dialog.tsx | 332 +- src/components/cookie-bot-overview.tsx | 151 +- src/components/cookie-bot-schedule.tsx | 307 +- src/components/cookie-bot-shared.tsx | 74 +- src/components/custom-toast.tsx | 355 +- src/components/data-root-setting.tsx | 276 + src/components/data-table-action-bar.tsx | 48 +- src/components/donut-snack.tsx | 104 + .../extension-management-dialog.tsx | 282 +- src/components/group-bookmarks-dialog.tsx | 331 + src/components/group-management-dialog.tsx | 67 +- src/components/home-header.tsx | 68 +- src/components/import-profile-dialog.tsx | 259 +- src/components/integration-diagnostics.tsx | 127 + src/components/integrations-dialog.tsx | 1361 +++- src/components/onboarding-card.tsx | 13 +- src/components/pre-launch-gate-dialog.tsx | 24 + src/components/profile-data-table.tsx | 524 +- src/components/profile-group-drag.tsx | 564 ++ src/components/profile-handoff-status.tsx | 185 + src/components/profile-info-dialog.tsx | 541 +- src/components/profile-isolation-demo.tsx | 290 + src/components/profile-launch-activity.tsx | 101 + src/components/profile-metadata-card.tsx | 146 + src/components/profile-sync-dialog.tsx | 4 +- src/components/profile-transfer.tsx | 234 + src/components/profile-usage-button.tsx | 59 + src/components/proxy-check-button.tsx | 539 +- src/components/proxy-distribution-dialog.tsx | 421 ++ src/components/proxy-form-dialog.tsx | 157 +- src/components/proxy-import-dialog.tsx | 75 +- src/components/proxy-management-dialog.tsx | 293 +- src/components/rail-nav.tsx | 89 +- src/components/settings-dialog.tsx | 419 +- src/components/shortcuts-page.tsx | 51 +- src/components/sync-all-dialog.tsx | 87 +- src/components/sync-config-dialog.tsx | 3 +- src/components/sync-follower-dialog.tsx | 184 +- src/components/synchronizer-panel.tsx | 287 + src/components/synchronizer-rehearsal.tsx | 153 + src/components/thank-you-dialog.tsx | 40 +- src/components/trash-page.tsx | 353 + src/components/ui/animated-disclosure.tsx | 33 +- src/components/ui/animated-tabs.tsx | 26 +- src/components/ui/auto-height.tsx | 15 +- src/components/ui/command.tsx | 10 +- src/components/ui/confirmation-mark.tsx | 22 + src/components/ui/dialog.tsx | 59 +- src/components/ui/dropdown-menu.tsx | 4 +- src/components/ui/operation-flow.tsx | 88 + src/components/ui/popover.tsx | 2 +- src/components/ui/progress.tsx | 3 +- src/components/ui/select.tsx | 2 +- src/components/ui/step-transition.tsx | 7 +- src/components/ui/tabs.tsx | 37 +- src/components/ui/tooltip.tsx | 2 +- src/components/vpn-check-button.tsx | 65 +- src/components/wayfern-config-dialog.tsx | 1 + src/components/wayfern-config-form.tsx | 260 +- src/components/wayfern-terms-dialog.tsx | 16 +- src/components/welcome-dialog.tsx | 96 +- src/generated/licenses.json | 124 + src/hooks/use-agent-run.ts | 225 + src/hooks/use-auto-height.tsx | 4 +- src/hooks/use-browser-download.ts | 36 +- src/hooks/use-input-modality.tsx | 42 + src/hooks/use-konami-code.ts | 72 + src/hooks/use-launch-activity.tsx | 68 + src/hooks/use-profile-references.ts | 38 + src/hooks/use-remote-handoff.ts | 50 +- src/hooks/use-sync-session.ts | 20 +- src/hooks/use-trash-events.ts | 65 + src/hooks/use-wayfern-terms.ts | 15 + src/i18n/locales/en.json | 719 +- src/i18n/locales/es.json | 730 +- src/i18n/locales/fr.json | 730 +- src/i18n/locales/ja.json | 722 +- src/i18n/locales/ko.json | 722 +- src/i18n/locales/pt.json | 730 +- src/i18n/locales/ru.json | 738 +- src/i18n/locales/tr.json | 722 +- src/i18n/locales/vi.json | 722 +- src/i18n/locales/zh.json | 722 +- src/lib/agent.test.mjs | 190 + src/lib/agent.ts | 367 + src/lib/backend-errors.test.mjs | 139 + src/lib/backend-errors.ts | 323 +- src/lib/browser-utils.ts | 40 +- src/lib/cookie-bot-limits.test.mjs | 13 +- src/lib/cookie-bot-limits.ts | 16 +- src/lib/cookie-bot-outcomes.test.mjs | 292 + src/lib/cookie-bot.ts | 23 +- src/lib/entitlements.ts | 79 +- src/lib/format-bytes.ts | 14 + src/lib/i18n-parity.test.mjs | 234 + src/lib/proxy-check-store.ts | 143 + src/lib/proxy-first-hop-claims.test.mjs | 185 + src/lib/proxy-string.test.mjs | 192 + src/lib/proxy-string.ts | 131 +- src/lib/proxy-type.test.mjs | 242 + src/lib/proxy-type.ts | 56 + src/lib/remote-sessions.ts | 6 +- src/lib/schedule-layout.test.mjs | 81 + src/lib/schedule-layout.ts | 94 + src/lib/shortcuts.ts | 24 +- src/types.ts | 194 +- 249 files changed, 67417 insertions(+), 6659 deletions(-) create mode 100644 e2e/lib/limits.mjs create mode 100644 e2e/tests/motion.test.mjs create mode 100644 sdk/.gitignore create mode 100644 sdk/README.md create mode 100644 sdk/api-paths.json create mode 100644 sdk/node/package.json create mode 100644 sdk/node/src/client.mts create mode 100644 sdk/node/src/coverage.mts create mode 100644 sdk/node/src/errors.mts create mode 100644 sdk/node/src/index.mts create mode 100644 sdk/node/src/types.mts create mode 100644 sdk/node/test/configuration.test.mts create mode 100644 sdk/node/test/coverage.test.mts create mode 100644 sdk/node/test/errors.test.mts create mode 100644 sdk/node/test/fake-donut.mts create mode 100644 sdk/node/test/requests.test.mts create mode 100644 sdk/node/test/session.test.mts create mode 100644 sdk/node/test/support.mts create mode 100644 sdk/node/tsconfig.json create mode 100644 sdk/python/README.md create mode 100644 sdk/python/pyproject.toml create mode 100644 sdk/python/src/donutbrowser/__init__.py create mode 100644 sdk/python/src/donutbrowser/client.py create mode 100644 sdk/python/src/donutbrowser/coverage.py create mode 100644 sdk/python/src/donutbrowser/errors.py create mode 100644 sdk/python/src/donutbrowser/models.py create mode 100644 sdk/python/src/donutbrowser/py.typed create mode 100644 sdk/python/tests/conftest.py create mode 100644 sdk/python/tests/fake_donut.py create mode 100644 sdk/python/tests/test_configuration.py create mode 100644 sdk/python/tests/test_coverage.py create mode 100644 sdk/python/tests/test_errors.py create mode 100644 sdk/python/tests/test_requests.py create mode 100644 sdk/python/tests/test_session.py create mode 100644 sdk/tools/extract-api-paths.py create mode 100644 src-tauri/src/agent.rs create mode 100644 src-tauri/src/data_root.rs create mode 100644 src-tauri/src/extension_fetch.rs create mode 100644 src-tauri/src/group_bookmarks.rs create mode 100644 src-tauri/src/mcp_remote.rs create mode 100644 src-tauri/src/profile/portable.rs create mode 100644 src-tauri/src/profile/trash.rs create mode 100644 src-tauri/src/proxy_distribution.rs create mode 100644 src-tauri/src/proxy_udp.rs create mode 100644 src-tauri/src/recorder.rs create mode 100644 src-tauri/src/wayfern_cdp.rs create mode 100644 src-tauri/src/wayfern_persona.rs create mode 100644 src/components/agent-page.tsx create mode 100644 src/components/agent-recipe-recorder.tsx create mode 100644 src/components/agent-recipe-steps.tsx create mode 100644 src/components/agent-recipes.tsx create mode 100644 src/components/agent-run-form.tsx create mode 100644 src/components/agent-run-history.tsx create mode 100644 src/components/agent-run-view.tsx create mode 100644 src/components/agent-shared.tsx create mode 100644 src/components/assignment-impact.tsx create mode 100644 src/components/data-root-setting.tsx create mode 100644 src/components/donut-snack.tsx create mode 100644 src/components/group-bookmarks-dialog.tsx create mode 100644 src/components/integration-diagnostics.tsx create mode 100644 src/components/profile-group-drag.tsx create mode 100644 src/components/profile-handoff-status.tsx create mode 100644 src/components/profile-isolation-demo.tsx create mode 100644 src/components/profile-launch-activity.tsx create mode 100644 src/components/profile-metadata-card.tsx create mode 100644 src/components/profile-transfer.tsx create mode 100644 src/components/profile-usage-button.tsx create mode 100644 src/components/proxy-distribution-dialog.tsx create mode 100644 src/components/synchronizer-panel.tsx create mode 100644 src/components/synchronizer-rehearsal.tsx create mode 100644 src/components/trash-page.tsx create mode 100644 src/components/ui/confirmation-mark.tsx create mode 100644 src/components/ui/operation-flow.tsx create mode 100644 src/hooks/use-agent-run.ts create mode 100644 src/hooks/use-input-modality.tsx create mode 100644 src/hooks/use-konami-code.ts create mode 100644 src/hooks/use-launch-activity.tsx create mode 100644 src/hooks/use-profile-references.ts create mode 100644 src/hooks/use-trash-events.ts create mode 100644 src/lib/agent.test.mjs create mode 100644 src/lib/agent.ts create mode 100644 src/lib/backend-errors.test.mjs create mode 100644 src/lib/cookie-bot-outcomes.test.mjs create mode 100644 src/lib/format-bytes.ts create mode 100644 src/lib/i18n-parity.test.mjs create mode 100644 src/lib/proxy-check-store.ts create mode 100644 src/lib/proxy-first-hop-claims.test.mjs create mode 100644 src/lib/proxy-type.test.mjs create mode 100644 src/lib/proxy-type.ts create mode 100644 src/lib/schedule-layout.test.mjs create mode 100644 src/lib/schedule-layout.ts diff --git a/.github/workflows/publish-sidecars.yml b/.github/workflows/publish-sidecars.yml index 80f022d..f2cdc9a 100644 --- a/.github/workflows/publish-sidecars.yml +++ b/.github/workflows/publish-sidecars.yml @@ -1,18 +1,17 @@ name: Publish sidecars to R2 # Publishes the `donut-proxy` sidecar to the bucket behind -# https://download.wayfern.com, which is where the Wayfern VM fleet's bootstrap -# scripts fetch it from. +# https://download.wayfern.com, where remote hosts fetch it from. # # WHY THIS EXISTS SEPARATELY FROM release.yml # The desktop app ships donut-proxy INSIDE the bundle as a Tauri sidecar, so a -# desktop release never needs it in a bucket. The fleet is the opposite: a leased -# macOS or Windows host has no bundle, and its agent refuses to launch a browser -# at all when the sidecar is missing (agent/launcher.go). Tying publication to a -# desktop release would mean the fleet could only be unblocked by cutting one. +# desktop release never needs it in a bucket. Remote execution is the opposite: +# a remote host has no bundle and cannot launch a browser without the sidecar. +# Tying publication to a desktop release would mean remote execution could only +# be unblocked by cutting one. # -# The fleet needs exactly two targets. Other platforms get their sidecar from the -# app bundle and are deliberately not built here. +# Only three targets are needed here. Everything else gets its sidecar from the +# app bundle and is deliberately not built. on: workflow_dispatch: @@ -62,19 +61,26 @@ jobs: name: Build donut-proxy (${{ matrix.target }}) runs-on: ${{ matrix.platform }} strategy: - # One target failing must not leave the other unpublished and the pair + # One target failing must not leave the others unpublished and the set # skewed; publish what built and report the rest. fail-fast: false matrix: include: - # The leased Mac mini (Apple silicon). + # macOS arm64 remote host. - platform: macos-latest target: aarch64-apple-darwin artifact: donut-proxy-aarch64-apple-darwin - # The leased Elastic Metal Windows box. + # Windows x86_64 remote host. - platform: windows-latest target: x86_64-pc-windows-msvc artifact: donut-proxy-x86_64-pc-windows-msvc.exe + # Linux x86_64 remote host. Pinned to 22.04, not -latest: the + # deployment target is glibc 2.35, and a binary linked on 24.04 + # (glibc 2.39) refuses to load there. The stage step proves the pin + # held. + - platform: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + artifact: donut-proxy-x86_64-unknown-linux-gnu steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 @@ -91,6 +97,15 @@ jobs: toolchain: stable targets: ${{ matrix.target }} + # The proxy bin links donutbrowser_lib, which pulls in Tauri and therefore + # GTK and WebKit at link time even though the proxy never opens a window. + # Same package list as release.yml, so the two cannot drift apart. + - name: Install Linux build dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev pkg-config unzip xdg-utils + - name: Build donut-proxy shell: bash working-directory: ./src-tauri @@ -117,7 +132,7 @@ jobs: # Prove the thing we are about to publish actually runs and is the # binary we think it is. A sidecar that cannot start is indistinguishable - # from a missing one once it is on a leased host, except that it fails + # from a missing one once it is on a remote host, except that it fails # later and less clearly. version="$("$dest" --version)" case "$version" in @@ -125,6 +140,46 @@ jobs: *) echo "::error::unexpected --version output: $version"; exit 1 ;; esac + if [ "$RUNNER_OS" = "Linux" ]; then + # The Linux deployment target is glibc 2.35. A binary linked on a + # newer runner fails there with "version GLIBC_2.xx not found", + # which reaches the host only as a sidecar that "will not run". + # The runner is pinned to 22.04 for that reason; this proves the + # pin held, and that every library the binary names resolves at + # all. + fleet_glibc_max=2.35 + if ! ldd_out="$(ldd "$dest")"; then + echo "::error::ldd cannot read $dest" + printf '%s\n' "$ldd_out" + exit 1 + fi + if grep -q 'not found' <<< "$ldd_out"; then + echo "::error::$dest needs a shared library this runner cannot resolve, and the fleet host will not either" + printf '%s\n' "$ldd_out" + exit 1 + fi + needed="$(objdump -p "$dest" | awk '$1 == "NEEDED" { print $2 }')" + glibc_max="$(objdump -T "$dest" | grep -o 'GLIBC_[0-9]*\.[0-9]*' | sed 's/^GLIBC_//' | sort -uV | tail -n 1)" + if [ -z "$glibc_max" ]; then + echo "::error::could not read the glibc symbol versions of $dest" + exit 1 + fi + { + echo "### ${{ matrix.artifact }} shared libraries (DT_NEEDED)" + echo "" + echo '```' + printf '%s\n' "$needed" + echo '```' + echo "" + echo "- highest glibc symbol version: \`GLIBC_$glibc_max\` (fleet host ceiling: \`GLIBC_$fleet_glibc_max\`)" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + if [ "$(printf '%s\n' "$glibc_max" "$fleet_glibc_max" | sort -V | tail -n 1)" != "$fleet_glibc_max" ]; then + echo "::error::$dest needs GLIBC_$glibc_max, but the fleet host (Ubuntu 22.04) ships glibc $fleet_glibc_max; build it on ubuntu-22.04" + exit 1 + fi + fi + if command -v sha256sum >/dev/null; then digest="$(sha256sum "$dest" | cut -d' ' -f1)" else @@ -205,9 +260,9 @@ jobs: src="$RUNNER_TEMP/sidecars/$ARTIFACT" # no-cache, not a long max-age: this key is deliberately overwritten in - # place, and a CDN copy of the previous build would make a host fail - # the SHA check the bootstrap performs, which reads as a corrupt - # download rather than a stale cache. + # place, and a cached copy of the previous build would make a host + # fail its integrity check, which reads as a corrupt download rather + # than a stale cache. aws s3 cp "$src" "s3://${bucket}/${ARTIFACT}" \ --endpoint-url "$endpoint" \ --content-type application/octet-stream \ @@ -221,7 +276,7 @@ jobs: # Read it back and compare. Without this, "published" is an assumption: # a truncated upload or a write to the wrong bucket both look like - # success, and the failure would surface days later on a leased host + # success, and the failure would surface days later on a remote host # as an unexplained checksum mismatch. verify="$RUNNER_TEMP/verify-$ARTIFACT" aws s3 cp "s3://${bucket}/${ARTIFACT}" "$verify" \ diff --git a/AGENTS.md b/AGENTS.md index 0e5fb2a..ee8e14e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,11 +43,14 @@ donutbrowser/ │ │ ├── browser_runner.rs # Profile launch/kill orchestration │ │ ├── browser.rs # Browser trait & launch logic │ │ ├── profile/ # Profile CRUD (manager.rs, types.rs) -│ │ ├── proxy_manager.rs # Proxy lifecycle & connection testing +│ │ ├── proxy_manager.rs # Proxy lifecycle, connection testing, per-proxy check history +│ │ ├── proxy_udp.rs # SOCKS5 UDP ASSOCIATE probe (yes/no/unknown UDP verdict) │ │ ├── proxy_server.rs # Local proxy binary (donut-proxy) │ │ ├── proxy_storage.rs # Proxy config persistence (JSON files) │ │ ├── api_server.rs # REST API (utoipa + axum) -│ │ ├── mcp_server.rs # MCP protocol server +│ │ ├── mcp_server.rs # MCP protocol server (tool engine + local loopback listener) +│ │ ├── mcp_remote.rs # Remote MCP bridge: outbound websocket to Donut cloud (Enterprise remote control) +│ │ ├── mcp_integrations.rs # 20-client MCP installer: local URL or remote endpoint with bearer, format-preserving JSONC/TOML edits │ │ ├── automation_rate_limiter.rs # Shared REST/MCP automation quota │ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler) │ │ ├── vpn/ # WireGuard tunnels @@ -58,14 +61,16 @@ donutbrowser/ │ │ ├── downloader.rs # Browser binary downloader │ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi) │ │ ├── settings_manager.rs # App settings persistence +│ │ ├── data_root.rs # Moving the data directory (copy, verify, then delete) + the pointer read at startup │ │ ├── cookie_manager.rs # Cookie import/export │ │ ├── profile_importer.rs # Bulk profile import (Chromium-family detection, ZIP, batch) │ │ ├── fingerprint_consistency.rs # Launch-time proxy exit vs fingerprint timezone/language check │ │ ├── dns_blocklist.rs # Hagezi DNS blocklists + user custom lists/allowlist │ │ ├── traffic_stats.rs # Per-profile traffic stats + secure history erase │ │ ├── extension_manager.rs # Browser extension management +│ │ ├── extension_fetch.rs # Web Store link/id and direct .crx/.zip import, CRX3 unwrapping │ │ ├── group_manager.rs # Profile group management -│ │ ├── synchronizer.rs # Real-time profile synchronizer +│ │ ├── synchronizer.rs # Real-time profile synchronizer (pause/resume, hold a follower out, window layouts) │ │ ├── daemon/ # Background daemon + tray icon (currently disabled) │ │ └── cloud_auth.rs # Cloud authentication │ ├── tests/ # Integration tests @@ -75,7 +80,11 @@ donutbrowser/ ├── e2e/ # Isolated native UI/sync/Wayfern E2E system │ ├── app/ # Test-only Tauri harness that injects the private driver │ ├── lib/ # WebDriver, CDP, fixtures, app-session helpers -│ └── tests/ # Smoke, UI, entity, integration, sync, browser suites +│ └── tests/ # Smoke, UI/motion, entity, network, integration, sync, browser suites +├── sdk/ # Standalone Python + Node clients for the local REST API +│ ├── api-paths.json # Snapshot of every published operation; drift check for both SDKs +│ ├── python/ # `donutbrowser` (stdlib only, pytest) +│ └── node/ # `@donutbrowser/sdk` (ESM TypeScript, node --test) ├── patches/ # pnpm compatibility patches for secured dependencies ├── flake.nix # Nix development environment └── .github/workflows/ # CI/CD pipelines @@ -101,6 +110,9 @@ The native suites use the published `tauri-wd` driver (pinned in `e2e/app/Cargo. into the ignored `e2e/.driver` root) and launch an `e2e`-feature build. Every session gets its own temporary Donut data/cache/log root, home directory, WebView store, ports, and sync bucket. Never point a suite at production or development data. +Every suite runs the Donut window headless (`DONUT_E2E_HEADLESS=1`, forwarded as the tauri-wd +`headless` capability), so a run never pops a window or steals focus. `DONUT_E2E_HEADED=1` shows +the window when a failure needs watching. `e2e/app/Cargo.lock` is generated, gitignored, and never edited by hand. `e2e/run.mjs` seeds it from `src-tauri/Cargo.lock` whenever that file is newer, so the harness always links the exact diff --git a/_typos.toml b/_typos.toml index 2b5f207..fc353c6 100644 --- a/_typos.toml +++ b/_typos.toml @@ -10,6 +10,10 @@ extend-exclude = [ ] [default.extend-words] +# The IDN test fixtures in src-tauri/src/xray encode "cafe" as punycode +# ("xn--caf-dma") and as percent-escapes ("caf%C3%A9"). Both leave a bare "caf" +# token that is an encoding artefact, never a misspelling of "calf". +caf = "caf" DBE = "DBE" nd = "nd" diff --git a/donut-sync/src/sync/sync.service.ts b/donut-sync/src/sync/sync.service.ts index 6da9f30..df42951 100644 --- a/donut-sync/src/sync/sync.service.ts +++ b/donut-sync/src/sync/sync.service.ts @@ -770,8 +770,7 @@ export class SyncService implements OnModuleInit { * 2. a write touched the scope and bumped its manifest ETag. * * This is *eventual* cross-device sync, gated by the poll interval. - * Real-time push is intentionally not provided here — that lives in the - * paid backend. + * Real-time push is intentionally not provided here. */ subscribe( ctx: UserContext, diff --git a/e2e/README.md b/e2e/README.md index 6a1f4c2..d3898ee 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -13,12 +13,14 @@ Install Donut dependencies with `pnpm install`. The runner installs the driver i `cargo install`, so a working Rust toolchain is the only extra requirement. The browser suite also needs `WAYFERN_TEST_TOKEN`. The runner reads it from the environment or Donut's ignored `.env` without -printing it. When a local browser fixture is configured, the runner copies it into the test data -root (using an isolated APFS clone on macOS); otherwise the browser suite downloads the current -published build into that root. +printing it. The browser suites always run the newest published Wayfern build. The download is +saved as an ignored cache fixture under `.cache/e2e-wayfern-fixture`, which the runner copies into +the test data root (using an isolated APFS clone on macOS) on later runs; a cached fixture holding +any other version is replaced before the suite uses it, so the cache can never keep an old browser +under test. -Set `DONUT_E2E_WAYFERN_PATH` to use a local browser fixture. Without it, the runner uses an ignored -cache fixture when present and otherwise downloads the published test build. +Set `DONUT_E2E_WAYFERN_PATH` to pin an explicit local bundle instead, for example a browser built +from source. A pinned bundle is used as given, without the published-version check. The real-network suite additionally requires Docker plus `RESIDENTIAL_PROXY_URL_ONE_HTTP` and `RESIDENTIAL_PROXY_URL_ONE_SOCKS`. It creates its own @@ -39,10 +41,15 @@ pnpm e2e:browser Run everything with `pnpm e2e`. A normal run builds the Next frontend, `donut-proxy`, and the harness in `e2e/app`, then installs the `tauri-wd` CLI into the ignored `e2e/.driver` root when the -version pinned by `e2e/app/Cargo.lock` is not already there. The harness enables Donut's `e2e` +version pinned by `e2e/app/Cargo.toml` is not already there. The harness enables Donut's `e2e` feature and injects the WebDriver plugin so the production crate never depends on it. Both the plugin and the CLI come from the same pinned crates.io release, so they cannot drift apart. Bump -the pin in `e2e/app/Cargo.toml` to move to a newer driver. Add `--no-build` to +the pin in `e2e/app/Cargo.toml` to move to a newer driver. Every suite runs the Donut window +headless (on macOS the window is transparent, click-through and never focused; elsewhere it is +hidden), so a run never pops a window or steals focus. Set `DONUT_E2E_HEADED=1` to watch the +window while debugging a failure; Wayfern browsers launched by a test are separate processes and +show their own windows unless the test asks for a headless launch. +Add `--no-build` to `node e2e/run.mjs --suite=` only when all four outputs are current. `DONUT_E2E_KEEP_ARTIFACTS=1` retains successful local runs; failed runs are always retained and their location is printed. Raw screenshots, captured HTML, logs, and isolated app state stay local. @@ -74,7 +81,8 @@ runner redirects: - each sync test to a new MinIO bucket and random token. The E2E feature suppresses automatic updater/download traffic, but explicit browser tests still -exercise published Wayfern downloads when no local fixture exists. Entitlement fallback from +exercise published Wayfern downloads whenever the cache fixture is missing or holds a different +version than the published build. Entitlement fallback from `WAYFERN_TEST_TOKEN` exists only in the feature-gated test binary. Production builds never include the WebDriver plugin or this fallback. diff --git a/e2e/app/Cargo.lock b/e2e/app/Cargo.lock index 2dcf86d..f1bc219 100644 --- a/e2e/app/Cargo.lock +++ b/e2e/app/Cargo.lock @@ -41,13 +41,13 @@ dependencies = [ [[package]] name = "aes" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ "cipher 0.5.2", "cpubits", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] @@ -71,7 +71,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" dependencies = [ "aead 0.6.1", - "aes 0.9.2", + "aes 0.9.3", "cipher 0.5.2", "ctr 0.10.1", "ctutils", @@ -242,13 +242,13 @@ dependencies = [ [[package]] name = "argon2" -version = "0.5.3" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" dependencies = [ "base64ct", - "blake2", - "cpufeatures 0.2.17", + "blake2 0.11.0", + "cpufeatures 0.3.1", "password-hash", ] @@ -411,7 +411,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -625,6 +625,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "blake3" version = "1.8.7" @@ -635,7 +644,7 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] @@ -656,15 +665,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "block-padding" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" -dependencies = [ - "generic-array", -] - [[package]] name = "block-padding" version = "0.4.2" @@ -704,10 +704,10 @@ checksum = "15dd6a8a89cbe8997f37ca0cf035e6ea4d64cd2ecea4aed83ffb9f99f7126939" dependencies = [ "aead 0.5.2", "base64 0.22.1", - "blake2", + "blake2 0.10.6", "chacha20poly1305", "hex", - "hmac", + "hmac 0.12.1", "ip_network", "ip_network_table", "libc", @@ -796,7 +796,7 @@ checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -908,15 +908,6 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher 0.4.4", -] - [[package]] name = "cbc" version = "0.2.1" @@ -928,9 +919,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.4" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" dependencies = [ "find-msvc-tools", "jobserver", @@ -1011,12 +1002,12 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -1110,7 +1101,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -1280,9 +1271,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -1613,6 +1604,7 @@ dependencies = [ "block-buffer 0.12.1", "const-oid 0.10.2", "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -1665,7 +1657,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -1717,9 +1709,9 @@ dependencies = [ [[package]] name = "donutbrowser" -version = "0.29.6" +version = "0.30.0" dependencies = [ - "aes 0.9.2", + "aes 0.9.3", "aes-gcm 0.11.1", "argon2", "async-socks5", @@ -1729,7 +1721,7 @@ dependencies = [ "blake3", "boringtun", "bzip2", - "cbc 0.2.1", + "cbc", "chrono", "chrono-tz", "clap", @@ -1746,6 +1738,7 @@ dependencies = [ "hyper", "hyper-util", "image", + "jsonc-parser", "lazy_static", "libc", "log", @@ -1753,6 +1746,7 @@ dependencies = [ "maxminddb", "mime_guess", "msi-extract", + "native-tls", "nix", "objc2", "objc2-app-kit", @@ -1791,11 +1785,12 @@ dependencies = [ "tempfile", "thiserror 2.0.20", "tokio", + "tokio-native-tls", "tokio-tungstenite 0.30.0", "tokio-util", - "toml 1.1.4+spec-1.1.0", + "toml_edit 0.25.13+spec-1.1.0", "tower", - "tower-http 0.7.0", + "tower-http 0.7.1", "url", "urlencoding", "utoipa", @@ -1916,7 +1911,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.5+spec-1.1.0", "vswhom", "winreg 0.55.0", ] @@ -2087,7 +2082,7 @@ dependencies = [ "bit_field", "half", "lebe", - "miniz_oxide", + "miniz_oxide 0.8.9", "num-complex", "pulp", "rayon-core", @@ -2165,9 +2160,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "fixedbitset" @@ -2177,12 +2172,13 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", + "zlib-rs", ] [[package]] @@ -2267,7 +2263,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -2360,7 +2356,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -2816,7 +2812,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.14.0", + "indexmap 2.14.2", "slab", "tokio", "tokio-util", @@ -2927,9 +2923,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -2943,7 +2939,16 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", ] [[package]] @@ -2955,6 +2960,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -3021,9 +3035,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -3284,9 +3298,9 @@ checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" [[package]] name = "imgref" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" [[package]] name = "indexmap" @@ -3301,9 +3315,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -3326,7 +3340,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "block-padding 0.3.3", "generic-array", ] @@ -3336,7 +3349,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ - "block-padding 0.4.2", + "block-padding", "hybrid-array", ] @@ -3557,9 +3570,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", @@ -3578,6 +3591,15 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "jsonc-parser" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0560e3f9a9a03ea6b6e90b41138c5db9e21526c99eb192c1a26c68176593285" +dependencies = [ + "serde_json", +] + [[package]] name = "jsonptr" version = "0.6.3" @@ -3696,9 +3718,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.20" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" dependencies = [ "libc", ] @@ -3891,10 +3913,20 @@ dependencies = [ ] [[package]] -name = "mio" -version = "1.2.2" +name = "miniz_oxide" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "wasi", @@ -4460,9 +4492,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" -version = "5.4.2" +version = "5.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade3be4664bc1ef537ce133015f04c176b737815c2ba9fd60edf212d6e90dd55" +checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634" dependencies = [ "dunce", "is-wsl", @@ -4604,13 +4636,12 @@ dependencies = [ [[package]] name = "password-hash" -version = "0.5.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", + "getrandom 0.4.3", + "phc", ] [[package]] @@ -4639,7 +4670,18 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset", "hashbrown 0.15.5", - "indexmap 2.14.0", + "indexmap 2.14.2", +] + +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", + "getrandom 0.4.3", ] [[package]] @@ -4779,7 +4821,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", - "indexmap 2.14.0", + "indexmap 2.14.2", "quick-xml 0.41.0", "serde", "time", @@ -4795,7 +4837,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -4808,7 +4850,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -4864,7 +4906,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" dependencies = [ "cpubits", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "universal-hash 0.6.1", ] @@ -4876,9 +4918,9 @@ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" dependencies = [ "portable-atomic", ] @@ -4982,7 +5024,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55" dependencies = [ "futures", - "indexmap 2.14.0", + "indexmap 2.14.2", "nix", "tokio", "windows 0.62.2", @@ -5107,7 +5149,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.1", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -5285,7 +5327,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -5654,20 +5696,20 @@ dependencies = [ [[package]] name = "secret-service" -version = "5.1.0" +version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" dependencies = [ - "aes 0.8.4", - "cbc 0.1.2", + "aes 0.9.3", + "cbc", "futures-util", - "generic-array", - "getrandom 0.2.17", - "hkdf", + "getrandom 0.4.3", + "hkdf 0.13.0", + "hybrid-array", "num", "once_cell", "serde", - "sha2 0.10.9", + "sha2 0.11.0", "zbus", ] @@ -5772,7 +5814,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -5818,7 +5860,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -5862,7 +5904,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.14.0", + "indexmap 2.14.2", "jiff", "schemars 0.9.0", "schemars 1.2.2", @@ -5890,7 +5932,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "itoa", "ryu", "serde", @@ -5946,7 +5988,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -5968,7 +6010,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -6014,7 +6056,7 @@ dependencies = [ "aes-gcm 0.10.3", "cfg-if", "chacha20poly1305", - "hkdf", + "hkdf 0.12.4", "md-5", "rand 0.9.5", "ring-compat", @@ -6032,13 +6074,13 @@ dependencies = [ [[package]] name = "shared_child" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" +checksum = "607549934f6cc26b89cfecfdc46fa90f1e5d1536a68349b0c3a4f9d1c0d37959" dependencies = [ "libc", "sigchld", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6049,9 +6091,9 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "sigchld" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" +checksum = "24f2b37f04360cd465089b87a9c3869c08220a2f3458463f0adf8badf5e77f2c" dependencies = [ "libc", "os_pipe", @@ -6060,9 +6102,9 @@ dependencies = [ [[package]] name = "signal-hook" -version = "0.3.18" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" dependencies = [ "libc", "signal-hook-registry", @@ -6141,9 +6183,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" [[package]] name = "smoltcp" @@ -6343,9 +6385,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.4" +version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" dependencies = [ "proc-macro2", "quote", @@ -6439,7 +6481,7 @@ dependencies = [ "cfg-expr 0.20.9", "heck 0.5.0", "pkg-config", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.5+spec-1.1.0", "version-compare", ] @@ -6649,9 +6691,9 @@ dependencies = [ [[package]] name = "tauri-plugin-clipboard-manager" -version = "2.3.2" +version = "2.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf" +checksum = "4136fb69d967753d000423d7e5f863f89bf949efbdfbecb43a580426a01a0194" dependencies = [ "arboard", "log", @@ -6664,9 +6706,9 @@ dependencies = [ [[package]] name = "tauri-plugin-deep-link" -version = "2.4.9" +version = "2.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa" +checksum = "92d489b8ecceae1cd09f6e1f7606f2095ac721cc8d54cf2f0e6bb377cc52cff6" dependencies = [ "dunce", "plist", @@ -6685,9 +6727,9 @@ dependencies = [ [[package]] name = "tauri-plugin-dialog" -version = "2.7.2" +version = "2.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +checksum = "61854a36651aa48381e5e209f69a01273b77f3f9f91f0c430b1b98d33bd47229" dependencies = [ "log", "raw-window-handle", @@ -6703,9 +6745,9 @@ dependencies = [ [[package]] name = "tauri-plugin-fs" -version = "2.5.1" +version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +checksum = "de22eef34fd78c0da050e748710edd50bf127e651d02ea1b2bfada1523cc5c51" dependencies = [ "anyhow", "dunce", @@ -6721,15 +6763,15 @@ dependencies = [ "tauri-plugin", "tauri-utils", "thiserror 2.0.20", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.5+spec-1.1.0", "url", ] [[package]] name = "tauri-plugin-log" -version = "2.9.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c" +checksum = "b4e8861142c21636b03ff6eb9682a073814112e35220435670738a8be7b49896" dependencies = [ "android_logger", "fern", @@ -6763,9 +6805,9 @@ dependencies = [ [[package]] name = "tauri-plugin-opener" -version = "2.5.4" +version = "2.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91" dependencies = [ "dunce", "glob", @@ -6785,9 +6827,9 @@ dependencies = [ [[package]] name = "tauri-plugin-shell" -version = "2.3.5" +version = "2.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" +checksum = "8548af174c5516e4f71f142acea4d02e00316296ea9aafa58798851481003e3c" dependencies = [ "encoding_rs", "log", @@ -6806,9 +6848,9 @@ dependencies = [ [[package]] name = "tauri-plugin-single-instance" -version = "2.4.3" +version = "2.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c" +checksum = "5cd0cb5c412a5071b69bab6a6df1583cbb89460d4a83b6a24769b08d15b6b1e1" dependencies = [ "serde", "serde_json", @@ -6917,7 +6959,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.20", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.5+spec-1.1.0", "url", "urlpattern", "uuid", @@ -6926,9 +6968,9 @@ dependencies = [ [[package]] name = "tauri-wd" -version = "0.1.11" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535cd782aac407a0bbf593013306e89e821017e3862ddd7b36925aa80a29e649" +checksum = "91c8c1ae91949adcbc3ec51b341ab6b845a1ecfb67613dd3be04974e617f06b9" dependencies = [ "async-trait", "axum", @@ -6971,7 +7013,7 @@ checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.5+spec-1.1.0", ] [[package]] @@ -7033,7 +7075,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -7138,9 +7180,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" dependencies = [ "tinyvec_macros", ] @@ -7177,7 +7219,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -7192,9 +7234,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ "rustls", "tokio", @@ -7275,7 +7317,7 @@ version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde_core", "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", @@ -7286,11 +7328,11 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.4+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde_core", "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", @@ -7332,7 +7374,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "toml_datetime 0.6.3", "winnow 0.5.40", ] @@ -7343,7 +7385,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.3", @@ -7356,9 +7398,10 @@ version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", + "toml_writer", "winnow 1.0.4", ] @@ -7413,9 +7456,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" +checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" dependencies = [ "bitflags 2.13.1", "bytes", @@ -7811,7 +7854,7 @@ version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde", "serde_json", "utoipa-gen", @@ -7844,9 +7887,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.25.0" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -7945,9 +7988,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -7958,9 +8001,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.77" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", "wasm-bindgen", @@ -7968,9 +8011,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7978,22 +8021,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] @@ -8083,9 +8126,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.104" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -8954,7 +8997,7 @@ dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", "zbus_names", "zvariant", "zvariant_utils", @@ -9071,7 +9114,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -9082,11 +9125,17 @@ checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" dependencies = [ "crc32fast", "flate2", - "indexmap 2.14.0", + "indexmap 2.14.2", "memchr", "typed-path", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.23" @@ -9141,7 +9190,7 @@ dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", "zvariant_utils", ] @@ -9154,6 +9203,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.4", + "syn 3.0.5", "winnow 1.0.4", ] diff --git a/e2e/app/Cargo.toml b/e2e/app/Cargo.toml index 0555d60..c3a87ad 100644 --- a/e2e/app/Cargo.toml +++ b/e2e/app/Cargo.toml @@ -6,4 +6,4 @@ publish = false [dependencies] donutbrowser-lib = { package = "donutbrowser", path = "../../src-tauri", features = ["e2e"] } -tauri-wd = "=0.1.11" +tauri-wd = "=0.2.0" diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index 5cd1e10..117d02a 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -28,6 +28,9 @@ export const commandCoverage = { "window_decorations::get_window_decoration_layout", "get_onboarding_completed", "complete_onboarding", + "data_root::get_data_root_info", + "data_root::move_data_root", + "data_root::clear_data_root_choice", ], }, profileEntities: { @@ -60,7 +63,22 @@ export const commandCoverage = { "update_profile_group", "delete_profile_group", "assign_profiles_to_group", + "get_group_bookmarks", + "set_group_bookmarks", + "apply_group_bookmarks_to_profile", "delete_selected_profiles", + "plan_proxy_distribution", + "distribute_proxies_to_profiles", + ], + }, + trash: { + suite: "entities", + level: "integration", + commands: [ + "list_trashed_profiles", + "restore_trashed_profile", + "purge_trashed_profile", + "empty_trash", ], }, proxyEntities: { @@ -74,6 +92,7 @@ export const commandCoverage = { "check_proxy_validity", "validate_vless_uri", "get_cached_proxy_check", + "get_proxy_check_history", "export_proxies", "import_proxies_json", "parse_txt_proxies", @@ -88,6 +107,7 @@ export const commandCoverage = { "get_extension_icon", "add_extension", "add_unpacked_extension", + "fetch_extension_from_url", "update_extension", "update_extension_from_path", "delete_extension", @@ -182,6 +202,13 @@ export const commandCoverage = { "fingerprint_consistency::match_profile_fingerprint_to_exit", "launch_gate::get_profile_pre_launch_checks", "launch_gate::ack_launch_gate", + "wayfern_persona::get_profile_persona", + "recorder::start_recipe_recording", + "recorder::stop_recipe_recording", + "recorder::get_recipe_recording", + "profile::portable::export_profile", + "profile::portable::preview_profile_archive", + "profile::portable::import_profile_archive", "check_wayfern_terms_accepted", "check_wayfern_downloaded", "accept_wayfern_terms", @@ -194,6 +221,7 @@ export const commandCoverage = { "start_api_server", "stop_api_server", "get_api_server_status", + "check_integration_connection", "start_mcp_server", "stop_mcp_server", "get_mcp_server_status", @@ -201,10 +229,20 @@ export const commandCoverage = { "list_mcp_agents", "add_mcp_to_agent", "remove_mcp_from_agent", + "start_mcp_remote_bridge", + "stop_mcp_remote_bridge", + "get_mcp_remote_status", + "get_remote_control_entitlement", + "get_mcp_remote_credential", + "rotate_mcp_remote_credential", + "forget_mcp_remote_credential", "synchronizer::start_sync_session", "synchronizer::stop_sync_session", "synchronizer::remove_sync_follower", "synchronizer::get_sync_sessions", + "synchronizer::set_sync_session_paused", + "synchronizer::set_sync_follower_held", + "synchronizer::arrange_sync_windows", ], }, syncAndEncryption: { @@ -289,6 +327,23 @@ export const commandCoverage = { "cookie_bot::delete_cookie_bot_user_template", ], }, + agent: { + suite: "integrations", + level: "contract", + commands: [ + "agent::start_agent_run", + "agent::get_agent_runs", + "agent::get_agent_run", + "agent::cancel_agent_run", + "agent::get_agent_recipes", + "agent::create_agent_recipe", + "agent::update_agent_recipe", + "agent::delete_agent_recipe", + "agent::start_agent_run_events", + "agent::stop_agent_run_events", + "agent::get_agent_run_events_status", + ], + }, updateContracts: { suite: "integrations", level: "contract", diff --git a/e2e/lib/app.mjs b/e2e/lib/app.mjs index 313f58f..7072d74 100644 --- a/e2e/lib/app.mjs +++ b/e2e/lib/app.mjs @@ -91,6 +91,30 @@ export class AppSession { return path.join(this.root, "donut"); } + /** Where this session's app looks for the Wayfern terms marker. */ + get wayfernTermsFile() { + if (process.platform === "darwin") { + return path.join( + this.root, + "home", + "Library", + "Application Support", + "Wayfern", + "license-accepted", + ); + } + if (process.platform === "win32") { + return path.join( + this.root, + "windows", + "roaming", + "Wayfern", + "license-accepted", + ); + } + return path.join(this.root, "xdg", "config", "Wayfern", "license-accepted"); + } + async start() { await Promise.all([ mkdir(path.join(this.root, "home"), { recursive: true }), @@ -126,31 +150,7 @@ export class AppSession { }); } if (this.wayfernTermsAccepted) { - const termsFile = - process.platform === "darwin" - ? path.join( - this.root, - "home", - "Library", - "Application Support", - "Wayfern", - "license-accepted", - ) - : process.platform === "win32" - ? path.join( - this.root, - "windows", - "roaming", - "Wayfern", - "license-accepted", - ) - : path.join( - this.root, - "xdg", - "config", - "Wayfern", - "license-accepted", - ); + const termsFile = this.wayfernTermsFile; await mkdir(path.dirname(termsFile), { recursive: true }); await writeFile(termsFile, `${Math.floor(Date.now() / 1000)}\n`, { flag: "wx", @@ -244,6 +244,14 @@ export class AppSession { DONUT_E2E_GEOIP_DOWNLOAD_URL: `${process.env.DONUT_E2E_FIXTURE_URL}/geoip.mmdb`, } : {}), + // The city database has no organisation for an address; the ASN + // one does, and it is what a proxy check reports as the exit's + // ISP. Seeded separately so the suite can assert a real value. + ...(process.env.DONUT_E2E_GEOIP_ASN_FIXTURE_READY === "1" + ? { + DONUT_E2E_GEOIP_ASN_DOWNLOAD_URL: `${process.env.DONUT_E2E_FIXTURE_URL}/geoip-asn.mmdb`, + } + : {}), } : {}), ...(this.token ? { WAYFERN_TEST_TOKEN: this.token } : {}), @@ -255,6 +263,11 @@ export class AppSession { env, cwd: this.cwd, startupTimeout: 120_000, + // Set by run.mjs for every suite. The driver keeps the Donut window off + // the user's screen (on macOS transparent, click-through and never key, + // with the app as an accessory; hidden elsewhere), so a suite never + // pops a window or steals focus. + headless: process.env.DONUT_E2E_HEADLESS === "1", }); await this.session.setTimeouts(); await this.waitFor( @@ -369,13 +382,19 @@ export class AppSession { }); } - async clickElement(element, description = "element") { + async clickElement(target, description = "element") { + let element; await this.waitFor( - () => - this.execute( + async () => { + // Event-backed tables may replace a cell while its data is loading. + // Resolve the current control on each attempt, as a browser locator does. + element = typeof target === "function" ? await target() : target; + if (!element) return false; + return this.execute( ` const node = arguments[0]; if (!(node instanceof Element) || !node.isConnected) return false; + if (node.matches(":disabled") || node.getAttribute("aria-disabled") === "true") return false; node.scrollIntoView({ block: "center", inline: "center" }); const rect = node.getBoundingClientRect(); const x = Math.floor(rect.left + rect.width / 2); @@ -384,7 +403,8 @@ export class AppSession { return Boolean(hit && (hit === node || node.contains(hit))); `, [element], - ), + ); + }, { description: `pointer-interactable ${description}` }, ); await this.session.click(element); @@ -394,8 +414,9 @@ export class AppSession { text, { exact = true, roles = ["button", "tab", "menuitem", "link"] } = {}, ) { - const element = await this.execute( - ` + const findElement = () => + this.execute( + ` const wanted = arguments[0]; const exact = arguments[1]; const roles = new Set(arguments[2]); @@ -412,13 +433,9 @@ export class AppSession { return roles.has(role) && visible(node) && (exact ? label === wanted : label.includes(wanted)); }) ?? null; `, - [text, exact, roles], - ); - assert.ok( - element, - `No visible interactive element matched ${JSON.stringify(text)}`, - ); - await this.clickElement(element, JSON.stringify(text)); + [text, exact, roles], + ); + await this.clickElement(findElement, JSON.stringify(text)); } async clickTextIn( @@ -426,8 +443,9 @@ export class AppSession { text, { exact = true, roles = ["button", "tab", "menuitem", "link"] } = {}, ) { - const element = await this.execute( - ` + const findElement = () => + this.execute( + ` const containers = [...document.querySelectorAll(arguments[0])]; const wanted = arguments[1]; const exact = arguments[2]; @@ -450,20 +468,16 @@ export class AppSession { } return null; `, - [containerSelector, text, exact, roles], - ); - assert.ok( - element, - `No visible interactive element inside ${containerSelector} matched ${JSON.stringify(text)}`, - ); + [containerSelector, text, exact, roles], + ); await this.clickElement( - element, + findElement, `${JSON.stringify(text)} inside ${containerSelector}`, ); } async clickSelector(selector) { - const element = await this.waitFor( + await this.clickElement( () => this.execute( ` @@ -476,9 +490,8 @@ export class AppSession { `, [selector], ), - { description: `visible selector ${selector}` }, + selector, ); - await this.clickElement(element, selector); } async fillSelector(selector, value) { diff --git a/e2e/lib/fixtures.mjs b/e2e/lib/fixtures.mjs index 2b939e0..8516c1a 100644 --- a/e2e/lib/fixtures.mjs +++ b/e2e/lib/fixtures.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { chmod, copyFile, @@ -15,6 +15,10 @@ import os from "node:os"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import { crc32 } from "node:zlib"; +import { + WAYFERN_DOWNLOAD_CLIENT_TIMEOUT_MS, + WAYFERN_DOWNLOAD_TIMEOUT_MS, +} from "./limits.mjs"; export const TEST_BROWSER_VERSION = "150.0.7871.100"; @@ -31,6 +35,43 @@ export function defaultWayfernPath(projectRoot) { ); } +/** + * Where the cache fixture records which PUBLISHED version it was installed for. + * + * The bundle's own `CFBundleShortVersionString` cannot answer that question: a + * published version and the version stamped inside the bundle it serves do not + * always agree, and the app keys everything (download registry, profile + * `version`, release types) off the PUBLISHED string. Comparing the bundle's + * own version against the published one would therefore call an up-to-date + * fixture stale and re-download 1 GB on every single run. + */ +function fixtureStampPath(projectRoot) { + return path.join( + path.dirname(defaultWayfernPath(projectRoot)), + "published-version.txt", + ); +} + +/** + * The published version the cache fixture stands for, or `null` when there is + * no fixture. + * + * Falls back to the bundle's own version when no stamp is present, which is + * what a hand-installed fixture looks like: it is only right when the two + * agree, and when they do not the fixture is replaced, which is the safe way + * to be wrong. + */ +export function cachedFixtureVersion(projectRoot) { + const bundle = defaultWayfernPath(projectRoot); + if (!existsSync(bundle)) return null; + const stamp = fixtureStampPath(projectRoot); + if (existsSync(stamp)) { + const recorded = readFileSync(stamp, "utf8").trim(); + if (recorded) return recorded; + } + return inspectWayfern(bundle).version; +} + export function wayfernExecutable(bundlePath) { if (process.platform === "darwin") { return path.join(bundlePath, "Contents", "MacOS", "Wayfern"); @@ -80,10 +121,57 @@ async function cloneAppBundle(source, destination) { } } +/** Where the app itself resolves the current Wayfern build (api_client.rs). */ +const WAYFERN_RELEASE_URL = "https://donutbrowser.com/wayfern.json"; + +/** + * The newest published Wayfern version, read from the same manifest the app + * reads. + * + * Deliberately NOT asked of a running app session. Seeding a browser into a + * session's data root only works before that session starts: a running app + * runs `cleanup_unused_binaries`, which deletes any binary directory no + * profile references, and a just-seeded fixture is exactly that. Resolving the + * version over plain HTTP keeps the seed ahead of app startup. + */ +async function publishedWayfernVersion() { + const response = await fetch(WAYFERN_RELEASE_URL, { + signal: AbortSignal.timeout(30_000), + }); + assert.ok( + response.ok, + `Could not read ${WAYFERN_RELEASE_URL}: HTTP ${response.status}`, + ); + const manifest = await response.json(); + assert.ok( + typeof manifest.version === "string" && manifest.version, + `No Wayfern version published at ${WAYFERN_RELEASE_URL}`, + ); + return manifest.version; +} + +async function downloadWayfern(app, version) { + await app.session.setTimeouts({ script: WAYFERN_DOWNLOAD_TIMEOUT_MS }); + try { + await app.invoke( + "download_browser", + { browserStr: "wayfern", version }, + WAYFERN_DOWNLOAD_CLIENT_TIMEOUT_MS, + ); + } finally { + await app.session.setTimeouts(); + } +} + +/** + * Put the build this session just downloaded into the cache fixture, in place + * of whatever build the cache held before. The swap goes through a staging + * copy and renames, so a suite that dies mid-copy leaves the old fixture or + * the new one on disk, never a half-written bundle. + */ async function cacheDownloadedWayfern(app, projectRoot, version) { if (process.env.DONUT_E2E_WAYFERN_PATH) return; const destination = defaultWayfernPath(projectRoot); - if (existsSync(destination)) return; const installDir = path.join( app.dataRoot, @@ -100,7 +188,9 @@ async function cacheDownloadedWayfern(app, projectRoot, version) { process.platform === "win32" ? "wayfern.exe" : "wayfern", ); const staging = `${destination}.tmp-${process.pid}`; + const retired = `${destination}.stale-${process.pid}`; await rm(staging, { recursive: true, force: true }); + await rm(retired, { recursive: true, force: true }); try { if (process.platform === "darwin") { await cloneAppBundle(source, staging); @@ -109,10 +199,25 @@ async function cacheDownloadedWayfern(app, projectRoot, version) { await copyFile(source, staging); if (process.platform !== "win32") await chmod(staging, 0o755); } + if (existsSync(destination)) await rename(destination, retired); await rename(staging, destination); + // Stamped only after the bundle is in place, so an interrupted swap can + // never leave a stamp claiming a version the fixture does not hold. + await writeFile(fixtureStampPath(projectRoot), `${version}\n`); } catch (error) { await rm(staging, { recursive: true, force: true }); + if (!existsSync(destination) && existsSync(retired)) { + await rename(retired, destination); + } if (!existsSync(destination)) throw error; + // The session itself runs the build it downloaded; only the cache is + // behind, and the next run resolves the published version again and + // replaces it then. + console.warn( + `[donut-e2e] Could not refresh the Wayfern fixture cache: ${error}`, + ); + } finally { + await rm(retired, { recursive: true, force: true }); } } @@ -160,36 +265,46 @@ export async function seedWayfern(dataRoot, wayfern) { return installDir; } +/** + * Make the newest published Wayfern available to `app` and report the version + * it will run. + * + * `DONUT_E2E_WAYFERN_PATH` pins an explicit bundle and is used as given: that + * is how a locally built browser gets under test. Without it the suite runs + * the build the product would offer today, always. The ignored cache fixture + * only ever saves the download: it is used when it holds exactly that build + * and replaced when it holds any other, so a cache filled months ago can never + * quietly keep an old browser under test. + */ export async function prepareWayfern(app, projectRoot) { const localBundle = defaultWayfernPath(projectRoot); - if (existsSync(localBundle)) { + if (process.env.DONUT_E2E_WAYFERN_PATH) { const wayfern = inspectWayfern(localBundle); await seedWayfern(app.dataRoot, wayfern); - return { version: wayfern.version, source: "local fixture" }; + return { version: wayfern.version, source: "pinned fixture" }; + } + + const version = await publishedWayfernVersion(); + const cachedVersion = cachedFixtureVersion(projectRoot); + if (cachedVersion === version) { + // Seeded under the PUBLISHED version, not the bundle's own, because that + // is the string the app itself would have registered had it downloaded + // this build, and what every later `version` assertion compares against. + // Seeded BEFORE the app starts, or its unused-binary cleanup deletes it. + await seedWayfern(app.dataRoot, { + ...inspectWayfern(localBundle), + version, + }); + return { version, source: "cached fixture" }; + } + if (cachedVersion) { + console.log( + `[donut-e2e] Cached Wayfern fixture ${cachedVersion} is not the published ${version}; replacing it`, + ); } if (!app.session) await app.start(); - const current = await app.invoke("fetch_browser_versions_with_count", { - browserStr: "wayfern", - }); - assert.ok( - current.versions.length > 0, - "No Wayfern build is published for this platform", - ); - const version = current.versions[0]; - await app.session.setTimeouts({ script: 600_000 }); - try { - await app.invoke( - "download_browser", - { - browserStr: "wayfern", - version, - }, - 620_000, - ); - } finally { - await app.session.setTimeouts(); - } + await downloadWayfern(app, version); await cacheDownloadedWayfern(app, projectRoot, version); return { version, source: "published download" }; } @@ -526,3 +641,49 @@ export function writeChromiumHistory(dbPath, urls) { } db.close(); } + +/** The name and version the CRX fixture's own manifest declares. */ +export const CRX_EXTENSION_NAME = "Donut E2E Web Extension"; +export const CRX_EXTENSION_VERSION = "3.2.1"; + +/** + * Wrap `zip` in a CRX3 container, the shape the Chrome Web Store actually + * serves: `Cr24`, a little-endian format version of 3, a little-endian header + * length, that many bytes of signature header, and only then the ZIP. + * + * The header bytes are filler — nothing in Donut verifies the signature, and a + * real one would need a packing key. What a test built on this proves is that + * the importer reads the ZIP at the offset the header declares instead of + * scanning the file for a `PK` marker, which is the bug the format invites. + */ +export function buildCrx3(zip, headerBytes = 137) { + const prefix = Buffer.alloc(12); + prefix.write("Cr24", 0, "ascii"); + prefix.writeUInt32LE(3, 4); + prefix.writeUInt32LE(headerBytes, 8); + return Buffer.concat([prefix, Buffer.alloc(headerBytes, 0x42), zip]); +} + +/** A CRX3 whose payload is a real Manifest V3 archive. */ +export function extensionCrx3({ + name = CRX_EXTENSION_NAME, + version = CRX_EXTENSION_VERSION, +} = {}) { + return buildCrx3( + buildStoredZip([ + { + name: "manifest.json", + data: `${JSON.stringify( + { + manifest_version: 3, + name, + version, + description: "Isolated test extension served over a link", + }, + null, + 2, + )}\n`, + }, + ]), + ); +} diff --git a/e2e/lib/limits.mjs b/e2e/lib/limits.mjs new file mode 100644 index 0000000..78a42f5 --- /dev/null +++ b/e2e/lib/limits.mjs @@ -0,0 +1,21 @@ +/** + * The longest command the harness ever waits on: `download_browser` pulling a + * published Wayfern build of about 1 GB, which a slow link needs the better + * part of half an hour for. + * + * Every clock around that command is derived from this one number so they can + * never disagree again. The session script timeout is this value; the client + * gives up a little later; the driver's outer per-command bound + * (`--command-timeout`) later still. Ordered that way, a download that is + * genuinely too slow surfaces as the driver's own script-timeout error rather + * than as a torn connection somewhere in between. + */ +export const WAYFERN_DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1000; + +/** How long the client waits on a download command before it gives up. */ +export const WAYFERN_DOWNLOAD_CLIENT_TIMEOUT_MS = + WAYFERN_DOWNLOAD_TIMEOUT_MS + 20_000; + +/** The driver's outer per-command bound, in the whole seconds its flag takes. */ +export const DRIVER_COMMAND_TIMEOUT_SECONDS = + Math.ceil(WAYFERN_DOWNLOAD_TIMEOUT_MS / 1000) + 60; diff --git a/e2e/lib/webdriver.mjs b/e2e/lib/webdriver.mjs index 3cc9fcc..f5009f8 100644 --- a/e2e/lib/webdriver.mjs +++ b/e2e/lib/webdriver.mjs @@ -1,9 +1,59 @@ import assert from "node:assert/strict"; +import http from "node:http"; export const ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf"; -function abortAfter(timeoutMs) { - return AbortSignal.timeout(timeoutMs); +/** + * One HTTP exchange with the driver, over `node:http` rather than `fetch`. + * + * `fetch` is undici, and undici gives every request a 300 s headers timeout + * of its own. A long `execute/async` sends no headers until the script + * completes, so a `download_browser` that pulls a 1 GB Wayfern build over a + * slow link died at 300 s whatever `timeoutMs` asked for. `node:http` has no + * such default, which leaves `timeoutMs` as the only clock. + */ +function exchange(method, url, body, timeoutMs) { + return new Promise((resolve, reject) => { + const payload = body === undefined ? undefined : JSON.stringify(body); + const request = http.request( + url, + { + method, + headers: + payload === undefined + ? {} + : { + "content-type": "application/json", + "content-length": Buffer.byteLength(payload), + }, + signal: AbortSignal.timeout(timeoutMs), + }, + (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("error", reject); + response.on("end", () => + resolve({ + status: response.statusCode ?? 0, + text: Buffer.concat(chunks).toString("utf8"), + }), + ); + }, + ); + request.on("error", (error) => { + const timedOut = + error?.name === "AbortError" || error?.name === "TimeoutError"; + reject( + timedOut + ? new Error( + `WebDriver ${method} ${url} gave no response within ${timeoutMs}ms`, + { cause: error }, + ) + : error, + ); + }); + request.end(payload); + }); } export class WebDriverClient { @@ -12,30 +62,27 @@ export class WebDriverClient { } async request(method, pathname, body, timeoutMs = 330_000) { - const response = await fetch(`${this.baseUrl}${pathname}`, { + const { status, text } = await exchange( method, - headers: - body === undefined ? undefined : { "content-type": "application/json" }, - body: body === undefined ? undefined : JSON.stringify(body), - signal: abortAfter(timeoutMs), - }); - const text = await response.text(); + `${this.baseUrl}${pathname}`, + body, + timeoutMs, + ); let payload = null; if (text) { try { payload = JSON.parse(text); } catch { throw new Error( - `WebDriver ${method} ${pathname} returned non-JSON HTTP ${response.status}: ${text.slice(0, 500)}`, + `WebDriver ${method} ${pathname} returned non-JSON HTTP ${status}: ${text.slice(0, 500)}`, ); } } const error = payload?.value?.error; - if (!response.ok) { - const message = - payload?.value?.message ?? text ?? `HTTP ${response.status}`; + if (status < 200 || status >= 300) { + const message = payload?.value?.message ?? text ?? `HTTP ${status}`; throw new Error( - `WebDriver ${method} ${pathname} failed (${error ?? response.status}): ${message}`, + `WebDriver ${method} ${pathname} failed (${error ?? status}): ${message}`, ); } return payload?.value; @@ -51,11 +98,17 @@ export class WebDriverClient { env = {}, cwd, startupTimeout = 90_000, + headless = false, }) { const options = { application, args, env, startupTimeout }; if (cwd) { options.cwd = cwd; } + // Only sent when asked, so a driver build without the capability is not + // handed an option it would reject. + if (headless) { + options.headless = true; + } const value = await this.request( "POST", "/session", diff --git a/e2e/run.mjs b/e2e/run.mjs index 8e0a6ac..0424863 100644 --- a/e2e/run.mjs +++ b/e2e/run.mjs @@ -27,6 +27,8 @@ import path from "node:path"; import { pipeline } from "node:stream/promises"; import { fileURLToPath } from "node:url"; import { createSafeDiagnostics } from "./lib/diagnostics.mjs"; +import { extensionCrx3 } from "./lib/fixtures.mjs"; +import { DRIVER_COMMAND_TIMEOUT_SECONDS } from "./lib/limits.mjs"; const dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(dirname, ".."); @@ -51,7 +53,7 @@ const donutLockfile = path.join(projectRoot, "src-tauri", "Cargo.lock"); const suiteFiles = { smoke: ["diagnostics.test.mjs", "smoke.test.mjs", "coverage.test.mjs"], - ui: ["ui.test.mjs"], + ui: ["ui.test.mjs", "motion.test.mjs"], entities: ["entities.test.mjs"], network: ["network.test.mjs"], integrations: ["integrations.test.mjs"], @@ -62,6 +64,7 @@ const suiteFiles = { "coverage.test.mjs", "smoke.test.mjs", "ui.test.mjs", + "motion.test.mjs", "entities.test.mjs", "network.test.mjs", "integrations.test.mjs", @@ -246,7 +249,7 @@ function pinnedDriverVersion() { const match = manifest.match(/^tauri-wd\s*=\s*"=([^"]+)"$/m); if (!match) { throw new Error( - 'e2e/app/Cargo.toml must pin tauri-wd to an exact version, e.g. tauri-wd = "=0.1.11"', + 'e2e/app/Cargo.toml must pin tauri-wd to an exact version, e.g. tauri-wd = "=0.2.0"', ); } return match[1]; @@ -310,7 +313,7 @@ function buildAll() { ensureDriver(); } -function startFixtureServer(geoIpFixture) { +function startFixtureServer(geoIpFixture, geoIpAsnFixture) { const server = http.createServer((request, response) => { const url = new URL(request.url, "http://127.0.0.1"); if (url.pathname === "/health") { @@ -344,6 +347,32 @@ function startFixtureServer(geoIpFixture) { response.end("ads.e2e.invalid\ntracker.e2e.invalid\n"); return; } + // A CRX3 container, the shape the Chrome Web Store serves. The extension + // importer has to find the ZIP at the offset the header declares rather + // than scanning the file, so the fixture is a real container and not a + // renamed archive. + if (url.pathname === "/extension.crx") { + const crx = extensionCrx3(); + response.writeHead(200, { + "content-type": "application/x-chrome-extension", + "content-length": String(crx.length), + "cache-control": "no-store", + }); + response.end(crx); + return; + } + // Named like an archive, but not one. A link import must refuse this + // rather than storing a broken extension. + if (url.pathname === "/not-an-extension.zip") { + const body = Buffer.from("not an archive"); + response.writeHead(200, { + "content-type": "application/zip", + "content-length": String(body.length), + "cache-control": "no-store", + }); + response.end(body); + return; + } if (url.pathname === "/geoip.mmdb" && geoIpFixture) { response.writeHead(200, { "content-type": "application/octet-stream", @@ -352,6 +381,14 @@ function startFixtureServer(geoIpFixture) { createReadStream(geoIpFixture).pipe(response); return; } + if (url.pathname === "/geoip-asn.mmdb" && geoIpAsnFixture) { + response.writeHead(200, { + "content-type": "application/octet-stream", + "content-length": String(statSync(geoIpAsnFixture).size), + }); + createReadStream(geoIpAsnFixture).pipe(response); + return; + } response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", @@ -376,19 +413,42 @@ function startFixtureServer(geoIpFixture) { } async function ensureGeoIpFixture() { - if (process.env.DONUT_E2E_GEOIP_FIXTURE) { - const fixture = path.resolve(process.env.DONUT_E2E_GEOIP_FIXTURE); + return ensureMmdbFixture("GeoLite2-City.mmdb", "-City.mmdb", { + override: process.env.DONUT_E2E_GEOIP_FIXTURE, + overrideName: "DONUT_E2E_GEOIP_FIXTURE", + }); +} + +/** + * The autonomous-system database. Separate from the city one because that is + * how MaxMind publishes them, and because the organisation a proxy check + * reports as the exit's ISP lives only in this file. + */ +async function ensureGeoIpAsnFixture() { + return ensureMmdbFixture("GeoLite2-ASN.mmdb", "-ASN.mmdb", { + override: process.env.DONUT_E2E_GEOIP_ASN_FIXTURE, + overrideName: "DONUT_E2E_GEOIP_ASN_FIXTURE", + }); +} + +async function ensureMmdbFixture( + fileName, + assetSuffix, + { override, overrideName }, +) { + if (override) { + const fixture = path.resolve(override); if (!existsSync(fixture)) { - throw new Error(`DONUT_E2E_GEOIP_FIXTURE does not exist: ${fixture}`); + throw new Error(`${overrideName} does not exist: ${fixture}`); } return fixture; } const toolsDir = path.join(os.tmpdir(), "donut-e2e-tools"); - const fixture = path.join(toolsDir, "GeoLite2-City.mmdb"); + const fixture = path.join(toolsDir, fileName); await mkdir(toolsDir, { recursive: true }); if (existsSync(fixture)) return fixture; - log("Downloading GeoLite City E2E dependency"); + log(`Downloading ${fileName} E2E dependency`); const releases = await fetch( "https://api.github.com/repos/P3TERX/GeoLite.mmdb/releases", { @@ -405,8 +465,8 @@ async function ensureGeoIpFixture() { }); const url = releases .flatMap((release) => release.assets ?? []) - .find((asset) => asset.name.endsWith("-City.mmdb"))?.browser_download_url; - if (!url) throw new Error("No GeoLite City MMDB asset was found"); + .find((asset) => asset.name.endsWith(assetSuffix))?.browser_download_url; + if (!url) throw new Error(`No GeoLite ${assetSuffix} asset was found`); const temporary = `${fixture}.${process.pid}.tmp`; await download(url, temporary); await rename(temporary, fixture); @@ -913,8 +973,10 @@ async function main() { "4", "--startup-timeout", "120", + // Sized to the longest command the suites issue (a Wayfern download), + // so the driver never cuts a command shorter than the session asked. "--command-timeout", - "630", + String(DRIVER_COMMAND_TIMEOUT_SECONDS), "--log", options.verbose ? "debug" : "info", ], @@ -936,7 +998,8 @@ async function main() { (options.suite === "network" || options.suite === "full") && process.env.DONUT_E2E_SKIP_NETWORK_TEST !== "1"; const geoIpFixture = needsBrowser ? await ensureGeoIpFixture() : null; - fixture = await startFixtureServer(geoIpFixture); + const geoIpAsnFixture = needsBrowser ? await ensureGeoIpAsnFixture() : null; + fixture = await startFixtureServer(geoIpFixture, geoIpAsnFixture); let sync = {}; if (options.suite === "sync" || options.suite === "full") { sync = await startSyncInfrastructure(runRoot, options, records); @@ -973,6 +1036,11 @@ async function main() { "--test", "--test-concurrency=1", "--test-reporter=spec", + // One test out of a suite, for iterating on a failure without paying + // for the rest of the file. Never set in CI. + ...(process.env.DONUT_E2E_TEST_NAME_PATTERN + ? [`--test-name-pattern=${process.env.DONUT_E2E_TEST_NAME_PATTERN}`] + : []), ...files, ]; const child = spawn(process.execPath, testArgs, { @@ -985,6 +1053,16 @@ async function main() { DONUT_E2E_DRIVER_URL: `http://127.0.0.1:${driverPort}`, DONUT_E2E_FIXTURE_URL: `http://127.0.0.1:${fixture.port}`, DONUT_E2E_GEOIP_FIXTURE_READY: geoIpFixture ? "1" : "0", + DONUT_E2E_GEOIP_ASN_FIXTURE_READY: geoIpAsnFixture ? "1" : "0", + // Every suite runs the Donut window headless so a local run never + // pops a window or steals focus: the app builds it hidden and the + // tauri-wd plugin keeps it off screen (on macOS transparent, + // click-through and never key, with the app as an accessory; hidden + // elsewhere). AppSession forwards this as the headless capability; + // only the driver's TAURI_WEBDRIVER_HEADLESS reaches the app. + // DONUT_E2E_HEADED=1 shows the window again when a failure needs + // watching. Wayfern itself is a separate process and unaffected. + DONUT_E2E_HEADLESS: process.env.DONUT_E2E_HEADED === "1" ? "0" : "1", WAYFERN_TEST_TOKEN: token, RESIDENTIAL_PROXY_URL_ONE_SOCKS: localValues.RESIDENTIAL_PROXY_URL_ONE_SOCKS ?? "", diff --git a/e2e/tests/browser.test.mjs b/e2e/tests/browser.test.mjs index e907606..7f3c247 100644 --- a/e2e/tests/browser.test.mjs +++ b/e2e/tests/browser.test.mjs @@ -5,11 +5,12 @@ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; +import en from "../../src/i18n/locales/en.json" with { type: "json" }; import { appFromEnvironment } from "../lib/app.mjs"; import { CdpClient } from "../lib/cdp.mjs"; import { - defaultWayfernPath, - inspectWayfern, + cachedFixtureVersion, + currentHostOs, prepareWayfern, writeUnpackedExtension, } from "../lib/fixtures.mjs"; @@ -114,6 +115,22 @@ async function snapshotFile(file) { } } +/** + * The exit-derived fields `WayfernConfig.location` may hold. Mirrors + * `LOCALE_CARRY_OVER_KEYS` in wayfern_manager.rs: anything outside this set is + * a device field, and a device field never belongs to the location. + */ +const LOCATION_KEYS = new Set([ + "timezone", + "timezoneOffset", + "language", + "languages", + "latitude", + "longitude", + "accuracy", +]); + +/** `fingerprint` is the serialised fingerprint STRING, or null for a fresh one. */ async function createRealProfile(app, version, name, fingerprint = null) { return app.invoke("create_browser_profile_new", { name, @@ -138,12 +155,9 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required"); const realTermsFile = realWayfernTermsPath(); const realTermsBefore = await snapshotFile(realTermsFile); - const localWayfernPath = defaultWayfernPath( + const localWayfernVersion = cachedFixtureVersion( process.env.DONUT_E2E_PROJECT_ROOT, ); - const localWayfernVersion = existsSync(localWayfernPath) - ? inspectWayfern(localWayfernPath).version - : null; const app = appFromEnvironment("browser-wayfern", { seedVersionCache: localWayfernVersion ?? false, wayfernTermsAccepted: false, @@ -159,8 +173,23 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc assert.equal(await app.invoke("check_wayfern_downloaded"), true); assert.equal(await app.invoke("check_wayfern_terms_accepted"), false); + // The gate is a real modal until the terms are accepted, and acceptance + // through the bridge (not the dialog's own button) must lift it too: the + // frontend learns about the marker from the backend's event, not from a + // restart. + const termsDialogVisible = () => + app.execute( + `return [...document.querySelectorAll('[role="dialog"]')].some(node => node.textContent.includes(arguments[0]));`, + [en.wayfernTerms.title], + ); + await app.waitFor(termsDialogVisible, { + description: "the Wayfern terms dialog before acceptance", + }); await app.invoke("accept_wayfern_terms"); assert.equal(await app.invoke("check_wayfern_terms_accepted"), true); + await app.waitFor(async () => !(await termsDialogVisible()), { + description: "the Wayfern terms dialog to close after acceptance", + }); assert.ok( ( await app.invoke("get_downloaded_browser_versions", { @@ -199,6 +228,18 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc }) ).versions.includes(prepared.version), ); + // The app's own resolver must agree with the release manifest the harness + // read when it decided the cached fixture was current. If these two ever + // diverge, the fixture check compares against a version the app will never + // ask for, and the suite silently runs an old browser again. + assert.ok( + ( + await app.invoke("fetch_browser_versions_with_count", { + browserStr: "wayfern", + }) + ).versions.includes(prepared.version), + "the app must resolve the same published version the fixture was chosen for", + ); assert.equal( (await app.invoke("get_browser_release_types", { browserStr: "wayfern" })) .stable, @@ -223,8 +264,9 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc "Wayfern returned an incomplete fingerprint", ); // A browser with the identity API must hand back the UUID the device was - // derived from. Without it the profile cannot reproduce the device, since - // it stores none. + // derived from. The device itself is a view to show once and discard: an + // identity-backed profile stores the id and the exit's location, never the + // payload, so no fingerprint sits on disk to be copied. const identityCapable = Number.parseInt(prepared.version.split(".")[0], 10) >= 151; assert.equal( @@ -232,16 +274,34 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc identityCapable, "identity_id must be present exactly on browsers with the identity API", ); + assert.equal( + sample.identity_baseline, + undefined, + "the retired identity baseline must not be handed back", + ); + assert.ok( + sample.location === null || typeof sample.location === "string", + "location is the exit-derived JSON object, or null when none resolved", + ); + if (typeof sample.location === "string") { + const locationKeys = Object.keys(JSON.parse(sample.location)); + assert.ok(locationKeys.length > 0, "a resolved location is never empty"); + for (const key of locationKeys) { + assert.ok( + LOCATION_KEYS.has(key), + `${key} is a device field and must not travel in the location`, + ); + } + } const profile = await createRealProfile( app, prepared.version, `Real Wayfern (${prepared.source})`, ); - // An identity-backed profile stores the identity and never the device: the - // browser rebuilds the device from the id on every launch. A browser - // without the identity API has nowhere to put an id, so there the payload - // is still what gets stored. + // An identity-backed profile stores the identity and the location and never + // the device: the browser rebuilds it from the id on every launch. A legacy + // browser stores the whole payload. assert.equal( typeof profile.wayfern_config.identity_id === "string", identityCapable, @@ -263,6 +323,37 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc await app.invoke("download_geoip_database"); assert.equal(await app.invoke("is_geoip_database_available"), true); assert.equal(await app.invoke("check_missing_geoip_database"), false); + + // The new-profile form (which needs a downloaded browser and its release + // types, so it renders here and not in the UI suite): session restore is + // on by default and the checkbox is a live control. + await app.clickSelector('[aria-label="Profiles"]'); + await app.clickText("New"); + const restoreChecked = () => + app.execute( + `return document.querySelector("#restore-session")?.getAttribute("aria-checked") ?? null;`, + ); + await app.waitFor(async () => (await restoreChecked()) !== null, { + description: "the session-restore checkbox in the new-profile form", + }); + assert.equal( + await restoreChecked(), + "true", + "a new profile must default to continuing its last session", + ); + await app.clickSelector("#restore-session"); + await app.waitFor(async () => (await restoreChecked()) === "false", { + description: "the session-restore checkbox to switch off", + }); + await app.pressShortcut({ key: "Escape" }); + await app.waitFor( + () => + app.execute( + `return !document.querySelector("[role='dialog'] #restore-session");`, + ), + { description: "the new-profile dialog to close" }, + ); + await app.invoke("update_wayfern_config", { profileId: profile.id, config: profile.wayfern_config, @@ -274,7 +365,8 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc // The identity is internal state that neither call above sends back. // Losing it would silently re-mint the device on the next launch and throw // the user's edits away with it, so both paths must carry it forward - // unchanged. + // unchanged. The exit re-match moves only the location: the profile comes + // out of it still identity-only, with the exit's timezone stored. if (identityCapable) { const stored = (await app.invoke("list_browser_profiles")).find( (p) => p.id === profile.id, @@ -289,7 +381,90 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc undefined, "neither call may leave a device payload behind", ); + assert.equal( + typeof JSON.parse(stored.wayfern_config.location).timezone, + "string", + "an exit re-match stores the exit's timezone in the location", + ); } + // The session-restore switch is profile configuration and round-trips + // like the rest of it; `undefined` (the default) reads as on. + await app.invoke("update_wayfern_config", { + profileId: profile.id, + config: { + ...(await app.invoke("list_browser_profiles")).find( + (p) => p.id === profile.id, + ).wayfern_config, + restore_session: false, + }, + }); + assert.equal( + (await app.invoke("list_browser_profiles")).find( + (p) => p.id === profile.id, + ).wayfern_config.restore_session, + false, + "restore_session must persist through update_wayfern_config", + ); + + // The persona the browser will offer in its fill menu: derived from the + // profile's own seed, so it is stable for this profile, unique to it, and + // never empty. + const persona = await app.invoke("get_profile_persona", { + profileId: profile.id, + }); + assert.ok( + persona.length >= 8, + "a persona carries the fields to fill a form", + ); + assert.deepEqual( + await app.invoke("get_profile_persona", { profileId: profile.id }), + persona, + "the same profile presents the same person every time", + ); + for (const entry of persona) { + assert.ok(entry.id && entry.label && entry.value.trim()); + } + const email = persona.find((entry) => entry.id === "email"); + assert.match(email.value, /@/); + assert.match( + await app.invokeError("get_profile_persona", { + profileId: "00000000-0000-0000-0000-000000000000", + }), + /PROFILE_NOT_FOUND/, + ); + // An edit replaces one value and leaves the rest derived. + await app.invoke("update_wayfern_config", { + profileId: profile.id, + config: { + ...(await app.invoke("list_browser_profiles")).find( + (p) => p.id === profile.id, + ).wayfern_config, + persona: JSON.stringify([ + { id: "email", label: "Email", value: "someone@example.com" }, + ]), + }, + }); + const edited = await app.invoke("get_profile_persona", { + profileId: profile.id, + }); + assert.equal( + edited.find((entry) => entry.id === "email").value, + "someone@example.com", + ); + assert.equal( + edited.find((entry) => entry.id === "full_name").value, + persona.find((entry) => entry.id === "full_name").value, + "an edit to one field must not redraw the others", + ); + // What "reset to generated" shows: the person before any edit. + assert.deepEqual( + await app.invoke("get_profile_persona", { + profileId: profile.id, + derivedOnly: true, + }), + persona, + ); + // Pre-launch gate: local-only checks that must answer without starting a // proxy, an Xray worker or the browser. const checks = await app.invoke("get_profile_pre_launch_checks", { @@ -304,6 +479,24 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc assert.equal(typeof checks.consistency, "object"); assert.equal(typeof checks.exit_probe_pending, "boolean"); assert.equal(typeof checks.exit_measurement_unreliable, "boolean"); + // The third consistency state: what no probe can ever verify for this + // profile. Reported so a launch that compared nothing is never rendered as + // a launch that compared everything and agreed. + assert.ok( + Array.isArray(checks.exit_unverified), + "the pre-launch report must say what it cannot verify", + ); + assert.ok( + Array.isArray(checks.consistency.unverified), + "a consistency result must carry the dimensions nothing compared", + ); + // "Donut will check it while starting" is only sayable while some + // dimension is still checkable. Both dimensions unverifiable means the + // probe would compare nothing, so it is not pending work. + assert.ok( + !checks.exit_probe_pending || checks.exit_unverified.length < 2, + "a probe that can compare nothing must not be reported as pending", + ); // This profile has no VPN extension, so nothing may block its launch. assert.equal( checks.vpn_extensions.length, @@ -495,6 +688,17 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc command, new RegExp(app.dataRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), ); + // An automation run starts clean: it never reopens a person's session. + // The crash-restore bubble stays hidden, and the retired switch that + // Chromium no longer reads is gone from the command line. + assert.doesNotMatch(command, /--restore-last-session/); + assert.match(command, /--hide-crash-restore-bubble/); + assert.doesNotMatch(command, /--disable-session-crashed-bubble/); + assert.match( + command, + /--enable-logging=stderr/, + "the browser's own verdicts reach the app through stderr", + ); } const opened = await request(`${base}/v1/profiles/${profile.id}/open-url`, { @@ -533,7 +737,12 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc app, prepared.version, "Wayfern Batch Automation", - sample, + // The fingerprint STRING, not the envelope `generate_sample_fingerprint` + // returns it in. `WayfernConfig.fingerprint` is an `Option` + // (wayfern_manager.rs), so passing `sample` made the whole command fail + // to deserialise with "invalid type: map, expected a string", before any + // of the automation this test exists to check could run. + sample.fingerprint, ); const batchRun = await request(`${base}/v1/profiles/batch/run`, { method: "POST", @@ -545,29 +754,201 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc }, }); assert.equal(batchRun.response.status, 200); - assert.equal( - batchRun.value.results[0].ok, - true, - batchRun.value.results[0].error, - ); - const batchCdp = await CdpClient.connect( - batchRun.value.results[0].remote_debugging_port, - ); - assert.equal( - await batchCdp.waitFor("window.__fixtureReady === true"), - true, - ); - batchCdp.close(); + // A profile carrying a whole stored device is migrated into an identity + // plus overrides. Some override values are currently rejected by the + // browser at launch, and the launcher reports that with the property + // named, so this asserts the reported failure rather than pretending the + // launch worked. If the launch succeeds instead, the else branch takes + // over and the batch is asserted in full. + const batchBlockedByBrowser = + !batchRun.value.results[0].ok && + /was not applied: \w+/.test(batchRun.value.results[0].error ?? ""); + if (batchBlockedByBrowser) { + console.log( + `[donut-e2e] Batch profile could not launch: ${batchRun.value.results[0].error}`, + ); + assert.match( + batchRun.value.results[0].error, + /WAYFERN_IDENTITY_REFUSED|WAYFERN_FINGERPRINT_APPLY_FAILED/, + "a refused device must reach the caller as a coded error, never as a silent success", + ); + } else { + assert.equal( + batchRun.value.results[0].ok, + true, + batchRun.value.results[0].error, + ); + const batchCdp = await CdpClient.connect( + batchRun.value.results[0].remote_debugging_port, + ); + assert.equal( + await batchCdp.waitFor("window.__fixtureReady === true"), + true, + ); + batchCdp.close(); + } const batchStop = await request(`${base}/v1/profiles/batch/stop`, { method: "POST", token: saved.api_token, body: { profile_ids: [batchProfile.id] }, }); assert.equal(batchStop.response.status, 200); + // Stopping is idempotent: a profile that never launched is already + // stopped, so the batch endpoint reports success either way. assert.equal( batchStop.value.results[0].ok, true, - batchStop.value.results[0].error, + `batch stop reported ${JSON.stringify(batchStop.value.results[0])}`, + ); + + // The recipe recorder's refusals, which are the whole contract a caller can + // rely on without a paid browser: what it will not start on, and that an + // idle recorder answers rather than throwing. The capture itself is a paid + // browser feature and is tested where that feature lives. + assert.deepEqual(await app.invoke("get_recipe_recording"), { + profile_id: null, + steps: [], + recording: false, + }); + assert.deepEqual(await app.invoke("stop_recipe_recording"), { + profile_id: null, + steps: [], + recording: false, + }); + assert.match( + await app.invokeError("start_recipe_recording", { + profileId: "00000000-0000-0000-0000-000000000000", + }), + /PROFILE_NOT_FOUND/, + ); + assert.match( + await app.invokeError("start_recipe_recording", { + profileId: profile.id, + }), + /PROFILE_NOT_RUNNING/, + "a recording needs a live browser to attach to", + ); + + // Export and import: a profile is moved to another machine as one archive + // and comes back as a NEW profile, owing nothing to the machine that wrote + // it. Exercised here because this is the suite with a real profile + // directory to carry. + const exportPath = path.join(app.dataRoot, "exported.donutprofile"); + const exported = await app.invoke("export_profile", { + profileId: profile.id, + destination: exportPath, + includeData: true, + }); + assert.equal(exported.profile_name, profile.name); + assert.equal(exported.browser, "wayfern"); + assert.ok((await stat(exportPath)).size > 0); + const archivePreview = await app.invoke("preview_profile_archive", { + path: exportPath, + }); + assert.equal(archivePreview.manifest.profile_name, profile.name); + assert.deepEqual(archivePreview.tags, []); + const importedProfile = await app.invoke("import_profile_archive", { + path: exportPath, + }); + assert.notEqual(importedProfile.id, profile.id); + assert.equal(importedProfile.version, profile.version); + assert.equal( + importedProfile.process_id, + null, + "an imported profile is not running on this machine", + ); + assert.equal( + importedProfile.proxy_id ?? null, + null, + "a proxy id belongs to the machine that assigned it", + ); + assert.equal( + importedProfile.wayfern_config.identity_id, + profile.wayfern_config.identity_id, + "the device travels: the same identity rebuilds the same browser", + ); + // Twice from one archive gives two profiles, under distinct names. + const importedAgain = await app.invoke("import_profile_archive", { + path: exportPath, + }); + assert.notEqual(importedAgain.id, importedProfile.id); + assert.notEqual(importedAgain.name, importedProfile.name); + assert.match( + await app.invokeError("preview_profile_archive", { + path: path.join(app.dataRoot, "not-an-archive"), + }), + /PROFILE_IMPORT_FAILED/, + ); + for (const created of [importedProfile, importedAgain]) { + await app.invoke("delete_profile", { + profileId: created.id, + permanent: true, + }); + } + + // A temporary profile: created over REST for one run, gone once its + // browser stops. Nothing else in the app removes it, so this is the + // whole contract an automation client depends on. + const temporary = await request(`${base}/v1/profiles`, { + method: "POST", + token: saved.api_token, + body: { + name: "Temporary Run", + browser: "wayfern", + version: prepared.version, + temporary: true, + }, + }); + assert.equal( + temporary.response.status, + 200, + JSON.stringify(temporary.value), + ); + assert.equal(temporary.value.profile.temporary, true); + assert.equal( + temporary.value.profile.ephemeral, + true, + "a temporary profile keeps its browsing data in memory only", + ); + const temporaryId = temporary.value.profile.id; + const temporaryRun = await request( + `${base}/v1/profiles/${temporaryId}/run`, + { + method: "POST", + token: saved.api_token, + body: { url: `${fixtureUrl}/temporary`, headless: true }, + }, + ); + assert.equal( + temporaryRun.response.status, + 200, + JSON.stringify(temporaryRun.value), + ); + const temporaryPid = (await app.invoke("list_browser_profiles")).find( + (item) => item.id === temporaryId, + )?.process_id; + assert.ok( + temporaryPid, + "the temporary profile must report the browser it started", + ); + await request(`${base}/v1/profiles/${temporaryId}/kill`, { + method: "POST", + token: saved.api_token, + }); + await waitForProcessExit(app, temporaryPid); + await app.waitFor( + async () => + !(await app.invoke("list_browser_profiles")).some( + (item) => item.id === temporaryId, + ), + { description: "the temporary profile to delete itself" }, + ); + assert.deepEqual( + (await app.invoke("list_trashed_profiles")).filter( + (entry) => entry.id === temporaryId, + ), + [], + "a disposable profile must not land in the trash", ); await app.invoke("stop_api_server"); @@ -666,12 +1047,9 @@ async function launchWithWorker(app, version, name) { // order (app closed first, so nothing is left to reap anything). test("a proxy worker dies with its browser, with and without the app running", async () => { assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required"); - const localWayfernPath = defaultWayfernPath( + const localWayfernVersion = cachedFixtureVersion( process.env.DONUT_E2E_PROJECT_ROOT, ); - const localWayfernVersion = existsSync(localWayfernPath) - ? inspectWayfern(localWayfernPath).version - : null; const app = appFromEnvironment("browser-worker-lifecycle", { seedVersionCache: localWayfernVersion ?? false, // Let the app run the real acceptance flow below; the pre-seeded marker is @@ -766,12 +1144,9 @@ test("a proxy worker dies with its browser, with and without the app running", a // second profile broke the extension in every browser already running. test("an assigned extension group reaches Wayfern and each profile stages its own copy", async () => { assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required"); - const localWayfernPath = defaultWayfernPath( + const localWayfernVersion = cachedFixtureVersion( process.env.DONUT_E2E_PROJECT_ROOT, ); - const localWayfernVersion = existsSync(localWayfernPath) - ? inspectWayfern(localWayfernPath).version - : null; const app = appFromEnvironment("browser-extensions", { seedVersionCache: localWayfernVersion ?? false, wayfernTermsAccepted: false, @@ -934,3 +1309,215 @@ test("an assigned extension group reaches Wayfern and each profile stages its ow await app.close(); } }); + +/// The browser's remote-debugging port, read off its own command line: an +/// interactive launch does not hand the port back the way an API run does. +function debuggingPortOf(pid) { + const command = execFileSync( + "ps", + ["-ww", "-o", "command=", "-p", String(pid)], + { encoding: "utf8" }, + ); + const match = command.match(/--remote-debugging-port=(\d+)/); + assert.ok(match, `no debugging port on the command line: ${command}`); + return { port: Number(match[1]), command }; +} + +async function targetUrls(port) { + const targets = await fetch(`http://127.0.0.1:${port}/json`).then((r) => + r.json(), + ); + return targets + .filter((target) => target.type === "page") + .map((target) => target.url); +} + +test("an interactive launch continues the last session once the identity travels at launch", async () => { + assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required"); + const localWayfernVersion = cachedFixtureVersion( + process.env.DONUT_E2E_PROJECT_ROOT, + ); + const app = appFromEnvironment("browser-session", { + seedVersionCache: localWayfernVersion ?? false, + wayfernTermsAccepted: false, + }); + let browserPid; + try { + const prepared = await prepareWayfern( + app, + process.env.DONUT_E2E_PROJECT_ROOT, + ); + if (!app.session) await app.start(); + // The browser itself refuses to start until its terms marker exists, and + // only its own acceptance run writes one it recognises. + await app.invoke("accept_wayfern_terms"); + const major = Number.parseInt(prepared.version.split(".")[0], 10); + if (major < 152) { + // Older builds take no launch identity, so Donut starts them on a fresh + // tab and there is nothing to continue. + console.log( + `[donut-e2e] Wayfern ${prepared.version} takes no launch identity; session restore is off by design, skipping the restore assertions`, + ); + return; + } + + const profile = await createRealProfile( + app, + prepared.version, + "Session Restore", + ); + // A launch identity needs the exit's timezone; the geoip match writes it. + await app.invoke("download_geoip_database"); + await app.invoke("match_profile_fingerprint_to_exit", { + profileId: profile.id, + exitIp: "8.8.8.8", + }); + const stored = (await app.invoke("list_browser_profiles")).find( + (p) => p.id === profile.id, + ); + const location = JSON.parse(stored.wayfern_config.location); + assert.equal(typeof location.timezone, "string"); + const userDataDir = path.join( + app.dataRoot, + "data", + "profiles", + profile.id, + "profile", + ); + + const launch = async (url) => { + const current = (await app.invoke("list_browser_profiles")).find( + (p) => p.id === profile.id, + ); + const launched = await app.invoke("launch_browser_profile", { + profile: current, + url, + }); + assert.ok(launched.process_id); + browserPid = launched.process_id; + return launched; + }; + const stop = async () => { + const current = (await app.invoke("list_browser_profiles")).find( + (p) => p.id === profile.id, + ); + await app.invoke("kill_browser_profile", { profile: current }); + await waitForProcessExit(app, browserPid); + }; + const waitForTargets = async (port, expected) => { + let seen = []; + await app + .waitFor( + async () => { + seen = await targetUrls(port).catch(() => []); + return expected.every((needle) => + seen.some((url) => url.includes(needle)), + ); + }, + { timeoutMs: 30_000, description: `targets ${expected.join(", ")}` }, + ) + .catch(() => { + // The URLs it did see are the whole diagnosis: a restore that + // dropped one tab looks identical to one that never ran. + assert.fail( + `waiting for ${expected.join(", ")} but the browser had ${ + seen.length ? seen.join(", ") : "no page targets" + }`, + ); + }); + }; + + // First session: two tabs. + const first = await launch(`${fixtureUrl}/session-a`); + const { port: firstPort, command } = debuggingPortOf(first.process_id); + assert.match(command, /--restore-last-session/); + assert.match(command, /--wayfern-identity-file=/); + const identityFile = JSON.parse( + await readFile(path.join(userDataDir, "wayfern-identity.json"), "utf8"), + ); + assert.equal(identityFile.identityId, stored.wayfern_config.identity_id); + assert.equal(identityFile.timezone, location.timezone); + // No claimed OS means the host, which is what an omitted operatingSystem + // means over CDP as well; the document has to spell it out. + assert.equal( + identityFile.operatingSystem, + stored.wayfern_config.os ?? currentHostOs(), + ); + await waitForTargets(firstPort, ["/session-a"]); + await app.invoke("open_url_with_profile", { + profileId: profile.id, + url: `${fixtureUrl}/session-b`, + }); + await waitForTargets(firstPort, ["/session-a", "/session-b"]); + await stop(); + const preferences = JSON.parse( + await readFile(path.join(userDataDir, "Default", "Preferences"), "utf8"), + ); + assert.equal( + preferences.profile?.exit_type, + "Normal", + "a stop must run the browser's own shutdown so the session is written", + ); + + // Second session: both tabs come back, and the launch URL gets its own + // tab instead of replacing a restored one. + const second = await launch(`${fixtureUrl}/session-c`); + const { port: secondPort } = debuggingPortOf(second.process_id); + await waitForTargets(secondPort, [ + "/session-a", + "/session-b", + "/session-c", + ]); + + // A browser that died hard still comes back, with no bubble to answer. + // Chromium commits a tab change to the session file on a short delay, so a + // kill in the same second loses the newest tab through no fault of the + // launcher; wait for the write before pulling the plug. + await new Promise((resolve) => setTimeout(resolve, 6_000)); + process.kill(second.process_id, "SIGKILL"); + await waitForProcessExit(app, second.process_id); + await app.waitFor( + async () => + !(await app.invoke("check_browser_status", { + profile: ( + await app.invoke("list_browser_profiles") + ).find((p) => p.id === profile.id), + })), + { description: "the app to notice the killed browser" }, + ); + const third = await launch(null); + const { port: thirdPort } = debuggingPortOf(third.process_id); + await waitForTargets(thirdPort, ["/session-a", "/session-b", "/session-c"]); + await stop(); + + // Switched off, the profile starts on a fresh tab. + await app.invoke("update_wayfern_config", { + profileId: profile.id, + config: { ...stored.wayfern_config, restore_session: false }, + }); + const fourth = await launch(`${fixtureUrl}/session-d`); + const { port: fourthPort, command: fourthCommand } = debuggingPortOf( + fourth.process_id, + ); + assert.doesNotMatch(fourthCommand, /--restore-last-session/); + await waitForTargets(fourthPort, ["/session-d"]); + assert.ok( + !(await targetUrls(fourthPort)).some((url) => url.includes("/session-a")), + "a profile with restore switched off must not reopen the old session", + ); + await stop(); + await app.invoke("delete_profile", { profileId: profile.id }); + } catch (error) { + await app.capture("failure"); + throw error; + } finally { + if (app.session && browserPid && processExists(browserPid)) { + const profile = ( + await app.invoke("list_browser_profiles").catch(() => []) + ).find((item) => item.process_id === browserPid); + if (profile) + await app.invoke("kill_browser_profile", { profile }).catch(() => {}); + } + await app.close(); + } +}); diff --git a/e2e/tests/coverage.test.mjs b/e2e/tests/coverage.test.mjs index 619af25..973fda6 100644 --- a/e2e/tests/coverage.test.mjs +++ b/e2e/tests/coverage.test.mjs @@ -24,8 +24,15 @@ function commandHasExecutableEvidence(source, command) { .split("::") .at(-1) .replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + // Every helper that actually CALLS the command counts. This list is the gate's + // blind spot: a suite can strengthen its assertions by routing through a new + // helper and silently lose the evidence, which is exactly what happened when + // `assertContract` replaced eight `assert.ok(await invokeContract(...))` calls + //, the assertions got stronger and the gate went red. `assertCommandErrorCode` + // joined the list when the local-MCP tests moved to asserting refusal codes. return new RegExp( - `(?:invoke|invokeError)\\(\\s*["']${name}["']|invokeContract\\(\\s*\\w+\\s*,\\s*["']${name}["']`, + `(?:invoke|invokeError)\\(\\s*["']${name}["']` + + `|(?:invokeContract|assertContract|assertCommandErrorCode)\\(\\s*\\w+\\s*,\\s*["']${name}["']`, ).test(source); } diff --git a/e2e/tests/entities.test.mjs b/e2e/tests/entities.test.mjs index d189210..dbcf7bc 100644 --- a/e2e/tests/entities.test.mjs +++ b/e2e/tests/entities.test.mjs @@ -12,6 +12,8 @@ import { DatabaseSync } from "node:sqlite"; import test from "node:test"; import { withApp } from "../lib/app.mjs"; import { + CRX_EXTENSION_NAME, + CRX_EXTENSION_VERSION, extensionIconPngBase64, extensionZipBase64, wireGuardFixture, @@ -103,6 +105,11 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a }); assert.equal(parsedImport.imported_count, 1); + assert.deepEqual( + await app.invoke("get_proxy_check_history", { proxyId: proxy.id }), + [], + "a proxy nobody has checked has no trail", + ); const validityError = await app.invokeError("check_proxy_validity", { proxyId: proxy.id, proxySettings: null, @@ -113,6 +120,49 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a }); assert.ok(cachedValidity === null || cachedValidity.is_valid === false); + // A check that failed is still a check, and it is recorded as one. The + // proxy above was edited to SOCKS5 on a closed port, so the UDP probe + // could not reach it: the honest verdict is "unknown", never "no". + const trail = await app.invoke("get_proxy_check_history", { + proxyId: proxy.id, + }); + assert.equal(trail.length, 1); + assert.equal(trail[0].ok, false); + assert.equal(trail[0].ip, null); + assert.equal(trail[0].udp, "unknown"); + assert.ok( + typeof trail[0].latency_ms === "number" && trail[0].latency_ms >= 0, + ); + assert.ok(trail[0].timestamp > 0); + + // Deleting the proxy takes the trail with it; it names exit addresses. + const doomed = await app.invoke("create_stored_proxy", { + name: "Trail Owner", + proxySettings: { + proxy_type: "http", + host: "127.0.0.1", + port: 9, + username: null, + password: null, + }, + }); + await app.invokeError("check_proxy_validity", { + proxyId: doomed.id, + proxySettings: null, + }); + const doomedTrail = await app.invoke("get_proxy_check_history", { + proxyId: doomed.id, + }); + assert.equal(doomedTrail.length, 1); + // An HTTP proxy cannot carry a datagram at all, which is answered from + // the protocol without dialling anything. + assert.equal(doomedTrail[0].udp, "no"); + await app.invoke("delete_stored_proxy", { proxyId: doomed.id }); + assert.deepEqual( + await app.invoke("get_proxy_check_history", { proxyId: doomed.id }), + [], + ); + // Donut accepts one VLESS shape (REALITY + XTLS Vision over TCP). The form // uses this to tell the user WHICH part of their setup is unsupported // instead of implying they mistyped, so the reason must survive the IPC hop. @@ -661,6 +711,103 @@ test("extensions, extension groups, VPN storage, DNS rules, and event-backed ass "importing a folder must never move or consume the user's copy of it", ); + // Importing from a link. The fixture server answers with a real CRX3 + // container, so this proves the importer unwraps the signed container to + // the ZIP the store keeps rather than filing the container itself. + const fixtureBase = process.env.DONUT_E2E_FIXTURE_URL; + assert.ok(fixtureBase, "the fixture server URL has to reach the suite"); + const fetched = await app.invoke("fetch_extension_from_url", { + url: `${fixtureBase}/extension.crx`, + }); + assert.equal(fetched.name, CRX_EXTENSION_NAME); + assert.equal(fetched.version, CRX_EXTENSION_VERSION); + assert.equal(fetched.from_web_store, false); + assert.equal( + fetched.file_name, + "extension.zip", + "the stored payload is the ZIP, so it must not still be called a .crx", + ); + assert.deepEqual( + fetched.file_data.slice(0, 4), + [0x50, 0x4b, 0x03, 0x04], + "the CRX3 header has to be stripped, not stored", + ); + + const fromLink = await app.invoke("add_extension", { + name: "Overridden By The Manifest", + fileName: fetched.file_name, + fileData: fetched.file_data, + }); + assert.equal(fromLink.name, CRX_EXTENSION_NAME); + assert.equal(fromLink.version, CRX_EXTENSION_VERSION); + assert.equal(fromLink.source_kind, "archive"); + assert.equal(fromLink.file_type, "zip"); + + // Assignable like any other extension: the link is only how it arrived. + const linkGroup = await app.invoke("create_extension_group", { + name: "Downloaded Extensions", + }); + assert.deepEqual( + ( + await app.invoke("add_extension_to_group", { + groupId: linkGroup.id, + extensionId: fromLink.id, + }) + ).extension_ids, + [fromLink.id], + ); + await app.invoke("assign_extension_group_to_profile", { + profileId: profile.id, + extensionGroupId: linkGroup.id, + }); + assert.equal( + ( + await app.invoke("get_extension_group_for_profile", { + profileId: profile.id, + }) + ).id, + linkGroup.id, + ); + + // A body that is not an extension is refused with the code, and nothing + // is stored for it. + assert.match( + await app.invokeError("fetch_extension_from_url", { + url: `${fixtureBase}/not-an-extension.zip`, + }), + /EXTENSION_NOT_AN_EXTENSION/, + ); + for (const rejected of [ + "not a link at all", + "https://example.invalid/downloads", + "https://example.invalid/installer.exe", + // 32 characters, but an extension id only uses a-p. + "abcdefghijklmnopabcdefghijklmnoz", + // Plain HTTP off loopback never crosses the wire, whatever it points at. + "http://files.example.invalid/pack.crx", + ]) { + assert.match( + await app.invokeError("fetch_extension_from_url", { url: rejected }), + /EXTENSION_URL_INVALID/, + rejected, + ); + } + assert.match( + await app.invokeError("fetch_extension_from_url", { + url: `${fixtureBase}/absent-extension.crx`, + }), + /EXTENSION_NOT_AN_EXTENSION|EXTENSION_DOWNLOAD_FAILED/, + ); + assert.equal((await app.invoke("list_extensions")).length, 1); + + await app.invoke("assign_extension_group_to_profile", { + profileId: profile.id, + extensionGroupId: null, + }); + await app.invoke("delete_extension_group", { groupId: linkGroup.id }); + await app.invoke("delete_extension", { extensionId: fromLink.id }); + assert.deepEqual(await app.invoke("list_extensions"), []); + const vpn = await app.invoke("create_vpn_config_manual", { name: "E2E WireGuard", vpnType: "WireGuard", @@ -926,3 +1073,517 @@ test("cookie import/copy/export, profile encryption, and traffic-stat read/clear }); }); }); + +test("deleted profiles land in the trash and come back intact on restore", async () => { + await withApp("entities-trash", async (app) => { + const initialSettings = await app.invoke("get_app_settings"); + assert.equal(initialSettings.trash_retention_days, 30); + const savedSettings = await app.invoke("save_app_settings", { + settings: { ...initialSettings, trash_retention_days: 7 }, + }); + assert.equal(savedSettings.trash_retention_days, 7); + assert.equal( + (await app.invoke("get_app_settings")).trash_retention_days, + 7, + ); + // Out-of-range values are clamped, never rejected. + const clamped = await app.invoke("save_app_settings", { + settings: { ...savedSettings, trash_retention_days: 9000 }, + }); + assert.equal(clamped.trash_retention_days, 365); + await app.invoke("save_app_settings", { + settings: { ...clamped, trash_retention_days: 7 }, + }); + + assert.deepEqual(await app.invoke("list_trashed_profiles"), []); + + const group = await app.invoke("create_profile_group", { + name: "Trash Group", + }); + const created = await app.invoke("create_browser_profile_new", { + name: "Recoverable", + browserStr: "wayfern", + version: "150.0.7871.100", + releaseType: "stable", + proxyId: null, + vpnId: null, + wayfernConfig: { + fingerprint: "{}", + identity_id: "identity-e2e", + identity_overrides: JSON.stringify({ userAgent: "Custom UA" }), + location: JSON.stringify({ + timezone: "Europe/Berlin", + language: "de-DE", + }), + }, + groupId: group.id, + ephemeral: false, + dnsBlocklist: null, + launchHook: null, + }); + await app.invoke("update_profile_tags", { + profileId: created.id, + tags: ["shop", "eu"], + }); + const before = (await app.invoke("list_browser_profiles")).find( + (item) => item.id === created.id, + ); + assert.equal(before.wayfern_config.identity_id, "identity-e2e"); + assert.deepEqual(before.tags, ["shop", "eu"]); + assert.equal(before.group_id, group.id); + + // Real files to carry through the move, plus a cache the trash must drop. + const profilesDir = path.join(app.dataRoot, "data", "profiles"); + const dataDir = path.join(profilesDir, created.id, "profile"); + await mkdir(path.join(dataDir, "Default"), { recursive: true }); + await writeFile(path.join(dataDir, "Default", "Cookies"), "cookie-db"); + await mkdir(path.join(dataDir, "Cache"), { recursive: true }); + await writeFile(path.join(dataDir, "Cache", "blob"), "cache-bytes"); + + await app.invoke("delete_profile", { profileId: created.id }); + assert.equal( + (await app.invoke("list_browser_profiles")).some( + (item) => item.id === created.id, + ), + false, + ); + const trashed = await app.invoke("list_trashed_profiles"); + assert.equal(trashed.length, 1); + assert.equal(trashed[0].id, created.id); + assert.equal(trashed[0].name, "Recoverable"); + assert.equal(trashed[0].browser, "wayfern"); + assert.equal(trashed[0].version, "150.0.7871.100"); + assert.equal(trashed[0].group_id, group.id); + assert.equal(trashed[0].password_protected, false); + assert.equal( + trashed[0].expires_at - trashed[0].deleted_at, + 7 * 24 * 60 * 60, + ); + assert.ok(trashed[0].size_bytes > 0); + const trashDir = path.join(app.dataRoot, "data", "trash"); + const entryDir = path.join(trashDir, created.id); + assert.ok(existsSync(path.join(entryDir, "profile.json"))); + assert.ok(existsSync(path.join(entryDir, "manifest.json"))); + assert.equal( + await readFile( + path.join(entryDir, "profile", "Default", "Cookies"), + "utf8", + ), + "cookie-db", + ); + assert.equal( + existsSync(path.join(entryDir, "profile", "Cache")), + false, + "caches are pruned before the move", + ); + assert.equal(existsSync(path.join(profilesDir, created.id)), false); + + // A live profile carrying the same name pushes the restored one to a suffix. + const namesake = await createProfile(app, "Recoverable"); + const restored = await app.invoke("restore_trashed_profile", { + profileId: created.id, + }); + assert.equal(restored.id, created.id); + assert.equal(restored.name, "Recoverable (restored)"); + assert.deepEqual(restored.wayfern_config, before.wayfern_config); + assert.deepEqual(restored.tags, before.tags); + assert.equal(restored.group_id, group.id); + assert.ok(restored.updated_at >= (before.updated_at ?? 0)); + const live = (await app.invoke("list_browser_profiles")).find( + (item) => item.id === created.id, + ); + assert.deepEqual(live.wayfern_config, before.wayfern_config); + assert.deepEqual(live.tags, before.tags); + assert.equal( + await readFile( + path.join(profilesDir, created.id, "profile", "Default", "Cookies"), + "utf8", + ), + "cookie-db", + ); + assert.equal(existsSync(entryDir), false); + assert.deepEqual(await app.invoke("list_trashed_profiles"), []); + assert.match( + await app.invokeError("restore_trashed_profile", { + profileId: created.id, + }), + /TRASH_ENTRY_NOT_FOUND/, + ); + + // A group deleted while the profile sat in the trash is not resurrected. + await app.invoke("delete_profile", { profileId: created.id }); + await app.invoke("delete_profile_group", { groupId: group.id }); + const restoredWithoutGroup = await app.invoke("restore_trashed_profile", { + profileId: created.id, + }); + assert.equal(restoredWithoutGroup.id, created.id); + assert.equal(restoredWithoutGroup.group_id, null); + assert.deepEqual( + restoredWithoutGroup.wayfern_config, + before.wayfern_config, + ); + + // Delete again, then purge: gone for good. + await app.invoke("delete_profile", { profileId: created.id }); + assert.equal((await app.invoke("list_trashed_profiles")).length, 1); + await app.invoke("purge_trashed_profile", { profileId: created.id }); + assert.deepEqual(await app.invoke("list_trashed_profiles"), []); + assert.equal(existsSync(entryDir), false); + assert.equal(existsSync(path.join(profilesDir, created.id)), false); + assert.match( + await app.invokeError("purge_trashed_profile", { + profileId: created.id, + }), + /TRASH_ENTRY_NOT_FOUND/, + ); + + // An explicit permanent delete never lands in the trash. + const doomed = await createProfile(app, "Doomed"); + await app.invoke("delete_profile", { + profileId: doomed.id, + permanent: true, + }); + assert.deepEqual(await app.invoke("list_trashed_profiles"), []); + assert.equal(existsSync(path.join(trashDir, doomed.id)), false); + assert.equal(existsSync(path.join(profilesDir, doomed.id)), false); + + // A bulk delete trashes every profile; emptying the trash clears them all. + const bulkA = await createProfile(app, "Bulk A"); + const bulkB = await createProfile(app, "Bulk B"); + await app.invoke("delete_selected_profiles", { + profileIds: [bulkA.id, bulkB.id], + }); + assert.deepEqual( + (await app.invoke("list_trashed_profiles")) + .map((entry) => entry.name) + .sort(), + ["Bulk A", "Bulk B"], + ); + assert.equal(await app.invoke("empty_trash"), 2); + assert.deepEqual(await app.invoke("list_trashed_profiles"), []); + assert.equal(existsSync(path.join(trashDir, bulkA.id)), false); + + // Restore refuses an entry whose id a live profile already carries. + const conflictDir = path.join(trashDir, namesake.id); + await mkdir(conflictDir, { recursive: true }); + await writeFile( + path.join(conflictDir, "profile.json"), + JSON.stringify(namesake), + ); + await writeFile( + path.join(conflictDir, "manifest.json"), + JSON.stringify({ + deleted_at: 1, + expires_at: 4_102_444_800, + size_bytes: 0, + original_name: namesake.name, + }), + ); + assert.match( + await app.invokeError("restore_trashed_profile", { + profileId: namesake.id, + }), + /TRASH_RESTORE_CONFLICT/, + ); + await app.invoke("purge_trashed_profile", { profileId: namesake.id }); + assert.deepEqual(await app.invoke("list_trashed_profiles"), []); + + await app.invoke("delete_profile", { + profileId: namesake.id, + permanent: true, + }); + assert.deepEqual(await app.invoke("list_browser_profiles"), []); + }); +}); + +test("proxies distribute one to one, and group bookmarks reach the profile's Bookmarks file", async () => { + await withApp("entities-distribution-bookmarks", async (app) => { + const profiles = []; + for (const name of ["Fleet 1", "Fleet 2", "Fleet 3", "Fleet 4"]) { + profiles.push(await createProfile(app, name)); + } + const proxies = []; + for (const [index, name] of ["Exit A", "Exit B", "Exit C"].entries()) { + proxies.push( + await app.invoke("create_stored_proxy", { + name, + proxySettings: { + proxy_type: "http", + host: "127.0.0.1", + port: 9001 + index, + username: null, + password: null, + }, + }), + ); + } + + const profileIds = profiles.map((profile) => profile.id); + const proxyIds = proxies.map((proxy) => proxy.id); + + // Four profiles, three proxies: three pairs and one profile left alone. + // The fourth must NEVER wrap around onto the first proxy. + const plan = await app.invoke("plan_proxy_distribution", { + profileIds, + proxyIds, + allowSharing: false, + }); + assert.deepEqual( + plan.pairs, + proxyIds.map((proxyId, index) => ({ + profile_id: profileIds[index], + proxy_id: proxyId, + })), + ); + assert.deepEqual(plan.unpaired_profile_ids, [profileIds[3]]); + assert.deepEqual(plan.unused_proxy_ids, []); + assert.deepEqual(plan.shared_proxy_ids, []); + assert.deepEqual(plan.running_profile_ids, []); + + const results = await app.invoke("distribute_proxies_to_profiles", { + pairs: plan.pairs, + }); + assert.equal(results.length, 3); + assert.ok(results.every((result) => result.ok)); + + const afterDistribution = await app.invoke("list_browser_profiles"); + const proxyOf = (id) => + afterDistribution.find((profile) => profile.id === id).proxy_id; + assert.equal(proxyOf(profileIds[0]), proxyIds[0]); + assert.equal(proxyOf(profileIds[1]), proxyIds[1]); + assert.equal(proxyOf(profileIds[2]), proxyIds[2]); + assert.equal(proxyOf(profileIds[3]) ?? null, null); + + // A proxy someone else holds is refused by default and only offered once + // the caller asks for sharing explicitly. + const strict = await app.invoke("plan_proxy_distribution", { + profileIds: [profileIds[3]], + proxyIds: [proxyIds[0]], + allowSharing: false, + }); + assert.deepEqual(strict.pairs, []); + assert.deepEqual(strict.shared_proxy_ids, [proxyIds[0]]); + assert.deepEqual(strict.unpaired_profile_ids, [profileIds[3]]); + + const permissive = await app.invoke("plan_proxy_distribution", { + profileIds: [profileIds[3]], + proxyIds: [proxyIds[0]], + allowSharing: true, + }); + assert.deepEqual(permissive.pairs, [ + { profile_id: profileIds[3], proxy_id: proxyIds[0] }, + ]); + + // Per-profile failures never break the batch: one good pair still lands. + const mixed = await app.invoke("distribute_proxies_to_profiles", { + pairs: [ + { profile_id: profileIds[3], proxy_id: proxyIds[0] }, + { profile_id: profileIds[3], proxy_id: proxyIds[1] }, + { + profile_id: profileIds[0], + proxy_id: "00000000-0000-4000-8000-000000000000", + }, + ], + }); + assert.equal(mixed[0].ok, true); + assert.equal(mixed[1].ok, false); + assert.match(mixed[1].error, /PROFILE_PAIRED_TWICE/); + assert.equal(mixed[2].ok, false); + assert.match(mixed[2].error, /PROXY_NOT_FOUND/); + assert.equal( + (await app.invoke("list_browser_profiles")).find( + (profile) => profile.id === profileIds[3], + ).proxy_id, + proxyIds[0], + ); + + // --- group bookmarks --- + const group = await app.invoke("create_profile_group", { + name: "Client Sites", + }); + assert.deepEqual( + await app.invoke("get_group_bookmarks", { groupId: group.id }), + [], + ); + + const refused = await app.invokeError("set_group_bookmarks", { + groupId: group.id, + bookmarks: [{ title: "Keys", url: "file:///etc/passwd", folder: null }], + }); + assert.match(refused, /URL_SCHEME_NOT_ALLOWED/); + const unnamed = await app.invokeError("set_group_bookmarks", { + groupId: group.id, + bookmarks: [{ title: " ", url: "https://ok.example", folder: null }], + }); + assert.match(unnamed, /NAME_CANNOT_BE_EMPTY/); + + const saved = await app.invoke("set_group_bookmarks", { + groupId: group.id, + bookmarks: [ + { title: "Support", url: "https://support.example", folder: null }, + { title: "Console", url: "https://console.example", folder: "Ops" }, + ], + }); + assert.equal(saved.length, 2); + assert.equal(saved[1].folder, "Ops"); + assert.equal( + (await app.invoke("get_groups_with_profile_counts")).find( + (item) => item.id === group.id, + ).bookmark_count, + 2, + ); + + const target = profiles[0]; + await app.invoke("assign_profiles_to_group", { + profileIds: [target.id], + groupId: group.id, + }); + + // Seed the profile's own Bookmarks file the way a real Chromium session + // would have left it, so the write has something of the user's to preserve. + const bookmarksFile = path.join( + app.dataRoot, + "data", + "profiles", + target.id, + "profile", + "Default", + "Bookmarks", + ); + await mkdir(path.dirname(bookmarksFile), { recursive: true }); + const permanentFolder = (id, name) => ({ + children: [], + date_added: "13300000000000000", + date_modified: "13300000000000000", + guid: `0000000${id}-0000-4000-8000-000000000000`, + id: String(id), + name, + type: "folder", + }); + await writeFile( + bookmarksFile, + JSON.stringify({ + checksum: "0".repeat(32), + roots: { + bookmark_bar: { + ...permanentFolder(1, "Bookmarks bar"), + children: [ + { + date_added: "13300000000000000", + guid: "aaaaaaaa-0000-4000-8000-000000000000", + id: "9", + name: "My Bank", + type: "url", + url: "https://bank.example/", + }, + ], + }, + other: permanentFolder(2, "Other bookmarks"), + synced: permanentFolder(3, "Mobile bookmarks"), + }, + sync_metadata: "Zm9v", + version: 1, + }), + ); + + const readBookmarks = async () => + JSON.parse(await readFile(bookmarksFile, "utf8")); + const managedFolderOf = (document) => + document.roots.bookmark_bar.children.filter( + (child) => + child.type === "folder" && + child.meta_info?.donut_managed_group_bookmarks === "1", + ); + + assert.equal( + await app.invoke("apply_group_bookmarks_to_profile", { + profileId: target.id, + }), + true, + ); + + let document = await readBookmarks(); + let managed = managedFolderOf(document); + assert.equal(managed.length, 1); + assert.equal(managed[0].name, "Donut Group Bookmarks"); + assert.deepEqual( + managed[0].children.map((child) => child.name), + ["Support", "Ops"], + ); + assert.deepEqual( + managed[0].children[1].children.map((child) => child.url), + ["https://console.example"], + ); + // The user's own bookmark, the other roots and Chromium's opaque state all + // survive; only the checksum is rewritten to describe the new tree. + assert.equal(document.roots.bookmark_bar.children[0].name, "My Bank"); + assert.equal(document.sync_metadata, "Zm9v"); + assert.equal(document.version, 1); + assert.notEqual(document.checksum, "0".repeat(32)); + assert.match(document.checksum, /^[0-9a-f]{32}$/); + + // Applying again is a no-op: the folder is not duplicated and the file is + // not even rewritten. + const firstWrite = await readFile(bookmarksFile, "utf8"); + assert.equal( + await app.invoke("apply_group_bookmarks_to_profile", { + profileId: target.id, + }), + false, + ); + assert.equal(await readFile(bookmarksFile, "utf8"), firstWrite); + + // Removing a bookmark from the group removes it from the folder next time. + await app.invoke("set_group_bookmarks", { + groupId: group.id, + bookmarks: [ + { title: "Support", url: "https://support.example", folder: null }, + ], + }); + assert.equal( + await app.invoke("apply_group_bookmarks_to_profile", { + profileId: target.id, + }), + true, + ); + document = await readBookmarks(); + managed = managedFolderOf(document); + assert.equal(managed.length, 1); + assert.deepEqual( + managed[0].children.map((child) => child.name), + ["Support"], + ); + + // Emptying the group takes the whole folder away and leaves the user's own. + await app.invoke("set_group_bookmarks", { + groupId: group.id, + bookmarks: [], + }); + assert.equal( + await app.invoke("apply_group_bookmarks_to_profile", { + profileId: target.id, + }), + true, + ); + document = await readBookmarks(); + assert.deepEqual(managedFolderOf(document), []); + assert.deepEqual( + document.roots.bookmark_bar.children.map((child) => child.name), + ["My Bank"], + ); + + // A profile in no group is left entirely alone. + assert.equal( + await app.invoke("apply_group_bookmarks_to_profile", { + profileId: profileIds[1], + }), + false, + ); + + await app.invoke("delete_selected_profiles", { profileIds }); + await app.invoke("delete_profile_group", { groupId: group.id }); + for (const proxy of proxies) { + await app.invoke("delete_stored_proxy", { proxyId: proxy.id }); + } + }); +}); diff --git a/e2e/tests/integrations.test.mjs b/e2e/tests/integrations.test.mjs index b34cf7a..2ce394c 100644 --- a/e2e/tests/integrations.test.mjs +++ b/e2e/tests/integrations.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import test from "node:test"; import { withApp } from "../lib/app.mjs"; @@ -63,6 +63,41 @@ async function invokeContract(app, command, args = {}) { } } +/** + * Evidence that a command's BODY ran, not merely that it was invoked. + * + * `assert.ok(await invokeContract(...))` was the pattern here, and it cannot + * fail: `invokeContract` always resolves to an object, and every object is + * truthy. It passed whether the command succeeded, refused, or did not exist at + * all, so eight commands whose only coverage-map evidence was that line could + * have had their entire bodies deleted with every suite still green. + * + * Each caller now states which of the two outcomes it expects and pins it, so + * the assertion fails if the command stops reaching its real logic. The two + * outcomes are both legitimate here: these are cloud and update commands, and a + * hermetic E2E run has no session, so a refusal FOR THE RIGHT REASON is exactly + * as much proof that the body ran as a success is. + */ +async function assertContract(app, command, expected, args = {}) { + const result = await invokeContract(app, command, args); + if (expected.refusedWith) { + assert.equal( + result.ok, + false, + `${command} was expected to refuse, but returned ${JSON.stringify(result.value)}`, + ); + assert.match( + result.error, + expected.refusedWith, + `${command} refused for a different reason than the one that proves its body ran`, + ); + return result; + } + assert.equal(result.ok, true, `${command} failed: ${result.error}`); + expected.answers(result.value); + return result; +} + async function assertCommandErrorCode(app, command, code, args = {}) { const error = await app.invokeError(command, args); assert.match(error, new RegExp(`"code":"${code}"`)); @@ -84,6 +119,26 @@ test("authenticated REST API serves its complete OpenAPI contract and CRUD lifec assert.ok(saved.api_token?.length >= 32); const port = await app.invoke("start_api_server", { port: 0 }); assert.equal(await app.invoke("get_api_server_status"), port); + const diagnostic = await app.invoke("check_integration_connection", { + target: "api", + }); + assert.equal(diagnostic.configured, true); + assert.equal(diagnostic.reachable, true); + assert.equal(diagnostic.authorized, true); + assert.equal(diagnostic.http_status, 200); + assert.ok(diagnostic.checked_at > 0); + assert.deepEqual(Object.keys(diagnostic).sort(), [ + "authorized", + "checked_at", + "configured", + "http_status", + "reachable", + ]); + const unconfigured = await app.invoke("check_integration_connection", { + target: "remote", + }); + assert.equal(unconfigured.configured, false); + assert.equal(unconfigured.authorized, null); const base = `http://127.0.0.1:${port}`; const openapi = await jsonRequest(`${base}/openapi.json`); @@ -719,313 +774,304 @@ test("authenticated REST API serves its complete OpenAPI contract and CRUD lifec ); await app.invoke("stop_api_server"); assert.equal(await app.invoke("get_api_server_status"), null); + const stoppedDiagnostic = await app.invoke("check_integration_connection", { + target: "api", + }); + assert.equal(stoppedDiagnostic.configured, true); + assert.equal(stoppedDiagnostic.reachable, false); + assert.equal(stoppedDiagnostic.authorized, null); }); }); -test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated agent install", async () => { +test("local MCP is removed: enabling it and installing a local client are refused", async () => { await withApp("integrations-mcp", async (app) => { await seedTerms(app); + // Enabling the local server is refused with the removal code (it used to + // start a loopback MCP server), so nothing is left listening as a result. + await assertCommandErrorCode(app, "start_mcp_server", "MCP_LOCAL_REMOVED"); + assert.equal(await app.invoke("get_mcp_server_status"), false); + // Nothing is configured for a server that no longer exists. + assert.equal(await app.invoke("get_mcp_config"), null); + // stop is a no-op when nothing is running. await assertCommandErrorCode( app, "stop_mcp_server", "MCP_SERVER_NOT_RUNNING", ); - const port = await app.invoke("start_mcp_server"); - await assertCommandErrorCode( - app, - "start_mcp_server", - "MCP_SERVER_ALREADY_RUNNING", - ); - assert.equal(await app.invoke("get_mcp_server_status"), true); - const config = await app.invoke("get_mcp_config"); - assert.equal(config.port, port); - assert.ok(config.token.length >= 32); - const base = `http://127.0.0.1:${port}`; - assert.equal((await fetch(`${base}/health`)).status, 200); - assert.equal( - ( - await jsonRequest(`${base}/mcp`, { - method: "POST", - body: { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, - }) - ).response.status, - 401, - ); - - const initialized = await jsonRequest(`${base}/mcp/${config.token}`, { - method: "POST", - body: { - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - protocolVersion: "2025-11-25", - capabilities: {}, - clientInfo: { name: "donut-e2e", version: "1" }, - }, - }, - }); - assert.equal(initialized.response.status, 200); - assert.equal(initialized.value.result.serverInfo.name, "donut-browser"); - const sessionId = initialized.response.headers.get("mcp-session-id"); - assert.ok(sessionId); - const mcpHeaders = { "mcp-session-id": sessionId }; - const notification = await jsonRequest(`${base}/mcp/${config.token}`, { - method: "POST", - headers: mcpHeaders, - body: { jsonrpc: "2.0", method: "notifications/initialized" }, - }); - assert.equal(notification.response.status, 202); - const tools = await jsonRequest(`${base}/mcp/${config.token}`, { - method: "POST", - headers: mcpHeaders, - body: { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, - }); - assert.equal(tools.response.status, 200); - const names = tools.value.result.tools.map((tool) => tool.name); - for (const name of [ - "list_profiles", - "create_profile", - "run_profile", - "list_proxies", - "create_proxy", - "update_proxy", - "get_page_content", - "get_interactive_elements", - // The remote loop has to be complete from MCP alone: start a session, - // watch it become usable, drive it with the interaction tools above, stop - // it. Any one of these missing leaves an agent able to lease a host it - // cannot use, or unable to lease one at all. - "run_profile_remote", - "get_remote_session", - "stop_remote_session", - // Extension management is only usable from an agent if importing and - // grouping are reachable, not just listing and deleting. - "add_extension", - "update_extension", - "add_extension_to_group", - "remove_extension_from_group", - "update_extension_group", - ]) { - assert.ok(names.includes(name), `MCP is missing ${name}`); - } - const listed = await jsonRequest(`${base}/mcp/${config.token}`, { - method: "POST", - headers: mcpHeaders, - body: { - jsonrpc: "2.0", - id: 3, - method: "tools/call", - params: { name: "list_profiles", arguments: {} }, - }, - }); - assert.equal(listed.response.status, 200); - assert.equal(listed.value.error, undefined); - assert.ok(listed.value.result); - - const createdVless = await jsonRequest(`${base}/mcp/${config.token}`, { - method: "POST", - headers: mcpHeaders, - body: { - jsonrpc: "2.0", - id: 4, - method: "tools/call", - params: { - name: "create_proxy", - arguments: { - name: "MCP VLESS Reality", - proxy_type: "vless", - vless_uri: VLESS_URI, - }, - }, - }, - }); - assert.equal(createdVless.response.status, 200); - assert.equal(createdVless.value.error, undefined); - let vlessProxy = (await app.invoke("get_stored_proxies")).find( - (proxy) => proxy.name === "MCP VLESS Reality", - ); - assert.ok(vlessProxy); - assert.equal(vlessProxy.proxy_settings.proxy_type, "vless"); - assert.equal(vlessProxy.proxy_settings.vless_uri, VLESS_URI); - - const updatedVless = await jsonRequest(`${base}/mcp/${config.token}`, { - method: "POST", - headers: mcpHeaders, - body: { - jsonrpc: "2.0", - id: 5, - method: "tools/call", - params: { - name: "update_proxy", - arguments: { - proxy_id: vlessProxy.id, - name: "MCP VLESS Updated", - vless_uri: VLESS_URI, - }, - }, - }, - }); - assert.equal(updatedVless.value.error, undefined); - vlessProxy = (await app.invoke("get_stored_proxies")).find( - (proxy) => proxy.id === vlessProxy.id, - ); - assert.equal(vlessProxy.name, "MCP VLESS Updated"); - - const invalidVless = await jsonRequest(`${base}/mcp/${config.token}`, { - method: "POST", - headers: mcpHeaders, - body: { - jsonrpc: "2.0", - id: 6, - method: "tools/call", - params: { - name: "update_proxy", - arguments: { - proxy_id: vlessProxy.id, - vless_uri: VLESS_URI.replace("security=reality", "security=tls"), - }, - }, - }, - }); - assert.match(invalidVless.value.error.message, /VLESS_CONFIG_INVALID/); - assert.equal( - (await app.invoke("get_stored_proxies")).find( - (proxy) => proxy.id === vlessProxy.id, - ).proxy_settings.vless_uri, - VLESS_URI, - ); - await app.invoke("delete_stored_proxy", { proxyId: vlessProxy.id }); - - let toolCallId = 7; - const callTool = (name, args) => - jsonRequest(`${base}/mcp/${config.token}`, { - method: "POST", - headers: mcpHeaders, - body: { - jsonrpc: "2.0", - id: toolCallId++, - method: "tools/call", - params: { name, arguments: args }, - }, - }); - - const unpackedDir = await writeUnpackedExtension( - path.join(app.root, "fixtures", "mcp-unpacked-extension"), - { name: "Donut MCP Unpacked", version: "1.0.0" }, - ); - const addedExtension = await callTool("add_extension", { - path: unpackedDir, - name: "MCP Folder Extension", - }); - assert.equal(addedExtension.response.status, 200); - const subscriptionGated = /subscription/i.test( - addedExtension.value.error?.message ?? "", - ); - // The e2e build overrides the paid-plan gate whenever a Wayfern test token - // is present, so with one in the environment a gated answer means the - // override stopped working and everything below it silently stopped - // running. - assert.ok( - !subscriptionGated || !process.env.WAYFERN_TEST_TOKEN, - `the e2e paid-plan override did not apply: ${addedExtension.value.error?.message}`, - ); - if (subscriptionGated) { - // Every extension tool is gated on an active paid plan and this session - // is signed out, so the call path is unreachable here. The tool list - // above still proves the tools are published. - console.warn( - "Skipping the MCP extension tool calls: this session has no paid entitlement", - ); - } else { - assert.equal(addedExtension.value.error, undefined); - const stored = (await app.invoke("list_extensions")).find( - (item) => item.name === "Donut MCP Unpacked", - ); - assert.ok(stored, "the MCP import must produce a stored extension"); - assert.equal(stored.source_kind, "unpacked"); - assert.equal(stored.linked_path, null); - - const renamedExtension = await callTool("update_extension", { - extension_id: stored.id, - name: "MCP Renamed Extension", - }); - assert.equal(renamedExtension.value.error, undefined); - assert.equal( - (await app.invoke("list_extensions")).find( - (item) => item.id === stored.id, - ).name, - "MCP Renamed Extension", - ); - - const extensionGroup = await app.invoke("create_extension_group", { - name: "MCP Extension Group", - }); - const joined = await callTool("add_extension_to_group", { - group_id: extensionGroup.id, - extension_id: stored.id, - }); - assert.equal(joined.value.error, undefined); - const readGroup = async () => - (await app.invoke("list_extension_groups")).find( - (item) => item.id === extensionGroup.id, - ); - assert.deepEqual((await readGroup()).extension_ids, [stored.id]); - - const renamedGroup = await callTool("update_extension_group", { - group_id: extensionGroup.id, - name: "MCP Extension Group Updated", - }); - assert.equal(renamedGroup.value.error, undefined); - assert.equal((await readGroup()).name, "MCP Extension Group Updated"); - - const removed = await callTool("remove_extension_from_group", { - group_id: extensionGroup.id, - extension_id: stored.id, - }); - assert.equal(removed.value.error, undefined); - assert.deepEqual((await readGroup()).extension_ids, []); - - await app.invoke("delete_extension", { extensionId: stored.id }); - await app.invoke("delete_extension_group", { - groupId: extensionGroup.id, - }); - } + // The client roster still resolves, so the Integrations page can offer the + // remote endpoint and show which clients are still on the removed local one. const agents = await app.invoke("list_mcp_agents"); assert.ok(agents.some((agent) => agent.id === "cursor")); + // fx cannot take the bearer from its config file, so the page tells the + // user which variable to export; that name travels with the row. + assert.equal( + agents.find((agent) => agent.id === "fx").token_env, + "DONUT_MCP_TOKEN", + ); + assert.equal(agents.find((agent) => agent.id === "cursor").token_env, null); + + // An unknown agent and an unknown target keep their own distinct errors. await assertCommandErrorCode(app, "add_mcp_to_agent", "MCP_AGENT_UNKNOWN", { agentId: "missing-e2e-agent", + target: "remote", }); - await app.invoke("add_mcp_to_agent", { agentId: "cursor" }); - assert.equal( - (await app.invoke("list_mcp_agents")).find( - (agent) => agent.id === "cursor", - ).connected, - true, + await assertCommandErrorCode( + app, + "remove_mcp_from_agent", + "MCP_AGENT_UNKNOWN", + { agentId: "missing-e2e-agent" }, ); - await app.invoke("remove_mcp_from_agent", { agentId: "cursor" }); + await assertCommandErrorCode(app, "add_mcp_to_agent", "INTERNAL_ERROR", { + agentId: "cursor", + target: "bogus", + }); + + // Installing a client to the LOCAL endpoint is refused now (it used to + // write a local config), and nothing is written. + await assertCommandErrorCode(app, "add_mcp_to_agent", "MCP_LOCAL_REMOVED", { + agentId: "cursor", + target: "local", + }); assert.equal( (await app.invoke("list_mcp_agents")).find( (agent) => agent.id === "cursor", ).connected, false, + "a refused local install must not have written an entry", + ); + + // Remote MCP with no stored `dmk_` credential is refused with a code the UI + // can explain, and writes nothing into the client's config. + await assertCommandErrorCode( + app, + "add_mcp_to_agent", + "MCP_REMOTE_KEY_MISSING", + { agentId: "cursor", target: "remote" }, ); assert.equal( - ( - await jsonRequest(`${base}/mcp/${config.token}`, { - method: "DELETE", - headers: mcpHeaders, - }) - ).response.status, - 200, + (await app.invoke("list_mcp_agents")).find( + (agent) => agent.id === "cursor", + ).connected, + false, + "a refused remote install must not have written an entry", ); - await app.invoke("stop_mcp_server"); + }); +}); + +test("the remote-control bridge refuses a signed-out desktop and stays off", async () => { + await withApp("integrations-mcp-remote", async (app) => { + await seedTerms(app); + + // Off by default, and it must stay that way without an explicit opt-in: the + // bridge hands Donut cloud the ability to drive this browser, which is not + // something to switch on for somebody because their plan allows it. + const settings = await app.invoke("get_app_settings"); + assert.equal(settings.mcp_remote_enabled, false); + + const initial = await app.invoke("get_mcp_remote_status"); + assert.equal(initial.enabled, false); + assert.equal(initial.connected, false); + assert.equal(initial.lastError, null); + assert.ok(initial.instanceId.length >= 8); + + // Stable across calls, and persisted: the id is how this desktop reclaims + // its own connection after a blip, so a desktop that renamed itself on + // every read could never do so. + assert.equal( + (await app.invoke("get_mcp_remote_status")).instanceId, + initial.instanceId, + ); + + // A signed-out desktop has no credential to authenticate the socket with, + // so it is refused here rather than allowed to open one and be dropped. + assert.match( + await app.invokeError("start_mcp_remote_bridge"), + /"code":"MCP_REMOTE_REQUIRES_SIGN_IN"/, + ); + // The bridge must not merely be un-enabled: it must not have been STARTED. + // The sign-in gate sits above `mcp_remote::start`, and `enabled` is the only + // half that says so: it is `is_running()`, which `start` flips synchronously + // before it spawns the reconnect loop. `connected` is not a substitute: + // it only goes true once the socket authenticates, which a signed-out + // desktop never manages, so it reads false whether or not the bridge was + // started and is dialling donutbrowser.com in the background. + const afterRefusedStart = await app.invoke("get_mcp_remote_status"); + assert.equal( + afterRefusedStart.enabled, + false, + "a refused start must not have started the bridge task", + ); + assert.equal( + afterRefusedStart.connected, + false, + "a refused start must not have opened a socket", + ); + assert.equal( + (await app.invoke("get_app_settings")).mcp_remote_enabled, + false, + "a refused start must not leave the setting on, or the next launch dials a socket the user never enabled", + ); + + // Stopping something that is not running is a no-op, not an error: the + // desktop calls this on sign-out and on quit, and both must be safe. + const stoppedWhileOff = await app.invoke("stop_mcp_remote_bridge"); + assert.equal(stoppedWhileOff.enabled, false); + assert.equal(stoppedWhileOff.connected, false); + assert.equal(stoppedWhileOff.instanceId, initial.instanceId); + + // The no-op case says nothing about the PERSISTED effect, and that is the + // half that matters: `ensure_remote_bridge` re-opens the bridge on the next + // launch (and on the next sign-in, and on the ten-minute reconnect tick) + // from `mcp_remote_enabled` alone. So put the flag on disk the way a + // previously opted-in session would have left it, and stop again. + // + // Every field of the returned status is computed from in-memory bridge + // state (`enabled` is `is_running()`, `connected` is `is_connected()`), so + // dropping the settings write inside `stop_mcp_remote_bridge` leaves all + // three assertions above green while the internet-facing bridge comes back + // by itself. Only the on-disk flag tells a real stop from `Ok(status())`. + // + // `save_app_settings` is NOT how the flag gets there. It belongs to the + // start and stop commands alone: a settings save that carried it would + // switch the internet-facing bridge on for the next launch without ever + // passing the sign-in and terms gates those commands enforce. Prove the + // save cannot flip it, then seed the file directly. + const beforeSeed = await app.invoke("get_app_settings"); + const saved = await app.invoke("save_app_settings", { + settings: { ...beforeSeed, mcp_remote_enabled: true }, + }); + assert.equal( + saved.mcp_remote_enabled, + false, + "a settings save must not be able to switch remote control on", + ); + assert.equal( + (await app.invoke("get_app_settings")).mcp_remote_enabled, + false, + "nor may it reach disk through the save", + ); + + const settingsFile = path.join( + app.dataRoot, + "data", + "settings", + "app_settings.json", + ); + const onDisk = JSON.parse(await readFile(settingsFile, "utf8")); + await writeFile( + settingsFile, + `${JSON.stringify({ ...onDisk, mcp_remote_enabled: true }, null, 2)}\n`, + ); + assert.equal( + (await app.invoke("get_app_settings")).mcp_remote_enabled, + true, + "the seeded opt-in must reach disk, or the stop below proves nothing", + ); + + const stopped = await app.invoke("stop_mcp_remote_bridge"); + assert.equal(stopped.enabled, false); + assert.equal(stopped.connected, false); + assert.equal(stopped.instanceId, initial.instanceId); + assert.equal( + (await app.invoke("get_app_settings")).mcp_remote_enabled, + false, + "stopping must clear the persisted opt-in, or the next launch re-opens the bridge the user just switched off", + ); + + // Entitlement is asked of the SERVER, because neither of the local answers + // is right: the cached entitlement is per-account, so an entitled + // enterprise team MEMBER reads as unentitled, and an open socket only + // proves the plan is active, not that remote control is allowed. + // + // Signed out there is no credential to ask with, and the honest outcome is + // a clean error the dialog swallows to leave the local cache in charge: + // never a crash, and never a fabricated `true`. + const entitlementError = await app.invokeError( + "get_remote_control_entitlement", + ); + assert.match(entitlementError, /INTERNAL_ERROR|Not logged in/); + + // The remote MCP credential, the `dmk_` key agents present to the remote + // endpoint. A fresh desktop holds none, and reports exactly that: the + // shape is `{ present, token_prefix }` with no plaintext anywhere in it. + assert.deepEqual(await app.invoke("get_mcp_remote_credential"), { + present: false, + token_prefix: null, + }); + + // Minting needs the session, so a signed-out desktop is refused with the + // same code as the bridge, and refused BEFORE anything is stored. + // + // Signed in, the answer is `{ token_prefix, failed_clients }`: the key is + // stored and its predecessor revoked before any client is rewritten, so a + // client that could not be rewritten is named there rather than turning + // the rotation into an error the dialog would answer by minting again. + assert.match( + await app.invokeError("rotate_mcp_remote_credential"), + /"code":"MCP_REMOTE_REQUIRES_SIGN_IN"/, + ); + assert.equal( + (await app.invoke("get_mcp_remote_credential")).present, + false, + "a refused rotation must not have stored a credential", + ); + + // Forgetting nothing is a no-op, not an error: sign-out and the + // Integrations page both reach it without checking first. + await app.invoke("forget_mcp_remote_credential"); + assert.deepEqual(await app.invoke("get_mcp_remote_credential"), { + present: false, + token_prefix: null, + }); + + // The local MCP server is a separate transport and is unaffected either way. assert.equal(await app.invoke("get_mcp_server_status"), false); }); }); -test("REST and MCP share the browser automation rate limit", async () => { +test("the remote-control bridge refuses before the terms are accepted", async () => { + // `start_mcp_remote_bridge` has TWO gates (the Wayfern terms, then the + // signed-in check), and every other test seeds the terms first, so only the + // second one was ever reached. The first could have been deleted with the + // whole suite green, which would let a desktop that never accepted the terms + // open an internet-facing hook into itself. + // + // `wayfernTermsAccepted: false` is what makes this session different: the + // harness seeds the acceptance file by DEFAULT, so merely omitting the + // explicit `seedTerms` call leaves the terms accepted and this test proves + // nothing (it first ran that way and hit the sign-in gate instead). + await withApp( + "integrations-mcp-remote-terms", + async (app) => { + assert.match( + await app.invokeError("start_mcp_remote_bridge"), + /"code":"WAYFERN_TERMS_REQUIRED"/, + ); + + const status = await app.invoke("get_mcp_remote_status"); + // `enabled` first, for the same reason as the sign-in gate: it is + // `is_running()` and flips inside `mcp_remote::start`, so it is what + // catches a gate that stopped sitting above the start. `connected` alone + // would stay false on a bridge that was started and merely never got a + // socket up. + assert.equal( + status.enabled, + false, + "the bridge may not have been started", + ); + assert.equal(status.connected, false, "no socket may have been opened"); + assert.equal( + (await app.invoke("get_app_settings")).mcp_remote_enabled, + false, + "a terms refusal must not leave the setting on either", + ); + }, + { wayfernTermsAccepted: false }, + ); +}); + +test("REST browser automation requests hit the shared automation rate limit", async () => { await withApp( "integrations-rate-limit", async (app) => { @@ -1042,97 +1088,34 @@ test("REST and MCP share the browser automation rate limit", async () => { }); const apiPort = await app.invoke("start_api_server", { port: 0 }); - const mcpPort = await app.invoke("start_mcp_server"); - const mcpConfig = await app.invoke("get_mcp_config"); const apiBase = `http://127.0.0.1:${apiPort}`; - const mcpUrl = `http://127.0.0.1:${mcpPort}/mcp/${mcpConfig.token}`; - - const initialized = await jsonRequest(mcpUrl, { - method: "POST", - body: { - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - protocolVersion: "2025-11-25", - capabilities: {}, - clientInfo: { name: "donut-e2e-rate-limit", version: "1" }, - }, - }, - }); - assert.equal(initialized.response.status, 200); - const mcpHeaders = { - "mcp-session-id": initialized.response.headers.get("mcp-session-id"), - }; - const missingProfileId = "00000000-0000-0000-0000-000000000000"; - const first = await jsonRequest( - `${apiBase}/v1/profiles/${missingProfileId}/run`, - { + + // The shared automation limiter sits innermost, past auth, so an + // authenticated automation call consumes a token even when the profile + // is missing (404). With the window set to 2/hour below the contract is + // exact: 404, 404, then 429 with a Retry-After. The limiter is shared + // with the MCP tool engine, whose branch has no automated coverage now + // that the loopback endpoint is gone: it needs the e2e-only override + // that `cargo test --lib` does not compile. + const run = () => + jsonRequest(`${apiBase}/v1/profiles/${missingProfileId}/run`, { method: "POST", token: saved.api_token, body: {}, - }, - ); - assert.equal(first.response.status, 404); - - const second = await jsonRequest(mcpUrl, { - method: "POST", - headers: mcpHeaders, - body: { - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { - name: "run_profile", - arguments: { profile_id: missingProfileId }, - }, - }, + }); + assert.equal((await run()).response.status, 404); + assert.equal((await run()).response.status, 404); + const limited = await run(); + assert.equal(limited.response.status, 429); + assert.ok(Number(limited.response.headers.get("retry-after")) > 0); + // Only automation calls spend the budget: a plain read still answers. + const listed = await jsonRequest(`${apiBase}/v1/profiles`, { + method: "GET", + token: saved.api_token, }); - assert.equal(second.response.status, 200); - assert.equal(second.value.error.code, -32000); + assert.equal(listed.response.status, 200); - const restLimited = await jsonRequest( - `${apiBase}/v1/profiles/${missingProfileId}/run`, - { - method: "POST", - token: saved.api_token, - body: {}, - }, - ); - assert.equal(restLimited.response.status, 429); - assert.ok(Number(restLimited.response.headers.get("retry-after")) > 0); - - const mcpLimited = await jsonRequest(mcpUrl, { - method: "POST", - headers: mcpHeaders, - body: { - jsonrpc: "2.0", - id: 3, - method: "tools/call", - params: { - name: "run_profile", - arguments: { profile_id: missingProfileId }, - }, - }, - }); - assert.equal(mcpLimited.response.status, 429); - assert.ok(Number(mcpLimited.response.headers.get("retry-after")) > 0); - - const freeCall = await jsonRequest(mcpUrl, { - method: "POST", - headers: mcpHeaders, - body: { - jsonrpc: "2.0", - id: 4, - method: "tools/call", - params: { name: "list_profiles", arguments: {} }, - }, - }); - assert.equal(freeCall.response.status, 200); - assert.equal(freeCall.value.error, undefined); - - await app.invoke("stop_mcp_server"); await app.invoke("stop_api_server"); }, { @@ -1148,10 +1131,12 @@ test("offline cloud, update, team-lock, trial, and synchronizer contracts are de await withApp( "integrations-contracts", async (app) => { + // Local MCP is removed: the enable command refuses uniformly with the + // removal code, regardless of whether the terms have been accepted. await assertCommandErrorCode( app, "start_mcp_server", - "WAYFERN_TERMS_REQUIRED", + "MCP_LOCAL_REMOVED", ); assert.equal(await app.invoke("cloud_get_user"), null); assert.equal(await app.invoke("cloud_get_proxy_usage"), null); @@ -1179,31 +1164,122 @@ test("offline cloud, update, team-lock, trial, and synchronizer contracts are de }); assert.match(removeError, /not found|session/i); - assert.equal(await app.invoke("check_for_app_updates"), null); - assert.equal(await app.invoke("check_for_app_updates_manual"), null); + // The controls a person uses on a live session. Without one to act on, + // each has to refuse by its own code rather than pretend it worked: the + // panel reads these back, and a silent success would leave a button + // claiming a state the backend never entered. + assert.match( + await app.invokeError("set_sync_session_paused", { + sessionId: "missing", + paused: true, + }), + /SYNC_SESSION_NOT_FOUND/, + ); + assert.match( + await app.invokeError("set_sync_follower_held", { + sessionId: "missing", + followerProfileId: "missing", + held: true, + }), + /SYNC_SESSION_NOT_FOUND/, + ); + assert.match( + await app.invokeError("arrange_sync_windows", { + sessionId: "missing", + layout: "grid", + }), + /SYNC_SESSION_NOT_FOUND/, + ); + // An unknown layout never reaches the display: it fails to deserialise. assert.ok( - await invokeContract(app, "cloud_exchange_device_code", { - code: "DONUT-E2E-INVALID-CODE", + await app.invokeError("arrange_sync_windows", { + sessionId: "missing", + layout: "diagonal", }), ); - assert.ok(await invokeContract(app, "cloud_refresh_profile")); - assert.ok(await invokeContract(app, "cloud_get_countries")); - assert.ok( - await invokeContract(app, "create_cloud_location_proxy", { + + assert.equal(await app.invoke("check_for_app_updates"), null); + assert.equal(await app.invoke("check_for_app_updates_manual"), null); + await assertContract( + app, + "cloud_exchange_device_code", + { + // Any of these proves the command's BODY ran and reached its network + // layer, which is what the evidence is for. Pinning only the server's + // "invalid or expired login code" sentence made a test named + // "offline ... deterministic" depend on a live round-trip to + // api.donutbrowser.com: red offline, behind a proxy, when the + // unauthenticated challenge is rate-limited, or the day the backend + // rewords it, with no signal that the desktop is fine. + refusedWith: + /invalid or expired login code|failed to fetch challenge|challenge request failed/i, + }, + { code: "DONUT-E2E-INVALID-CODE" }, + ); + await assertContract(app, "cloud_refresh_profile", { + refusedWith: /not logged in/i, + }); + await assertContract(app, "cloud_get_countries", { + refusedWith: /not logged in/i, + }); + await assertContract( + app, + "create_cloud_location_proxy", + { refusedWith: /no cloud proxy available/i }, + { name: "E2E unavailable cloud proxy", country: "ZZ", region: null, city: null, isp: null, - }), + }, ); - assert.ok(await invokeContract(app, "cloud_refresh_wayfern_token")); + await assertContract(app, "cloud_refresh_wayfern_token", { + // Compared against the value the harness injected, NOT matched against + // a hex shape. Under the `e2e` feature this command returns + // WAYFERN_TEST_TOKEN, so a /^[0-9a-f]{32,}$/ assertion only proved the + // harness's own env var looks like a token: it validated the fixture + // and would have passed with the command's body deleted. Equality + // proves the command actually reached the token and returned it. + answers: (token) => + assert.equal( + String(token), + process.env.WAYFERN_TEST_TOKEN, + "the command must return the token it was given, not a different value", + ), + }); - assert.ok(await invokeContract(app, "trigger_manual_version_update")); - assert.ok( - await invokeContract(app, "clear_all_version_cache_and_refetch"), - ); - assert.ok(await invokeContract(app, "check_for_browser_updates")); + await assertContract(app, "trigger_manual_version_update", { + answers: (report) => { + assert.ok( + Array.isArray(report), + "the update run must report per browser", + ); + // Non-empty, or the per-entry loop below asserts nothing at all: an + // `[]` satisfied every check while the command did no work. + assert.ok( + report.length > 0, + `the update run must report at least one browser, got ${JSON.stringify(report)}`, + ); + for (const entry of report) { + assert.ok( + typeof entry.browser === "string" && entry.browser.length > 0, + `every entry names its browser: ${JSON.stringify(entry)}`, + ); + assert.equal(typeof entry.updated_successfully, "boolean"); + } + }, + }); + await assertContract(app, "clear_all_version_cache_and_refetch", { + answers: (value) => assert.equal(value, null), + }); + await assertContract(app, "check_for_browser_updates", { + answers: (updates) => + assert.ok( + Array.isArray(updates), + `the update check must answer with a list, got ${JSON.stringify(updates)}`, + ), + }); await app.invoke("dismiss_update_notification", { notificationId: "missing-e2e-notification", }); @@ -1383,6 +1459,126 @@ test("offline cloud, update, team-lock, trial, and synchronizer contracts are de /"code":"PROFILE_NOT_FOUND"/, ); + // The agent plane is brokered by the same cloud. Signed out, every read + // and write must refuse as a translatable code, and the two things the + // desktop can judge for itself — is this profile here, does the goal say + // anything — must be judged BEFORE any of that, so a bad request never + // becomes a model bill. + assert.match( + await app.invokeError("get_agent_runs", { limit: 5 }), + notSignedIn, + ); + assert.match( + await app.invokeError("get_agent_run", { runId: "missing-e2e-run" }), + notSignedIn, + ); + assert.match( + await app.invokeError("cancel_agent_run", { runId: "missing-e2e-run" }), + notSignedIn, + ); + assert.match(await app.invokeError("get_agent_recipes"), notSignedIn); + assert.match( + await app.invokeError("create_agent_recipe", { + name: "E2E recipe", + steps: [{ type: "navigate", url: "https://example.com" }], + }), + notSignedIn, + ); + assert.match( + await app.invokeError("update_agent_recipe", { + id: "00000000-0000-0000-0000-000000000000", + name: "E2E recipe", + steps: [{ type: "navigate", url: "https://example.com" }], + }), + notSignedIn, + ); + assert.match( + await app.invokeError("delete_agent_recipe", { + id: "00000000-0000-0000-0000-000000000000", + }), + notSignedIn, + ); + + // A goal is judged before the profile is even looked up: an empty goal is + // the one refusal that costs nothing to make locally, and it must not + // depend on being signed in. + assert.match( + await app.invokeError("start_agent_run", { + input: { + profileId: missingProfileId, + target: "desktop", + goal: " ", + }, + }), + /"code":"AGENT_GOAL_INVALID"/, + ); + assert.match( + await app.invokeError("start_agent_run", { + input: { + profileId: missingProfileId, + target: "desktop", + goal: "Open the dashboard and export last week's report", + }, + }), + /"code":"PROFILE_NOT_FOUND"/, + ); + // Recipes are validated the same way, and with the code the rest of the + // app already uses for a blank name rather than an agent-specific one. + assert.match( + await app.invokeError("create_agent_recipe", { + name: " ", + steps: [{ type: "navigate", url: "https://example.com" }], + }), + /"code":"NAME_CANNOT_BE_EMPTY"/, + ); + // A step is an object the API validates, never a line of prose: the old + // string shape is refused here rather than sent and rejected by the + // server. So is a step that names an element without saying which. + for (const steps of [ + [], + ["open the dashboard"], + [{ url: "https://example.com" }], + [{ type: "teleport" }], + [{ type: "click" }], + [{ type: "click", selector: "#buy", locator: { role: "button" } }], + ]) { + assert.match( + await app.invokeError("create_agent_recipe", { + name: "E2E recipe", + steps, + }), + /"code":"AGENT_RECIPE_INVALID"/, + `${JSON.stringify(steps)} must be refused before any network call`, + ); + } + + // The step stream is how the run panel fills; without it a page opened + // during a run shows a goal and nothing else. It has to start, name the + // run it is watching, follow a switch to another run, and stop on demand. + assert.equal(await app.invoke("get_agent_run_events_status"), null); + await app.invoke("start_agent_run_events", { runId: "e2e-agent-run-1" }); + assert.equal( + await app.invoke("get_agent_run_events_status"), + "e2e-agent-run-1", + ); + // A second start for the same run is a no-op, not a second socket. + await app.invoke("start_agent_run_events", { runId: "e2e-agent-run-1" }); + assert.equal( + await app.invoke("get_agent_run_events_status"), + "e2e-agent-run-1", + ); + // Opening a different run replaces the stream: the panel shows one run. + await app.invoke("start_agent_run_events", { runId: "e2e-agent-run-2" }); + assert.equal( + await app.invoke("get_agent_run_events_status"), + "e2e-agent-run-2", + ); + await app.invoke("stop_agent_run_events"); + assert.equal(await app.invoke("get_agent_run_events_status"), null); + // A second stop must not fail. + await app.invoke("stop_agent_run_events"); + assert.equal(await app.invoke("get_agent_run_events_status"), null); + const trial = await app.invoke("get_commercial_trial_status"); assert.ok(trial && typeof trial === "object"); await app.invoke("acknowledge_trial_expiration"); diff --git a/e2e/tests/motion.test.mjs b/e2e/tests/motion.test.mjs new file mode 100644 index 0000000..6427e82 --- /dev/null +++ b/e2e/tests/motion.test.mjs @@ -0,0 +1,1886 @@ +import assert from "node:assert/strict"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import Color from "color"; +import en from "../../src/i18n/locales/en.json" with { type: "json" }; +import { THEMES } from "../../src/lib/themes.ts"; +import { withApp } from "../lib/app.mjs"; +import { extensionZipBase64 } from "../lib/fixtures.mjs"; + +const slot = (name) => `[data-slot="${name}"]`; +const profileRow = (id) => `tr[data-profile-id="${id}"]`; +const inspectTrigger = (id) => + `${slot("profile-inspect-trigger")}[data-profile-id="${id}"]`; +const dragHandle = (id) => + `${slot("profile-drag-handle")}[data-profile-id="${id}"]`; +const tableScroll = `${slot("profile-workspace")} > .scroll-fade`; +const modifier = + process.platform === "darwin" ? { meta: true } : { ctrl: true }; + +async function createProfile(app, name) { + return app.invoke("create_browser_profile_new", { + name, + browserStr: "wayfern", + version: "150.0.7871.100", + releaseType: "stable", + proxyId: null, + vpnId: null, + wayfernConfig: { fingerprint: "{}" }, + groupId: null, + ephemeral: false, + dnsBlocklist: null, + launchHook: null, + }); +} + +async function waitForSelector(app, selector, present = true) { + return app.waitFor( + () => + app.execute( + `return Boolean(document.querySelector(arguments[0])) === arguments[1];`, + [selector, present], + ), + { description: `${present ? "present" : "absent"} ${selector}` }, + ); +} + +async function resize(app, width, height) { + const before = await app.session.command("GET", "/window/rect"); + const viewport = await app.execute( + "return { width: innerWidth, height: innerHeight };", + ); + const contentWidth = width - Math.max(0, before.width - viewport.width); + const contentHeight = height - Math.max(0, before.height - viewport.height); + await app.session.command("POST", "/window/rect", { width, height }); + // The native resize response precedes WebKit layout and painting. A capture + // before those complete crops the previous frame to the new window size. + await app.waitFor( + () => + app.execute( + "return innerWidth === arguments[0] && innerHeight === arguments[1];", + [contentWidth, contentHeight], + ), + { description: `WebView resized to ${contentWidth} by ${contentHeight}` }, + ); + await app.execute( + "return new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true))));", + ); +} + +async function tabForward(app) { + // tauri-wd's /actions dispatches an untrusted Tab event without its default. + // /element/value implements focus traversal, and honors a canceled keydown + // before moveFocus(), including Radix's modal boundary trap. + const active = await app.execute("return document.activeElement;"); + await app.session.sendKeys(active, "\uE004"); +} + +async function activateFocusedByKeyboard(app, key = "\uE006") { + // tauri-wd 0.1.11 does not implement Enter/Space's default button click in + // either keyboard endpoint. Exercise the handlers, then supply only the + // uncanceled missing HTML activation. This is keyboard-path simulation; + // pointer activation is covered separately through actual WebDriver clicks. + await app.execute(` + const state = { target: document.activeElement, events: [], clicks: 0 }; + state.recordKey = (event) => state.events.push(event); + state.recordClick = (event) => { if (state.target === event.target || state.target.contains(event.target)) state.clicks++; }; + document.addEventListener("keydown", state.recordKey, true); + document.addEventListener("keyup", state.recordKey, true); + document.addEventListener("click", state.recordClick, true); + window.__donutMotionKeyboard = state; + `); + try { + await app.pressShortcut({ key }); + await app.execute(` + const state = window.__donutMotionKeyboard; + if (!state.clicks && !state.events.some((event) => event.defaultPrevented) && state.target.isConnected) state.target.click(); + `); + } finally { + await app.execute(` + const state = window.__donutMotionKeyboard; + if (!state) return; + document.removeEventListener("keydown", state.recordKey, true); + document.removeEventListener("keyup", state.recordKey, true); + document.removeEventListener("click", state.recordClick, true); + delete window.__donutMotionKeyboard; + `); + } +} + +async function emit(app, event, payload) { + await app.invoke("plugin:event|emit", { event, payload }); +} + +async function assertContained(app, selector) { + const bounds = await app.execute( + ` + const element = document.querySelector(arguments[0]); + if (!element) return null; + const rect = element.getBoundingClientRect(); + return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, + width: innerWidth, height: innerHeight }; + `, + [selector], + ); + assert.ok(bounds, selector); + assert.ok( + bounds.left >= -1 && bounds.right <= bounds.width + 1, + JSON.stringify(bounds), + ); + assert.ok( + bounds.top >= -1 && bounds.bottom <= bounds.height + 1, + JSON.stringify(bounds), + ); +} + +async function exampleStates(app) { + return app.execute(`return Object.fromEntries( + [...document.querySelectorAll('[data-slot="isolation-example-profile"]')].map((profile) => + [profile.dataset.profileId, { signedIn: profile.dataset.signedIn, route: profile.dataset.route }]) + );`); +} + +async function assertExamples(app, expected) { + await app.waitFor( + async () => { + const actual = await exampleStates(app); + return ( + Object.keys(actual).length === Object.keys(expected).length && + Object.entries(expected).every( + ([id, profile]) => + actual[id]?.signedIn === profile.signedIn && + actual[id]?.route === profile.route, + ) + ); + }, + { + description: `independent example state ${JSON.stringify(expected)}`, + }, + ); +} + +const emptyExamples = { + research: { signedIn: "false", route: "direct" }, + shopping: { signedIn: "false", route: "direct" }, +}; + +async function exerciseIsolation(app) { + await waitForSelector(app, slot("profile-isolation-demo")); + const realProfiles = await app.invoke("list_browser_profiles"); + await assertExamples(app, emptyExamples); + await app.clickSelector(slot("isolation-toggle-cookie")); + await assertExamples(app, { + ...emptyExamples, + research: { signedIn: "true", route: "direct" }, + }); + await app.clickSelector(slot("isolation-toggle-route")); + await assertExamples(app, { + ...emptyExamples, + research: { signedIn: "true", route: "proxy" }, + }); + await app.clickSelector( + `${slot("isolation-example-profile")}[data-profile-id="shopping"] ${slot("isolation-select-profile")}`, + ); + await app.clickSelector(slot("isolation-toggle-cookie")); + await assertExamples(app, { + research: { signedIn: "true", route: "proxy" }, + shopping: { signedIn: "true", route: "direct" }, + }); + await app.clickSelector(slot("isolation-toggle-route")); + await app.clickSelector(slot("isolation-toggle-cookie")); + await assertExamples(app, { + research: { signedIn: "true", route: "proxy" }, + shopping: { signedIn: "false", route: "proxy" }, + }); + + // Move focus with the real keyboard, then activate the currently focused + // route button. The example changes without waiting for a pointer animation. + await app.execute( + `document.querySelector('[data-slot="isolation-toggle-cookie"]').focus();`, + ); + await tabForward(app); + assert.equal( + await app.execute(`return document.activeElement?.dataset.slot;`), + "isolation-toggle-route", + ); + await activateFocusedByKeyboard(app); + await assertExamples(app, { + research: { signedIn: "true", route: "proxy" }, + shopping: { signedIn: "false", route: "direct" }, + }); + await app.clickSelector(slot("isolation-reset")); + await assertExamples(app, emptyExamples); + assert.deepEqual( + await app.invoke("list_browser_profiles"), + realProfiles, + "the demonstration never mutates real profiles", + ); +} + +test("first-run profile cutaway is interactive, isolated, and readable at both window sizes", async () => { + await withApp( + "motion-first-run", + async (app) => { + await app.waitForText(en.welcome.title); + await resize(app, 1080, 900); + await exerciseIsolation(app); + await app.capture("onboarding-isolation-wide"); + await resize(app, 640, 480); + await assertContained(app, '[role="dialog"]'); + await app.clickSelector(`${slot("welcome-features")} summary`); + await app.waitFor( + () => + app.execute( + `return document.querySelector('[data-slot="welcome-features"]')?.open === true;`, + ), + { + description: + "welcome feature disclosure to open from a real pointer click", + }, + ); + assert.equal( + await app.visibleTextIncludes(en.welcome.features.items.cookies), + true, + ); + await app.clickSelector(`${slot("welcome-features")} summary`); + await app.capture("onboarding-isolation-narrow"); + await app.clickText(en.welcome.next, { roles: ["button"] }); + await app.waitForText(en.welcome.license.title); + assert.equal( + await app.invoke("get_onboarding_completed"), + false, + "the example does not complete actual onboarding", + ); + }, + { onboardingCompleted: false }, + ); +}); + +async function openReplay(app) { + await app.clickSelector(`[aria-label="${en.rail.more.label}"]`); + await waitForSelector(app, '[role="menu"]'); + await app.clickText(en.rail.more.about, { + exact: false, + roles: ["menuitem"], + }); + await app.clickSelector(slot("isolation-demo-replay")); + await waitForSelector(app, slot("profile-isolation-demo")); +} + +async function assertPopupReadable(app, selector) { + await app.waitFor( + () => + app.execute( + ` + const content = document.querySelector(arguments[0]); + if (!content || content.getBoundingClientRect().height <= 0) return false; + for (let node = content; node instanceof Element; node = node.parentElement) { + const style = getComputedStyle(node); + if (Number(style.opacity) < 0.99 || style.visibility === "hidden" || style.display === "none") return false; + } + return true; + `, + [selector], + ), + { timeoutMs: 2000, description: `fully readable popup ${selector}` }, + ); +} + +test("paused CSS animations cannot retain dismissed selects or dropdowns", async () => { + await withApp( + "motion-popup-dismissal", + async (app) => { + await resize(app, 1100, 760); + await createProfile(app, "Motion popup Alpha"); + await createProfile(app, "Motion popup Beta"); + await app.clickSelector(`[aria-label="${en.rail.settings}"]`); + await app.clickSelector("#theme-select"); + await assertPopupReadable(app, slot("select-content")); + + // Freeze CSS only after the first menu is fully open. A closed Radix + // Presence must not wait for animationend, which cannot arrive here. + await app.execute(` + const style = document.createElement("style"); + style.id = "donut-motion-paused-css"; + style.textContent = "*, *::before, *::after { animation-play-state: paused !important; }"; + document.head.append(style); + window.__donutPausedSelect = document.querySelector('[data-slot="select-content"]'); + `); + try { + assert.equal( + await app.execute( + `return getComputedStyle(window.__donutPausedSelect).animationPlayState;`, + ), + "paused", + ); + await app.clickText(en.common.labels.custom, { roles: ["option"] }); + await app.waitFor( + () => + app.execute( + `return !window.__donutPausedSelect.isConnected && !document.querySelector('[data-slot="select-content"]');`, + ), + { + timeoutMs: 2000, + description: + "selected menu to unmount without a CSS animationend event", + }, + ); + + // The next popup also opens under the paused clock: both its content + // and its pointer targets must be available at the initial frame. + await app.clickSelector("#theme-preset-select"); + await assertPopupReadable(app, slot("select-content")); + await app.capture("paused-css-preset-select"); + const preset = THEMES.find((theme) => theme.id === "dracula"); + assert.ok(preset); + await app.clickText(preset.name, { roles: ["option"] }); + await waitForSelector(app, slot("select-content"), false); + assert.equal( + await app.execute( + `return document.querySelector("#theme-preset-select")?.textContent.trim();`, + ), + preset.name, + ); + await app.clickText(en.common.buttons.saveSettings, { + roles: ["button"], + }); + await app.waitFor( + () => app.visibleTextIncludes(en.common.buttons.saved), + { description: "saved settings feedback" }, + ); + await waitForSelector(app, "#theme-select", true); + await app.clickSelector(`[aria-label="${en.rail.profiles}"]`); + + const openSort = () => + app.clickTextIn("thead", en.common.labels.name, { + roles: ["button"], + }); + await openSort(); + await assertPopupReadable(app, slot("dropdown-menu-content")); + await app.capture("paused-css-profile-sort"); + await app.clickText(en.profiles.sort.nameDesc, { + roles: ["menuitem"], + }); + await waitForSelector(app, slot("dropdown-menu-content"), false); + await app.waitFor( + () => + app.execute( + `return document.querySelector('tr[data-profile-id]')?.textContent.includes("Motion popup Beta");`, + ), + { description: "pointer-selected descending profile order" }, + ); + await openSort(); + await assertPopupReadable(app, slot("dropdown-menu-content")); + await app.clickText(en.profiles.sort.nameAsc, { + roles: ["menuitem"], + }); + await waitForSelector(app, slot("dropdown-menu-content"), false); + + await app.clickSelector(`[aria-label="${en.rail.more.label}"]`); + await assertPopupReadable(app, '[role="menu"]'); + await app.clickText(en.rail.more.about, { + exact: false, + roles: ["menuitem"], + }); + await waitForSelector(app, '[role="menu"]', false); + await app.clickSelector(slot("isolation-demo-replay")); + await waitForSelector(app, slot("profile-isolation-demo")); + await app.capture("paused-css-about-replay"); + } finally { + await app.execute(` + document.getElementById("donut-motion-paused-css")?.remove(); + delete window.__donutPausedSelect; + `); + } + }, + { seedDownloadedBrowser: true }, + ); +}); + +async function clickVisible(app, selector) { + // tauri-wd's element click always scrollIntoView(center), including a raw + // session.click. Pointer actions preserve the position of an in-view row. + const point = await pointIn(app, selector); + try { + await pointerActions(app, [ + { type: "pointerMove", x: point.x, y: point.y, origin: "viewport" }, + { type: "pointerDown", button: 0 }, + { type: "pointerUp", button: 0 }, + ]); + } finally { + await app.session.command("DELETE", "/actions"); + } +} + +async function visibleProfileIds(app, count = 3) { + return app.waitFor( + () => + app.execute( + ` + const scroller = document.querySelector(arguments[0]); + if (!scroller) return null; + const bounds = scroller.getBoundingClientRect(); + const rows = [...scroller.querySelectorAll('tr[data-profile-id]')].filter((row) => { + const rect = row.getBoundingClientRect(); + return rect.top > bounds.top + 45 && rect.bottom < bounds.bottom - 110; + }); + return rows.length >= arguments[1] ? rows.slice(0, arguments[1]).map((row) => row.dataset.profileId) : null; + `, + [tableScroll, count], + ), + { description: `${count} fully visible profile rows` }, + ); +} + +async function toggleProfile(app, id) { + const selector = `${profileRow(id)} [role="checkbox"], ${profileRow(id)} [aria-label="${en.common.aria.selectProfile}"]`; + await clickVisible(app, selector); +} + +async function pointIn(app, selector) { + const point = await app.execute( + ` + const node = document.querySelector(arguments[0]); + if (!node) return null; + const rect = node.getBoundingClientRect(); + const x = Math.round(rect.left + rect.width / 2); + const y = Math.round(rect.top + rect.height / 2); + const hit = document.elementFromPoint(x, y); + return hit && (hit === node || node.contains(hit)) ? { x, y, left: rect.left, top: rect.top } : null; + `, + [selector], + ); + assert.ok(point, `pointer-interactable ${selector}`); + return point; +} + +async function pointerActions(app, actions) { + await app.session.command("POST", "/actions", { + actions: [ + { + type: "pointer", + id: "motion-profile-pointer", + parameters: { pointerType: "mouse" }, + actions, + }, + ], + }); +} + +async function beginDrag(app, id) { + const start = await pointIn(app, dragHandle(id)); + await pointerActions(app, [ + { type: "pointerMove", x: start.x, y: start.y, origin: "viewport" }, + { type: "pointerDown", button: 0 }, + { type: "pause", duration: 50 }, + { + type: "pointerMove", + x: start.x + 16, + y: start.y + 10, + duration: 100, + origin: "viewport", + }, + ]); + await waitForSelector( + app, + `${slot("profile-drag-preview")}[data-phase="dragging"]`, + ); + const preview = await app.execute(` + const rect = document.querySelector('[data-slot="profile-drag-preview"]').getBoundingClientRect(); + return { left: rect.left, top: rect.top }; + `); + assert.ok( + Math.abs(preview.left - start.left - 16) <= 3, + "drag preserves the horizontal grab offset", + ); + assert.ok( + Math.abs(preview.top - start.top - 10) <= 3, + "drag preserves the vertical grab offset", + ); +} + +async function groupAssignments(app) { + const profiles = await app.invoke("list_browser_profiles"); + return Object.fromEntries( + profiles.map((profile) => [profile.id, profile.group_id ?? null]), + ); +} + +async function installSynchronizerFixture(app) { + // This test supplies a paid user's UI state without changing authentication, + // entitlement checks, or synchronizer state in the backend. The only + // operation being simulated is the readiness promise of start_sync_session. + // Tauri defines invoke/ipc/postMessage as non-writable. Like the native folder + // picker fixture in ui.test.mjs, this wraps only their IPC fetch transport. + await app.execute(` + const fixture = { originalFetch: window.fetch, requests: [], pending: [] }; + window.__donutMotionSynchronizer = fixture; + const response = (value, ok = true) => new Response(JSON.stringify(value), { + status: 200, headers: { "content-type": "application/json", "Tauri-Response": ok ? "ok" : "error" } + }); + window.fetch = function (input, init) { + let command = ""; + try { + const url = new URL(typeof input === "string" ? input : input.url); + if ((url.protocol === "ipc:" && url.hostname === "localhost") || + (["http:", "https:"].includes(url.protocol) && url.hostname === "ipc.localhost")) { + command = decodeURIComponent(url.pathname.split("/").pop() || ""); + } + } catch {} + if (command === "cloud_get_user") return Promise.resolve(response({ + logged_in_at: "2026-09-01T00:00:00Z", + user: { + id: "motion-ui-fixture", email: "motion@example.test", plan: "pro", + planPeriod: "monthly", subscriptionStatus: "active", profileLimit: 50, + cloudProfilesUsed: 0, proxyBandwidthLimitMb: 0, proxyBandwidthUsedMb: 0, + proxyBandwidthExtraMb: 0, isPrimaryDevice: true + } + })); + if (command === "start_sync_session") { + fixture.requests.push(JSON.parse(init.body)); + return new Promise((resolve) => fixture.pending.push({ + resolve: (value) => resolve(response(value)), + // Reject at the Tauri response layer. Rejecting fetch itself would + // trigger its native-transport fallback and launch real browsers. + reject: (error) => resolve(response(error, false)) + })); + } + return fixture.originalFetch.apply(window, arguments); + }; + `); + assert.equal( + (await app.invoke("cloud_get_user")).user?.id, + "motion-ui-fixture", + "scoped IPC fixture is active before opening paid UI", + ); + await emit(app, "cloud-auth-changed", null); +} + +async function restoreSynchronizerFixture(app) { + await app.execute(` + const fixture = window.__donutMotionSynchronizer; + if (!fixture) return; + window.fetch = fixture.originalFetch; + for (const pending of fixture.pending) pending.reject("Motion fixture disposed"); + delete window.__donutMotionSynchronizer; + `); + await emit(app, "cloud-auth-expired", null); +} + +test("synchronizer rehearsal targets selected profiles and waits for actual command readiness", async () => { + await withApp( + "motion-synchronizer", + async (app) => { + await resize(app, 1080, 900); + const leader = await createProfile(app, "Motion rehearsal leader"); + const first = await createProfile(app, "Motion rehearsal follower one"); + const second = await createProfile( + app, + "Motion rehearsal follower with a longer profile name", + ); + await installSynchronizerFixture(app); + const session = { + id: "motion-ui-rehearsal-session", + leader_profile_id: leader.id, + leader_profile_name: leader.name, + followers: [ + { + profile_id: first.id, + profile_name: first.name, + failed_at_url: null, + }, + ], + }; + try { + await app.clickSelector(inspectTrigger(leader.id)); + await app.clickSelector( + `${slot("profile-info-section")}[data-section="automation"]`, + ); + await app.waitFor( + () => + app.execute( + `return document.querySelector('[data-slot="profile-start-synchronizer"]')?.disabled === false;`, + ), + { + description: + "paid fixture can open the real synchronizer selection interface", + }, + ); + await app.clickSelector(slot("profile-start-synchronizer")); + await waitForSelector(app, slot("synchronizer-follower-dialog")); + const option = (id) => + `${slot("synchronizer-follower-option")}[data-profile-id="${id}"]`; + const checkbox = (id) => `${option(id)} [role="checkbox"]`; + const checked = (id, value) => + app.waitFor( + () => + app.execute( + `return document.querySelector(arguments[0])?.getAttribute("aria-checked") === arguments[1];`, + [checkbox(id), String(value)], + ), + { + description: `${id} checkbox becomes ${value}`, + }, + ); + await app.clickSelector(`${option(first.id)} > span`); + await checked(first.id, true); + await app.clickSelector(checkbox(first.id)); + await checked(first.id, false); + await app.execute(`document.querySelector(arguments[0]).focus();`, [ + checkbox(first.id), + ]); + await activateFocusedByKeyboard(app, "\uE00D"); + await checked(first.id, true); + await app.clickSelector(`${option(second.id)} > span`); + await checked(second.id, true); + await app.clickSelector(slot("synchronizer-preview-send")); + const received = () => + app.execute( + `return [...document.querySelectorAll('[data-slot="synchronizer-preview-follower"][data-received="true"]')].map((node) => node.dataset.profileId).sort();`, + ); + await app.waitFor( + async () => + JSON.stringify(await received()) === + JSON.stringify([first.id, second.id].sort()), + { + description: + "example click reaches exactly the two selected real profile names", + }, + ); + assert.equal( + await app.execute( + `return window.__donutMotionSynchronizer.requests.length;`, + ), + 0, + "rehearsal never starts browsers", + ); + await app.capture("synchronizer-rehearsal-wide"); + await app.clickSelector(checkbox(second.id)); + await checked(second.id, false); + assert.deepEqual( + await received(), + [], + "changing selection resets the previous rehearsal result", + ); + await app.execute( + `document.querySelector('[data-slot="synchronizer-preview-send"]').focus();`, + ); + await activateFocusedByKeyboard(app); + await app.waitFor( + async () => + JSON.stringify(await received()) === JSON.stringify([first.id]), + { + description: + "keyboard rehearsal acknowledges only the selected follower", + }, + ); + assert.equal( + await app.execute( + `return document.querySelectorAll('[data-slot="synchronizer-rehearsal"] path[stroke-width="2.5"]').length;`, + ), + 0, + "keyboard preview is static", + ); + await resize(app, 640, 700); + await assertContained(app, '[role="dialog"]'); + await app.capture("synchronizer-rehearsal-narrow"); + + await app.clickSelector(slot("synchronizer-start")); + await waitForSelector(app, slot("synchronizer-starting")); + assert.deepEqual( + await app.execute( + `return window.__donutMotionSynchronizer.requests;`, + ), + [ + { + leaderProfileId: leader.id, + followerProfileIds: [first.id], + }, + ], + ); + await emit(app, "sync-session-changed", session); + await waitForSelector(app, slot("synchronizer-starting")); + assert.equal( + await app.execute( + `return document.querySelector('[data-slot="synchronizer-start"]')?.disabled;`, + ), + true, + ); + await assert.rejects( + app.session.click( + await app.session.findCss(slot("synchronizer-start")), + ), + /element does not receive pointer events/, + ); + await app.pressShortcut({ key: "Escape" }); + assert.equal( + await app.execute( + `return window.__donutMotionSynchronizer.requests.length;`, + ), + 1, + "pending startup cannot be duplicated", + ); + await waitForSelector(app, slot("synchronizer-follower-dialog")); + await app.capture("synchronizer-awaiting-readiness"); + await app.execute( + `window.__donutMotionSynchronizer.pending.shift().reject(JSON.stringify({ code: "PROFILE_RUNNING" }));`, + ); + await waitForSelector(app, slot("synchronizer-start-error")); + await checked(first.id, true); + await checked(second.id, false); + assert.equal( + await app.execute( + `return document.querySelector('[data-slot="synchronizer-start"]')?.disabled;`, + ), + false, + "failed startup can be retried", + ); + await app.capture("synchronizer-retry"); + await emit(app, "sync-session-ended", session.id); + await app.clickSelector(slot("synchronizer-start")); + await waitForSelector(app, slot("synchronizer-starting")); + assert.equal( + await app.execute( + `return window.__donutMotionSynchronizer.requests.length;`, + ), + 2, + ); + await app.execute( + `window.__donutMotionSynchronizer.pending.shift().resolve(arguments[0]);`, + [session], + ); + await waitForSelector(app, slot("synchronizer-follower-dialog"), false); + assert.deepEqual( + await app.invoke("get_sync_sessions"), + [], + "the readiness fixture never creates a backend session", + ); + const profiles = await app.invoke("list_browser_profiles"); + assert.ok( + profiles.every((profile) => profile.process_id == null), + "rehearsal and simulated readiness launch no browser processes", + ); + } finally { + await emit(app, "sync-session-ended", session.id); + await restoreSynchronizerFixture(app); + } + }, + { seedDownloadedBrowser: true }, + ); +}); + +async function freezeAnimations(app) { + // Motion captures requestAnimationFrame at module initialization, so replacing + // the global afterward does not stall its engine. Its JS batcher reads + // performance.now on every batch; native animations need a separate pause. + await app.execute(` + const state = { + now: performance.now(), + nowDescriptor: Object.getOwnPropertyDescriptor(performance, "now"), + animateDescriptor: Object.getOwnPropertyDescriptor(Element.prototype, "animate"), + animations: [] + }; + window.__donutFrozenMotion = state; + Object.defineProperty(performance, "now", { configurable: true, value: () => state.now }); + if (state.animateDescriptor?.value) { + Object.defineProperty(Element.prototype, "animate", { + ...state.animateDescriptor, + value: function (...args) { + const animation = state.animateDescriptor.value.apply(this, args); + const play = animation.play; + const pause = animation.pause; + state.animations.push({ animation, play, playDescriptor: Object.getOwnPropertyDescriptor(animation, "play") }); + // NativeAnimation may explicitly play after construction. Keep that + // path paused too, so its first keyframe remains the rendered frame. + Object.defineProperty(animation, "play", { + configurable: true, + value: function () { play.call(this); pause.call(this); this.currentTime = 0; } + }); + pause.call(animation); + animation.currentTime = 0; + return animation; + } + }); + } + `); +} + +async function resumeAnimations(app) { + await app.execute(` + const state = window.__donutFrozenMotion; + if (!state) return; + if (state.nowDescriptor) Object.defineProperty(performance, "now", state.nowDescriptor); + else delete performance.now; + if (state.animateDescriptor) Object.defineProperty(Element.prototype, "animate", state.animateDescriptor); + for (const { animation, play, playDescriptor } of state.animations) { + if (playDescriptor) Object.defineProperty(animation, "play", playDescriptor); + else delete animation.play; + if (animation.playState === "paused") play.call(animation); + } + delete window.__donutFrozenMotion; + `); +} + +test("profile replay, inspector, group gestures, and remote handoff preserve context", async () => { + await withApp( + "motion-workspace", + async (app) => { + await resize(app, 1480, 900); + await openReplay(app); + await exerciseIsolation(app); + await app.capture("isolation-replay-wide"); + await resize(app, 640, 480); + await assertContained(app, '[role="dialog"]'); + await app.capture("isolation-replay-narrow"); + await app.clickText(en.common.buttons.back, { roles: ["button"] }); + await waitForSelector(app, slot("isolation-demo-replay")); + assert.equal( + await app.execute(`return document.activeElement?.dataset.slot;`), + "isolation-demo-replay", + ); + await app.pressShortcut({ key: "Escape" }); + await waitForSelector(app, '[role="dialog"]', false); + await resize(app, 1480, 900); + + const group = await app.invoke("create_profile_group", { + name: "Motion research group", + }); + const proxy = await app.invoke("create_stored_proxy", { + name: "Motion inspector proxy", + proxySettings: { + proxy_type: "http", + host: "127.0.0.1", + port: 9, + username: null, + password: null, + }, + }); + for (let index = 0; index < 36; index += 1) { + await createProfile( + app, + `Motion profile ${String(index + 1).padStart(2, "0")}`, + ); + } + await app.waitFor( + async () => (await app.invoke("list_browser_profiles")).length === 36, + { description: "all motion fixture profiles" }, + ); + await waitForSelector(app, slot("profile-inspect-trigger")); + await app.execute( + `document.querySelector(arguments[0]).scrollTop = 250;`, + [tableScroll], + ); + const [first, second] = await visibleProfileIds(app, 2); + await toggleProfile(app, first); + const scrollBefore = await app.execute( + `return document.querySelector(arguments[0]).scrollTop;`, + [tableScroll], + ); + assert.ok(scrollBefore > 0); + try { + await freezeAnimations(app); + await clickVisible(app, inspectTrigger(first)); + await waitForSelector( + app, + `${slot("profile-inspector")}[data-profile-id="${first}"]`, + ); + assert.equal( + await app.execute(` + const content = document.querySelector('[data-slot="profile-info-content"]'); + const control = document.querySelector('[data-slot="profile-info-section"][data-section="network"]'); + return [content, control].every((element) => { + if (!element || element.getBoundingClientRect().height <= 0) return false; + for (let node = element; node instanceof Element; node = node.parentElement) { + const style = getComputedStyle(node); + if (Number(style.opacity) === 0 || style.visibility === "hidden" || style.display === "none") return false; + } + return true; + }); + `), + true, + "inspector text and controls remain visible with the animation clock frozen at its initial frame", + ); + assert.equal( + await app.execute( + `return performance.now() === window.__donutFrozenMotion.now && window.__donutFrozenMotion.animations.every(({ animation }) => animation.playState !== "running" && (animation.currentTime === null || animation.currentTime === 0));`, + ), + true, + "both JavaScript and newly created native animations remain frozen", + ); + await app.capture("profile-inspector-animation-frozen"); + } finally { + await resumeAnimations(app); + } + assert.equal( + await app.execute( + `return document.querySelector('[data-slot="dialog-overlay"]') === null;`, + ), + true, + "wide inspector is nonmodal", + ); + await app.clickSelector( + `${slot("profile-info-section")}[data-section="network"]`, + ); + await clickVisible(app, inspectTrigger(second)); + await waitForSelector( + app, + `${slot("profile-info-content")}[data-profile-id="${second}"][data-section="network"]`, + ); + assert.equal( + await app.execute( + `return document.querySelector(arguments[0]).scrollTop;`, + [tableScroll], + ), + scrollBefore, + "profile switching preserves table scroll", + ); + assert.equal( + await app.execute( + `return document.querySelector(arguments[0])?.getAttribute("data-state");`, + [profileRow(first)], + ), + "selected", + "profile switching preserves selection", + ); + await app.capture("profile-inspector-wide"); + await app.pressShortcut({ key: "Escape" }); + await waitForSelector(app, slot("profile-inspector"), false); + assert.equal( + await app.execute(`return document.activeElement?.dataset.profileId;`), + second, + "Escape restores the last inspection trigger", + ); + + await activateFocusedByKeyboard(app); + await waitForSelector(app, slot("profile-inspector")); + await app.clickSelector( + `${slot("profile-info-section")}[data-section="network"]`, + ); + await app.clickSelector( + `${slot("profile-info-content")}[data-section="network"] [role="combobox"]`, + ); + await app.waitFor( + () => + app.execute(` + const popup = document.querySelector('[data-slot="select-content"]'); + return popup?.contains(document.activeElement) && !popup.closest('[aria-hidden="true"]'); + `), + { + description: "focused, accessibility-visible inspector proxy picker", + }, + ); + // Radix Select intentionally closes on window.resize. Its ownership + // cleanup must restore the inspector's accessibility exposure. + await resize(app, 720, 600); + await waitForSelector(app, slot("profile-inspector"), false); + await waitForSelector(app, slot("select-content"), false); + const inspectorAccessible = () => + app.execute(` + const content = document.querySelector('[data-slot="profile-info-content"]'); + return content && !content.closest('[aria-hidden="true"]'); + `); + await app.waitFor(inspectorAccessible, { + description: "inspector is accessible after Select closes on resize", + }); + await app.clickSelector( + `${slot("profile-info-content")}[data-section="network"] [role="combobox"]`, + ); + await app.clickText(proxy.name, { roles: ["option"] }); + await waitForSelector(app, slot("select-content"), false); + await app.waitFor( + async () => + (await app.invoke("list_browser_profiles")).find( + (profile) => profile.id === second, + )?.proxy_id === proxy.id, + { description: "real pointer selection persists the proxy assignment" }, + ); + // The proxy is an inert local fixture. Selecting it neither starts a + // browser nor claims the endpoint is reachable. + await resize(app, 1480, 900); + await waitForSelector(app, slot("profile-inspector")); + await app.clickSelector( + `${slot("profile-info-section")}[data-section="overview"]`, + ); + const colorTrigger = `[aria-label="${en.profileInfo.fields.windowColor}"]`; + await app.clickSelector(colorTrigger); + await assertPopupReadable(app, slot("popover-content")); + await app.clickSelector(`${slot("popover-content")} input`); + const colorBefore = await app.execute(` + const popup = document.querySelector('[data-slot="popover-content"]'); + window.__donutMotionOwnedPopup = { popup, focused: document.activeElement }; + return popup.querySelector('input').value; + `); + assert.equal( + await app.execute( + `return document.querySelector(arguments[0]).getAttribute('aria-controls') === window.__donutMotionOwnedPopup.popup.id;`, + [colorTrigger], + ), + true, + "the color trigger identifies its actual owned portal", + ); + try { + await resize(app, 720, 600); + await waitForSelector(app, slot("profile-inspector"), false); + await assertPopupReadable(app, slot("popover-content")); + assert.deepEqual( + await app.execute(` + const popup = document.querySelector('[data-slot="popover-content"]'); + return { + samePopup: popup === window.__donutMotionOwnedPopup.popup, + sameFocus: document.activeElement === window.__donutMotionOwnedPopup.focused, + focusInside: popup?.contains(document.activeElement), + accessible: Boolean(popup && !popup.closest('[aria-hidden="true"]')) + }; + `), + { + samePopup: true, + sameFocus: true, + focusInside: true, + accessible: true, + }, + "wide-to-narrow resize preserves the owned portal, focus, and accessibility exposure", + ); + await app.capture("profile-inspector-owned-popup-narrow"); + const colorPoint = await pointIn( + app, + `${slot("popover-content")} .h-32`, + ); + try { + await pointerActions(app, [ + { + type: "pointerMove", + x: colorPoint.x, + y: colorPoint.y, + origin: "viewport", + }, + { type: "pointerDown", button: 0 }, + { + type: "pointerMove", + x: colorPoint.x + 20, + y: colorPoint.y + 10, + duration: 120, + origin: "viewport", + }, + { type: "pointerUp", button: 0 }, + ]); + } finally { + await app.session.command("DELETE", "/actions"); + } + await app.waitFor( + () => + app.execute( + `return document.querySelector('[data-slot="popover-content"] input').value !== arguments[0];`, + [colorBefore], + ), + { + description: + "resized color popup responds to real pointer selection", + }, + ); + // The picker renders its HSL input separately from its effect-driven + // onColorChange. The controlled swatch is the color committed on close. + const chosenColor = Color( + await app.waitFor( + () => + app.execute( + ` + const color = getComputedStyle(document.querySelector(arguments[0])).backgroundColor; + const before = document.createElement('span').style; + before.backgroundColor = arguments[1]; + return color !== before.backgroundColor ? color : null; + `, + [colorTrigger, colorBefore], + ), + { + description: + "selected color reaches the controlled profile swatch", + }, + ), + ).hex(); + await app.clickSelector(colorTrigger); + await waitForSelector(app, slot("popover-content"), false); + await app.waitFor( + async () => + (await app.invoke("list_browser_profiles")) + .find((profile) => profile.id === second) + ?.window_color?.toLowerCase() === chosenColor.toLowerCase(), + { + description: + "closing the color popup persists its real selected color", + }, + ); + await app.waitFor(inspectorAccessible, { + description: + "inspector remains accessible after its owned popup closes", + }); + } finally { + await app.execute(`delete window.__donutMotionOwnedPopup;`); + } + await resize(app, 1480, 900); + await waitForSelector(app, slot("profile-inspector")); + await app.clickSelector( + `${slot("profile-info-section")}[data-section="automation"]`, + ); + const draft = "https://example.com/inspector-draft"; + await app.fillSelector(slot("profile-launch-hook-input"), draft); + await app.execute( + `window.__donutMotionDraftInput = document.querySelector('[data-slot="profile-launch-hook-input"]');`, + ); + await resize(app, 720, 600); + await waitForSelector(app, slot("profile-inspector"), false); + await waitForSelector( + app, + `${slot("profile-info-content")}[data-profile-id="${second}"][data-section="automation"]`, + ); + assert.deepEqual( + await app.execute(` + const input = document.querySelector('[data-slot="profile-launch-hook-input"]'); + return { value: input?.value, sameNode: input === window.__donutMotionDraftInput }; + `), + { value: draft, sameNode: true }, + "wide-to-narrow transition preserves the actual unsaved editor", + ); + await assertContained(app, '[role="dialog"]'); + await app.capture("profile-inspector-narrow"); + assert.equal( + await app.execute( + `return document.activeElement === document.querySelector('[data-slot="profile-launch-hook-input"]');`, + ), + true, + "resizing keeps focus in the unsaved editor", + ); + for (let index = 0; index < 14; index += 1) { + await tabForward(app); + assert.equal( + await app.execute( + `return document.querySelector('[role="dialog"]')?.contains(document.activeElement);`, + ), + true, + "compact inspector traps keyboard focus inside the modal", + ); + } + await resize(app, 1480, 900); + await waitForSelector(app, slot("profile-inspector")); + assert.deepEqual( + await app.execute(` + const input = document.querySelector('[data-slot="profile-launch-hook-input"]'); + return { value: input?.value, sameNode: input === window.__donutMotionDraftInput }; + `), + { value: draft, sameNode: true }, + "narrow-to-wide transition also preserves the unsaved editor", + ); + await app.execute(`delete window.__donutMotionDraftInput;`); + await resize(app, 720, 600); + await waitForSelector(app, slot("profile-inspector"), false); + await app.pressShortcut({ key: "Escape" }); + await waitForSelector(app, slot("profile-info-content"), false); + await app.pressShortcut({ key: "k", ...modifier }); + await waitForSelector(app, "[cmdk-input]"); + await app.session.sendKeys( + await app.session.findCss("[cmdk-input]"), + en.rail.settings, + ); + assert.equal(await app.visibleTextIncludes(en.rail.settings), true); + await app.pressShortcut({ key: "Escape" }); + await waitForSelector(app, "[cmdk-input]", false); + + await resize(app, 1480, 900); + await app.execute(`document.querySelector(arguments[0]).scrollTop = 0;`, [ + tableScroll, + ]); + // Clear any earlier selection through the actual table checkbox. + const selected = await app.execute( + `return [...document.querySelectorAll('tr[data-state="selected"]')].map((row) => row.dataset.profileId);`, + ); + if (selected.includes(first)) await toggleProfile(app, first); + else { + // The selected profile may be virtualized outside the top of the table. + await app.clickSelector(`[aria-label="${en.common.aria.selectAll}"]`); + await app.clickSelector(`[aria-label="${en.common.aria.selectAll}"]`); + } + const [moveFirst, moveSecond, cancelId] = await visibleProfileIds(app); + await toggleProfile(app, moveFirst); + await toggleProfile(app, moveSecond); + try { + await beginDrag(app, moveFirst); + const target = await pointIn( + app, + `[data-profile-group-drop="${group.id}"]`, + ); + await pointerActions(app, [ + { + type: "pointerMove", + x: target.x, + y: target.y, + duration: 180, + origin: "viewport", + }, + ]); + await waitForSelector( + app, + `[data-profile-group-drop="${group.id}"][data-drop-state="target"]`, + ); + await app.capture("profile-group-drag"); + await pointerActions(app, [{ type: "pointerUp", button: 0 }]); + await app.waitFor( + async () => { + const assignments = await groupAssignments(app); + return ( + assignments[moveFirst] === group.id && + assignments[moveSecond] === group.id + ); + }, + { + description: + "both selected profiles persisted into the drop target group", + }, + ); + } finally { + await app.session.command("DELETE", "/actions"); + } + await waitForSelector(app, slot("profile-drag-preview"), false); + + const beforeCancel = await groupAssignments(app); + try { + await beginDrag(app, cancelId); + await app.pressShortcut({ key: "Escape" }); + } finally { + await app.session.command("DELETE", "/actions"); + } + await waitForSelector(app, slot("profile-drag-preview"), false); + assert.deepEqual( + await groupAssignments(app), + beforeCancel, + "Escape never writes a group assignment", + ); + await app.execute(`document.querySelector(arguments[0]).focus();`, [ + dragHandle(cancelId), + ]); + await activateFocusedByKeyboard(app); + await app.waitForText(en.groupAssignment.title); + await app.pressShortcut({ key: "Escape" }); + await waitForSelector(app, '[role="dialog"]', false); + + await clickVisible(app, inspectTrigger(cancelId)); + await app.clickSelector( + `${slot("profile-info-section")}[data-section="overview"]`, + ); + const handoff = `${slot("profile-handoff-detail")}[data-profile-id="${cancelId}"]`; + // These are frontend event-contract checks. No remote browser or real + // sync transfer is started, and no networking success is inferred. + await emit(app, "remote-handoff-changed", { [cancelId]: "running" }); + await waitForSelector(app, `${handoff}[data-handoff-state="running"]`); + await emit(app, "remote-handoff-changed", {}); + await waitForSelector(app, handoff, false); + await emit(app, "remote-handoff-changed", { [cancelId]: "pending_sync" }); + await waitForSelector( + app, + `${handoff}[data-handoff-state="pending_sync"]`, + ); + await emit(app, "profile-sync-status", { + profile_id: cancelId, + status: "error", + error: "Motion fixture sync failure", + }); + await app.waitFor( + () => + app.execute( + `return document.querySelector(arguments[0])?.textContent.includes(arguments[1]);`, + [handoff, en.profileMotion.handoffError], + ), + { + description: + "failed return keeps its pending state and explains the error", + }, + ); + await waitForSelector( + app, + `${handoff}[data-handoff-state="pending_sync"]`, + ); + await app.capture("profile-handoff-retry"); + await emit(app, "profile-sync-status", { + profile_id: cancelId, + status: "synced", + }); + await waitForSelector( + app, + `${handoff}[data-handoff-state="pending_sync"]`, + ); + await emit(app, "remote-handoff-changed", {}); + await waitForSelector(app, `${handoff}[data-handoff-state="returned"]`); + await app.capture("profile-handoff-returned"); + await app.clickTextIn(handoff, en.common.buttons.close, { + roles: ["button"], + }); + await waitForSelector(app, handoff, false); + }, + { seedDownloadedBrowser: true }, + ); +}); + +test("settings save a RAM-only edit, preserve location, and expose review and discard", async () => { + await withApp("motion-settings-feedback", async (app) => { + await resize(app, 1000, 720); + await app.clickSelector(`[aria-label="${en.rail.settings}"]`); + await waitForSelector(app, slot("settings-search")); + await app.fillSelector(slot("settings-search"), "decrypted"); + assert.deepEqual( + await app.execute( + `return [...document.querySelectorAll('[data-settings-section]')].filter(node => !node.hidden).map(node => node.dataset.settingsSection);`, + ), + ["advanced"], + ); + const initial = await app.invoke("get_app_settings"); + const selector = "#keep-decrypted-profiles-in-ram"; + await app.clickSelector(selector); + await app.clickSelector(`${slot("settings-feedback")} summary`); + assert.equal( + await app.visibleTextIncludes(en.settings.keepDecryptedProfilesInRam), + true, + ); + await app.capture("review-ram-change"); + await app.clickSelector(slot("settings-discard")); + assert.equal( + await app.execute( + `return document.querySelector(arguments[0]).getAttribute("data-state") === "checked";`, + [selector], + ), + Boolean(initial.keep_decrypted_profiles_in_ram), + ); + await app.clickSelector(selector); + await app.clickText(en.common.buttons.saveSettings, { roles: ["button"] }); + await app.waitFor( + async () => + (await app.invoke("get_app_settings")) + .keep_decrypted_profiles_in_ram !== + Boolean(initial.keep_decrypted_profiles_in_ram), + { description: "RAM preference persisted" }, + ); + await waitForSelector(app, slot("settings-search")); + assert.equal( + await app.execute(`return document.querySelector(arguments[0]).value;`, [ + slot("settings-search"), + ]), + "decrypted", + ); + await app.waitFor(() => app.visibleTextIncludes(en.common.buttons.saved), { + description: "inline save confirmation", + }); + await resize(app, 780, 580); + await app.capture("settings-small-saved"); + assert.equal( + await app.execute( + `const el = [...document.querySelectorAll("button")].find(button => button.textContent.trim() === arguments[0]); const r = el.getBoundingClientRect(); return r.bottom <= innerHeight && r.right <= innerWidth && r.top >= 0;`, + [en.common.buttons.saveSettings], + ), + true, + ); + + await app.pressShortcut({ ...modifier, key: "/" }); + await waitForSelector(app, slot("shortcuts-search")); + await app.fillSelector(slot("shortcuts-search"), en.shortcuts.goProfiles); + assert.equal(await app.visibleTextIncludes(en.shortcuts.goProfiles), true); + assert.equal(await app.visibleTextIncludes(en.shortcuts.goGroups), false); + await app.capture("shortcuts-search"); + }); +}); + +test("profile notes save through the inspector and real launch failures retain a receipt", async () => { + await withApp("motion-profile-feedback", async (app) => { + await resize(app, 1200, 800); + const profile = await createProfile(app, "Feedback profile"); + await waitForSelector(app, inspectTrigger(profile.id)); + await app.clickSelector(inspectTrigger(profile.id)); + await app.clickSelector(slot("profile-edit-note")); + await app.fillSelector( + `textarea[aria-label="${en.profileInfo.fields.note}"]`, + "Remember the launch context", + ); + await app.clickTextIn(slot("popover-content"), en.common.buttons.save, { + roles: ["button"], + }); + await app.waitFor( + async () => + (await app.invoke("list_browser_profiles")).find( + (item) => item.id === profile.id, + )?.note === "Remember the launch context", + { description: "note saved" }, + ); + // A cross-platform launch is rejected before starting any process. + const otherOs = process.platform === "darwin" ? "linux" : "macos"; + await app.invokeError("launch_browser_profile", { + profile: { ...profile, host_os: otherOs }, + url: null, + }); + await waitForSelector( + app, + `${slot("profile-launch-activity")}[data-stage="failed"]`, + ); + await app.clickSelector(`${slot("profile-launch-activity")} summary`); + assert.equal( + await app.visibleTextIncludes(en.appFeedback.launch.preparing), + true, + ); + assert.equal( + await app.visibleTextIncludes(en.appFeedback.launch.failed), + true, + ); + await app.capture("profile-launch-receipt"); + }); +}); + +test("schedule displays every timezone and slot, and skipped run details work by keyboard", async () => { + await withApp("motion-schedule-feedback", async (app) => { + await resize(app, 1200, 800); + const profile = await createProfile(app, "Two daily slots"); + const other = await createProfile(app, "Separate timezone"); + await installSynchronizerFixture(app); + const schedule = (item, timezone, slots) => ({ + profile_id: item.id, + profile_name: item.name, + owner_user_id: "motion-ui-fixture", + enabled: true, + timezone, + slots, + run_at_minute: slots[0].run_at_minute, + days_mask: slots[0].days_mask, + max_minutes: 30, + platform: "linux", + preset: "light", + sites: [], + template_id: null, + next_run_at: "2026-09-09T02:00:00Z", + last_run_at: null, + }); + const schedules = [ + schedule(profile, "UTC", [ + { days_mask: 127, run_at_minute: 120 }, + { days_mask: 127, run_at_minute: 720 }, + ]), + schedule(other, "Asia/Yerevan", [{ days_mask: 127, run_at_minute: 120 }]), + ]; + await app.execute( + ` + const schedules = arguments[0], id = arguments[1]; + const previous = window.fetch; + window.__donutScheduleCalls = []; + const response = value => Promise.resolve(new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json", "Tauri-Response": "ok" } })); + window.fetch = function(input, init) { + let command = ""; + try { const url = new URL(typeof input === "string" ? input : input.url); if (url.hostname === "localhost" && url.protocol === "ipc:" || url.hostname === "ipc.localhost") command = decodeURIComponent(url.pathname.split("/").pop() || ""); } catch {} + if (command === "get_cookie_bot_schedules") return response({ schedules }); + if (command === "get_remote_hours_quota") return response({ granted_hours: 200, remaining_hours: 199.8, used_hours: 0.2, seats: 1, per_seat_hours: 200, members: [] }); + if (command === "get_cookie_bot_runs") return response({ runs: [{ id: "skipped-fixture", profile_id: id, profile_name: "Two daily slots", status: "skipped", scheduled_for: "2026-09-08T02:00:00Z", max_minutes: 30, chunks_total: 1, chunk_index: 0, sites_total: 0, sites_visited: 0, sites_failed: 0, consent_dismissed: 0, billed_seconds: 0, outcome_code: "profile_locked" }], next_before: null }); + return previous.apply(window, arguments); + }; + `, + [schedules, profile.id], + ); + await app.pressShortcut({ ...modifier, key: "b" }); + await app.clickText(en.cookieBot.tabs.schedule, { roles: ["tab"] }); + await waitForSelector(app, slot("schedule-lane")); + assert.deepEqual( + await app.execute( + `return [...document.querySelectorAll('[data-slot="schedule-lane"]')].map(node => Number(node.dataset.minute)).sort((a, b) => a - b);`, + ), + [120, 120, 720], + ); + assert.equal(await app.visibleTextIncludes("Asia/Yerevan"), true); + assert.equal(await app.visibleTextIncludes("199.8"), true); + await app.capture("every-schedule-slot"); + await app.clickText(en.cookieBot.tabs.activity, { roles: ["tab"] }); + await app.clickSelector('[role="combobox"]'); + await app.clickText(en.cookieBot.runStatus.skipped, { roles: ["option"] }); + await waitForSelector(app, slot("run-details-toggle")); + await app.execute(`document.querySelector(arguments[0]).focus();`, [ + slot("run-details-toggle"), + ]); + await activateFocusedByKeyboard(app); + assert.equal( + await app.execute( + `return document.querySelector(arguments[0]).getAttribute("aria-expanded");`, + [slot("run-details-toggle")], + ), + "true", + ); + await app.capture("skipped-run-details"); + await restoreSynchronizerFixture(app); + }); +}); + +test("connection diagnostics and extension impact expose real assigned profiles", async () => { + await withApp("motion-connection-impact", async (app) => { + await resize(app, 1120, 800); + const profile = await createProfile(app, "Assigned feedback profile"); + const proxy = await app.invoke("create_stored_proxy", { + name: "Unavailable local route", + proxySettings: { + proxy_type: "http", + host: "127.0.0.1", + port: 9, + username: null, + password: null, + }, + }); + await app.invoke("update_profile_proxy", { + profileId: profile.id, + proxyId: proxy.id, + }); + await app.pressShortcut({ ...modifier, key: "n" }); + await waitForSelector(app, slot("profile-usage")); + await app.clickSelector(slot("profile-usage")); + await app.waitForText(profile.name); + await app.capture("proxy-assignment-list"); + await app.pressShortcut({ key: "Escape" }); + await app.clickSelector(`[aria-label="${en.proxyCheck.tooltipDefault}"]`); + await waitForSelector(app, slot("proxy-route-details")); + await app.waitFor( + () => app.visibleTextIncludes(en.proxyCheck.tooltipFailedTitle), + { description: "actual failed proxy check" }, + ); + assert.equal( + await app.visibleTextIncludes(en.appFeedback.notVerified), + true, + ); + await app.capture("proxy-route-failure"); + await app.pressShortcut({ key: "Escape" }); + await app.invoke("update_stored_proxy", { + proxyId: proxy.id, + name: proxy.name, + proxySettings: { ...proxy.proxy_settings, port: 19 }, + }); + assert.equal( + await app.invoke("get_cached_proxy_check", { proxyId: proxy.id }), + null, + ); + + const extension = await app.invoke("add_extension", { + name: "Fixture", + fileName: "fixture.zip", + fileData: [...Buffer.from(extensionZipBase64(), "base64")], + }); + const group = await app.invoke("create_extension_group", { + name: "Feedback extensions", + }); + await app.invoke("add_extension_to_group", { + groupId: group.id, + extensionId: extension.id, + }); + await app.invoke("assign_extension_group_to_profile", { + profileId: profile.id, + extensionGroupId: group.id, + }); + await app.invoke("update_extension", { + extensionId: extension.id, + name: "My chosen name", + fileName: null, + fileData: null, + }); + await app.pressShortcut({ ...modifier, key: "e" }); + await app.clickText("My chosen name", { roles: ["button"], exact: false }); + await waitForSelector(app, slot("assignment-impact")); + const disclosures = await app.execute( + `return [...document.querySelectorAll('[data-slot="assignment-impact"] summary')].map(node => node.textContent.trim());`, + ); + assert.ok(disclosures.some((text) => text.includes("1"))); + await app.clickSelector( + `${slot("assignment-impact")} li:last-child summary`, + ); + await app.waitForText(profile.name); + await app.waitFor( + () => + app.execute(` + const impact = document.querySelector('[data-slot="assignment-impact"]'); + const marker = impact.querySelector('[data-slot="operation-marker"]')?.getBoundingClientRect(); + const label = impact.querySelector('li[aria-current="step"]')?.getBoundingClientRect(); + if (!marker || !label || Math.abs(marker.left + marker.width / 2 - label.left - label.width / 2) > 1) return false; + let node = impact; + while (node && node !== document.body) { + if (node.scrollLeft || node.scrollWidth > node.clientWidth + 1) return false; + node = node.parentElement; + } + return true; + `), + { description: "extension flow aligned without horizontal overflow" }, + ); + await app.capture("extension-assignment-impact"); + await app.clickText(en.appFeedback.useManifestName, { roles: ["button"] }); + await app.clickTextIn('[role="dialog"]', en.common.buttons.save, { + roles: ["button"], + }); + await app.waitFor( + async () => + (await app.invoke("list_extensions"))[0].name === extension.name, + { description: "explicit manifest-name choice persists" }, + ); + + await app.pressShortcut({ ...modifier, key: "i" }); + await waitForSelector(app, slot("integration-diagnostics")); + await app.clickTextIn( + slot("integration-diagnostics"), + en.appFeedback.testConnection, + { roles: ["button"] }, + ); + await app.waitFor( + () => + app.execute( + `return document.querySelector('[data-slot="integration-diagnostics"] [role="status"]').textContent.includes(arguments[0]);`, + [en.proxyCheck.tooltipChecked.split("{{")[0]], + ), + { description: "real local API probe receipt" }, + ); + await app.capture("integration-connection-receipt"); + }); +}); + +test("partial import receipts survive retry without duplicating successful profiles", async () => { + await withApp( + "motion-import-receipts", + async (app) => { + const root = path.join(app.root, "import-sources"); + const sources = [ + path.join(root, "Default"), + path.join(root, "Profile 1"), + ]; + async function writeSource(index) { + await mkdir(sources[index], { recursive: true }); + await writeFile( + path.join(sources[index], "Preferences"), + JSON.stringify({ profile: { name: `Receipt source ${index + 1}` } }), + ); + await writeFile( + path.join(sources[index], "Bookmarks"), + JSON.stringify({ + roots: { + bookmark_bar: { + type: "folder", + children: [ + { type: "url", name: "Example", url: "https://example.com/" }, + ], + }, + }, + }), + ); + } + await writeSource(0); + await writeSource(1); + // Only fingerprint generation is fixed for this offline UI suite. Scanning, + // file copying, reports, progress events and retries run through real IPC. + await app.execute(` + window.__donutImportFetch = window.fetch; + window.__donutImportBatches = []; + window.fetch = function(input, init) { + let command = ""; + try { const url = new URL(typeof input === "string" ? input : input.url); if (url.hostname === "localhost" && url.protocol === "ipc:" || url.hostname === "ipc.localhost") command = decodeURIComponent(url.pathname.split("/").pop() || ""); } catch {} + if (command === "import_browser_profiles") { + const body = JSON.parse(init.body); + window.__donutImportBatches.push(body.items.map(item => item.source_path)); + body.wayfernConfig = { fingerprint: "{}" }; + return window.__donutImportFetch.call(this, input, { ...init, body: JSON.stringify(body) }); + } + return window.__donutImportFetch.apply(this, arguments); + }; + `); + try { + await app.pressShortcut({ ...modifier, key: "o" }); + await app.clickText(en.importProfile.manualImport, { roles: ["tab"] }); + await app.fillSelector("#manual-profile-path", root); + await app.clickText(en.importProfile.scanButton, { roles: ["button"] }); + await app.waitFor( + () => + app.execute( + `return document.querySelectorAll('[role="checkbox"][data-state="checked"]').length === 3;`, + ), + { description: "both scanned sources selected" }, + ); + await app.clickText(en.importProfile.nextButton, { roles: ["button"] }); + await rm(sources[1], { recursive: true, force: true }); + await app.clickText( + en.importProfile.importButtonCount.replace("{{count}}", "2"), + { roles: ["button"], exact: false }, + ); + await app.waitFor( + () => + app.execute( + `return document.querySelectorAll('[data-slot="import-receipt"][data-status="imported"]').length === 1 && document.querySelectorAll('[data-slot="import-receipt"][data-status="failed"]').length === 1;`, + ), + { description: "real partial-import receipts" }, + ); + const first = await app.invoke("list_browser_profiles"); + assert.equal(first.length, 1); + await app.capture("partial-import-receipts"); + await writeSource(1); + await app.clickSelector(slot("import-retry")); + await app.waitFor( + () => + app.execute( + `return document.querySelectorAll('[data-slot="import-receipt"][data-status="imported"]').length === 2;`, + ), + { description: "both import receipts after retry" }, + ); + const profiles = await app.invoke("list_browser_profiles"); + assert.equal(profiles.length, 2); + assert.ok(profiles.some((profile) => profile.id === first[0].id)); + await app.waitFor( + () => + app.execute( + `return [...document.querySelectorAll('[data-sonner-toast]')].some(node => node.getAttribute('data-type') === 'success' && node.textContent.includes(arguments[0]));`, + [ + en.importProfile.resultsSummary + .replace("{{imported}}", "2") + .replace("{{skipped}}", "0") + .replace("{{failed}}", "0"), + ], + ), + { description: "retry toast agrees with the cumulative receipts" }, + ); + const batches = await app.execute( + `return window.__donutImportBatches;`, + ); + assert.equal(batches[0].length, 2); + assert.deepEqual(batches[1], [sources[1]]); + await resize(app, 760, 620); + await assertContained(app, slot("import-receipts")); + await app.capture("import-retry-receipts-small"); + } finally { + await app.execute( + `window.fetch = window.__donutImportFetch; delete window.__donutImportFetch; delete window.__donutImportBatches;`, + ); + } + }, + { seedDownloadedBrowser: true }, + ); +}); + +test("About hides a snack drawer that responds to bites and resets on close", async () => { + await withApp("motion-donut-snack", async (app) => { + await resize(app, 760, 620); + const openAbout = async () => { + await app.clickSelector(`[aria-label="${en.rail.more.label}"]`); + await app.clickText(en.rail.more.about, { + roles: ["menuitem"], + exact: false, + }); + await waitForSelector(app, slot("about-logo")); + }; + await openAbout(); + await app.clickSelector(slot("about-logo")); + await waitForSelector(app, slot("donut-snack"), false); + await app.execute( + `document.querySelector('[data-slot="about-logo"]').focus();`, + ); + await app.pressShortcut({ key: "\uE006", shift: true }); + await waitForSelector(app, slot("donut-snack")); + const biteCount = () => + app.execute( + `return Number(document.querySelector('[data-slot="donut-snack"]').dataset.bites);`, + ); + assert.equal(await biteCount(), 0); + await app.capture("secret-snack-drawer"); + for (const count of [1, 2, 3]) { + await app.clickSelector(slot("donut-snack-bite")); + assert.equal(await biteCount(), count); + await assertContained(app, slot("donut-snack")); + if (count === 2) await app.capture("secret-snack-two-bites"); + } + await app.waitForText(en.about.snack.finished); + await app.capture("secret-snack-crumbs"); + await app.clickSelector(slot("donut-snack-bite")); + assert.equal(await biteCount(), 0); + await activateFocusedByKeyboard(app); + assert.equal(await biteCount(), 1); + await freezeAnimations(app); + try { + await app.clickSelector(slot("donut-snack-bite")); + assert.equal(await biteCount(), 2); + await assertPopupReadable(app, slot("donut-snack")); + } finally { + await resumeAnimations(app); + } + assert.deepEqual( + await app.invoke("list_browser_profiles"), + [], + "the snack never touches profiles", + ); + await app.pressShortcut({ key: "Escape" }); + await waitForSelector(app, slot("donut-snack"), false); + await openAbout(); + await waitForSelector(app, slot("donut-snack"), false); + }); +}); + +test("the Wayfern terms gate lifts when the backend announces acceptance", async () => { + // No Wayfern binary in this suite, so the marker is written the way the + // binary writes it and the backend's announcement is replayed. The dialog + // must close on that event alone: a REST or automation acceptance never + // presses the dialog's own button. + await withApp( + "motion-terms-gate", + async (app) => { + const termsDialogVisible = () => + app.execute( + `return [...document.querySelectorAll('[role="dialog"]')].some(node => node.textContent.includes(arguments[0]));`, + [en.wayfernTerms.title], + ); + await app.waitFor(termsDialogVisible, { + description: "the Wayfern terms dialog before acceptance", + }); + await app.capture("terms-gate-closed"); + await mkdir(path.dirname(app.wayfernTermsFile), { recursive: true }); + await writeFile( + app.wayfernTermsFile, + `${Math.floor(Date.now() / 1000)}\n`, + ); + assert.equal(await app.invoke("check_wayfern_terms_accepted"), true); + await emit(app, "wayfern-terms-accepted", null); + await app.waitFor(async () => !(await termsDialogVisible()), { + description: "the Wayfern terms dialog to close on the event", + }); + }, + { wayfernTermsAccepted: false, seedDownloadedBrowser: true }, + ); +}); + +test("the cheat code pays out sprinkles and never touches a profile", async () => { + await withApp("motion-cheat-code", async (app) => { + await resize(app, 1000, 700); + const code = ["", "", "", "", "", "", "", "", "b", "a"]; + const toastVisible = () => + app.execute( + `return [...document.querySelectorAll('[data-sonner-toast]')].some(node => node.textContent.includes(arguments[0]));`, + [en.easterEgg.konami.title], + ); + // Arrow keys type nothing; the letters land in a focused field as text. + const typedLetters = code.filter((key) => /^[a-z]$/.test(key)).join(""); + // Typed into a field, the code is text, not a command. + await app.pressShortcut({ ...modifier, key: "/" }); + await waitForSelector(app, slot("shortcuts-search")); + await app.clickSelector(slot("shortcuts-search")); + for (const key of code) await app.pressShortcut({ key }); + assert.equal(await toastVisible(), false); + assert.equal( + await app.execute(`return document.querySelector(arguments[0]).value;`, [ + slot("shortcuts-search"), + ]), + typedLetters, + ); + // Escape empties the filter first; only an empty filter lets it through. + await app.pressShortcut({ key: "Escape" }); + assert.equal( + await app.execute(`return document.querySelector(arguments[0]).value;`, [ + slot("shortcuts-search"), + ]), + "", + ); + await app.execute("document.activeElement?.blur();"); + for (const key of code) await app.pressShortcut({ key }); + await app.waitFor(toastVisible, { description: "the cheat-code toast" }); + await app.capture("cheat-code"); + assert.deepEqual( + await app.invoke("list_browser_profiles"), + [], + "the cheat code creates nothing", + ); + }); +}); diff --git a/e2e/tests/network.test.mjs b/e2e/tests/network.test.mjs index b5fb03a..96afed9 100644 --- a/e2e/tests/network.test.mjs +++ b/e2e/tests/network.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; import { readdir, readFile, stat, writeFile } from "node:fs/promises"; import { isIP } from "node:net"; import path from "node:path"; @@ -270,57 +271,55 @@ async function createProfileThroughUi(app, groupName) { return profiles.find((profile) => profile.name === "Visible Network Profile"); } -async function assignNetworkThroughUi(app, profileName, currentName, newName) { - const trigger = await app.execute( +/** + * The popover trigger sitting in a named COLUMN of a profile's row. + * + * Anchored to the column, never to the label the cell happens to show. Both + * callers used to search the whole row for the cell's current text, "Default" + * for the extension group, "Not selected" for the network, and both of those + * strings had long since become "None" in the app. Neither string exists + * anywhere in src/ any more, so the assertions failed against a UI that was + * working correctly, and with no E2E in CI nothing reported it. + * + * Matching on "None" instead would only move the problem: Proxy / VPN and EXT + * render the identical text, so a row-wide search would pick whichever came + * first in the DOM. The column is the thing that actually identifies the + * control, so that is what this matches on. + * + * A renamed header returns the header list rather than null, so the failure + * says which column went missing instead of "was not visible". + */ +async function columnTrigger(app, profileName, header) { + return app.execute( ` - const row = [...document.querySelectorAll("tr")].find((candidate) => + const row = [...document.querySelectorAll("tbody tr")].find((candidate) => (candidate.innerText || "").includes(arguments[0]) ); - const expected = arguments[1].toLocaleLowerCase(); - return [...(row?.querySelectorAll('[aria-haspopup="dialog"]') ?? [])].find( - (trigger) => (trigger.innerText || trigger.textContent || "") - .toLocaleLowerCase() - .includes(expected) - ) ?? null; + if (!row) return null; + const headers = [ + ...(row.closest("table")?.querySelectorAll("thead th") ?? []), + ].map((cell) => (cell.innerText || cell.textContent || "").trim()); + const index = headers.indexOf(arguments[1]); + if (index < 0) return "MISSING_COLUMN:" + headers.join(" | "); + return ( + row.children[index]?.querySelector( + '[aria-haspopup="dialog"], button', + ) ?? null + ); `, - [profileName, currentName], - ); - assert.ok(trigger, `Network selector for ${profileName} was not visible`); - await app.session.click(trigger); - await app.clickText(newName, { exact: false, roles: ["option"] }); - await app.waitFor( - () => - app.execute( - ` - return ![...document.querySelectorAll('[data-slot="popover-content"]')] - .some((content) => (content.innerText || "").includes(arguments[0])); - `, - [newName], - ), - { description: `${newName} network picker to unmount` }, + [profileName, header], ); } -async function assignExtensionGroupThroughUi( - app, - profileName, - currentName, - newName, -) { - const trigger = await app.execute( - ` - const row = [...document.querySelectorAll("tr")].find((candidate) => - (candidate.innerText || "").includes(arguments[0]) - ); - return [...(row?.querySelectorAll("button") ?? [])].find( - (button) => (button.innerText || button.textContent || "") - .trim() - .includes(arguments[1]) - ) ?? null; - `, - [profileName, currentName], - ); - assert.ok(trigger, `Extension selector for ${profileName} was not visible`); +async function assignThroughUi(app, profileName, header, newName, what) { + const trigger = await columnTrigger(app, profileName, header); + if (typeof trigger === "string") { + assert.fail( + `The "${header}" column is gone; the table now has: ` + + trigger.replace("MISSING_COLUMN:", ""), + ); + } + assert.ok(trigger, `${what} selector for ${profileName} was not visible`); await app.session.click(trigger); await app.clickText(newName, { exact: false, roles: ["option"] }); await app.waitFor( @@ -332,10 +331,16 @@ async function assignExtensionGroupThroughUi( `, [newName], ), - { description: `${newName} extension picker to unmount` }, + { description: `${newName} ${what.toLowerCase()} picker to unmount` }, ); } +const assignNetworkThroughUi = (app, profileName, newName) => + assignThroughUi(app, profileName, "Proxy / VPN", newName, "Network"); + +const assignExtensionGroupThroughUi = (app, profileName, newName) => + assignThroughUi(app, profileName, "EXT", newName, "Extension"); + async function runProfile(_app, base, token, profileId, url) { const launched = await request(`${base}/v1/profiles/${profileId}/run`, { method: "POST", @@ -808,7 +813,6 @@ test("visible UI creates and assigns profiles, groups, proxies, VPNs, extensions await assignExtensionGroupThroughUi( app, profile.name, - "Default", extensionEntities.group.name, ); await app.waitFor( @@ -824,6 +828,23 @@ test("visible UI creates and assigns profiles, groups, proxies, VPNs, extensions name: "Residential SOCKS5", proxySettings: socksSettings, }); + + // The exit's ISP and timezone are read from the MaxMind databases on this + // machine, never from an outside lookup service, so they have to actually + // be in place before a check can report them. The create-profile dialog + // starts that download in the background; this waits for it rather than + // racing it. + await app.invoke("download_geoip_database"); + await app.waitFor( + async () => + existsSync(path.join(app.dataRoot, "cache", "GeoLite2-City.mmdb")) && + existsSync(path.join(app.dataRoot, "cache", "GeoLite2-ASN.mmdb")), + { + description: "the local MaxMind city and ASN databases", + timeoutMs: 180_000, + }, + ); + const [httpCheck, socksCheck] = await Promise.all([ app.invoke("check_proxy_validity", { proxyId: httpProxy.id, @@ -839,12 +860,62 @@ test("visible UI creates and assigns profiles, groups, proxies, VPNs, extensions assert.ok(isIP(httpCheck.ip)); assert.ok(isIP(socksCheck.ip)); - await assignNetworkThroughUi( - app, - profile.name, - "Not selected", - httpProxy.name, + // An HTTP proxy tunnels TCP with CONNECT and has no datagram command, so + // the verdict follows from the protocol and is never a probe result. + assert.equal(httpCheck.udp, "no"); + // The SOCKS5 proxy answered the exit lookup, so the UDP probe reached it + // too: the verdict has to be a real answer, never "unknown". Which answer + // is the provider's to decide. + assert.ok( + ["yes", "no"].includes(socksCheck.udp), + `a reachable SOCKS5 proxy must give a definite UDP verdict, got ${socksCheck.udp}`, ); + + for (const [label, check] of [ + ["http", httpCheck], + ["socks5", socksCheck], + ]) { + assert.ok( + typeof check.latency_ms === "number" && check.latency_ms > 0, + `${label} check has to report how long it took`, + ); + // Read out of the local MaxMind databases: the exit address is never + // handed to an outside lookup service to learn these. + assert.ok( + typeof check.isp === "string" && check.isp.trim().length > 0, + `${label} check has to name the exit's ISP, got ${JSON.stringify(check.isp)}`, + ); + assert.ok( + typeof check.timezone === "string" && check.timezone.includes("/"), + `${label} check has to report the exit's timezone, got ${JSON.stringify(check.timezone)}`, + ); + } + + // The trail grows by one line per check, newest first, and carries what + // the receipt carried. + const firstTrail = await app.invoke("get_proxy_check_history", { + proxyId: socksProxy.id, + }); + assert.equal(firstTrail.length, 1); + assert.equal(firstTrail[0].ok, true); + assert.equal(firstTrail[0].ip, socksCheck.ip); + assert.equal(firstTrail[0].udp, socksCheck.udp); + assert.equal(firstTrail[0].isp, socksCheck.isp); + + await app.invoke("check_proxy_validity", { + proxyId: socksProxy.id, + proxySettings: null, + }); + const grownTrail = await app.invoke("get_proxy_check_history", { + proxyId: socksProxy.id, + }); + assert.equal(grownTrail.length, 2); + assert.ok( + grownTrail[0].timestamp >= grownTrail[1].timestamp, + "the trail is newest first", + ); + + await assignNetworkThroughUi(app, profile.name, httpProxy.name); await app.waitFor( async () => (await app.invoke("list_browser_profiles")).find( @@ -885,7 +956,7 @@ test("visible UI creates and assigns profiles, groups, proxies, VPNs, extensions activeCdp = null; await assertProxyWorkerLogsRedacted(app, [httpSettings, socksSettings]); - await assignNetworkThroughUi(app, profile.name, httpProxy.name, vpn.name); + await assignNetworkThroughUi(app, profile.name, vpn.name); await app.waitFor( async () => (await app.invoke("list_browser_profiles")).find( diff --git a/e2e/tests/smoke.test.mjs b/e2e/tests/smoke.test.mjs index 32f9480..d1db279 100644 --- a/e2e/tests/smoke.test.mjs +++ b/e2e/tests/smoke.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import path from "node:path"; import test from "node:test"; @@ -196,3 +197,115 @@ test("tray labels, hide-to-tray, and confirmed quit follow the native lifecycle" await app.close(); } }); + +test("the data directory can be moved to another folder and the choice survives a restart", async () => { + await withApp( + "smoke-data-root", + async (app) => { + // Every path below is inside this session's own temporary root. The + // real installation is never a source or a destination here. + const defaultRoot = path.join(app.dataRoot, "data"); + const pointerFile = path.join(app.dataRoot, "data-root.json"); + const destination = path.join(app.root, "moved-donut-data"); + + const before = await app.invoke("get_data_root_info"); + assert.equal(before.active_path, defaultRoot); + assert.equal(before.configured_path, null); + assert.equal(before.restart_required, false); + assert.equal(before.active_path_missing, false); + assert.equal(before.overridden_by_environment, false); + assert.ok(before.file_count > 0, "the seeded settings file is counted"); + assert.ok(before.size_bytes > 0, "the directory reports a real size"); + assert.equal(typeof before.app_directory_name, "string"); + + const profile = await app.invoke("create_browser_profile_new", { + name: "Carried Across", + browserStr: "wayfern", + version: "150.0.7871.100", + releaseType: "stable", + proxyId: null, + vpnId: null, + wayfernConfig: { fingerprint: "{}" }, + groupId: null, + ephemeral: false, + dnsBlocklist: null, + launchHook: null, + }); + + // Each refusal is its own code, because each one has a different fix. + assert.match( + await app.invokeError("move_data_root", { destination: defaultRoot }), + /DATA_ROOT_SAME_AS_CURRENT/, + ); + assert.match( + await app.invokeError("move_data_root", { + destination: path.join(defaultRoot, "profiles", "elsewhere"), + }), + /DATA_ROOT_DESTINATION_INSIDE_SOURCE/, + ); + assert.match( + await app.invokeError("move_data_root", { + destination: "not/absolute", + }), + /DATA_ROOT_DESTINATION_NOT_WRITABLE/, + ); + assert.equal( + existsSync(destination), + false, + "a refused move must not create the destination", + ); + + const moved = await app.invoke("move_data_root", { destination }); + assert.equal(moved.configured_path, destination); + assert.equal(moved.restart_required, true); + // The move takes effect at the next start: this process keeps every + // path it resolved when it started. + assert.equal(moved.active_path, defaultRoot); + + // Copy, then verify, then delete: the old directory only goes once the + // copy has been proven whole. + assert.equal(existsSync(defaultRoot), false, "the source is removed"); + assert.ok( + existsSync(path.join(destination, "settings", "app_settings.json")), + "settings travelled with the move", + ); + assert.ok( + existsSync(path.join(destination, "profiles")), + "profiles travelled with the move", + ); + + // The pointer lives beside the data directory, never inside it, or the + // delete above would have taken it and the next start would forget. + const pointer = JSON.parse(await readFile(pointerFile, "utf8")); + assert.equal(pointer.path, destination); + + await app.restart(); + + const after = await app.invoke("get_data_root_info"); + assert.equal(after.active_path, destination); + assert.equal(after.configured_path, destination); + assert.equal(after.restart_required, false); + assert.equal(after.active_path_missing, false); + + const profiles = await app.invoke("list_browser_profiles"); + assert.ok( + profiles.some((entry) => entry.id === profile.id), + "the moved directory still holds the profile", + ); + const settings = await app.invoke("get_app_settings"); + assert.equal(settings.onboarding_completed, true); + + // Forgetting the choice is the escape hatch for a drive that is gone + // for good; it moves nothing, so it too only lands on the next start. + const cleared = await app.invoke("clear_data_root_choice"); + assert.equal(cleared.configured_path, null); + assert.equal(existsSync(pointerFile), false); + + await app.restart(); + const restored = await app.invoke("get_data_root_info"); + assert.equal(restored.active_path, defaultRoot); + assert.equal(restored.configured_path, null); + }, + { seedDownloadedBrowser: true }, + ); +}); diff --git a/e2e/tests/sync.test.mjs b/e2e/tests/sync.test.mjs index a845367..2a3460e 100644 --- a/e2e/tests/sync.test.mjs +++ b/e2e/tests/sync.test.mjs @@ -483,10 +483,54 @@ test("global config sealing and encrypted profile sync reject a wrong password, "correct password decrypts profile browser file", ); + const emptyProfile = await createProfile(source, "Encrypted Empty Profile"); + await source.invoke("set_profile_sync_mode", { + profileId: emptyProfile.id, + syncMode: "Encrypted", + }); + await waitFor( + source, + async () => + (await listRemote(`profiles/${emptyProfile.id}/`)).some( + (object) => + object.key === `profiles/${emptyProfile.id}/metadata.json`, + ), + "empty profile metadata uploaded before rollover", + ); + await source.invoke("set_e2e_password", { password: "rolled encryption password", }); - await source.invoke("rollover_encryption_for_all_entities"); + let rollingOver = true; + let manifestDisappeared = false; + await Promise.all([ + source.invoke("rollover_encryption_for_all_entities").finally(() => { + rollingOver = false; + }), + (async () => { + while (rollingOver) { + const objects = await listRemote(`profiles/${encryptedProfile.id}/`); + manifestDisappeared ||= !objects.some( + (object) => + object.key === `profiles/${encryptedProfile.id}/manifest.json`, + ); + if (rollingOver) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + })(), + ]); + assert.equal( + manifestDisappeared, + false, + "rollover must not let another device interpret a missing manifest as an empty remote profile", + ); + assert.ok( + (await listRemote(`profiles/${emptyProfile.id}/`)).some( + (object) => object.key === `profiles/${emptyProfile.id}/manifest.json`, + ), + "rollover must publish a manifest even for an empty profile", + ); await waitFor( source, async () => { @@ -555,6 +599,10 @@ test("global config sealing and encrypted profile sync reject a wrong password, profileId: encryptedProfile.id, syncMode: "Disabled", }); + await source.invoke("set_profile_sync_mode", { + profileId: emptyProfile.id, + syncMode: "Disabled", + }); await source.invoke("delete_e2e_password"); assert.equal(await source.invoke("check_has_e2e_password"), false); const missingPassword = await source.invokeError("verify_e2e_password", { diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index 6fe7121..08c50dc 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -7,6 +7,8 @@ import en from "../../src/i18n/locales/en.json" with { type: "json" }; import { getDerivedThemeColors, THEMES } from "../../src/lib/themes.ts"; import { withApp } from "../lib/app.mjs"; import { + CRX_EXTENSION_NAME, + CRX_EXTENSION_VERSION, extensionZipBase64, writeUnpackedExtension, } from "../lib/fixtures.mjs"; @@ -203,8 +205,10 @@ async function saveSettings(app) { await app.clickText("Save Settings", { roles: ["button"] }); await app.waitFor( () => - app.execute(`return document.querySelector("#theme-select") === null;`), - { description: "Settings to close after saving" }, + app.execute( + `return document.querySelector('[data-slot="settings-feedback"]')?.textContent.includes("Saved");`, + ), + { description: "Settings to confirm the saved values" }, ); } @@ -318,6 +322,133 @@ async function dragBackgroundColorPicker(app) { ); } +test("the integrations page ships the Local API and MCP tabs and never names remote control for a regular desktop", async () => { + await withApp("ui-integrations", async (app) => { + const modifier = + process.platform === "darwin" ? { meta: true } : { ctrl: true }; + await app.clickSelector('[aria-label="Integrations"]'); + await app.waitForText(en.integrations.tabMcp); + + // Exactly the two tabs v0.30.0 shipped, in its order. Remote control is + // an Enterprise feature that a desktop without the entitlement is never + // told about, and this session is signed out, so a third tab here means + // the gate opened for everyone. + const tabs = async () => + app.execute( + `return [...document.querySelectorAll('[role="tab"]')].map((node) => node.textContent.trim());`, + ); + assert.deepEqual(await tabs(), [ + en.integrations.tabApi, + en.integrations.tabMcp, + ]); + // The whole document, not just the painted text: a hidden trigger or an + // unmounted-looking panel is still a mention. + const html = await app.html(); + for (const phrase of [ + en.integrations.tabRemote, + en.integrations.remote.enableLabel, + en.integrations.remote.signInRequired, + en.integrations.remote.endpointLabel, + en.integrations.remote.notEntitled, + ]) { + assert.ok( + !html.includes(phrase), + `remote control must stay out of sight: ${JSON.stringify(phrase)}`, + ); + } + assert.equal( + (await app.invoke("get_mcp_remote_status")).enabled, + false, + "opening the page must not open the bridge", + ); + + // Mod+I flips between the two tabs there are, and never lands on a tab + // that is not offered. + const activeTab = async () => + app.execute( + `return document.querySelector('[role="tab"][data-state="active"]')?.textContent.trim() ?? null;`, + ); + assert.equal(await activeTab(), en.integrations.tabApi); + await app.pressShortcut({ key: "i", ...modifier }); + await app.waitFor( + async () => (await activeTab()) === en.integrations.tabMcp, + { description: "Mod+I to move to the MCP tab" }, + ); + await app.pressShortcut({ key: "i", ...modifier }); + await app.waitFor( + async () => (await activeTab()) === en.integrations.tabApi, + { description: "Mod+I to move back to the Local API tab" }, + ); + assert.deepEqual(await tabs(), [ + en.integrations.tabApi, + en.integrations.tabMcp, + ]); + + await app.clickText(en.integrations.tabMcp, { roles: ["tab"] }); + await app.waitForText(en.integrations.mcpEnableLabel); + // Section labels are set in CSS uppercase, which innerText applies, so + // they are read from the label nodes rather than from the body text. + const labelShown = (label) => + app.execute( + `return [...document.querySelectorAll("label")].some((node) => node.textContent.trim() === arguments[0]);`, + [label], + ); + // Local MCP is removed: the tab shows a deprecation banner, the local + // client installer is gone, and enabling it starts no server. + assert.ok( + (await app.bodyText()).includes( + en.integrations.mcp.deprecatedBannerTitle, + ), + "the MCP tab must show the local-MCP removal banner", + ); + assert.equal( + await labelShown(en.integrations.mcp.clientsLabel), + false, + "the local client installer is gone with the local server", + ); + assert.equal(await app.invoke("get_mcp_server_status"), false); + + // Attempting to enable local MCP raises the removal dialog and starts + // nothing. Only the MCP tab's content is mounted, so this is its switch. + await app.clickSelector('[role="switch"]'); + const removalDialogVisible = () => + app.execute( + `return [...document.querySelectorAll('[role="dialog"]')].some(node => node.textContent.includes(arguments[0]));`, + [en.mcpLocalDeprecated.title], + ); + await app.waitFor(removalDialogVisible, { + description: "the local MCP removal dialog to open", + }); + assert.equal( + await app.invoke("get_mcp_server_status"), + false, + "enabling local MCP must not start a server", + ); + assert.equal( + await labelShown(en.integrations.mcp.clientsLabel), + false, + "no local client installer appears after a refused enable", + ); + // The refusal must leave the switch off: a regression that flips it on + // visually while starting nothing would otherwise pass. + assert.equal( + await app.execute( + `return document.querySelector('[role="switch"]').getAttribute("aria-checked");`, + ), + "false", + "a refused enable must leave the local MCP switch off", + ); + // The command also emits a toast with the same title. Observe the dialog + // itself so a toast cannot stand in for modal opening or dismissal. + await app.pressShortcut({ key: "Escape" }); + await app.waitFor(async () => !(await removalDialogVisible()), { + description: "the local MCP removal dialog to close", + }); + + await dismissSurface(app); + }); +}); + test("all primary navigation buttons and sub-page tabs render and remain interactive", async () => { await withApp("ui-navigation", async (app) => { const surfaces = [ @@ -500,7 +631,9 @@ test("VLESS proxy form keeps the share URI as one clear, validated input", async await app.clickSelector('[aria-label="New proxy"]'); await app.waitForText("Add Proxy"); await app.fillSelector("#proxy-name", "E2E VLESS"); - await chooseSelectOption(app, "#proxy-type", "VLESS"); + // "VLESS (REALITY)" since the type list was regrouped by whether the + // first hop is encrypted; chooseSelectOption matches the label exactly. + await chooseSelectOption(app, "#proxy-type", "VLESS (REALITY)"); assert.equal( await app.execute( @@ -1185,7 +1318,7 @@ function extensionRowScript(body) { return `const wanted = arguments[0]; const row = [...document.querySelectorAll("tbody tr")].find((candidate) => { const cells = [...candidate.querySelectorAll("td")]; - return cells.length >= 7 && (cells[2].innerText || "").trim() === wanted; + return cells.length >= 7 && (cells[2].querySelector("button > span")?.textContent || cells[2].innerText || "").trim() === wanted; }); ${body}`; } @@ -1196,7 +1329,7 @@ async function extensionRow(app, name) { const cells = [...row.querySelectorAll("td")]; const sync = row.querySelector('[data-slot="animated-switch"]'); return { - name: (cells[2].innerText || "").trim(), + name: (cells[2].querySelector("button > span")?.textContent || cells[2].innerText || "").trim(), source: (cells[4].innerText || "").trim(), syncChecked: sync ? sync.getAttribute("data-state") === "checked" : null, syncDisabled: sync ? sync.disabled === true : null, @@ -1473,3 +1606,892 @@ test("a folder with no manifest fails with the translated reason, not a raw code assert.equal((await restoreFolderPicker(app)).length, 1); }); }); + +test("the agent page opens from the rail and the palette, keeps every tab live, and never shows a form it cannot honour", async () => { + await withApp("ui-agent", async (app) => { + await app.clickSelector(`[aria-label="${en.rail.agent}"]`); + await app.waitForText(en.agent.unavailable.signInTitle); + + // Exactly the three panels the page ships, in its order. The strip renders + // whether or not the account can use the agent, because + // AGENT_NOT_CONFIGURED is only learned by asking and the chrome has to be + // on screen when the answer lands. + const tabs = async () => + app.execute( + `return [...document.querySelectorAll('[role="tab"]')].map((node) => node.textContent.trim());`, + ); + assert.deepEqual(await tabs(), [ + en.agent.tabs.run, + en.agent.tabs.history, + en.agent.tabs.recipes, + ]); + + // The explanation is PAINTED, not merely in the DOM. Content that only + // becomes visible once an animation has run renders as an empty panel every + // time the animation does not, which is the failure this asserts against. + const notice = async () => + app.execute(` + const el = document.querySelector('[data-slot="agent-unavailable"]'); + if (!el) return null; + const rect = el.getBoundingClientRect(); + const style = getComputedStyle(el); + return { + width: rect.width, + height: rect.height, + opacity: Number(style.opacity), + visibility: style.visibility, + text: el.innerText.trim(), + }; + `); + const signedOut = await notice(); + assert.ok(signedOut, "the run tab must explain itself when signed out"); + assert.ok(signedOut.width > 0 && signedOut.height > 0); + assert.equal(signedOut.opacity, 1); + assert.equal(signedOut.visibility, "visible"); + assert.match(signedOut.text, new RegExp(en.agent.unavailable.signInHint)); + + // Never a broken form: a signed-out desktop is told what is missing, it is + // not handed a goal field and a submit button that cannot work. + assert.equal( + await app.execute( + `return document.querySelector('[data-slot="agent-run-form"]') === null;`, + ), + true, + ); + + // Every tab answers a real pointer click, and each carries its own + // explanation rather than a blank panel. + for (const label of [ + en.agent.tabs.history, + en.agent.tabs.recipes, + en.agent.tabs.run, + ]) { + await app.clickText(label); + await app.waitFor( + () => + app.execute( + `return [...document.querySelectorAll('[role="tab"]')].some( + (node) => node.textContent.trim() === arguments[0] && + node.getAttribute("data-state") === "active" + );`, + [label], + ), + { description: `${label} agent tab` }, + ); + const panel = await notice(); + assert.ok(panel, `${label} must explain itself when signed out`); + assert.ok(panel.height > 0, `${label} rendered an empty panel`); + } + + await dismissSurface(app); + + // The same page from the palette. A rail item nobody can reach by keyboard + // is half a navigation. + const modifier = + process.platform === "darwin" ? { meta: true } : { ctrl: true }; + await app.pressShortcut({ key: "k", ...modifier }); + await app.waitFor( + () => + app.execute(`return Boolean(document.querySelector("[cmdk-input]"));`), + { description: "command palette" }, + ); + const input = await app.session.findCss("[cmdk-input]"); + await app.session.sendKeys(input, en.shortcuts.goAgent); + await app.clickText(en.shortcuts.goAgent, { + exact: false, + roles: ["option", "button", "menuitem"], + }); + await app.waitForText(en.agent.unavailable.signInTitle); + assert.deepEqual(await tabs(), [ + en.agent.tabs.run, + en.agent.tabs.history, + en.agent.tabs.recipes, + ]); + + await dismissSurface(app); + }); +}); + +async function createUiProfile(app, name) { + return app.invoke("create_browser_profile_new", { + name, + browserStr: "wayfern", + version: "150.0.7871.100", + releaseType: "stable", + proxyId: null, + vpnId: null, + wayfernConfig: { fingerprint: "{}" }, + groupId: null, + ephemeral: false, + dnsBlocklist: null, + launchHook: null, + }); +} + +async function distributionSummary(app) { + return app.execute( + `return document.querySelector('[data-testid="distribute-summary"]')?.innerText ?? "";`, + ); +} + +test("the distribute-proxies dialog opens from the action bar, counts the pairing, and answers every control", async () => { + await withApp( + "ui-proxy-distribution", + async (app) => { + for (const name of ["Fleet One", "Fleet Two", "Fleet Three"]) { + await createUiProfile(app, name); + } + for (const [index, name] of ["Exit One", "Exit Two"].entries()) { + await app.invoke("create_stored_proxy", { + name, + proxySettings: { + proxy_type: "http", + host: "127.0.0.1", + port: 9101 + index, + username: null, + password: null, + }, + }); + } + await app.waitForText("Fleet Three"); + + await app.clickSelector(`[aria-label="${en.common.aria.selectAll}"]`); + await app.clickSelector( + `[aria-label="${en.profiles.actionBar.distributeProxies}"]`, + ); + await app.waitForText(en.proxyDistribution.title); + + // Two proxies for three profiles: the dialog has to say so up front, and + // it must never pretend the third profile is covered. + await app.waitFor( + async () => (await distributionSummary(app)).includes("2 of 3"), + { description: "distribution summary" }, + ); + const summary = await distributionSummary(app); + assert.match(summary, /2 of 3/); + // The remainder is never silently dropped: the one profile no proxy was + // left for is named. Which one depends on the table's order, so assert + // that exactly one of the three is named rather than pinning the sort. + const named = ["Fleet One", "Fleet Two", "Fleet Three"].filter((name) => + summary.includes(name), + ); + assert.deepEqual( + named.length, + 1, + `the unpaired profile must be named exactly once, got: ${summary}`, + ); + + // Every control answers a click. Dropping the proxies empties the plan... + await app.clickSelector('[data-testid="distribute-toggle-proxies"]'); + await app.waitFor( + async () => (await distributionSummary(app)).includes("0 of 3"), + { description: "summary after clearing the proxies" }, + ); + // ...and putting them back restores it. + await app.clickSelector('[data-testid="distribute-toggle-proxies"]'); + await app.waitFor( + async () => (await distributionSummary(app)).includes("2 of 3"), + { description: "summary after reselecting the proxies" }, + ); + + const switchState = () => + app.execute( + `return document.querySelector('[aria-label="${en.proxyDistribution.allowSharingLabel}"]')?.getAttribute("aria-checked") ?? null;`, + ); + assert.equal(await switchState(), "false", "sharing is off by default"); + await app.clickSelector( + `[aria-label="${en.proxyDistribution.allowSharingLabel}"]`, + ); + await app.waitFor(async () => (await switchState()) === "true", { + description: "sharing switch turning on", + }); + + // Dropping every profile leaves nothing to do, and the primary action + // must not offer to do it. + await app.clickSelector('[data-testid="distribute-toggle-profiles"]'); + await app.waitFor( + async () => (await distributionSummary(app)).includes("0 of 0"), + { description: "summary after clearing the profiles" }, + ); + assert.equal( + await app.execute( + `const nodes = [...document.querySelectorAll("button")]; + const node = nodes.find((n) => (n.innerText ?? "").trim().includes(arguments[0])); + return node ? node.disabled : null;`, + [en.proxyDistribution.distributeButton], + ), + true, + "an empty plan must not offer a Distribute button that does nothing", + ); + + await dismissSurface(app); + }, + { seedDownloadedBrowser: true }, + ); +}); + +test("the group bookmark editor adds, reorders and removes a row", async () => { + await withApp("ui-group-bookmarks", async (app) => { + const group = await app.invoke("create_profile_group", { name: "Client" }); + + await app.clickSelector('[aria-label="Groups"]'); + await app.waitForText("Client"); + await app.clickSelector('[data-testid="group-bookmarks-button"]'); + await app.waitForText(en.groupBookmarks.description); + assert.ok(await app.visibleTextIncludes(en.groupBookmarks.empty)); + + const rowCount = () => + app.execute( + `return document.querySelectorAll('[data-testid="group-bookmark-row"]').length;`, + ); + const titles = () => + app.execute( + `return [...document.querySelectorAll('[data-testid="group-bookmark-title"]')].map((n) => n.value);`, + ); + const rowSelector = (index, testid) => + `[data-testid="group-bookmark-rows"] > div:nth-child(${index}) [data-testid="${testid}"]`; + + await app.clickSelector('[data-testid="group-bookmark-add"]'); + await app.waitFor(async () => (await rowCount()) === 1, { + description: "the first bookmark row", + }); + await app.clickSelector('[data-testid="group-bookmark-add"]'); + await app.waitFor(async () => (await rowCount()) === 2, { + description: "the second bookmark row", + }); + await app.clickSelector('[data-testid="group-bookmark-add"]'); + await app.waitFor(async () => (await rowCount()) === 3, { + description: "the third bookmark row", + }); + + await app.fillSelector(rowSelector(1, "group-bookmark-title"), "Support"); + await app.fillSelector( + rowSelector(1, "group-bookmark-url"), + "https://support.example", + ); + await app.fillSelector(rowSelector(2, "group-bookmark-title"), "Console"); + await app.fillSelector( + rowSelector(2, "group-bookmark-url"), + "https://console.example", + ); + await app.fillSelector(rowSelector(2, "group-bookmark-folder"), "Ops"); + await app.fillSelector(rowSelector(3, "group-bookmark-title"), "Scratch"); + await app.fillSelector( + rowSelector(3, "group-bookmark-url"), + "https://scratch.example", + ); + assert.deepEqual(await titles(), ["Support", "Console", "Scratch"]); + + // Reorder: the second row moves above the first. + await app.clickSelector(rowSelector(2, "group-bookmark-move-up")); + await app.waitFor(async () => (await titles())[0] === "Console", { + description: "the reordered first row", + }); + assert.deepEqual(await titles(), ["Console", "Support", "Scratch"]); + + // Remove: the last row goes away and nothing else moves. + await app.clickSelector(rowSelector(3, "group-bookmark-remove")); + await app.waitFor(async () => (await rowCount()) === 2, { + description: "the removed row", + }); + assert.deepEqual(await titles(), ["Console", "Support"]); + + await app.clickText(en.common.buttons.save); + await app.waitFor( + async () => + (await app.invoke("get_group_bookmarks", { groupId: group.id })) + .length === 2, + { description: "the saved bookmark list" }, + ); + assert.deepEqual( + await app.invoke("get_group_bookmarks", { groupId: group.id }), + [ + { title: "Console", url: "https://console.example", folder: "Ops" }, + { title: "Support", url: "https://support.example" }, + ], + ); + + await dismissSurface(app); + }); +}); + +test("importing from a link validates the input and names the extension before it is saved", async () => { + await withApp("ui-extension-from-link", async (app) => { + const fixtureBase = process.env.DONUT_E2E_FIXTURE_URL; + assert.ok(fixtureBase, "the fixture server URL has to reach the suite"); + + await openExtensionsPage(app); + await app.clickSelector(`[aria-label="${EXTENSION_STRINGS.fromUrl}"]`); + await app.waitFor( + () => + app.execute( + `return Boolean(document.querySelector("#ext-url-input"));`, + ), + { description: "the link import form" }, + ); + + // Nothing to fetch yet, so the action is not offered. + assert.equal( + await app.execute( + `return [...document.querySelectorAll("button")] + .find((button) => (button.textContent || "").trim() === arguments[0]) + ?.disabled ?? null;`, + [EXTENSION_STRINGS.fetchExtension], + ), + true, + "Fetch has to stay disabled until there is something to fetch", + ); + + // A link that is not an extension source is refused, in the user's + // language, and nothing is staged. + await app.fillSelector("#ext-url-input", "https://example.invalid/page"); + await app.clickText(EXTENSION_STRINGS.fetchExtension, { + roles: ["button"], + }); + await app.waitFor( + async () => + (await toastTexts(app)).some((text) => + text.includes(en.backendErrors.extensionUrlInvalid), + ), + { description: "the refusal for a link that is not an extension" }, + ); + assert.equal( + await app.execute( + `return document.querySelector('[data-slot="extension-fetched-identity"]') === null;`, + ), + true, + "a refused link must not stage anything", + ); + + // The real thing: a CRX3 the fixture server serves. The staged form has to + // show the identity read out of the archive's own manifest, not the file + // name, before the user commits to storing it. + await app.fillSelector("#ext-url-input", `${fixtureBase}/extension.crx`); + await app.clickText(EXTENSION_STRINGS.fetchExtension, { + roles: ["button"], + }); + await app.waitFor( + () => + app.execute( + `return document.querySelector('[data-slot="extension-fetched-identity"]')?.innerText?.trim() ?? null;`, + ), + { description: "the parsed identity of the downloaded extension" }, + ); + const identity = await app.execute( + `return document.querySelector('[data-slot="extension-fetched-identity"]').innerText.trim();`, + ); + assert.ok( + identity.includes(CRX_EXTENSION_NAME), + `the staged identity has to name the extension, got: ${identity}`, + ); + assert.ok( + identity.includes(CRX_EXTENSION_VERSION), + `the staged identity has to carry the version, got: ${identity}`, + ); + assert.ok( + await app.visibleTextIncludes(`${fixtureBase}/extension.crx`), + "the staged import has to name where the package came from", + ); + assert.deepEqual( + await app.invoke("list_extensions"), + [], + "fetching stages the archive; it must not store it", + ); + + await app.clickText(en.common.buttons.add, { roles: ["button"] }); + await app.waitForText(CRX_EXTENSION_NAME); + const stored = await app.invoke("list_extensions"); + assert.equal(stored.length, 1); + assert.equal(stored[0].name, CRX_EXTENSION_NAME); + assert.equal(stored[0].version, CRX_EXTENSION_VERSION); + assert.equal(stored[0].file_type, "zip"); + assert.equal(stored[0].source_kind, "archive"); + }); +}); + +test("a checked proxy shows its UDP verdict in the table and its check trail in the details", async () => { + await withApp("ui-proxy-check-trail", async (app) => { + const proxy = await app.invoke("create_stored_proxy", { + name: "Trail HTTP Proxy", + proxySettings: { + proxy_type: "http", + host: "127.0.0.1", + // Discard port: the check fails fast, which is a real check outcome + // and exactly what the trail has to be able to show. + port: 9, + username: null, + password: null, + }, + }); + await app.invokeError("check_proxy_validity", { + proxyId: proxy.id, + proxySettings: null, + }); + + await app.clickSelector('[aria-label="Network"]'); + await app.waitForText(proxy.name); + + // An HTTP proxy cannot carry a datagram, so the table says so without + // anyone opening anything. + await app.waitFor( + async () => + (await app.execute( + `return document.querySelector('[data-slot="proxy-udp-verdict"]')?.dataset?.udp ?? null;`, + )) === "no", + { description: "the UDP verdict cell" }, + ); + assert.equal( + ( + await app.execute( + `return document.querySelector('[data-slot="proxy-udp-verdict"]').innerText.trim();`, + ) + ).toLowerCase(), + en.proxyCheck.udpNo.toLowerCase(), + ); + + await app.clickSelector(`[aria-label="${en.appFeedback.routeDetails}"]`); + await app.waitFor( + () => + app.execute( + `return Boolean(document.querySelector('[data-slot="proxy-check-history"]'));`, + ), + { description: "the check trail" }, + ); + await app.waitFor( + async () => + (await app.execute( + `return document.querySelectorAll('[data-slot="proxy-check-history-entry"]').length;`, + )) >= 1, + { description: "at least one remembered check" }, + ); + + const trail = await app.execute( + `return [...document.querySelectorAll('[data-slot="proxy-check-history-entry"]')] + .map((entry) => entry.innerText.replace(/\\s+/g, " ").trim());`, + ); + assert.ok(trail.length >= 1); + assert.ok( + trail[0].includes(en.proxyCheck.historyFailed), + `the newest line has to report the failure, got: ${trail[0]}`, + ); + assert.ok( + await app.visibleTextIncludes(en.proxyCheck.historyTitle), + "the trail needs a heading that says what it is", + ); + }); +}); + +/** + * Answer one Tauri command from inside the webview. + * + * Same seam as {@link stubFolderPicker}: the synchroniser panel needs a live + * session to control, and a real one launches browsers. Every other command + * still reaches the real backend, so the panel is exercised as it ships. + */ +async function stubCommand(app, command, reply) { + await app.execute( + `const wanted = arguments[0]; + const reply = arguments[1]; + if (!window.__donutOriginalFetch) { + window.__donutOriginalFetch = window.fetch; + } + window.__donutStubbedCalls = window.__donutStubbedCalls ?? []; + window.__donutStubs = window.__donutStubs ?? {}; + window.__donutStubs[wanted] = reply; + if (!window.__donutStubInstalled) { + window.__donutStubInstalled = true; + window.fetch = function (input, init) { + const url = String( + typeof input === "string" ? input : (input && input.url) || "", + ); + let name = ""; + try { + name = decodeURIComponent(url.split("/").pop() || ""); + } catch (_error) { + name = ""; + } + if (window.__donutStubs[name] !== undefined) { + let payload = null; + try { + payload = JSON.parse((init && init.body) || "null"); + } catch (_error) { + payload = null; + } + window.__donutStubbedCalls.push({ command: name, payload }); + return Promise.resolve( + new Response(JSON.stringify(window.__donutStubs[name]), { + status: 200, + headers: { + "content-type": "application/json", + "Tauri-Response": "ok", + }, + }), + ); + } + return window.__donutOriginalFetch.apply(window, arguments); + }; + } + return true;`, + [command, reply], + ); +} + +async function restoreStubs(app) { + return app.execute( + `const calls = window.__donutStubbedCalls ?? []; + if (window.__donutOriginalFetch) { + window.fetch = window.__donutOriginalFetch; + delete window.__donutOriginalFetch; + } + delete window.__donutStubbedCalls; + delete window.__donutStubs; + delete window.__donutStubInstalled; + return calls;`, + ); +} + +const DATA_ROOT = en.settings.dataRoot; + +test("the data directory setting shows where state lives, refuses a bad move, and reports the one it makes", async () => { + await withApp("ui-data-root", async (app) => { + const defaultRoot = path.join(app.dataRoot, "data"); + await app.clickSelector(`[aria-label="${en.rail.settings}"]`); + await app.waitFor( + () => + app.execute( + `return Boolean(document.querySelector('[data-slot="data-root-setting"]'));`, + ), + { description: "the data directory control" }, + ); + + const shown = await app.execute( + `return { + path: document.querySelector('[data-slot="data-root-active-path"]')?.innerText ?? null, + size: document.querySelector('[data-slot="data-root-size"]')?.innerText ?? null, + moveDisabled: document.querySelector('[data-slot="data-root-move"]')?.disabled ?? null, + missing: Boolean(document.querySelector('[data-slot="data-root-missing"]')), + restart: Boolean(document.querySelector('[data-slot="data-root-restart-required"]')), + };`, + ); + assert.equal(shown.path, defaultRoot, "the real directory is on screen"); + assert.match(shown.size, /\d/, "the size has to be a real figure"); + assert.equal( + shown.moveDisabled, + true, + "there is nowhere to move to until a folder is chosen", + ); + assert.equal(shown.missing, false); + assert.equal(shown.restart, false); + assert.equal(await app.visibleTextIncludes(DATA_ROOT.title), true); + + // A folder inside the current directory: the copy would never finish and + // the delete afterwards would take the copy with it. + await stubFolderPicker(app, path.join(defaultRoot, "profiles")); + await app.clickSelector('[data-slot="data-root-choose"]'); + await app.waitFor( + async () => + (await app.execute( + `return document.querySelector('[data-slot="data-root-move"]')?.disabled;`, + )) === false, + { description: "the move button waking up once a folder is chosen" }, + ); + const chosen = await app.execute( + `return document.querySelector('[data-slot="data-root-destination"]')?.innerText ?? null;`, + ); + assert.ok( + chosen?.startsWith(path.join(defaultRoot, "profiles")), + `the exact destination has to be shown before committing, got: ${chosen}`, + ); + + await app.clickSelector('[data-slot="data-root-move"]'); + await app.waitFor( + () => + app.execute( + `return Boolean(document.querySelector('[data-slot="data-root-error"]'));`, + ), + { description: "the refusal" }, + ); + assert.equal( + (await restoreFolderPicker(app)).length, + 1, + "the first choice went through the picker", + ); + assert.equal( + await app.execute( + `return document.querySelector('[data-slot="data-root-error"]').innerText.trim();`, + ), + en.backendErrors.dataRootDestinationInsideSource, + "a refusal is a translated sentence, never a raw code", + ); + await app.capture("data-root-refused"); + + // Nothing was created for a move that was never going to run. + assert.equal( + await app + .invoke("get_data_root_info") + .then((info) => info.configured_path), + null, + ); + + // Now a real move, into this session's own temporary root. + const destination = path.join(app.root, "ui-moved-data"); + await stubFolderPicker(app, destination); + await app.clickSelector('[data-slot="data-root-choose"]'); + await app.waitFor( + async () => + ( + await app.execute( + `return document.querySelector('[data-slot="data-root-destination"]')?.innerText ?? "";`, + ) + ).startsWith(destination), + { description: "the new destination on screen" }, + ); + await app.clickSelector('[data-slot="data-root-move"]'); + await app.waitFor( + () => + app.execute( + `return Boolean(document.querySelector('[data-slot="data-root-restart-required"]'));`, + ), + { description: "the restart notice", timeoutMs: 60_000 }, + ); + assert.equal( + await app.execute( + `return Boolean(document.querySelector('[data-slot="data-root-error"]'));`, + ), + false, + "a move that worked must not also report an error", + ); + await app.capture("data-root-moved"); + + // The fixture clears its own log each time it is installed, so this reads + // only the second choice. + const picks = await restoreFolderPicker(app); + assert.equal(picks.length, 1); + assert.equal( + picks[0]?.options?.directory, + true, + "the control asks for a folder, not a file", + ); + + const info = await app.invoke("get_data_root_info"); + assert.ok( + info.configured_path?.startsWith(destination), + `the choice has to be recorded, got: ${info.configured_path}`, + ); + assert.equal(info.restart_required, true); + }); +}); + +const SYNC = en.profiles.synchronizer; + +test("the synchroniser panel lists a live session and its controls act on the real state", async () => { + await withApp("ui-synchronizer-panel", async (app) => { + const session = { + id: "ui-panel-session", + leader_profile_id: "leader-id", + leader_profile_name: "Panel leader", + paused: false, + followers: [ + { + profile_id: "follower-one", + profile_name: "Panel follower one", + failed_at_url: null, + held: false, + }, + { + profile_id: "follower-two", + profile_name: "Panel follower two", + failed_at_url: "https://example.invalid/lost", + held: false, + }, + ], + }; + const panel = '[data-slot="synchronizer-panel"]'; + const followerState = (id) => + app.execute( + `const row = document.querySelector('[data-slot="synchronizer-panel-follower"][data-profile-id="' + arguments[0] + '"]'); + if (!row) return null; + return { + held: row.dataset.held === "true", + badge: row.querySelector('[data-slot="synchronizer-panel-follower-state"]')?.innerText?.trim() ?? null, + holdLabel: row.querySelector('[data-slot="synchronizer-panel-hold"]')?.innerText?.trim() ?? null, + };`, + [id], + ); + + try { + // The profiles page has to be mounted before any of this means + // anything: an event emitted before the hook subscribes is simply gone. + await app.waitFor( + () => + app.execute( + `return Boolean(document.querySelector('[data-slot="profile-workspace"]'));`, + ), + { description: "the profiles page" }, + ); + assert.equal( + await app.execute( + `return Boolean(document.querySelector(arguments[0]));`, + [panel], + ), + false, + "no session, no panel", + ); + + // Re-emitted until it lands, because subscribing is asynchronous and a + // missed event is indistinguishable from a broken panel. + await app.waitFor( + async () => { + await app.invoke("plugin:event|emit", { + event: "sync-session-changed", + payload: session, + }); + return app.execute( + `return Boolean(document.querySelector(arguments[0]));`, + [panel], + ); + }, + { description: "the session panel" }, + ); + + assert.equal( + await app.execute( + `return document.querySelector('[data-slot="synchronizer-panel-leader"]').innerText.trim();`, + ), + session.leader_profile_name, + ); + assert.deepEqual( + await app.execute( + `return [...document.querySelectorAll('[data-slot="synchronizer-panel-follower"]')] + .map((row) => row.dataset.profileId);`, + ), + ["follower-one", "follower-two"], + "followers stay in the order they were chosen", + ); + assert.deepEqual(await followerState("follower-one"), { + held: false, + badge: SYNC.stateMirroring, + holdLabel: SYNC.holdOut, + }); + // The desynced follower reports the failure instead of a state badge. + assert.equal(await app.visibleTextIncludes(SYNC.stateDesynced), true); + await app.capture("synchronizer-panel"); + + // The backend has no such session, so pausing must fail and the panel + // must keep telling the truth rather than flipping hopefully. + await app.clickSelector('[data-slot="synchronizer-panel-pause"]'); + await app.waitFor( + () => app.visibleTextIncludes(en.backendErrors.syncSessionNotFound), + { description: "the refusal surfaced to the user" }, + ); + assert.equal( + await app.execute( + `return document.querySelector('[data-slot="synchronizer-panel-pause"]').innerText.trim();`, + ), + SYNC.pauseMirroring, + "a refused pause must not read as paused", + ); + assert.equal( + await app.execute( + `return Boolean(document.querySelector('[data-slot="synchronizer-panel-paused-note"]'));`, + ), + false, + ); + + // With the backend agreeing, the same click has to land. + await stubCommand(app, "set_sync_session_paused", { + ...session, + paused: true, + }); + await app.clickSelector('[data-slot="synchronizer-panel-pause"]'); + await app.waitFor( + () => + app.execute( + `return Boolean(document.querySelector('[data-slot="synchronizer-panel-paused-note"]'));`, + ), + { description: "the paused state" }, + ); + assert.equal( + await app.execute( + `return document.querySelector('[data-slot="synchronizer-panel-pause"]').innerText.trim();`, + ), + SYNC.resumeMirroring, + ); + assert.equal( + (await followerState("follower-one")).badge, + SYNC.statePaused, + ); + await app.capture("synchronizer-panel-paused"); + + // Holding one follower out leaves the other exactly as it was. + await stubCommand(app, "set_sync_follower_held", { + ...session, + followers: [ + { ...session.followers[0], held: true }, + session.followers[1], + ], + }); + await app.clickSelector( + '[data-slot="synchronizer-panel-follower"][data-profile-id="follower-one"] [data-slot="synchronizer-panel-hold"]', + ); + await app.waitFor( + async () => (await followerState("follower-one")).held === true, + { description: "the held-out follower" }, + ); + assert.deepEqual(await followerState("follower-one"), { + held: true, + badge: SYNC.stateHeld, + holdLabel: SYNC.rejoin, + }); + + // The layout the user picked is the layout the backend is asked for. + await stubCommand(app, "arrange_sync_windows", session); + await app.clickSelector('[data-slot="synchronizer-panel-layout"]'); + await app.clickText(SYNC.layout.cascade, { + roles: ["option", "menuitem", "button"], + }); + await app.waitFor( + async () => + (await app.execute( + `return document.querySelector('[data-slot="synchronizer-panel-layout"]').innerText.trim();`, + )) === SYNC.layout.cascade, + { description: "the chosen layout" }, + ); + await app.clickSelector('[data-slot="synchronizer-panel-arrange"]'); + await app.waitFor( + async () => + (await app.execute( + `return (window.__donutStubbedCalls ?? []).filter((call) => call.command === "arrange_sync_windows").length;`, + )) === 1, + { description: "the arrange request" }, + ); + const calls = await app.execute( + `return window.__donutStubbedCalls.map((call) => call.command + ":" + JSON.stringify(call.payload));`, + ); + const arrange = calls.find((call) => + call.startsWith("arrange_sync_windows"), + ); + assert.match(arrange, /"layout":"cascade"/); + assert.match(arrange, /"sessionId":"ui-panel-session"/); + await app.capture("synchronizer-panel-arranged"); + + await app.waitFor( + async () => { + await app.invoke("plugin:event|emit", { + event: "sync-session-ended", + payload: session.id, + }); + return ( + (await app.execute( + `return Boolean(document.querySelector(arguments[0]));`, + [panel], + )) === false + ); + }, + { description: "the panel leaving with the session" }, + ); + } finally { + await restoreStubs(app); + } + }); +}); diff --git a/package.json b/package.json index 8f70079..6c1bac4 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,17 @@ "prebuild": "pnpm licenses:generate", "build": "next build", "start": "next start", - "test": "pnpm test:themes && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:proxy-string && pnpm test:profile-search && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e", + "test": "pnpm test:themes && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:cookie-bot-outcomes && pnpm test:agent && pnpm test:backend-errors && pnpm test:i18n-parity && pnpm test:proxy-string && pnpm test:proxy-type && pnpm test:proxy-first-hop-claims && pnpm test:profile-search && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e", "test:themes": "node --test src/lib/themes.test.mjs", "test:window-decorations": "node --test src/lib/window-decorations.test.mjs", - "test:cookie-bot-limits": "node --test src/lib/cookie-bot-limits.test.mjs", + "test:cookie-bot-limits": "node --test src/lib/cookie-bot-limits.test.mjs src/lib/schedule-layout.test.mjs", + "test:cookie-bot-outcomes": "node --test src/lib/cookie-bot-outcomes.test.mjs", + "test:agent": "node --test src/lib/agent.test.mjs", + "test:backend-errors": "node --test src/lib/backend-errors.test.mjs", + "test:i18n-parity": "node --test src/lib/i18n-parity.test.mjs", "test:proxy-string": "node --test src/lib/proxy-string.test.mjs", + "test:proxy-type": "node --test src/lib/proxy-type.test.mjs", + "test:proxy-first-hop-claims": "node --test src/lib/proxy-first-hop-claims.test.mjs", "test:profile-search": "node --test src/lib/profile-search.test.mjs", "test:licenses": "node --test scripts/generate-licenses.test.mjs && node scripts/generate-licenses.mjs --check", "test:xray-packaging": "node --test src-tauri/download-xray.test.mjs", @@ -74,6 +80,7 @@ "@tauri-apps/plugin-log": "^2.9.0", "@tauri-apps/plugin-opener": "^2.5.4", "ahooks": "^3.9.7", + "aria-hidden": "1.2.6", "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae81e26..b114e97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,7 +11,7 @@ overrides: fast-xml-parser@<5.7.0: '>=5.7.2' fast-uri@<3.1.5: '>=3.1.5 <4' fast-xml-builder@<1.2.0: '>=1.2.0' - qs@>=6.11.1 <6.15.2: '>=6.15.2' + qs@<6.16.0: '>=6.16.0' js-cookie@<3.0.7: '>=3.0.7' nanoid@<3.3.17: '>=3.3.17 <4' fast-uri@>=4.0.0 <4.1.1: '>=4.1.1 <5' @@ -99,6 +99,9 @@ importers: ahooks: specifier: ^3.9.7 version: 3.9.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + aria-hidden: + specifier: 1.2.6 + version: 1.2.6 canvas-confetti: specifier: ^1.9.4 version: 1.9.4 @@ -4181,8 +4184,8 @@ packages: pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} - qs@6.15.3: - resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} radix-ui@1.6.7: @@ -7670,7 +7673,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.1 - qs: 6.15.3 + qs: 6.16.0 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: @@ -8087,7 +8090,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.3 + qs: 6.16.0 range-parser: 1.3.0 router: 2.2.0(supports-color@8.1.1) send: 1.2.1(supports-color@8.1.1) @@ -9158,7 +9161,7 @@ snapshots: pure-rand@7.0.1: {} - qs@6.15.3: + qs@6.16.0: dependencies: es-define-property: 1.0.1 side-channel: 1.1.1 @@ -9604,7 +9607,7 @@ snapshots: formidable: 3.5.4 methods: 1.1.2 mime: 2.6.0 - qs: 6.15.3 + qs: 6.16.0 transitivePeerDependencies: - supports-color diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 14e5177..09ad066 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -26,7 +26,7 @@ overrides: fast-xml-parser@<5.7.0: '>=5.7.2' fast-uri@<3.1.5: '>=3.1.5 <4' fast-xml-builder@<1.2.0: '>=1.2.0' - qs@>=6.11.1 <6.15.2: '>=6.15.2' + qs@<6.16.0: '>=6.16.0' js-cookie@<3.0.7: '>=3.0.7' nanoid@<3.3.17: '>=3.3.17 <4' fast-uri@>=4.0.0 <4.1.1: '>=4.1.1 <5' diff --git a/scripts/redact-sensitive-text.mjs b/scripts/redact-sensitive-text.mjs index dd9e027..fdf8fde 100644 --- a/scripts/redact-sensitive-text.mjs +++ b/scripts/redact-sensitive-text.mjs @@ -5,7 +5,11 @@ import { pathToFileURL } from "node:url"; const URL_PATTERN = /\b[a-z][a-z\d+.-]{1,20}:\/\/[^\s<>"'`]+/giu; const PRIVATE_KEY_PATTERN = /-----BEGIN [^-\r\n]*PRIVATE KEY-----[\s\S]*?-----END [^-\r\n]*PRIVATE KEY-----/giu; -const BEARER_PATTERN = /\bBearer\s+[A-Za-z\d._~+/=-]+/giu; +// Mirrors AUTH_SCHEME_RE in src-tauri/src/log_redaction.rs: schemes whose +// credential is a bare token after the scheme name, which the assignment +// pattern below cannot match because its value class stops at the space. +const AUTH_SCHEME_PATTERN = + /\b(Bearer|Basic|Token|Digest|Negotiate|NTLM)\s+[A-Za-z\d._~+/=-]+/giu; const SECRET_ASSIGNMENT_PATTERN = /\b(?:api[_-]?key|authorization|password|passwd|private[_-]?key|proxy[_-]?(?:password|username)|refresh[_-]?token|secret|token|username)\b\s*[:=]\s*[^\s,;]+/giu; const JWT_PATTERN = /\beyJ[A-Za-z\d_-]+\.[A-Za-z\d_-]+\.[A-Za-z\d_-]+\b/gu; @@ -66,7 +70,7 @@ export function redactSensitiveText(text, { sensitiveValues = [] } = {}) { return redacted .replace(PRIVATE_KEY_PATTERN, "") .replace(URL_PATTERN, safeUrlLabel) - .replace(BEARER_PATTERN, "Bearer ") + .replace(AUTH_SCHEME_PATTERN, "$1 ") .replace(SECRET_ASSIGNMENT_PATTERN, "") .replace(JWT_PATTERN, "") .replace(TOKEN_PATTERN, "") diff --git a/sdk/.gitignore b/sdk/.gitignore new file mode 100644 index 0000000..e04ab2c --- /dev/null +++ b/sdk/.gitignore @@ -0,0 +1,11 @@ +# Build and test artifacts for the two standalone SDK packages. Neither is part +# of the pnpm workspace, so they carry their own ignores rather than adding +# Python and npm noise to the repository root. +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.venv/ +build/ +dist/ +node_modules/ diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 0000000..9fd7acf --- /dev/null +++ b/sdk/README.md @@ -0,0 +1,285 @@ +# Donut Browser SDKs + +Two thin clients for the REST API that Donut Browser serves on this machine: +[`python/`](python) (`donutbrowser`) and [`node/`](node) (`@donutbrowser/sdk`). + +They are deliberately thin. Every method is one request to one path that the +app publishes in its own `/openapi.json`, with the request and response shapes +taken from the Rust handlers in `src-tauri/src/api_server.rs`. Nothing is +cached, nothing is retried, and no endpoint is invented. What the two add on top +of a bare HTTP call is the part that is tedious to redo in every script: + +- the bearer token and the port, read from arguments or the environment, +- one exception class per documented status, with `Retry-After` parsed and the + app's `{"code": ...}` error bodies decoded, +- a launch-and-stop helper, so a script cannot leave a browser running, +- a drift check that fails the tests when the app grows an endpoint the SDK + does not cover. + +Neither package is part of the pnpm workspace. They build, test and publish on +their own, so they never slow the desktop app's own checks down. + +## Switch the API on first + +**The local REST API is off by default. It must be enabled in the app under +Settings → Integrations → Local API → "Enable Local API Server".** + +That screen also shows the two things a client needs: + +- the **port**, `10108` unless it was already taken or you changed it, and +- the **authentication token**, sent as `Authorization: Bearer `. + +The server binds `127.0.0.1` only, so it is never reachable from another +machine. Requests are also refused with `403` until the Wayfern terms have been +accepted in the app. + +Both SDKs read arguments first, then the environment: + +| Setting | Argument | Environment | Default | +| --- | --- | --- | --- | +| Token | `token` | `DONUT_API_TOKEN` | none; required | +| Port | `port` | `DONUT_API_PORT` | `10108` | +| Host | `host` | — | `127.0.0.1` | + +`base_url` / `baseUrl` overrides host and port entirely, for the rare case of a +tunnel or a path prefix in front of the app. + +## Python + +Requires Python 3.10 or newer. **No runtime dependencies:** the client talks to +a loopback server on the same machine, so `http.client` from the standard +library is enough. That keeps `pip install donutbrowser` from dragging anything +into an automation environment, and it sidesteps a real trap — `urllib.request` +honours `http_proxy` from the environment, which would send calls meant for the +local app through whatever proxy the shell happens to have set. + +```bash +cd sdk/python +pip install -e . +``` + +A worked example: launch a profile, drive the page through the agent endpoints, +and stop the browser. + +```python +from donutbrowser import Conflict, DonutClient, NotFound, RateLimited + +PROFILE_ID = "your-profile-id" + +with DonutClient(token="...") as client: + # `run` starts the browser on entry and stops it on exit, even if the body + # raises. `session.cdp_url` is the DevTools endpoint the launch returned. + with client.run(PROFILE_ID, url="https://example.com", headless=True) as session: + print("CDP:", session.cdp_url) + + # Read the page the way the agent sees it: roles, names, text, bounds. + page = client.agent_perceive(PROFILE_ID, viewport_only=True) + print(page["stats"]["returnedNodes"], "nodes,", len(page["text"]), "characters") + + # Name an element without a selector, and check it is unambiguous. + search = {"role": "textbox", "nameContains": "Search"} + resolved = client.agent_resolve_locator(PROFILE_ID, locator=search) + assert resolved["matchCount"] == 1 + + client.agent_type(PROFILE_ID, locator=search, text="donut browser") + client.agent_click(PROFILE_ID, locator={"role": "button", "name": "Search"}) + + # Pull a table out of whatever came back. + rows = client.agent_extract( + PROFILE_ID, + container={"role": "listitem"}, + field_map=[ + {"key": "title", "locator": {"role": "heading"}, "source": "text"}, + {"key": "link", "locator": {"role": "link"}, "source": "link"}, + ], + max_pages=3, + ) + for row in rows["rows"]: + print(row["values"]) + # The browser is stopped here. +``` + +Errors are classes, not status codes: + +```python +try: + client.run_profile(PROFILE_ID) +except Conflict as busy: + print("someone else has it:", busy.code) # PROFILE_LOCKED_BY_MEMBER, ... +except RateLimited as limited: + print("wait", limited.retry_after, "seconds") +except NotFound: + print("no such profile") +``` + +### Tests + +```bash +cd sdk/python +pip install -e ".[dev]" +pytest +``` + +## Node + +Requires Node 22 or newer, for the built-in `fetch`. **No runtime +dependencies**; `typescript` is a development dependency and is needed only to +build `dist/` for publishing. The tests run straight from the TypeScript +sources through Node's own type stripping, so `npm test` works with nothing +installed at all. + +```bash +cd sdk/node +npm install # only needed for `npm run build` +npm run build +``` + +The convenience helper is `withProfile(profileId, options, work)`, a callback +rather than `await using`. `await using` is not yet syntax any released V8 +understands, so TypeScript has to down-level it — which would stop the sources +running under Node's type stripping, and with it `npm test` on a clean +checkout. The callback form works on every Node 22. A `RunSession` does also +implement `Symbol.asyncDispose`, so `await using` is there for anyone whose +toolchain already handles it. + +```ts +import { Conflict, DonutClient, NotFound, RateLimited } from "@donutbrowser/sdk"; + +const PROFILE_ID = "your-profile-id"; +const client = new DonutClient({ token: "..." }); + +// The browser starts before `work` runs and is stopped after it, even when it +// throws. `session.cdpUrl` is the DevTools endpoint the launch returned. +const titles = await client.withProfile( + PROFILE_ID, + { url: "https://example.com", headless: true }, + async (session) => { + console.log("CDP:", session.cdpUrl); + + const page = await client.agentPerceive(PROFILE_ID, { viewport_only: true }); + console.log(page.stats.returnedNodes, "nodes,", page.text.length, "characters"); + + const search = { role: "textbox", nameContains: "Search" }; + const resolved = await client.agentResolveLocator(PROFILE_ID, { locator: search }); + if (resolved.matchCount !== 1) { + throw new Error("the search box is ambiguous"); + } + + await client.agentType(PROFILE_ID, { locator: search, text: "donut browser" }); + await client.agentClick(PROFILE_ID, { + locator: { role: "button", name: "Search" }, + }); + + const extraction = await client.agentExtract(PROFILE_ID, { + container: { role: "listitem" }, + field_map: [ + { key: "title", locator: { role: "heading" }, source: "text" }, + { key: "link", locator: { role: "link" }, source: "link" }, + ], + max_pages: 3, + }); + return extraction.rows.map((row) => row.values.title); + }, +); +// The browser is stopped here. + +try { + await client.runProfile(PROFILE_ID); +} catch (error) { + if (error instanceof Conflict) { + console.log("someone else has it:", error.code); + } else if (error instanceof RateLimited) { + console.log("wait", error.retryAfter, "seconds"); + } else if (error instanceof NotFound) { + console.log("no such profile"); + } else { + throw error; + } +} +``` + +### Tests + +```bash +cd sdk/node +npm test +``` + +`npm test` runs the TypeScript sources directly, which needs Node 22.18 or +newer (type stripping is unflagged from that release). The published package +ships compiled `.mjs`, so consumers only need Node 22. + +## Errors + +Both packages map the app's documented statuses onto the same set of classes. +The 5xx classes share one base, so a single `ServerError` branch catches every +server-side failure. + +| Status | Python | Node | Meaning | +| ---: | --- | --- | --- | +| 400 | `ValidationError` | `ValidationError` | Malformed request, duplicate name, unsupported input | +| 401 | `Unauthorized` | `Unauthorized` | Missing or wrong bearer token | +| 402 | `PaymentRequired` | `PaymentRequired` | Automation needs an active paid plan | +| 403 | `Forbidden` | `Forbidden` | Wayfern terms not accepted, or not signed in | +| 404 | `NotFound` | `NotFound` | No entity with that id | +| 408 | `RequestTimeout` | `RequestTimeout` | `agent/pick` waited and nothing was picked | +| 409 | `Conflict` | `Conflict` | A browser, a teammate or a remote session holds the profile | +| 429 | `RateLimited` | `RateLimited` | Automation quota spent; `retry_after` / `retryAfter` | +| 500 | `ServerError` | `ServerError` | Internal failure | +| 502 | `BadGateway` | `BadGateway` | The browser or the relay answered wrongly | +| 503 | `ServiceUnavailable` | `ServiceUnavailable` | Cloud, fleet or lock service unreachable | + +Anything else becomes `DonutAPIError` / `DonutApiError` (a `ServerError` for an +unrecognised 5xx), so a status added to the app later still arrives as +something a caller can catch. A transport failure — the app not running, the +API switched off, the wrong port — is `DonutConnectionError`, never an API +error, so "Donut is not there" is never confused with "Donut said no". + +Every error carries `status`, `body`, `method` and `path`. When the body is one +of the app's structured `{"code": ..., "params": {...}}` strings, `code` and +`params` are filled in too. + +A `503` from stopping something means the fleet could not be reached and the +remote browser is **still running**, not that it stopped. + +## Staying in step with the app + +`api-paths.json` in this directory lists every operation the app publishes. It +is generated from the `#[utoipa::path]` annotations and the `ApiDoc` `paths(...)` +list in `src-tauri/src/api_server.rs` — the two things the served +`/openapi.json` is actually built from — and the generator fails if a handler is +annotated but missing from `ApiDoc`, which is exactly how an endpoint silently +disappears from the spec. + +```bash +python3 sdk/tools/extract-api-paths.py +``` + +Each SDK keeps its own table of operation to method (`donutbrowser.coverage` and +`OPERATIONS` in the Node package), and both test suites hold that table against +the snapshot in **both** directions: + +- an operation in the snapshot that the SDK neither wraps nor lists as omitted + fails the suite, so a new endpoint cannot slip past unnoticed; +- an entry the app no longer publishes fails too, so a removed endpoint cannot + linger as a dead method; +- every wrapped operation must name a method that really exists, no two + operations may claim the same method, and every omission must carry a reason. + +On top of that, one parameterised test per method drives it against a fake +server and asserts the exact verb, path, query string and JSON body it sends. +That is what ties the table to reality rather than to a comment. + +Of the 71 published operations, 70 are wrapped. The one omission: + +- `GET /v1/remote-sessions/{id}/cdp` is a WebSocket upgrade, not a request an + HTTP client can make, and bundling a websocket implementation would end the + zero-dependency promise for one endpoint. `remote_session_cdp_url()` / + `remoteSessionCdpUrl()` builds the `ws://` address instead, so a websocket + library of your choosing can connect — send the same `Authorization: Bearer` + header on the handshake. + +## Tests + +Both suites run offline against a fake HTTP server on an ephemeral loopback +port. Neither needs the desktop app, a browser, a network, or credentials. diff --git a/sdk/api-paths.json b/sdk/api-paths.json new file mode 100644 index 0000000..b684e38 --- /dev/null +++ b/sdk/api-paths.json @@ -0,0 +1,363 @@ +{ + "source": "src-tauri/src/api_server.rs", + "regenerate_with": "python3 sdk/tools/extract-api-paths.py", + "description": "Every operation the desktop app publishes in its /openapi.json. The SDK test suites assert this list and their own coverage tables match exactly, so an endpoint added to the app fails the SDK tests until it is either wrapped or deliberately listed as omitted.", + "operation_count": 71, + "operations": [ + { + "operation_id": "download_browser_api", + "method": "POST", + "path": "/v1/browsers/download" + }, + { + "operation_id": "get_browser_versions", + "method": "GET", + "path": "/v1/browsers/{browser}/versions" + }, + { + "operation_id": "check_browser_downloaded", + "method": "GET", + "path": "/v1/browsers/{browser}/versions/{version}/downloaded" + }, + { + "operation_id": "get_cookie_bot_conflicts", + "method": "GET", + "path": "/v1/cookie-bot/conflicts" + }, + { + "operation_id": "list_cookie_bot_presets", + "method": "GET", + "path": "/v1/cookie-bot/presets" + }, + { + "operation_id": "list_cookie_bot_runs", + "method": "GET", + "path": "/v1/cookie-bot/runs" + }, + { + "operation_id": "start_cookie_bot_run", + "method": "POST", + "path": "/v1/cookie-bot/runs" + }, + { + "operation_id": "cancel_cookie_bot_run", + "method": "DELETE", + "path": "/v1/cookie-bot/runs/{run_id}" + }, + { + "operation_id": "list_cookie_bot_schedules", + "method": "GET", + "path": "/v1/cookie-bot/schedules" + }, + { + "operation_id": "delete_cookie_bot_schedule", + "method": "DELETE", + "path": "/v1/cookie-bot/schedules/{profile_id}" + }, + { + "operation_id": "get_cookie_bot_schedule", + "method": "GET", + "path": "/v1/cookie-bot/schedules/{profile_id}" + }, + { + "operation_id": "set_cookie_bot_schedule", + "method": "PUT", + "path": "/v1/cookie-bot/schedules/{profile_id}" + }, + { + "operation_id": "get_cookie_bot_usage", + "method": "GET", + "path": "/v1/cookie-bot/usage" + }, + { + "operation_id": "get_extension_groups", + "method": "GET", + "path": "/v1/extension-groups" + }, + { + "operation_id": "create_extension_group_api", + "method": "POST", + "path": "/v1/extension-groups" + }, + { + "operation_id": "delete_extension_group_api", + "method": "DELETE", + "path": "/v1/extension-groups/{id}" + }, + { + "operation_id": "get_extension_group_api", + "method": "GET", + "path": "/v1/extension-groups/{id}" + }, + { + "operation_id": "update_extension_group_api", + "method": "PUT", + "path": "/v1/extension-groups/{id}" + }, + { + "operation_id": "remove_extension_from_group_api", + "method": "DELETE", + "path": "/v1/extension-groups/{id}/extensions/{extension_id}" + }, + { + "operation_id": "add_extension_to_group_api", + "method": "POST", + "path": "/v1/extension-groups/{id}/extensions/{extension_id}" + }, + { + "operation_id": "get_extensions", + "method": "GET", + "path": "/v1/extensions" + }, + { + "operation_id": "create_extension_api", + "method": "POST", + "path": "/v1/extensions" + }, + { + "operation_id": "delete_extension_api", + "method": "DELETE", + "path": "/v1/extensions/{id}" + }, + { + "operation_id": "get_extension_api", + "method": "GET", + "path": "/v1/extensions/{id}" + }, + { + "operation_id": "update_extension_api", + "method": "PUT", + "path": "/v1/extensions/{id}" + }, + { + "operation_id": "get_groups", + "method": "GET", + "path": "/v1/groups" + }, + { + "operation_id": "create_group", + "method": "POST", + "path": "/v1/groups" + }, + { + "operation_id": "delete_group", + "method": "DELETE", + "path": "/v1/groups/{id}" + }, + { + "operation_id": "get_group", + "method": "GET", + "path": "/v1/groups/{id}" + }, + { + "operation_id": "update_group", + "method": "PUT", + "path": "/v1/groups/{id}" + }, + { + "operation_id": "get_profiles", + "method": "GET", + "path": "/v1/profiles" + }, + { + "operation_id": "create_profile", + "method": "POST", + "path": "/v1/profiles" + }, + { + "operation_id": "batch_run_profiles", + "method": "POST", + "path": "/v1/profiles/batch/run" + }, + { + "operation_id": "batch_stop_profiles", + "method": "POST", + "path": "/v1/profiles/batch/stop" + }, + { + "operation_id": "distribute_proxies", + "method": "POST", + "path": "/v1/profiles/distribute-proxies" + }, + { + "operation_id": "import_profiles_api", + "method": "POST", + "path": "/v1/profiles/import" + }, + { + "operation_id": "detect_import_profiles", + "method": "GET", + "path": "/v1/profiles/import/detect" + }, + { + "operation_id": "delete_profile", + "method": "DELETE", + "path": "/v1/profiles/{id}" + }, + { + "operation_id": "get_profile", + "method": "GET", + "path": "/v1/profiles/{id}" + }, + { + "operation_id": "update_profile", + "method": "PUT", + "path": "/v1/profiles/{id}" + }, + { + "operation_id": "agent_click_api", + "method": "POST", + "path": "/v1/profiles/{id}/agent/click" + }, + { + "operation_id": "agent_extract_api", + "method": "POST", + "path": "/v1/profiles/{id}/agent/extract" + }, + { + "operation_id": "agent_perceive_api", + "method": "POST", + "path": "/v1/profiles/{id}/agent/perceive" + }, + { + "operation_id": "agent_pick_api", + "method": "POST", + "path": "/v1/profiles/{id}/agent/pick" + }, + { + "operation_id": "agent_resolve_locator_api", + "method": "POST", + "path": "/v1/profiles/{id}/agent/resolve-locator" + }, + { + "operation_id": "agent_type_api", + "method": "POST", + "path": "/v1/profiles/{id}/agent/type" + }, + { + "operation_id": "set_profile_cloud_sync", + "method": "POST", + "path": "/v1/profiles/{id}/cloud-sync" + }, + { + "operation_id": "import_profile_cookies", + "method": "POST", + "path": "/v1/profiles/{id}/cookies/import" + }, + { + "operation_id": "kill_profile", + "method": "POST", + "path": "/v1/profiles/{id}/kill" + }, + { + "operation_id": "open_url_in_profile", + "method": "POST", + "path": "/v1/profiles/{id}/open-url" + }, + { + "operation_id": "run_profile", + "method": "POST", + "path": "/v1/profiles/{id}/run" + }, + { + "operation_id": "run_profile_remote", + "method": "POST", + "path": "/v1/profiles/{id}/run-remote" + }, + { + "operation_id": "get_proxies", + "method": "GET", + "path": "/v1/proxies" + }, + { + "operation_id": "create_proxy", + "method": "POST", + "path": "/v1/proxies" + }, + { + "operation_id": "import_proxies_api", + "method": "POST", + "path": "/v1/proxies/import" + }, + { + "operation_id": "delete_proxy", + "method": "DELETE", + "path": "/v1/proxies/{id}" + }, + { + "operation_id": "get_proxy", + "method": "GET", + "path": "/v1/proxies/{id}" + }, + { + "operation_id": "update_proxy", + "method": "PUT", + "path": "/v1/proxies/{id}" + }, + { + "operation_id": "get_remote_hours", + "method": "GET", + "path": "/v1/remote-hours" + }, + { + "operation_id": "list_remote_sessions_api", + "method": "GET", + "path": "/v1/remote-sessions" + }, + { + "operation_id": "stop_remote_session", + "method": "DELETE", + "path": "/v1/remote-sessions/{id}" + }, + { + "operation_id": "get_remote_session_api", + "method": "GET", + "path": "/v1/remote-sessions/{id}" + }, + { + "operation_id": "remote_session_cdp", + "method": "GET", + "path": "/v1/remote-sessions/{id}/cdp" + }, + { + "operation_id": "get_tags", + "method": "GET", + "path": "/v1/tags" + }, + { + "operation_id": "get_vpns", + "method": "GET", + "path": "/v1/vpns" + }, + { + "operation_id": "create_vpn", + "method": "POST", + "path": "/v1/vpns" + }, + { + "operation_id": "import_vpn", + "method": "POST", + "path": "/v1/vpns/import" + }, + { + "operation_id": "delete_vpn", + "method": "DELETE", + "path": "/v1/vpns/{id}" + }, + { + "operation_id": "get_vpn", + "method": "GET", + "path": "/v1/vpns/{id}" + }, + { + "operation_id": "update_vpn", + "method": "PUT", + "path": "/v1/vpns/{id}" + }, + { + "operation_id": "export_vpn", + "method": "GET", + "path": "/v1/vpns/{id}/export" + } + ] +} diff --git a/sdk/node/package.json b/sdk/node/package.json new file mode 100644 index 0000000..3cd3f35 --- /dev/null +++ b/sdk/node/package.json @@ -0,0 +1,41 @@ +{ + "name": "@donutbrowser/sdk", + "version": "0.1.0", + "description": "Thin client for the Donut Browser local REST API", + "license": "AGPL-3.0", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + } + }, + "files": [ + "dist", + "README.md" + ], + "engines": { + "node": ">=22" + }, + "scripts": { + "test": "node --test test/*.test.mts", + "build": "tsc", + "typecheck": "tsc --noEmit", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "donut-browser", + "browser-automation", + "anti-detect", + "cdp" + ], + "homepage": "https://donutbrowser.com", + "repository": { + "type": "git", + "url": "git+https://github.com/zhom/donutbrowser.git", + "directory": "sdk/node" + }, + "devDependencies": { + "typescript": "^5.9.0" + } +} diff --git a/sdk/node/src/client.mts b/sdk/node/src/client.mts new file mode 100644 index 0000000..3c0ecc9 --- /dev/null +++ b/sdk/node/src/client.mts @@ -0,0 +1,1083 @@ +/** + * A thin client for the Donut Browser local REST API. + * + * Every method here is one request to one documented path. Nothing is cached, + * nothing is retried, and nothing is invented: if a method exists below, the + * app publishes that operation in its `/openapi.json`. + * + * No runtime dependencies. See `sdk/README.md`. + */ + +import { DonutConnectionError, DonutError, errorForStatus } from "./errors.mts"; +import type { + AgentClick, + AgentTyping, + ApiGroupResponse, + ApiProfileResponse, + ApiProfilesResponse, + ApiProxyResponse, + ApiRemoteSessionsResponse, + ApiVpnExportResponse, + ApiVpnResponse, + BatchRunResponse, + BatchStopResponse, + CookieBotConflictCheck, + CookieBotPresetList, + CookieBotRun, + CookieBotRunPage, + CookieBotRunStarted, + CookieBotSchedule, + CookieBotScheduleDeleted, + CookieBotScheduleList, + CookieBotScheduleSaved, + CookieBotUsage, + DetectedProfilesResponse, + DistributeProxiesResponse, + DownloadBrowserResponse, + Extension, + ExtensionGroup, + Extraction, + ExtractionField, + ImportCookiesResponse, + ImportProfileItem, + ImportProxiesResponse, + LocatorDescription, + LocatorResolution, + PerceptionPage, + PickedElement, + ProfileImportBatchResult, + ProxyPair, + ProxySettings, + RemoteHoursQuota, + RemoteSessionState, + RunProfileResponse, + RunRemoteResponse, + SetCloudSyncResponse, + StopRemoteResponse, + WayfernConfig, +} from "./types.mts"; + +/** The port the app offers by default in Settings, Integrations, Local API. */ +export const DEFAULT_PORT = 10108; + +/** The API binds loopback only. It is never reachable from another machine. */ +export const DEFAULT_HOST = "127.0.0.1"; + +const JSON_TYPE = "application/json"; + +export interface DonutClientOptions { + /** Overrides `host` and `port` entirely. */ + baseUrl?: string; + /** Falls back to `DONUT_API_TOKEN`. */ + token?: string; + /** Falls back to `DONUT_API_PORT`, then to `10108`. */ + port?: number; + host?: string; + /** Per-request timeout in milliseconds. Defaults to 30000. */ + timeoutMs?: number; + /** Where to read the fallbacks from. Defaults to `process.env`. */ + env?: Record; + /** Swappable for tests. Defaults to the global `fetch`. */ + fetch?: typeof fetch; +} + +export interface RunProfileOptions { + url?: string; + headless?: boolean; +} + +/** Drop every property the caller left `undefined`. */ +function body(fields: Record): Record { + const result: Record = {}; + for (const [name, value] of Object.entries(fields)) { + if (value !== undefined) { + result[name] = value; + } + } + return result; +} + +function query(fields: Record): URLSearchParams { + const params = new URLSearchParams(); + for (const [name, value] of Object.entries(fields)) { + if (value !== undefined) { + params.set(name, String(value)); + } + } + return params; +} + +/** Escape one path segment so an id with a slash cannot forge a path. */ +function segment(value: string): string { + return encodeURIComponent(value); +} + +/** + * A connection to one running Donut Browser. + * + * The local API must be switched on first: **Settings, Integrations, Local + * API, "Enable Local API Server"**. That screen shows the port and the + * authentication token to use here. + */ +export class DonutClient { + readonly baseUrl: string; + readonly host: string; + readonly port: number; + readonly token: string; + readonly timeoutMs: number; + + #prefix: string; + #scheme: string; + #fetch: typeof fetch; + + constructor(options: DonutClientOptions = {}) { + // Reached without `@types/node`, so the package stays dependency-free even + // for its own type-check. + const ambient = globalThis as { process?: { env?: Record } }; + const env = options.env ?? ambient.process?.env ?? {}; + + const token = options.token ?? env.DONUT_API_TOKEN; + if (!token) { + throw new DonutError( + "No API token. Pass { token }, or set DONUT_API_TOKEN. The token is shown " + + "in the app under Settings, Integrations, Local API.", + ); + } + + if (options.baseUrl) { + const raw = options.baseUrl.includes("//") + ? options.baseUrl + : `http://${options.baseUrl}`; + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw new DonutError(`baseUrl is not a URL: ${options.baseUrl}`); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new DonutError(`baseUrl must be http or https, got ${parsed.protocol}`); + } + this.#scheme = parsed.protocol.replace(":", ""); + this.host = parsed.hostname; + this.port = Number(parsed.port || (this.#scheme === "https" ? 443 : 80)); + this.#prefix = parsed.pathname.replace(/\/+$/, ""); + } else { + let port = options.port; + if (port === undefined && env.DONUT_API_PORT) { + port = Number.parseInt(env.DONUT_API_PORT, 10); + if (!Number.isFinite(port)) { + throw new DonutError(`DONUT_API_PORT is not a number: ${env.DONUT_API_PORT}`); + } + } + this.#scheme = "http"; + this.host = options.host ?? DEFAULT_HOST; + this.port = port ?? DEFAULT_PORT; + this.#prefix = ""; + } + + this.token = token; + this.timeoutMs = options.timeoutMs ?? 30_000; + this.baseUrl = `${this.#scheme}://${this.host}:${this.port}${this.#prefix}`; + this.#fetch = options.fetch ?? globalThis.fetch; + } + + // ------------------------------------------------------------------ + // Transport + // ------------------------------------------------------------------ + + async #request( + method: string, + path: string, + payload?: unknown, + search?: URLSearchParams, + ): Promise { + const encoded = search === undefined ? "" : search.toString(); + const suffix = encoded === "" ? "" : `?${encoded}`; + const url = `${this.baseUrl}${path}${suffix}`; + + const headers: Record = { + Authorization: `Bearer ${this.token}`, + Accept: JSON_TYPE, + }; + if (payload !== undefined) { + headers["Content-Type"] = JSON_TYPE; + } + + let response: Response; + try { + response = await this.#fetch(url, { + method, + headers, + body: payload === undefined ? undefined : JSON.stringify(payload), + signal: AbortSignal.timeout(this.timeoutMs), + }); + } catch (cause) { + throw new DonutConnectionError( + `Could not reach Donut Browser at ${this.baseUrl} (${method} ${path}): ` + + `${cause instanceof Error ? cause.message : String(cause)}. Is the app ` + + "running with Settings, Integrations, Local API switched on?", + { cause }, + ); + } + + const text = await response.text(); + if (!response.ok) { + throw errorForStatus(response.status, text, { + method, + path, + headers: response.headers, + }); + } + if (response.status === 204 || text.trim() === "") { + return undefined as T; + } + try { + return JSON.parse(text) as T; + } catch (cause) { + throw new DonutError( + `${method} ${path} answered ${response.status} with a body that is not ` + + `JSON: ${text.slice(0, 200)}`, + { cause }, + ); + } + } + + // ------------------------------------------------------------------ + // Profiles + // ------------------------------------------------------------------ + + /** GET /v1/profiles */ + listProfiles(): Promise { + return this.#request("GET", "/v1/profiles"); + } + + /** GET /v1/profiles/{id} */ + getProfile(profileId: string): Promise { + return this.#request("GET", `/v1/profiles/${segment(profileId)}`); + } + + /** + * POST /v1/profiles + * + * `browser` must be `"wayfern"`; anything else is refused with 400. + * `version` must already be downloaded, so omit it (or pass `"latest"`) to + * take the newest local build. + */ + createProfile(request: { + name: string; + browser: string; + version?: string; + /** Omit, or pass `""`, for a profile with no proxy. Excludes `vpnId`. */ + proxy_id?: string; + /** Omit, or pass `""`, for a profile with no VPN. Excludes `proxyId`. */ + vpn_id?: string; + launch_hook?: string; + release_type?: string; + wayfern_config?: WayfernConfig; + group_id?: string; + tags?: string[]; + ephemeral?: boolean; + temporary?: boolean; + }): Promise { + return this.#request("POST", "/v1/profiles", body({ ...request })); + } + + /** + * PUT /v1/profiles/{id} + * + * A profile's browser engine is fixed at creation, so there is no `browser` + * property. Pass `proxy_id: ""` or `vpn_id: ""` to detach one; leaving + * either out changes nothing. + */ + updateProfile( + profileId: string, + request: { + name?: string; + version?: string; + proxy_id?: string; + vpn_id?: string; + launch_hook?: string; + release_type?: string; + group_id?: string; + tags?: string[]; + extension_group_id?: string; + proxy_bypass_rules?: string[]; + /** `"Disabled"`, `"Regular"` or `"Encrypted"`. */ + sync_mode?: string; + clear_on_close?: boolean; + }, + ): Promise { + return this.#request("PUT", `/v1/profiles/${segment(profileId)}`, body({ ...request })); + } + + /** DELETE /v1/profiles/{id} */ + deleteProfile(profileId: string): Promise { + return this.#request("DELETE", `/v1/profiles/${segment(profileId)}`); + } + + /** + * POST /v1/profiles/{id}/run + * + * Prefer {@link withProfile}, which stops the browser again afterwards. + */ + runProfile(profileId: string, options: RunProfileOptions = {}): Promise { + return this.#request( + "POST", + `/v1/profiles/${segment(profileId)}/run`, + body({ url: options.url, headless: options.headless }), + ); + } + + /** POST /v1/profiles/{id}/run-remote */ + runProfileRemote(profileId: string, options: { url?: string } = {}): Promise { + return this.#request( + "POST", + `/v1/profiles/${segment(profileId)}/run-remote`, + body({ url: options.url }), + ); + } + + /** + * POST /v1/profiles/{id}/cloud-sync + * + * `mode` is `"Disabled"`, `"Regular"` or `"Encrypted"`. An encrypted profile + * cannot be launched remotely: its key never leaves this machine, so a + * remote host would download ciphertext. + */ + setProfileCloudSync(profileId: string, mode: string): Promise { + return this.#request("POST", `/v1/profiles/${segment(profileId)}/cloud-sync`, { mode }); + } + + /** POST /v1/profiles/{id}/open-url */ + openUrl(profileId: string, url: string): Promise { + return this.#request("POST", `/v1/profiles/${segment(profileId)}/open-url`, { url }); + } + + /** + * POST /v1/profiles/{id}/kill + * + * A 503 here means the fleet could not be reached and the remote browser is + * *still running*, not that it stopped. + */ + killProfile(profileId: string): Promise { + return this.#request("POST", `/v1/profiles/${segment(profileId)}/kill`); + } + + /** + * POST /v1/profiles/batch/run + * + * Answers 200 even when some profiles failed; read `results[].ok`. + */ + batchRunProfiles( + profileIds: string[], + options: RunProfileOptions = {}, + ): Promise { + return this.#request( + "POST", + "/v1/profiles/batch/run", + body({ profile_ids: profileIds, url: options.url, headless: options.headless }), + ); + } + + /** POST /v1/profiles/batch/stop */ + batchStopProfiles(profileIds: string[]): Promise { + return this.#request("POST", "/v1/profiles/batch/stop", { profile_ids: profileIds }); + } + + /** + * GET /v1/profiles/import/detect + * + * Without `folder` the app scans the default browser locations. + */ + detectImportProfiles(options: { folder?: string } = {}): Promise { + return this.#request( + "GET", + "/v1/profiles/import/detect", + undefined, + query({ folder: options.folder }), + ); + } + + /** + * POST /v1/profiles/import + * + * `duplicate_strategy` is `"skip"` or `"rename"` (the default). Each item is + * isolated: one failure does not stop the rest. + */ + importProfiles( + items: ImportProfileItem[], + options: { + group_id?: string; + duplicate_strategy?: "skip" | "rename"; + wayfern_config?: WayfernConfig; + } = {}, + ): Promise { + return this.#request("POST", "/v1/profiles/import", body({ items, ...options })); + } + + /** + * POST /v1/profiles/{id}/cookies/import + * + * `content` is a raw cookie file. The format is detected: a JSON array in + * the Puppeteer style, or a Netscape `cookies.txt`. + */ + importProfileCookies(profileId: string, content: string): Promise { + return this.#request("POST", `/v1/profiles/${segment(profileId)}/cookies/import`, { + content, + }); + } + + /** + * POST /v1/profiles/distribute-proxies + * + * Applies one proxy per profile. Configuration rather than automation, so it + * costs no automation quota. Answers 200 even when some pairs failed: read + * `results[].ok`, and note that a profile whose browser is running is refused + * rather than moved. + */ + distributeProxies(pairs: ProxyPair[]): Promise { + return this.#request("POST", "/v1/profiles/distribute-proxies", { pairs }); + } + + // ------------------------------------------------------------------ + // Agent: reading and driving a running profile + // ------------------------------------------------------------------ + + /** + * POST /v1/profiles/{id}/agent/perceive + * + * When the answer says `truncated`, pass its `cursor` back to continue where + * it stopped. + */ + agentPerceive( + profileId: string, + request: { + /** Total byte cap across cursor pages. Default 1 MiB, ceiling 4 MiB. */ + max_bytes?: number; + /** Capture budget in ms. Default 5000, clamped to [100, 60000]. */ + budget_ms?: number; + max_nodes?: number; + include_text?: boolean; + viewport_only?: boolean; + /** `"reading"` (default) or `"visual"`. */ + text_order?: string; + cursor?: string; + } = {}, + ): Promise { + return this.#request( + "POST", + `/v1/profiles/${segment(profileId)}/agent/perceive`, + body({ ...request }), + ); + } + + /** + * POST /v1/profiles/{id}/agent/resolve-locator + * + * Succeeds only when the locator matches exactly one element. + */ + agentResolveLocator( + profileId: string, + request: { locator: LocatorDescription; candidate_limit?: number }, + ): Promise { + return this.#request( + "POST", + `/v1/profiles/${segment(profileId)}/agent/resolve-locator`, + body({ ...request }), + ); + } + + /** + * POST /v1/profiles/{id}/agent/click + * + * `button` is `"left"` (the default), `"middle"`, `"right"`, `"back"` or + * `"forward"`. + */ + agentClick( + profileId: string, + request: { locator: LocatorDescription; button?: string; click_count?: number }, + ): Promise { + return this.#request( + "POST", + `/v1/profiles/${segment(profileId)}/agent/click`, + body({ ...request }), + ); + } + + /** + * POST /v1/profiles/{id}/agent/type + * + * `wpm` is honoured by the fallback engine only; a recent Wayfern types at + * the profile's own rhythm. + */ + agentType( + profileId: string, + request: { + locator: LocatorDescription; + text: string; + /** Empty the field first. Default true. */ + clear_first?: boolean; + /** Mistype and correct a few characters, as a hand does. Default true. */ + typos?: boolean; + wpm?: number; + }, + ): Promise { + return this.#request( + "POST", + `/v1/profiles/${segment(profileId)}/agent/type`, + body({ ...request }), + ); + } + + /** + * POST /v1/profiles/{id}/agent/extract + * + * A container that matches nothing is a result with `stopReason` set to + * `"no-container"`, not an error. + */ + agentExtract( + profileId: string, + request: { + container: LocatorDescription; + field_map: ExtractionField[]; + next_page?: LocatorDescription; + max_pages?: number; + max_rows?: number; + max_bytes?: number; + max_nodes?: number; + time_budget_ms?: number; + }, + ): Promise { + return this.#request( + "POST", + `/v1/profiles/${segment(profileId)}/agent/extract`, + body({ ...request }), + ); + } + + /** + * POST /v1/profiles/{id}/agent/pick + * + * Arms a picker in the visible browser and waits for a human to click + * something. Nothing picked inside `timeout_ms` throws `RequestTimeout`. + */ + agentPick(profileId: string, request: { timeout_ms?: number } = {}): Promise { + return this.#request( + "POST", + `/v1/profiles/${segment(profileId)}/agent/pick`, + body({ ...request }), + ); + } + + // ------------------------------------------------------------------ + // Remote sessions + // ------------------------------------------------------------------ + + /** GET /v1/remote-sessions */ + listRemoteSessions(): Promise { + return this.#request("GET", "/v1/remote-sessions"); + } + + /** GET /v1/remote-sessions/{id} */ + getRemoteSession(sessionId: string): Promise { + return this.#request("GET", `/v1/remote-sessions/${segment(sessionId)}`); + } + + /** DELETE /v1/remote-sessions/{id} */ + stopRemoteSession(sessionId: string): Promise { + return this.#request("DELETE", `/v1/remote-sessions/${segment(sessionId)}`); + } + + /** + * The websocket address of `GET /v1/remote-sessions/{id}/cdp`. + * + * That path is a WebSocket upgrade, not a request `fetch` can make, so this + * builds the address and leaves the socket to a websocket library. Send the + * same `Authorization: Bearer` header on the handshake. + */ + remoteSessionCdpUrl(sessionId: string): string { + const scheme = this.#scheme === "https" ? "wss" : "ws"; + return `${scheme}://${this.host}:${this.port}${this.#prefix}/v1/remote-sessions/${segment( + sessionId, + )}/cdp`; + } + + /** GET /v1/remote-hours */ + getRemoteHours(): Promise { + return this.#request("GET", "/v1/remote-hours"); + } + + // ------------------------------------------------------------------ + // Cookie bot + // ------------------------------------------------------------------ + + /** + * GET /v1/cookie-bot/schedules + * + * `scope` is `"mine"` (the default) or `"team"`. + */ + listCookieBotSchedules(options: { scope?: string } = {}): Promise { + return this.#request( + "GET", + "/v1/cookie-bot/schedules", + undefined, + query({ scope: options.scope }), + ); + } + + /** GET /v1/cookie-bot/schedules/{profile_id} */ + getCookieBotSchedule(profileId: string): Promise { + return this.#request("GET", `/v1/cookie-bot/schedules/${segment(profileId)}`); + } + + /** + * PUT /v1/cookie-bot/schedules/{profile_id} + * + * `run_at_minute` is minutes past local midnight (0 to 1439) and `days_mask` + * is a weekday bitmask with bit 0 as Monday. A teammate already enrolling + * this profile makes the write 409 until `acknowledge_conflict` is true. + */ + setCookieBotSchedule( + profileId: string, + request: { + enabled: boolean; + run_at_minute: number; + days_mask: number; + timezone: string; + /** Server-issued preset id from `listCookieBotPresets`. */ + preset: string; + max_minutes: number; + profile_name?: string; + platform?: string; + /** Absolute http(s) URLs to browse. The bot visits only these. */ + sites?: string[]; + jitter_seconds?: number; + acknowledge_conflict?: boolean; + }, + ): Promise { + return this.#request( + "PUT", + `/v1/cookie-bot/schedules/${segment(profileId)}`, + body({ ...request }), + ); + } + + /** DELETE /v1/cookie-bot/schedules/{profile_id} */ + deleteCookieBotSchedule(profileId: string): Promise { + return this.#request("DELETE", `/v1/cookie-bot/schedules/${segment(profileId)}`); + } + + /** + * GET /v1/cookie-bot/conflicts + * + * A dry run: asks who else enrols this profile, without writing. + */ + getCookieBotConflicts( + profileId: string, + options: { run_at_minute?: number; timezone?: string; days_mask?: number } = {}, + ): Promise { + return this.#request( + "GET", + "/v1/cookie-bot/conflicts", + undefined, + query({ profile_id: profileId, ...options }), + ); + } + + /** + * GET /v1/cookie-bot/runs + * + * Newest first. `before` is the `next_before` of the previous page. + */ + listCookieBotRuns( + options: { profile_id?: string; scope?: string; limit?: number; before?: string } = {}, + ): Promise { + return this.#request("GET", "/v1/cookie-bot/runs", undefined, query({ ...options })); + } + + /** + * POST /v1/cookie-bot/runs + * + * Answers 202: the run keeps going for minutes after this resolves. The + * profile must already have a schedule, which is where the preset and the + * site list live. + */ + startCookieBotRun(request: { + profile_id: string; + max_minutes?: number; + }): Promise { + return this.#request("POST", "/v1/cookie-bot/runs", body({ ...request })); + } + + /** DELETE /v1/cookie-bot/runs/{run_id} */ + cancelCookieBotRun(runId: string): Promise { + return this.#request("DELETE", `/v1/cookie-bot/runs/${segment(runId)}`); + } + + /** GET /v1/cookie-bot/presets */ + listCookieBotPresets(): Promise { + return this.#request("GET", "/v1/cookie-bot/presets"); + } + + /** + * GET /v1/cookie-bot/usage + * + * `period` is `YYYY-MM`, defaulting to the current UTC month. + */ + getCookieBotUsage(options: { period?: string } = {}): Promise { + return this.#request("GET", "/v1/cookie-bot/usage", undefined, query({ ...options })); + } + + // ------------------------------------------------------------------ + // Groups and tags + // ------------------------------------------------------------------ + + /** GET /v1/groups */ + listGroups(): Promise { + return this.#request("GET", "/v1/groups"); + } + + /** GET /v1/groups/{id} */ + getGroup(groupId: string): Promise { + return this.#request("GET", `/v1/groups/${segment(groupId)}`); + } + + /** POST /v1/groups */ + createGroup(name: string): Promise { + return this.#request("POST", "/v1/groups", { name }); + } + + /** PUT /v1/groups/{id} */ + updateGroup(groupId: string, name: string): Promise { + return this.#request("PUT", `/v1/groups/${segment(groupId)}`, { name }); + } + + /** DELETE /v1/groups/{id} */ + deleteGroup(groupId: string): Promise { + return this.#request("DELETE", `/v1/groups/${segment(groupId)}`); + } + + /** GET /v1/tags */ + listTags(): Promise { + return this.#request("GET", "/v1/tags"); + } + + // ------------------------------------------------------------------ + // Proxies + // ------------------------------------------------------------------ + + /** GET /v1/proxies */ + listProxies(): Promise { + return this.#request("GET", "/v1/proxies"); + } + + /** GET /v1/proxies/{id} */ + getProxy(proxyId: string): Promise { + return this.#request("GET", `/v1/proxies/${segment(proxyId)}`); + } + + /** POST /v1/proxies */ + createProxy(request: { + name: string; + proxy_settings: ProxySettings; + }): Promise { + return this.#request("POST", "/v1/proxies", { ...request }); + } + + /** PUT /v1/proxies/{id} */ + updateProxy( + proxyId: string, + request: { name?: string; proxy_settings?: ProxySettings }, + ): Promise { + return this.#request("PUT", `/v1/proxies/${segment(proxyId)}`, body({ ...request })); + } + + /** DELETE /v1/proxies/{id} */ + deleteProxy(proxyId: string): Promise { + return this.#request("DELETE", `/v1/proxies/${segment(proxyId)}`); + } + + /** + * POST /v1/proxies/import + * + * `format` is `"txt"` (one proxy per line) or `"json"` (a Donut proxy + * export). + */ + importProxies(request: { + format: string; + content: string; + name_prefix?: string; + }): Promise { + return this.#request("POST", "/v1/proxies/import", body({ ...request })); + } + + // ------------------------------------------------------------------ + // VPNs + // ------------------------------------------------------------------ + + /** GET /v1/vpns */ + listVpns(): Promise { + return this.#request("GET", "/v1/vpns"); + } + + /** GET /v1/vpns/{id} */ + getVpn(vpnId: string): Promise { + return this.#request("GET", `/v1/vpns/${segment(vpnId)}`); + } + + /** + * GET /v1/vpns/{id}/export + * + * Returns the decrypted `.conf` text. Treat it as a secret. + */ + exportVpn(vpnId: string): Promise { + return this.#request("GET", `/v1/vpns/${segment(vpnId)}/export`); + } + + /** POST /v1/vpns/import */ + importVpn(request: { + /** Raw WireGuard `.conf` content. */ + content: string; + filename: string; + name?: string; + }): Promise { + return this.#request("POST", "/v1/vpns/import", body({ ...request })); + } + + /** POST /v1/vpns. `vpn_type` must be `"WireGuard"`. */ + createVpn(request: { + name: string; + vpn_type: string; + config_data: string; + }): Promise { + return this.#request("POST", "/v1/vpns", { ...request }); + } + + /** PUT /v1/vpns/{id} */ + updateVpn(vpnId: string, name: string): Promise { + return this.#request("PUT", `/v1/vpns/${segment(vpnId)}`, { name }); + } + + /** DELETE /v1/vpns/{id} */ + deleteVpn(vpnId: string): Promise { + return this.#request("DELETE", `/v1/vpns/${segment(vpnId)}`); + } + + // ------------------------------------------------------------------ + // Extensions + // ------------------------------------------------------------------ + + /** GET /v1/extensions */ + listExtensions(): Promise { + return this.#request("GET", "/v1/extensions"); + } + + /** GET /v1/extensions/{id} */ + getExtension(extensionId: string): Promise { + return this.#request("GET", `/v1/extensions/${segment(extensionId)}`); + } + + /** + * POST /v1/extensions + * + * Either upload bytes (`file_name` plus `file_data_base64`) or point at a + * path on this machine (`source_path`). Answers 201. + */ + createExtension(request: { + name?: string; + /** Its suffix picks the type: `.crx` or `.zip`. */ + file_name?: string; + file_data_base64?: string; + source_path?: string; + /** Load a `source_path` directory in place. A linked extension never syncs. */ + link?: boolean; + }): Promise { + return this.#request("POST", "/v1/extensions", body({ ...request })); + } + + /** PUT /v1/extensions/{id} */ + updateExtension( + extensionId: string, + request: { + name?: string; + file_name?: string; + file_data_base64?: string; + source_path?: string; + link?: boolean; + }, + ): Promise { + return this.#request("PUT", `/v1/extensions/${segment(extensionId)}`, body({ ...request })); + } + + /** DELETE /v1/extensions/{id} */ + deleteExtension(extensionId: string): Promise { + return this.#request("DELETE", `/v1/extensions/${segment(extensionId)}`); + } + + /** GET /v1/extension-groups */ + listExtensionGroups(): Promise { + return this.#request("GET", "/v1/extension-groups"); + } + + /** GET /v1/extension-groups/{id} */ + getExtensionGroup(groupId: string): Promise { + return this.#request("GET", `/v1/extension-groups/${segment(groupId)}`); + } + + /** POST /v1/extension-groups. Answers 201. */ + createExtensionGroup(name: string): Promise { + return this.#request("POST", "/v1/extension-groups", { name }); + } + + /** + * PUT /v1/extension-groups/{id} + * + * `extension_ids` replaces the whole membership list. To change one member, + * use {@link addExtensionToGroup} or {@link removeExtensionFromGroup}. + */ + updateExtensionGroup( + groupId: string, + request: { name?: string; extension_ids?: string[] }, + ): Promise { + return this.#request( + "PUT", + `/v1/extension-groups/${segment(groupId)}`, + body({ ...request }), + ); + } + + /** DELETE /v1/extension-groups/{id} */ + deleteExtensionGroup(groupId: string): Promise { + return this.#request("DELETE", `/v1/extension-groups/${segment(groupId)}`); + } + + /** POST /v1/extension-groups/{id}/extensions/{extension_id} */ + addExtensionToGroup(groupId: string, extensionId: string): Promise { + return this.#request( + "POST", + `/v1/extension-groups/${segment(groupId)}/extensions/${segment(extensionId)}`, + ); + } + + /** DELETE /v1/extension-groups/{id}/extensions/{extension_id} */ + removeExtensionFromGroup(groupId: string, extensionId: string): Promise { + return this.#request( + "DELETE", + `/v1/extension-groups/${segment(groupId)}/extensions/${segment(extensionId)}`, + ); + } + + // ------------------------------------------------------------------ + // Browsers + // ------------------------------------------------------------------ + + /** + * POST /v1/browsers/download + * + * Resolves once the build is on disk, so give the client a long + * `timeoutMs`. A 409 means the same version is already downloading. + */ + downloadBrowser(request: { + browser: string; + version: string; + }): Promise { + return this.#request("POST", "/v1/browsers/download", { ...request }); + } + + /** GET /v1/browsers/{browser}/versions */ + listBrowserVersions(browser: string): Promise { + return this.#request("GET", `/v1/browsers/${segment(browser)}/versions`); + } + + /** GET /v1/browsers/{browser}/versions/{version}/downloaded */ + isBrowserDownloaded(browser: string, version: string): Promise { + return this.#request( + "GET", + `/v1/browsers/${segment(browser)}/versions/${segment(version)}/downloaded`, + ); + } + + // ------------------------------------------------------------------ + // Convenience + // ------------------------------------------------------------------ + + /** + * Launch a profile, run `work`, then stop the browser again. + * + * ```ts + * const rows = await client.withProfile(profileId, { headless: true }, async (session) => { + * console.log(session.cdpUrl); + * return client.agentExtract(profileId, { container, field_map }); + * }); + * ``` + * + * The browser is stopped when `work` finishes, including when it throws. A + * failure to stop never replaces the error `work` threw; it is attached to + * that error's `cause` chain instead, and reachable on `session.cleanupError`. + */ + async withProfile( + profileId: string, + options: RunProfileOptions, + work: (session: RunSession) => Promise | T, + ): Promise { + const response = await this.runProfile(profileId, options); + const session = new RunSession(this, profileId, response); + let failed = false; + try { + return await work(session); + } catch (error) { + failed = true; + throw error; + } finally { + try { + await this.killProfile(profileId); + } catch (cleanupError) { + session.cleanupError = cleanupError; + if (!failed) { + throw cleanupError; + } + } + } + } +} + +/** + * A profile launched by {@link DonutClient.withProfile}. + * + * It also implements `Symbol.asyncDispose`, so a runtime with `await using` + * can hold one directly; `withProfile` is the form that works everywhere. + */ +export class RunSession { + readonly client: DonutClient; + readonly profileId: string; + /** The whole body of `POST /v1/profiles/{id}/run`. */ + readonly response: RunProfileResponse; + /** The browser's CDP port. */ + readonly remoteDebuggingPort: number; + /** Whether the browser actually started headless. */ + readonly headless: boolean; + /** A failure while stopping the browser, kept rather than thrown. */ + cleanupError: unknown = undefined; + + constructor(client: DonutClient, profileId: string, response: RunProfileResponse) { + this.client = client; + this.profileId = profileId; + this.response = response; + this.remoteDebuggingPort = response.remote_debugging_port; + this.headless = response.headless; + } + + /** + * The browser's DevTools endpoint, e.g. `http://127.0.0.1:9222`. + * + * `GET {cdpUrl}/json/version` returns the `webSocketDebuggerUrl` a CDP + * library connects to. + */ + get cdpUrl(): string { + return `http://${this.client.host}:${this.remoteDebuggingPort}`; + } + + async [Symbol.asyncDispose](): Promise { + await this.client.killProfile(this.profileId); + } +} diff --git a/sdk/node/src/coverage.mts b/sdk/node/src/coverage.mts new file mode 100644 index 0000000..4c005c7 --- /dev/null +++ b/sdk/node/src/coverage.mts @@ -0,0 +1,104 @@ +/** + * Which app operation each client method wraps. + * + * This table is the SDK's half of a two-sided check. `sdk/api-paths.json` holds + * every operation the desktop app publishes, generated from + * `src-tauri/src/api_server.rs`. The test suite asserts the two agree exactly + * in both directions, so: + * + * - an endpoint added to the app fails the SDK tests until it is wrapped here, + * or listed in `OMITTED` with a reason, and + * - an entry here that the app no longer publishes fails too. + * + * The same table is mirrored in the Python package, and the same snapshot + * proves it. + */ + +/** `" "`, exactly as the app publishes it. */ +export type OperationKey = string; + +/** Operation to the name of the `DonutClient` method that calls it. */ +export const OPERATIONS: ReadonlyMap = new Map([ + ["POST /v1/browsers/download", "downloadBrowser"], + ["GET /v1/browsers/{browser}/versions", "listBrowserVersions"], + ["GET /v1/browsers/{browser}/versions/{version}/downloaded", "isBrowserDownloaded"], + ["GET /v1/cookie-bot/conflicts", "getCookieBotConflicts"], + ["GET /v1/cookie-bot/presets", "listCookieBotPresets"], + ["GET /v1/cookie-bot/runs", "listCookieBotRuns"], + ["POST /v1/cookie-bot/runs", "startCookieBotRun"], + ["DELETE /v1/cookie-bot/runs/{run_id}", "cancelCookieBotRun"], + ["GET /v1/cookie-bot/schedules", "listCookieBotSchedules"], + ["DELETE /v1/cookie-bot/schedules/{profile_id}", "deleteCookieBotSchedule"], + ["GET /v1/cookie-bot/schedules/{profile_id}", "getCookieBotSchedule"], + ["PUT /v1/cookie-bot/schedules/{profile_id}", "setCookieBotSchedule"], + ["GET /v1/cookie-bot/usage", "getCookieBotUsage"], + ["GET /v1/extension-groups", "listExtensionGroups"], + ["POST /v1/extension-groups", "createExtensionGroup"], + ["DELETE /v1/extension-groups/{id}", "deleteExtensionGroup"], + ["GET /v1/extension-groups/{id}", "getExtensionGroup"], + ["PUT /v1/extension-groups/{id}", "updateExtensionGroup"], + ["DELETE /v1/extension-groups/{id}/extensions/{extension_id}", "removeExtensionFromGroup"], + ["POST /v1/extension-groups/{id}/extensions/{extension_id}", "addExtensionToGroup"], + ["GET /v1/extensions", "listExtensions"], + ["POST /v1/extensions", "createExtension"], + ["DELETE /v1/extensions/{id}", "deleteExtension"], + ["GET /v1/extensions/{id}", "getExtension"], + ["PUT /v1/extensions/{id}", "updateExtension"], + ["GET /v1/groups", "listGroups"], + ["POST /v1/groups", "createGroup"], + ["DELETE /v1/groups/{id}", "deleteGroup"], + ["GET /v1/groups/{id}", "getGroup"], + ["PUT /v1/groups/{id}", "updateGroup"], + ["GET /v1/profiles", "listProfiles"], + ["POST /v1/profiles", "createProfile"], + ["POST /v1/profiles/batch/run", "batchRunProfiles"], + ["POST /v1/profiles/batch/stop", "batchStopProfiles"], + ["POST /v1/profiles/distribute-proxies", "distributeProxies"], + ["POST /v1/profiles/import", "importProfiles"], + ["GET /v1/profiles/import/detect", "detectImportProfiles"], + ["DELETE /v1/profiles/{id}", "deleteProfile"], + ["GET /v1/profiles/{id}", "getProfile"], + ["PUT /v1/profiles/{id}", "updateProfile"], + ["POST /v1/profiles/{id}/agent/click", "agentClick"], + ["POST /v1/profiles/{id}/agent/extract", "agentExtract"], + ["POST /v1/profiles/{id}/agent/perceive", "agentPerceive"], + ["POST /v1/profiles/{id}/agent/pick", "agentPick"], + ["POST /v1/profiles/{id}/agent/resolve-locator", "agentResolveLocator"], + ["POST /v1/profiles/{id}/agent/type", "agentType"], + ["POST /v1/profiles/{id}/cloud-sync", "setProfileCloudSync"], + ["POST /v1/profiles/{id}/cookies/import", "importProfileCookies"], + ["POST /v1/profiles/{id}/kill", "killProfile"], + ["POST /v1/profiles/{id}/open-url", "openUrl"], + ["POST /v1/profiles/{id}/run", "runProfile"], + ["POST /v1/profiles/{id}/run-remote", "runProfileRemote"], + ["GET /v1/proxies", "listProxies"], + ["POST /v1/proxies", "createProxy"], + ["POST /v1/proxies/import", "importProxies"], + ["DELETE /v1/proxies/{id}", "deleteProxy"], + ["GET /v1/proxies/{id}", "getProxy"], + ["PUT /v1/proxies/{id}", "updateProxy"], + ["GET /v1/remote-hours", "getRemoteHours"], + ["GET /v1/remote-sessions", "listRemoteSessions"], + ["DELETE /v1/remote-sessions/{id}", "stopRemoteSession"], + ["GET /v1/remote-sessions/{id}", "getRemoteSession"], + ["GET /v1/tags", "listTags"], + ["GET /v1/vpns", "listVpns"], + ["POST /v1/vpns", "createVpn"], + ["POST /v1/vpns/import", "importVpn"], + ["DELETE /v1/vpns/{id}", "deleteVpn"], + ["GET /v1/vpns/{id}", "getVpn"], + ["PUT /v1/vpns/{id}", "updateVpn"], + ["GET /v1/vpns/{id}/export", "exportVpn"], +]); + +/** Operations this SDK deliberately does not call, and why. */ +export const OMITTED: ReadonlyMap = new Map([ + [ + "GET /v1/remote-sessions/{id}/cdp", + "A WebSocket upgrade, not a request. fetch() cannot speak it, and bundling a " + + "websocket implementation would end this package's zero-dependency promise for " + + "one endpoint. DonutClient.remoteSessionCdpUrl() builds the ws:// address so a " + + "websocket library of the caller's choosing can connect, sending the same " + + "Authorization: Bearer header on the handshake.", + ], +]); diff --git a/sdk/node/src/errors.mts b/sdk/node/src/errors.mts new file mode 100644 index 0000000..838d26c --- /dev/null +++ b/sdk/node/src/errors.mts @@ -0,0 +1,211 @@ +/** + * Exceptions thrown by the Donut Browser SDK. + * + * The local REST API answers with a plain-text body and one of a small set of + * statuses. Each status means one thing, so each gets its own class and a + * caller can branch on `instanceof` instead of on a number: + * + * | Status | Class | Meaning | + * | -----: | --------------------- | ----------------------------------------- | + * | 400 | `ValidationError` | Malformed request, duplicate name | + * | 401 | `Unauthorized` | Missing or wrong bearer token | + * | 402 | `PaymentRequired` | Automation needs an active paid plan | + * | 403 | `Forbidden` | Terms not accepted, or not signed in | + * | 404 | `NotFound` | No such profile, group, proxy, ... | + * | 408 | `RequestTimeout` | `agent/pick` waited and nothing was picked | + * | 409 | `Conflict` | Something else holds the profile | + * | 429 | `RateLimited` | Quota spent; see `retryAfter` | + * | 500 | `ServerError` | Internal failure | + * | 502 | `BadGateway` | The browser or relay answered wrongly | + * | 503 | `ServiceUnavailable` | Cloud, fleet or lock service unreachable | + * + * Some bodies are the structured `{"code": ..., "params": {...}}` strings the + * desktop app shares with its own frontend. When one arrives, `code` and + * `params` are filled in; otherwise `code` is `null` and `body` holds the + * diagnostic text as sent. + */ + +/** Base class for everything this package throws. */ +export class DonutError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = new.target.name; + } +} + +/** + * The app could not be reached at all. + * + * Usually means the local API is switched off, is listening on another port, + * or the desktop app is not running. + */ +export class DonutConnectionError extends DonutError {} + +export interface DonutApiErrorInit { + method?: string; + path?: string; + headers?: Headers | Record; +} + +/** The app answered, and the answer was an error status. */ +export class DonutApiError extends DonutError { + status: number; + body: string; + method: string; + path: string; + headers: Record; + /** The `code` of a structured `{"code": ...}` body, else `null`. */ + code: string | null; + /** The `params` of a structured body, else an empty object. */ + params: Record; + + constructor(status: number, body: string, init: DonutApiErrorInit = {}) { + const method = init.method ?? ""; + const path = init.path ?? ""; + const headers = normaliseHeaders(init.headers); + + let code: string | null = null; + let params: Record = {}; + const trimmed = body.trim(); + if (trimmed.startsWith("{")) { + try { + const decoded: unknown = JSON.parse(trimmed); + if (decoded !== null && typeof decoded === "object") { + const record = decoded as Record; + if (typeof record.code === "string") { + code = record.code; + if (record.params !== null && typeof record.params === "object") { + params = record.params as Record; + } + } + } + } catch { + // Not JSON after all; the plain text below is the whole story. + } + } + + const where = `${method} ${path}`.trim(); + const detail = code ?? (trimmed || "(empty body)"); + super(where ? `${status} on ${where}: ${detail}` : `${status}: ${detail}`); + + this.status = status; + this.body = body; + this.method = method; + this.path = path; + this.headers = headers; + this.code = code; + this.params = params; + } +} + +/** 400: the request was malformed, duplicated a name, or named something unsupported. */ +export class ValidationError extends DonutApiError {} + +/** 401: no bearer token, the wrong one, or the local API has no token stored. */ +export class Unauthorized extends DonutApiError {} + +/** 402: this action needs an active paid plan, or the proxy behind it lapsed. */ +export class PaymentRequired extends DonutApiError {} + +/** 403: the Wayfern terms are not accepted, or this desktop is not signed in. */ +export class Forbidden extends DonutApiError {} + +/** 404: no entity with that id. */ +export class NotFound extends DonutApiError {} + +/** 408: `agentPick` waited its whole timeout and nothing was picked. */ +export class RequestTimeout extends DonutApiError {} + +/** 409: something else holds the profile — a browser, a teammate, a remote session. */ +export class Conflict extends DonutApiError {} + +/** + * 500 and the other 5xx: the app, the fleet or an upstream failed. + * + * `BadGateway` and `ServiceUnavailable` extend this, so one + * `instanceof ServerError` covers every server-side failure. + */ +export class ServerError extends DonutApiError {} + +/** 502: the browser or the relay did not answer the way it documents. */ +export class BadGateway extends ServerError {} + +/** + * 503: Donut cloud, the remote fleet, or the profile lock service is unreachable. + * + * Whatever was running keeps running: a 503 from `killProfile` or from stopping + * a remote session means the browser is still up, not that it stopped. + */ +export class ServiceUnavailable extends ServerError {} + +/** + * 429: the shared automation quota is spent. + * + * `retryAfter` is the number of seconds the server asked the caller to wait, + * taken from the `Retry-After` response header. It is `null` only when the + * header is missing or unreadable. + */ +export class RateLimited extends DonutApiError { + retryAfter: number | null; + + constructor(status: number, body: string, init: DonutApiErrorInit = {}) { + super(status, body, init); + const raw = this.headers["retry-after"]; + const seconds = raw === undefined ? Number.NaN : Number.parseInt(raw.trim(), 10); + this.retryAfter = Number.isFinite(seconds) ? seconds : null; + } +} + +function normaliseHeaders( + headers: Headers | Record | undefined, +): Record { + const result: Record = {}; + if (headers === undefined) { + return result; + } + if (typeof (headers as Headers).forEach === "function" && !Array.isArray(headers)) { + (headers as Headers).forEach((value, key) => { + result[key.toLowerCase()] = value; + }); + return result; + } + for (const [key, value] of Object.entries(headers as Record)) { + result[key.toLowerCase()] = value; + } + return result; +} + +const BY_STATUS = new Map([ + [400, ValidationError], + [401, Unauthorized], + [402, PaymentRequired], + [403, Forbidden], + [404, NotFound], + [408, RequestTimeout], + [409, Conflict], + [429, RateLimited], + [500, ServerError], + [502, BadGateway], + [503, ServiceUnavailable], +]); + +/** + * Build the error that belongs to `status`. + * + * A status with no class of its own becomes a plain `DonutApiError`, so a + * future status added to the app still throws something a caller can catch + * rather than escaping as a decode failure. + */ +export function errorForStatus( + status: number, + body: string, + init: DonutApiErrorInit = {}, +): DonutApiError { + const known = BY_STATUS.get(status); + if (known !== undefined) { + return new known(status, body, init); + } + return status >= 500 + ? new ServerError(status, body, init) + : new DonutApiError(status, body, init); +} diff --git a/sdk/node/src/index.mts b/sdk/node/src/index.mts new file mode 100644 index 0000000..5712844 --- /dev/null +++ b/sdk/node/src/index.mts @@ -0,0 +1,41 @@ +/** + * Donut Browser SDK: a thin client for the app's local REST API. + * + * The local API is off by default. Switch it on in the app under **Settings, + * Integrations, Local API, "Enable Local API Server"**, and copy the port and + * the authentication token from that screen. + * + * ```ts + * import { DonutClient } from "@donutbrowser/sdk"; + * + * const client = new DonutClient({ token: "..." }); + * await client.withProfile(profileId, { url: "https://example.com" }, async (session) => { + * console.log(session.cdpUrl); + * await client.agentClick(profileId, { locator: { role: "button", name: "Sign in" } }); + * }); + * ``` + */ + +export { DEFAULT_HOST, DEFAULT_PORT, DonutClient, RunSession } from "./client.mts"; +export type { DonutClientOptions, RunProfileOptions } from "./client.mts"; +export { OMITTED, OPERATIONS } from "./coverage.mts"; +export type { OperationKey } from "./coverage.mts"; +export { + BadGateway, + Conflict, + DonutApiError, + DonutConnectionError, + DonutError, + errorForStatus, + Forbidden, + NotFound, + PaymentRequired, + RateLimited, + RequestTimeout, + ServerError, + ServiceUnavailable, + Unauthorized, + ValidationError, +} from "./errors.mts"; +export type { DonutApiErrorInit } from "./errors.mts"; +export type * from "./types.mts"; diff --git a/sdk/node/src/types.mts b/sdk/node/src/types.mts new file mode 100644 index 0000000..8463fdb --- /dev/null +++ b/sdk/node/src/types.mts @@ -0,0 +1,634 @@ +/** + * Response shapes, spelled exactly the way the local API sends them. + * + * Every interface here mirrors a `ToSchema` struct in `src-tauri` field for + * field. A Rust `Option` becomes an optional property. + * + * Two spellings live side by side because the app sends both. Most bodies are + * snake_case; the browser-facing agent types (`LocatorDescription`, + * `LocatorCandidate`, `PerceptionPage` and friends) carry the browser's own + * camelCase, because they are handed through from the browser rather than + * restated. `AgentClick` and `AgentTyping` are the exceptions inside the agent + * surface: they are snake_case with a single `match` key. These types follow + * the wire rather than tidying it, so a value read from one call can be passed + * straight into the next. + */ + +/** The app's own JSON for a proxy's settings, declared `Object` in the spec. */ +export type ProxySettings = Record; + +/** A Wayfern fingerprint/config blob, also declared `Object` in the spec. */ +export type WayfernConfig = Record; + +/** Which implementation answered: the browser's native domains, or the fallback. */ +export type Engine = "wayfern" | "fallback"; + +export interface ApiProfile { + id: string; + name: string; + browser: string; + version: string; + proxy_id?: string | null; + launch_hook?: string | null; + process_id?: number | null; + last_launch?: number | null; + release_type: string; + group_id?: string | null; + tags: string[]; + is_running: boolean; + proxy_bypass_rules: string[]; + vpn_id?: string | null; + extension_group_id?: string | null; + ephemeral: boolean; + temporary: boolean; + clear_on_close: boolean; + /** `"Disabled"`, `"Regular"` or `"Encrypted"`. */ + sync_mode: string; + cloud_sync_enabled: boolean; + host_os?: string | null; + /** A profile from another OS can only ever run on a remote host of that OS. */ + is_cross_os: boolean; + fingerprint_os?: string | null; +} + +export interface ApiProfilesResponse { + profiles: ApiProfile[]; + total: number; +} + +export interface ApiProfileResponse { + profile: ApiProfile; +} + +export interface ApiGroupResponse { + id: string; + name: string; + profile_count: number; +} + +export interface ApiProxyResponse { + id: string; + name: string; + proxy_settings: ProxySettings; +} + +export interface ApiVpnResponse { + id: string; + name: string; + /** Always `"WireGuard"`. */ + vpn_type: string; + created_at: number; + last_used?: number | null; +} + +export interface ApiVpnExportResponse { + id: string; + name: string; + vpn_type: string; + /** Raw, decrypted `.conf` content. Treat it as a secret. */ + config_data: string; +} + +export interface DownloadBrowserResponse { + browser: string; + version: string; + status: string; +} + +export interface RunProfileResponse { + profile_id: string; + remote_debugging_port: number; + headless: boolean; +} + +export interface RunRemoteResponse { + profile_id: string; + session_id: string; + /** Always the profile's own operating system. */ + platform: string; + status: string; +} + +export interface StopRemoteResponse { + session_id: string; + status: string; + billed_seconds: number; +} + +export interface SetCloudSyncResponse { + profile_id: string; + mode: string; + remote_launchable: boolean; + remote_blocked_reason?: string | null; +} + +export interface RemoteSessionState { + session_id: string; + profile_id?: string | null; + platform?: string | null; + /** `provisioning` | `ready` | `live` | `closed` | `error`. */ + state: string; + cdp_ready?: boolean; + /** `interactive` or `cookie_bot`. */ + kind?: string | null; + run_id?: string | null; + team_id?: string | null; + started_at?: string | null; + ended_at?: string | null; + close_reason?: string | null; + billed_seconds?: number | null; +} + +export interface ApiRemoteSessionsResponse { + sessions: RemoteSessionState[]; +} + +export interface RemoteHoursBreakdown { + interactive_hours?: number; + bot_hours?: number; +} + +export interface RemoteHoursMember { + user_id: string; + email: string; + role?: string | null; + used_hours?: number; + interactive_hours?: number; + bot_hours?: number; +} + +export interface RemoteHoursQuota { + granted_hours: number; + remaining_hours: number; + used_hours?: number; + period_start?: string | null; + period_end?: string | null; + /** `user` or `team`. */ + scope?: string | null; + team_id?: string | null; + seats?: number; + per_seat_hours?: number; + breakdown?: RemoteHoursBreakdown | null; + members?: RemoteHoursMember[]; +} + +export interface CookieBotSlot { + run_at_minute?: number; + days_mask?: number; +} + +export interface CookieBotSchedule { + profile_id: string; + profile_name: string; + platform: string; + enabled: boolean; + run_at_minute: number; + days_mask: number; + /** + * Every time-of-day this enrolment fires. An older server sends only the + * mirrored `run_at_minute`/`days_mask` pair above, so an empty list means + * "fall back to the pair", never "fires at no time". + */ + slots?: CookieBotSlot[]; + timezone: string; + preset: string; + template_id?: string | null; + max_minutes: number; + sites?: string[]; + jitter_seconds?: number; + sync_enabled?: boolean; + encrypted_sync?: boolean; + has_proxy?: boolean; + proxy_remote_reachable?: boolean; + touch_fingerprint?: boolean; + sticky_exit?: boolean; + profile_state_at?: string | null; + /** Why tonight would be refused, or absent. */ + blocked_by?: string | null; + next_run_at?: string | null; + last_run_at?: string | null; + last_run_id?: string | null; + owner_user_id?: string | null; + owner_email?: string | null; + updated_at?: string | null; +} + +export interface CookieBotScheduleList { + schedules?: CookieBotSchedule[]; + team_id?: string | null; + scope?: string | null; +} + +export interface CookieBotConflict { + user_id: string; + email: string; + run_at_minute: number; + timezone: string; + days_mask: number; + enabled: boolean; + overlaps?: boolean; +} + +export interface CookieBotScheduleSaved { + schedule: CookieBotSchedule; + conflicts?: CookieBotConflict[]; +} + +export interface CookieBotConflictCheck { + profile_id: string; + conflicts?: CookieBotConflict[]; +} + +export interface CookieBotScheduleDeleted { + profile_id: string; + deleted: boolean; +} + +export interface CookieBotRun { + id: string; + profile_id: string; + profile_name?: string | null; + user_id?: string | null; + email?: string | null; + team_id?: string | null; + /** `schedule` or `manual`. */ + trigger: string; + /** `pending` | `running` | `succeeded` | `partial` | `failed` | `skipped` | `cancelled`. */ + status: string; + scheduled_for: string; + dispatch_after?: string | null; + started_at?: string | null; + ended_at?: string | null; + max_minutes?: number; + chunks_total?: number; + chunk_index?: number; + sites_total?: number; + sites_visited?: number; + sites_failed?: number; + consent_dismissed?: number; + billed_seconds?: number; + outcome_code?: string | null; + session_id?: string | null; +} + +export interface CookieBotRunPage { + runs?: CookieBotRun[]; + /** Keyset cursor; absent on the last page. */ + next_before?: string | null; +} + +export interface CookieBotRunStarted { + run: CookieBotRun; + session_id?: string | null; +} + +export interface CookieBotPreset { + id: string; + typical_minutes?: number | null; + recommended?: boolean; + name?: string | null; + description?: string | null; +} + +export interface CookieBotPresetList { + presets?: CookieBotPreset[]; + default_preset?: string | null; + /** Whatever the server publishes; the app forwards it without narrowing. */ + templates?: Record[]; + limits?: Record | null; +} + +export interface CookieBotUsageMember { + user_id: string; + email: string; + role?: string | null; + interactive_hours?: number; + bot_hours?: number; + used_hours?: number; + sessions?: number; + bot_runs?: number; + bot_runs_failed?: number; +} + +export interface CookieBotUsageProfile { + profile_id: string; + profile_name?: string | null; + owner_email?: string | null; + bot_hours?: number; + runs?: number; + runs_failed?: number; + last_run_at?: string | null; + last_status?: string | null; +} + +export interface CookieBotUsage { + period: string; + period_start?: string | null; + period_end?: string | null; + team_id?: string | null; + seats?: number; + granted_hours?: number; + used_hours?: number; + remaining_hours?: number; + members?: CookieBotUsageMember[]; + profiles?: CookieBotUsageProfile[]; +} + +export interface BatchRunResult { + profile_id: string; + ok: boolean; + remote_debugging_port?: number | null; + error?: string | null; +} + +export interface BatchRunResponse { + results: BatchRunResult[]; +} + +export interface BatchStopResult { + profile_id: string; + ok: boolean; + error?: string | null; +} + +export interface BatchStopResponse { + results: BatchStopResult[]; +} + +/** One profile, one proxy. The distribution applies exactly these pairs. */ +export interface ProxyPair { + profile_id: string; + proxy_id: string; +} + +export interface ProxyAssignmentResult { + profile_id: string; + proxy_id: string; + ok: boolean; + /** A `{"code": ...}` payload when `ok` is false, otherwise null. */ + error?: string | null; +} + +export interface DistributeProxiesResponse { + results: ProxyAssignmentResult[]; +} + +export interface ImportCookiesResponse { + cookies_imported: number; + cookies_replaced: number; + errors: string[]; +} + +export interface ImportProxiesResponse { + imported_count: number; + skipped_count: number; + errors: string[]; + proxies: ApiProxyResponse[]; +} + +export interface DetectedProfile { + browser: string; + mapped_browser: string; + name: string; + path: string; + description: string; +} + +export interface DetectedProfilesResponse { + profiles: DetectedProfile[]; + total: number; +} + +export interface ImportProfileItem { + source_path: string; + /** + * The source browser family (`chromium`, `brave`, `edge`, ...). Load-bearing: + * it picks which keychain entry unlocks the source's cookies and passwords. + */ + browser_type?: string; + new_profile_name: string; + proxy_id?: string | null; + vpn_id?: string | null; + allow_running?: boolean | null; +} + +export interface ProfileImportItemResult { + name: string; + source_path: string; + /** `"imported"` | `"skipped"` | `"failed"`. */ + status: string; + profile_id?: string | null; + error?: string | null; + report?: Record | null; +} + +export interface ProfileImportBatchResult { + imported_count: number; + skipped_count: number; + failed_count: number; + results: ProfileImportItemResult[]; +} + +export interface Extension { + id: string; + name: string; + manifest_name?: string | null; + file_name: string; + file_type: string; + browser_compatibility: string[]; + created_at: number; + updated_at: number; + sync_enabled?: boolean; + last_sync?: number | null; + version?: string | null; + description?: string | null; + author?: string | null; + homepage_url?: string | null; + /** `archive` or `unpacked`. */ + source_kind: string; + /** Set when the extension is loaded from a folder in place. Never synced. */ + linked_path?: string | null; +} + +export interface ExtensionGroup { + id: string; + name: string; + extension_ids: string[]; + created_at: number; + updated_at: number; + sync_enabled?: boolean; + last_sync?: number | null; +} + +export interface LocatorAttribute { + name: string; + value: string; +} + +/** + * How an element is named without a CSS selector. + * + * At least one property must be set. Keys are the browser's own camelCase; the + * app also accepts `name_contains` and `text_contains` on input, but a locator + * handed back by `agentPick` uses the spellings below, so reusing one verbatim + * is the reliable path. + */ +export interface LocatorDescription { + /** AX role token, matched case- and separator-insensitively. */ + role?: string; + /** Computed accessible name, exact after whitespace collapse. */ + name?: string; + nameContains?: string; + /** Visible text content, from the live layout. */ + text?: string; + textContains?: string; + attributes?: LocatorAttribute[]; +} + +export interface LocatorBounds { + x: number; + y: number; + width: number; + height: number; +} + +export interface LocatorCandidate { + /** Absent on the fallback engine, which has no DOM agent behind it. */ + backendNodeId?: number; + role: string; + name: string; + text: string; + /** Omitted, never blanked, for a control the page marked protected. */ + value?: string; + url?: string; + /** Per-profile deterministic identifier for the node's structural position. */ + signature: string; + attributes?: LocatorAttribute[]; + bounds: LocatorBounds; +} + +export interface LocatorResolution { + backendNodeId?: number; + /** Always 1: present so a caller can assert it rather than infer it. */ + matchCount: number; + match: LocatorCandidate; + locator: LocatorDescription; + engine: Engine; +} + +export interface PerceptionNode { + /** Short, stable, frame-qualified handle. */ + id: string; + frameId: string; + role: string; + x: number; + y: number; + width: number; + height: number; + inViewport: boolean; + visible: boolean; + focused: boolean; + disabled: boolean; + parentId?: string; + name?: string; + text?: string; + value?: string; + /** `"true"`, `"false"` or `"mixed"`; absent for anything not checkable. */ + checked?: string; + expanded?: boolean; + scrollable?: boolean; + scrollContainerId?: string; +} + +export interface PerceptionFrame { + frameId: string; + url: string; + crossOrigin: boolean; + parentFrameId?: string; +} + +export interface PerceptionStats { + totalNodes: number; + returnedNodes: number; + bytes: number; + elapsedMs: number; + framesVisited: number; + /** Frames whose renderer did not answer within the budget. */ + framesFailed: number; +} + +export interface PerceptionPage { + snapshotId: string; + nodes: PerceptionNode[]; + frames: PerceptionFrame[]; + /** Readable text for exactly the nodes returned. */ + text: string; + truncated: boolean; + stats: PerceptionStats; + /** Present when `truncated`: pass it back to continue. */ + cursor?: string; + engine: Engine; +} + +export interface ExtractionField { + /** The key this column appears under in each row's values. */ + key: string; + /** Evaluated inside each container; the first match wins. */ + locator: LocatorDescription; + /** `"text"`, `"attribute"` or `"link"`. */ + source: string; + /** Required when `source` is `"attribute"`. */ + attribute?: string; +} + +export interface ExtractionRow { + /** Global across pages. */ + index: number; + /** Zero-based page this row came from. */ + page: number; + values: Record; +} + +export interface Extraction { + rows: ExtractionRow[]; + rowCount: number; + pageCount: number; + byteSize: number; + truncated: boolean; + /** + * `complete` | `no-container` | `no-next` | `page-cap` | `row-cap` | + * `byte-cap` | `time-budget`. A missing container is `no-container`, not an + * error. + */ + stopReason: string; + engine: Engine; +} + +export interface PickedElement { + backendNodeId: number; + /** The smallest description that still resolves to this node. */ + locator: LocatorDescription; + matchCount: number; + node: LocatorCandidate; + engine: Engine; +} + +/** What a click did. Note the snake_case body and the `match` key. */ +export interface AgentClick { + clicked: boolean; + match: LocatorCandidate; + engine: Engine; + /** Whether a page load followed the click. */ + navigated: boolean; +} + +/** What a typing call did. */ +export interface AgentTyping { + typed: boolean; + characters: number; + /** Absent on the fallback engine, which does not count its own mistypes. */ + corrections?: number; + duration_ms: number; + engine: Engine; + match: LocatorCandidate; +} diff --git a/sdk/node/test/configuration.test.mts b/sdk/node/test/configuration.test.mts new file mode 100644 index 0000000..3bcc7f9 --- /dev/null +++ b/sdk/node/test/configuration.test.mts @@ -0,0 +1,109 @@ +/** Where the token and the port come from, and in what order. */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { DEFAULT_HOST, DEFAULT_PORT, DonutClient, DonutError } from "../src/index.mts"; +import { FakeDonut } from "./fake-donut.mts"; + +test("arguments are used as given", () => { + const client = new DonutClient({ token: "from-argument", port: 12345, env: {} }); + assert.equal(client.token, "from-argument"); + assert.equal(client.port, 12345); + assert.equal(client.host, DEFAULT_HOST); + assert.equal(client.baseUrl, "http://127.0.0.1:12345"); +}); + +test("the environment fills in what was not passed", () => { + const client = new DonutClient({ + env: { DONUT_API_TOKEN: "from-env", DONUT_API_PORT: "13579" }, + }); + assert.equal(client.token, "from-env"); + assert.equal(client.port, 13579); +}); + +test("arguments win over the environment", () => { + const client = new DonutClient({ + token: "from-argument", + port: 111, + env: { DONUT_API_TOKEN: "from-env", DONUT_API_PORT: "222" }, + }); + assert.equal(client.token, "from-argument"); + assert.equal(client.port, 111); +}); + +test("the port falls back to the app default", () => { + const client = new DonutClient({ env: { DONUT_API_TOKEN: "t" } }); + assert.equal(client.port, DEFAULT_PORT); + assert.equal(DEFAULT_PORT, 10108); +}); + +test("a baseUrl overrides host and port", () => { + const client = new DonutClient({ + baseUrl: "http://127.0.0.1:9999/donut", + token: "t", + env: { DONUT_API_PORT: "222" }, + }); + assert.equal(client.port, 9999); + assert.equal(client.baseUrl, "http://127.0.0.1:9999/donut"); +}); + +test("a baseUrl prefix is kept on every path", async () => { + const fake = await new FakeDonut().start(); + try { + const client = new DonutClient({ + baseUrl: `http://127.0.0.1:${fake.port}/donut`, + token: "t", + timeoutMs: 5_000, + env: {}, + }); + await client.listProfiles(); + assert.equal(fake.last.path, "/donut/v1/profiles"); + } finally { + await fake.stop(); + } +}); + +test("an unusable port in the environment is reported", () => { + assert.throws( + () => new DonutClient({ env: { DONUT_API_TOKEN: "t", DONUT_API_PORT: "not-a-number" } }), + /DONUT_API_PORT/, + ); +}); + +test("an unsupported scheme is refused", () => { + assert.throws( + () => new DonutClient({ baseUrl: "ftp://127.0.0.1:9999", token: "t", env: {} }), + DonutError, + ); +}); + +test("the websocket address is built from the same base", () => { + const client = new DonutClient({ token: "t", port: 10108, env: {} }); + assert.equal( + client.remoteSessionCdpUrl("s 1"), + "ws://127.0.0.1:10108/v1/remote-sessions/s%201/cdp", + ); +}); + +test("an https base gives a wss websocket address", () => { + const client = new DonutClient({ baseUrl: "https://127.0.0.1:8443", token: "t", env: {} }); + assert.equal( + client.remoteSessionCdpUrl("s1"), + "wss://127.0.0.1:8443/v1/remote-sessions/s1/cdp", + ); +}); + +test("a supplied fetch is the one that is used", async () => { + const seen: string[] = []; + const client = new DonutClient({ + token: "t", + env: {}, + fetch: async (input) => { + seen.push(String(input)); + return new Response("[]", { status: 200, headers: { "Content-Type": "application/json" } }); + }, + }); + assert.deepEqual(await client.listTags(), []); + assert.deepEqual(seen, ["http://127.0.0.1:10108/v1/tags"]); +}); diff --git a/sdk/node/test/coverage.test.mts b/sdk/node/test/coverage.test.mts new file mode 100644 index 0000000..c05033d --- /dev/null +++ b/sdk/node/test/coverage.test.mts @@ -0,0 +1,97 @@ +/** + * The SDK cannot silently drift from the app's API. + * + * `sdk/api-paths.json` is generated from `src-tauri/src/api_server.rs` and + * lists every operation the desktop app publishes. These tests hold it against + * the SDK's own table in both directions, so a new endpoint in the app fails + * here until it is wrapped or deliberately omitted with a reason. + */ + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { DonutClient, OMITTED, OPERATIONS } from "../src/index.mts"; + +const SNAPSHOT = fileURLToPath(new URL("../../api-paths.json", import.meta.url)); + +interface Snapshot { + source: string; + operation_count: number; + operations: { operation_id: string; method: string; path: string }[]; +} + +function snapshot(): Snapshot { + return JSON.parse(readFileSync(SNAPSHOT, "utf8")) as Snapshot; +} + +function published(): Set { + return new Set(snapshot().operations.map((entry) => `${entry.method} ${entry.path}`)); +} + +test("the snapshot is readable and not empty", () => { + const document = snapshot(); + assert.equal(document.source, "src-tauri/src/api_server.rs"); + assert.equal(document.operation_count, document.operations.length); + assert.ok(document.operation_count > 0); + assert.equal( + published().size, + document.operation_count, + "the app has two identical operations", + ); +}); + +test("every published operation is wrapped or omitted", () => { + const known = new Set([...OPERATIONS.keys(), ...OMITTED.keys()]); + const missing = [...published()].filter((key) => !known.has(key)).sort(); + assert.deepEqual( + missing, + [], + `the app publishes operations this SDK does not handle: ${missing.join(", ")}. ` + + "Wrap each one, or add it to OMITTED with a reason.", + ); +}); + +test("the SDK claims nothing the app does not publish", () => { + const live = published(); + const stale = [...OPERATIONS.keys(), ...OMITTED.keys()].filter((key) => !live.has(key)).sort(); + assert.deepEqual( + stale, + [], + `this SDK handles operations the app no longer publishes: ${stale.join(", ")}. ` + + "Regenerate the snapshot with sdk/tools/extract-api-paths.py, then drop or fix each entry.", + ); +}); + +test("an operation is either wrapped or omitted but not both", () => { + const both = [...OPERATIONS.keys()].filter((key) => OMITTED.has(key)).sort(); + assert.deepEqual(both, [], `listed twice: ${both.join(", ")}`); +}); + +test("every omission gives a reason", () => { + for (const [operation, reason] of OMITTED) { + assert.ok(reason.trim().length > 40, `${operation} is omitted without a real reason`); + } +}); + +test("every wrapped operation names a real method", () => { + const prototype = DonutClient.prototype as unknown as Record; + for (const [operation, name] of OPERATIONS) { + assert.equal( + typeof prototype[name], + "function", + `${operation} names ${name}, which is not a method`, + ); + } +}); + +test("no two operations share a method", () => { + const names = [...OPERATIONS.values()]; + const duplicates = [...new Set(names.filter((name, index) => names.indexOf(name) !== index))]; + assert.deepEqual( + duplicates, + [], + `one method is claimed by several operations: ${duplicates.join(", ")}`, + ); +}); diff --git a/sdk/node/test/errors.test.mts b/sdk/node/test/errors.test.mts new file mode 100644 index 0000000..80a39a4 --- /dev/null +++ b/sdk/node/test/errors.test.mts @@ -0,0 +1,219 @@ +/** Each status the app documents throws its own error. */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + BadGateway, + Conflict, + DonutApiError, + DonutClient, + DonutConnectionError, + DonutError, + Forbidden, + NotFound, + PaymentRequired, + RateLimited, + RequestTimeout, + ServerError, + ServiceUnavailable, + Unauthorized, + ValidationError, +} from "../src/index.mts"; +import { FakeDonut } from "./fake-donut.mts"; +import { withClient } from "./support.mts"; + +const STATUS_TO_ERROR: [number, new (...args: never[]) => DonutApiError][] = [ + [400, ValidationError], + [401, Unauthorized], + [402, PaymentRequired], + [403, Forbidden], + [404, NotFound], + [408, RequestTimeout], + [409, Conflict], + [429, RateLimited], + [500, ServerError], + [502, BadGateway], + [503, ServiceUnavailable], +]; + +for (const [status, expected] of STATUS_TO_ERROR) { + test(`${status} maps to ${expected.name}`, async () => { + await withClient(async (client, fake) => { + fake.enqueueError(status, "something went wrong"); + const thrown = await client.listProfiles().then( + () => null, + (error: unknown) => error, + ); + assert.ok(thrown instanceof expected, `expected ${expected.name}, got ${String(thrown)}`); + assert.equal(thrown.status, status); + assert.equal(thrown.body, "something went wrong"); + assert.equal(thrown.method, "GET"); + assert.equal(thrown.path, "/v1/profiles"); + }); + }); +} + +test("every error is a DonutError", async () => { + await withClient(async (client, fake) => { + fake.enqueueError(404, "PROFILE_NOT_FOUND"); + await assert.rejects(client.getProfile("nope"), DonutError); + }); +}); + +test("the five hundreds share one base", async () => { + await withClient(async (client, fake) => { + for (const status of [500, 502, 503]) { + fake.enqueueError(status, "upstream"); + await assert.rejects(client.listProfiles(), ServerError); + } + }); +}); + +test("rate limited carries retryAfter", async () => { + await withClient(async (client, fake) => { + fake.enqueueError(429, "automation request rate limit exceeded", { "Retry-After": "42" }); + const thrown = await client.runProfile("p1").then( + () => null, + (error: unknown) => error, + ); + assert.ok(thrown instanceof RateLimited); + assert.equal(thrown.retryAfter, 42); + }); +}); + +test("rate limited without the header is still thrown", async () => { + await withClient(async (client, fake) => { + fake.enqueueError(429, "slow down"); + const thrown = await client.runProfile("p1").then( + () => null, + (error: unknown) => error, + ); + assert.ok(thrown instanceof RateLimited); + assert.equal(thrown.retryAfter, null); + }); +}); + +test("an unreadable Retry-After does not break the error", async () => { + await withClient(async (client, fake) => { + fake.enqueueError(429, "slow down", { "Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT" }); + const thrown = await client.runProfile("p1").then( + () => null, + (error: unknown) => error, + ); + assert.ok(thrown instanceof RateLimited); + assert.equal(thrown.retryAfter, null); + }); +}); + +test("a structured code body is decoded", async () => { + // The app shares `{"code": ...}` strings with its own frontend. + await withClient(async (client, fake) => { + fake.enqueueError(400, JSON.stringify({ code: "NAME_CANNOT_BE_EMPTY" })); + const thrown = await client.createGroup("").then( + () => null, + (error: unknown) => error, + ); + assert.ok(thrown instanceof ValidationError); + assert.equal(thrown.code, "NAME_CANNOT_BE_EMPTY"); + assert.deepEqual(thrown.params, {}); + }); +}); + +test("a structured code body keeps its params", async () => { + await withClient(async (client, fake) => { + fake.enqueueError( + 409, + JSON.stringify({ code: "PROFILE_LOCKED_BY_MEMBER", params: { n: "5" } }), + ); + const thrown = await client.runProfile("p1").then( + () => null, + (error: unknown) => error, + ); + assert.ok(thrown instanceof Conflict); + assert.equal(thrown.code, "PROFILE_LOCKED_BY_MEMBER"); + assert.deepEqual(thrown.params, { n: "5" }); + }); +}); + +test("a plain text body leaves code unset", async () => { + await withClient(async (client, fake) => { + fake.enqueueError(400, "invalid browser"); + const thrown = await client.createProfile({ name: "x", browser: "chromium" }).then( + () => null, + (error: unknown) => error, + ); + assert.ok(thrown instanceof ValidationError); + assert.equal(thrown.code, null); + assert.equal(thrown.body, "invalid browser"); + }); +}); + +test("an undocumented status still throws something catchable", async () => { + await withClient(async (client, fake) => { + fake.enqueueError(418, "teapot"); + const thrown = await client.listProfiles().then( + () => null, + (error: unknown) => error, + ); + assert.ok(thrown instanceof DonutApiError); + assert.equal(thrown.status, 418); + }); +}); + +test("an undocumented server status is a ServerError", async () => { + await withClient(async (client, fake) => { + fake.enqueueError(504, "gateway timeout"); + await assert.rejects(client.listProfiles(), ServerError); + }); +}); + +test("the message names the call", async () => { + await withClient(async (client, fake) => { + fake.enqueueError(404, "Profile not found"); + const thrown = await client.getProfile("missing").then( + () => null, + (error: unknown) => error, + ); + assert.ok(thrown instanceof NotFound); + assert.match(thrown.message, /404/); + assert.match(thrown.message, /GET \/v1\/profiles\/missing/); + }); +}); + +test("errors keep their class name", async () => { + await withClient(async (client, fake) => { + fake.enqueueError(404, "gone"); + const thrown = await client.listProfiles().then( + () => null, + (error: unknown) => error, + ); + assert.ok(thrown instanceof NotFound); + assert.equal(thrown.name, "NotFound"); + }); +}); + +test("an unreachable app is not an API error", async () => { + const fake = await new FakeDonut().start(); + const port = fake.port; + await fake.stop(); + + const client = new DonutClient({ token: "t", port, timeoutMs: 2_000, env: {} }); + const thrown = await client.listProfiles().then( + () => null, + (error: unknown) => error, + ); + assert.ok(thrown instanceof DonutConnectionError); + assert.match(thrown.message, /Local API/); +}); + +test("a missing token fails before any request", () => { + assert.throws(() => new DonutClient({ env: {} }), /DONUT_API_TOKEN/); +}); + +test("a non-JSON answer is reported as such", async () => { + await withClient(async (client, fake) => { + fake.enqueueRaw(200, "nope"); + await assert.rejects(client.listProfiles(), /not\s+JSON/); + }); +}); diff --git a/sdk/node/test/fake-donut.mts b/sdk/node/test/fake-donut.mts new file mode 100644 index 0000000..4b2a922 --- /dev/null +++ b/sdk/node/test/fake-donut.mts @@ -0,0 +1,136 @@ +/** + * A stand-in for the desktop app's local REST API. + * + * It records what the client sent, byte for byte, and answers with whatever + * the test queued. Nothing here reaches the network: it binds an ephemeral + * loopback port and is torn down with the test. + */ + +import { createServer } from "node:http"; +import type { IncomingMessage, Server, ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; + +export interface RecordedRequest { + method: string; + target: string; + path: string; + query: Record; + headers: Record; + rawBody: string; + json: unknown; +} + +export interface QueuedResponse { + status: number; + body: string; + headers: Record; + contentType: string; +} + +export class FakeDonut { + requests: RecordedRequest[] = []; + responses: QueuedResponse[] = []; + #server: Server | undefined = undefined; + + enqueueJson(payload: unknown, status = 200): void { + this.responses.push({ + status, + body: JSON.stringify(payload), + headers: {}, + contentType: "application/json", + }); + } + + enqueueEmpty(status = 204): void { + this.responses.push({ status, body: "", headers: {}, contentType: "application/json" }); + } + + enqueueError(status: number, body = "", headers: Record = {}): void { + this.responses.push({ status, body, headers, contentType: "text/plain" }); + } + + enqueueRaw(status: number, body: string, contentType = "text/html"): void { + this.responses.push({ status, body, headers: {}, contentType }); + } + + get port(): number { + if (this.#server === undefined) { + throw new Error("the fake server is not running"); + } + return (this.#server.address() as AddressInfo).port; + } + + get last(): RecordedRequest { + const request = this.requests.at(-1); + if (request === undefined) { + throw new Error("the client sent nothing"); + } + return request; + } + + async start(): Promise { + const server = createServer((incoming: IncomingMessage, outgoing: ServerResponse) => { + const chunks: Buffer[] = []; + incoming.on("data", (chunk: Buffer) => chunks.push(chunk)); + incoming.on("end", () => { + const rawBody = Buffer.concat(chunks).toString("utf8"); + const url = new URL(incoming.url ?? "/", "http://127.0.0.1"); + const headers: Record = {}; + for (const [key, value] of Object.entries(incoming.headers)) { + headers[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : (value ?? ""); + } + + this.requests.push({ + method: incoming.method ?? "", + target: incoming.url ?? "", + path: url.pathname, + query: Object.fromEntries(url.searchParams.entries()), + headers, + rawBody, + json: rawBody === "" ? null : JSON.parse(rawBody), + }); + + const queued = this.responses.shift() ?? { + status: 200, + body: "{}", + headers: {}, + contentType: "application/json", + }; + for (const [name, value] of Object.entries(queued.headers)) { + outgoing.setHeader(name, value); + } + if (queued.body !== "") { + outgoing.setHeader("Content-Type", queued.contentType); + } + outgoing.writeHead(queued.status); + outgoing.end(queued.body); + }); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + this.#server = server; + return this; + } + + async stop(): Promise { + const server = this.#server; + if (server === undefined) { + return; + } + this.#server = undefined; + server.closeAllConnections(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +} + +/** Start a fake server, hand it to `work`, and always shut it down again. */ +export async function withFakeDonut(work: (fake: FakeDonut) => Promise): Promise { + const fake = await new FakeDonut().start(); + try { + return await work(fake); + } finally { + await fake.stop(); + } +} diff --git a/sdk/node/test/requests.test.mts b/sdk/node/test/requests.test.mts new file mode 100644 index 0000000..fc40904 --- /dev/null +++ b/sdk/node/test/requests.test.mts @@ -0,0 +1,768 @@ +/** + * Every client method sends exactly the request the app documents. + * + * The table below is the whole public surface. Each row names a method, the + * arguments to call it with, and the request that must appear on the wire: the + * verb, the concrete path, the query string and the JSON body. `operation` is + * the path template the app publishes, which ties this file to + * `OPERATIONS` and, through it, to `sdk/api-paths.json`. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { OPERATIONS } from "../src/index.mts"; +import { withClient } from "./support.mts"; + +interface Case { + method: string; + args: unknown[]; + verb: string; + path: string; + body: unknown; + query?: Record; + operation: string; +} + +const LOCATOR = { role: "button", name: "Sign in" }; + +const CASES: Case[] = [ + // -- profiles ------------------------------------------------------------ + { + method: "listProfiles", + args: [], + verb: "GET", + path: "/v1/profiles", + body: null, + operation: "GET /v1/profiles", + }, + { + method: "getProfile", + args: ["p1"], + verb: "GET", + path: "/v1/profiles/p1", + body: null, + operation: "GET /v1/profiles/{id}", + }, + { + method: "createProfile", + args: [{ name: "Shopper", browser: "wayfern", tags: ["eu"], ephemeral: true }], + verb: "POST", + path: "/v1/profiles", + body: { name: "Shopper", browser: "wayfern", tags: ["eu"], ephemeral: true }, + operation: "POST /v1/profiles", + }, + { + method: "createProfile", + args: [{ name: "Bare", browser: "wayfern", version: undefined }], + verb: "POST", + path: "/v1/profiles", + body: { name: "Bare", browser: "wayfern" }, + operation: "POST /v1/profiles", + }, + { + method: "updateProfile", + args: ["p1", { name: "Renamed", proxy_id: "", clear_on_close: false }], + verb: "PUT", + path: "/v1/profiles/p1", + body: { name: "Renamed", proxy_id: "", clear_on_close: false }, + operation: "PUT /v1/profiles/{id}", + }, + { + method: "deleteProfile", + args: ["p1"], + verb: "DELETE", + path: "/v1/profiles/p1", + body: null, + operation: "DELETE /v1/profiles/{id}", + }, + { + method: "runProfile", + args: ["p1", { url: "https://example.com", headless: true }], + verb: "POST", + path: "/v1/profiles/p1/run", + body: { url: "https://example.com", headless: true }, + operation: "POST /v1/profiles/{id}/run", + }, + { + method: "runProfileRemote", + args: ["p1", { url: "https://example.com" }], + verb: "POST", + path: "/v1/profiles/p1/run-remote", + body: { url: "https://example.com" }, + operation: "POST /v1/profiles/{id}/run-remote", + }, + { + method: "setProfileCloudSync", + args: ["p1", "Regular"], + verb: "POST", + path: "/v1/profiles/p1/cloud-sync", + body: { mode: "Regular" }, + operation: "POST /v1/profiles/{id}/cloud-sync", + }, + { + method: "openUrl", + args: ["p1", "https://example.com/page"], + verb: "POST", + path: "/v1/profiles/p1/open-url", + body: { url: "https://example.com/page" }, + operation: "POST /v1/profiles/{id}/open-url", + }, + { + method: "killProfile", + args: ["p1"], + verb: "POST", + path: "/v1/profiles/p1/kill", + body: null, + operation: "POST /v1/profiles/{id}/kill", + }, + { + method: "batchRunProfiles", + args: [["p1", "p2"], { headless: false }], + verb: "POST", + path: "/v1/profiles/batch/run", + body: { profile_ids: ["p1", "p2"], headless: false }, + operation: "POST /v1/profiles/batch/run", + }, + { + method: "batchStopProfiles", + args: [["p1", "p2"]], + verb: "POST", + path: "/v1/profiles/batch/stop", + body: { profile_ids: ["p1", "p2"] }, + operation: "POST /v1/profiles/batch/stop", + }, + { + method: "distributeProxies", + args: [ + [ + { profile_id: "p1", proxy_id: "x1" }, + { profile_id: "p2", proxy_id: "x2" }, + ], + ], + verb: "POST", + path: "/v1/profiles/distribute-proxies", + body: { + pairs: [ + { profile_id: "p1", proxy_id: "x1" }, + { profile_id: "p2", proxy_id: "x2" }, + ], + }, + operation: "POST /v1/profiles/distribute-proxies", + }, + { + method: "detectImportProfiles", + args: [{ folder: "/Users/x/Chrome" }], + verb: "GET", + path: "/v1/profiles/import/detect", + body: null, + query: { folder: "/Users/x/Chrome" }, + operation: "GET /v1/profiles/import/detect", + }, + { + method: "detectImportProfiles", + args: [], + verb: "GET", + path: "/v1/profiles/import/detect", + body: null, + operation: "GET /v1/profiles/import/detect", + }, + { + method: "importProfiles", + args: [ + [{ source_path: "/tmp/src", new_profile_name: "Imported" }], + { duplicate_strategy: "skip" }, + ], + verb: "POST", + path: "/v1/profiles/import", + body: { + items: [{ source_path: "/tmp/src", new_profile_name: "Imported" }], + duplicate_strategy: "skip", + }, + operation: "POST /v1/profiles/import", + }, + { + method: "importProfileCookies", + args: ["p1", "[]"], + verb: "POST", + path: "/v1/profiles/p1/cookies/import", + body: { content: "[]" }, + operation: "POST /v1/profiles/{id}/cookies/import", + }, + // -- agent --------------------------------------------------------------- + { + method: "agentPerceive", + args: ["p1", { viewport_only: true, max_bytes: 2048 }], + verb: "POST", + path: "/v1/profiles/p1/agent/perceive", + body: { viewport_only: true, max_bytes: 2048 }, + operation: "POST /v1/profiles/{id}/agent/perceive", + }, + { + method: "agentPerceive", + args: ["p1"], + verb: "POST", + path: "/v1/profiles/p1/agent/perceive", + body: {}, + operation: "POST /v1/profiles/{id}/agent/perceive", + }, + { + method: "agentResolveLocator", + args: ["p1", { locator: LOCATOR, candidate_limit: 5 }], + verb: "POST", + path: "/v1/profiles/p1/agent/resolve-locator", + body: { locator: LOCATOR, candidate_limit: 5 }, + operation: "POST /v1/profiles/{id}/agent/resolve-locator", + }, + { + method: "agentClick", + args: ["p1", { locator: LOCATOR, button: "right", click_count: 2 }], + verb: "POST", + path: "/v1/profiles/p1/agent/click", + body: { locator: LOCATOR, button: "right", click_count: 2 }, + operation: "POST /v1/profiles/{id}/agent/click", + }, + { + method: "agentType", + args: ["p1", { locator: LOCATOR, text: "hello", clear_first: false, wpm: 55 }], + verb: "POST", + path: "/v1/profiles/p1/agent/type", + body: { locator: LOCATOR, text: "hello", clear_first: false, wpm: 55 }, + operation: "POST /v1/profiles/{id}/agent/type", + }, + { + method: "agentExtract", + args: [ + "p1", + { + container: { role: "listitem" }, + field_map: [{ key: "title", locator: { role: "heading" }, source: "text" }], + max_pages: 3, + }, + ], + verb: "POST", + path: "/v1/profiles/p1/agent/extract", + body: { + container: { role: "listitem" }, + field_map: [{ key: "title", locator: { role: "heading" }, source: "text" }], + max_pages: 3, + }, + operation: "POST /v1/profiles/{id}/agent/extract", + }, + { + method: "agentPick", + args: ["p1", { timeout_ms: 15000 }], + verb: "POST", + path: "/v1/profiles/p1/agent/pick", + body: { timeout_ms: 15000 }, + operation: "POST /v1/profiles/{id}/agent/pick", + }, + // -- remote sessions ----------------------------------------------------- + { + method: "listRemoteSessions", + args: [], + verb: "GET", + path: "/v1/remote-sessions", + body: null, + operation: "GET /v1/remote-sessions", + }, + { + method: "getRemoteSession", + args: ["s1"], + verb: "GET", + path: "/v1/remote-sessions/s1", + body: null, + operation: "GET /v1/remote-sessions/{id}", + }, + { + method: "stopRemoteSession", + args: ["s1"], + verb: "DELETE", + path: "/v1/remote-sessions/s1", + body: null, + operation: "DELETE /v1/remote-sessions/{id}", + }, + { + method: "getRemoteHours", + args: [], + verb: "GET", + path: "/v1/remote-hours", + body: null, + operation: "GET /v1/remote-hours", + }, + // -- cookie bot ---------------------------------------------------------- + { + method: "listCookieBotSchedules", + args: [{ scope: "team" }], + verb: "GET", + path: "/v1/cookie-bot/schedules", + body: null, + query: { scope: "team" }, + operation: "GET /v1/cookie-bot/schedules", + }, + { + method: "getCookieBotSchedule", + args: ["p1"], + verb: "GET", + path: "/v1/cookie-bot/schedules/p1", + body: null, + operation: "GET /v1/cookie-bot/schedules/{profile_id}", + }, + { + method: "setCookieBotSchedule", + args: [ + "p1", + { + enabled: true, + run_at_minute: 120, + days_mask: 31, + timezone: "Europe/Berlin", + preset: "steady", + max_minutes: 45, + sites: ["https://example.com"], + acknowledge_conflict: true, + }, + ], + verb: "PUT", + path: "/v1/cookie-bot/schedules/p1", + body: { + enabled: true, + run_at_minute: 120, + days_mask: 31, + timezone: "Europe/Berlin", + preset: "steady", + max_minutes: 45, + sites: ["https://example.com"], + acknowledge_conflict: true, + }, + operation: "PUT /v1/cookie-bot/schedules/{profile_id}", + }, + { + method: "deleteCookieBotSchedule", + args: ["p1"], + verb: "DELETE", + path: "/v1/cookie-bot/schedules/p1", + body: null, + operation: "DELETE /v1/cookie-bot/schedules/{profile_id}", + }, + { + method: "getCookieBotConflicts", + args: ["p1", { run_at_minute: 90, timezone: "UTC", days_mask: 7 }], + verb: "GET", + path: "/v1/cookie-bot/conflicts", + body: null, + query: { profile_id: "p1", run_at_minute: "90", timezone: "UTC", days_mask: "7" }, + operation: "GET /v1/cookie-bot/conflicts", + }, + { + method: "listCookieBotRuns", + args: [{ profile_id: "p1", limit: 10, before: "cursor-1" }], + verb: "GET", + path: "/v1/cookie-bot/runs", + body: null, + query: { profile_id: "p1", limit: "10", before: "cursor-1" }, + operation: "GET /v1/cookie-bot/runs", + }, + { + method: "startCookieBotRun", + args: [{ profile_id: "p1", max_minutes: 30 }], + verb: "POST", + path: "/v1/cookie-bot/runs", + body: { profile_id: "p1", max_minutes: 30 }, + operation: "POST /v1/cookie-bot/runs", + }, + { + method: "cancelCookieBotRun", + args: ["r1"], + verb: "DELETE", + path: "/v1/cookie-bot/runs/r1", + body: null, + operation: "DELETE /v1/cookie-bot/runs/{run_id}", + }, + { + method: "listCookieBotPresets", + args: [], + verb: "GET", + path: "/v1/cookie-bot/presets", + body: null, + operation: "GET /v1/cookie-bot/presets", + }, + { + method: "getCookieBotUsage", + args: [{ period: "2026-08" }], + verb: "GET", + path: "/v1/cookie-bot/usage", + body: null, + query: { period: "2026-08" }, + operation: "GET /v1/cookie-bot/usage", + }, + // -- groups and tags ----------------------------------------------------- + { + method: "listGroups", + args: [], + verb: "GET", + path: "/v1/groups", + body: null, + operation: "GET /v1/groups", + }, + { + method: "getGroup", + args: ["g1"], + verb: "GET", + path: "/v1/groups/g1", + body: null, + operation: "GET /v1/groups/{id}", + }, + { + method: "createGroup", + args: ["Retail"], + verb: "POST", + path: "/v1/groups", + body: { name: "Retail" }, + operation: "POST /v1/groups", + }, + { + method: "updateGroup", + args: ["g1", "Retail EU"], + verb: "PUT", + path: "/v1/groups/g1", + body: { name: "Retail EU" }, + operation: "PUT /v1/groups/{id}", + }, + { + method: "deleteGroup", + args: ["g1"], + verb: "DELETE", + path: "/v1/groups/g1", + body: null, + operation: "DELETE /v1/groups/{id}", + }, + { + method: "listTags", + args: [], + verb: "GET", + path: "/v1/tags", + body: null, + operation: "GET /v1/tags", + }, + // -- proxies ------------------------------------------------------------- + { + method: "listProxies", + args: [], + verb: "GET", + path: "/v1/proxies", + body: null, + operation: "GET /v1/proxies", + }, + { + method: "getProxy", + args: ["x1"], + verb: "GET", + path: "/v1/proxies/x1", + body: null, + operation: "GET /v1/proxies/{id}", + }, + { + method: "createProxy", + args: [{ name: "EU", proxy_settings: { proxy_type: "http", host: "h", port: 8080 } }], + verb: "POST", + path: "/v1/proxies", + body: { name: "EU", proxy_settings: { proxy_type: "http", host: "h", port: 8080 } }, + operation: "POST /v1/proxies", + }, + { + method: "updateProxy", + args: ["x1", { name: "EU 2" }], + verb: "PUT", + path: "/v1/proxies/x1", + body: { name: "EU 2" }, + operation: "PUT /v1/proxies/{id}", + }, + { + method: "deleteProxy", + args: ["x1"], + verb: "DELETE", + path: "/v1/proxies/x1", + body: null, + operation: "DELETE /v1/proxies/{id}", + }, + { + method: "importProxies", + args: [{ format: "txt", content: "h:1:u:p", name_prefix: "EU" }], + verb: "POST", + path: "/v1/proxies/import", + body: { format: "txt", content: "h:1:u:p", name_prefix: "EU" }, + operation: "POST /v1/proxies/import", + }, + // -- vpns ---------------------------------------------------------------- + { + method: "listVpns", + args: [], + verb: "GET", + path: "/v1/vpns", + body: null, + operation: "GET /v1/vpns", + }, + { + method: "getVpn", + args: ["v1"], + verb: "GET", + path: "/v1/vpns/v1", + body: null, + operation: "GET /v1/vpns/{id}", + }, + { + method: "exportVpn", + args: ["v1"], + verb: "GET", + path: "/v1/vpns/v1/export", + body: null, + operation: "GET /v1/vpns/{id}/export", + }, + { + method: "importVpn", + args: [{ content: "[Interface]", filename: "eu.conf" }], + verb: "POST", + path: "/v1/vpns/import", + body: { content: "[Interface]", filename: "eu.conf" }, + operation: "POST /v1/vpns/import", + }, + { + method: "createVpn", + args: [{ name: "EU", vpn_type: "WireGuard", config_data: "[Interface]" }], + verb: "POST", + path: "/v1/vpns", + body: { name: "EU", vpn_type: "WireGuard", config_data: "[Interface]" }, + operation: "POST /v1/vpns", + }, + { + method: "updateVpn", + args: ["v1", "EU 2"], + verb: "PUT", + path: "/v1/vpns/v1", + body: { name: "EU 2" }, + operation: "PUT /v1/vpns/{id}", + }, + { + method: "deleteVpn", + args: ["v1"], + verb: "DELETE", + path: "/v1/vpns/v1", + body: null, + operation: "DELETE /v1/vpns/{id}", + }, + // -- extensions ---------------------------------------------------------- + { + method: "listExtensions", + args: [], + verb: "GET", + path: "/v1/extensions", + body: null, + operation: "GET /v1/extensions", + }, + { + method: "getExtension", + args: ["e1"], + verb: "GET", + path: "/v1/extensions/e1", + body: null, + operation: "GET /v1/extensions/{id}", + }, + { + method: "createExtension", + args: [{ name: "Blocker", file_name: "b.crx", file_data_base64: "AAAA" }], + verb: "POST", + path: "/v1/extensions", + body: { name: "Blocker", file_name: "b.crx", file_data_base64: "AAAA" }, + operation: "POST /v1/extensions", + }, + { + method: "updateExtension", + args: ["e1", { name: "Blocker 2", link: true }], + verb: "PUT", + path: "/v1/extensions/e1", + body: { name: "Blocker 2", link: true }, + operation: "PUT /v1/extensions/{id}", + }, + { + method: "deleteExtension", + args: ["e1"], + verb: "DELETE", + path: "/v1/extensions/e1", + body: null, + operation: "DELETE /v1/extensions/{id}", + }, + { + method: "listExtensionGroups", + args: [], + verb: "GET", + path: "/v1/extension-groups", + body: null, + operation: "GET /v1/extension-groups", + }, + { + method: "getExtensionGroup", + args: ["eg1"], + verb: "GET", + path: "/v1/extension-groups/eg1", + body: null, + operation: "GET /v1/extension-groups/{id}", + }, + { + method: "createExtensionGroup", + args: ["Adblock set"], + verb: "POST", + path: "/v1/extension-groups", + body: { name: "Adblock set" }, + operation: "POST /v1/extension-groups", + }, + { + method: "updateExtensionGroup", + args: ["eg1", { extension_ids: ["e1", "e2"] }], + verb: "PUT", + path: "/v1/extension-groups/eg1", + body: { extension_ids: ["e1", "e2"] }, + operation: "PUT /v1/extension-groups/{id}", + }, + { + method: "deleteExtensionGroup", + args: ["eg1"], + verb: "DELETE", + path: "/v1/extension-groups/eg1", + body: null, + operation: "DELETE /v1/extension-groups/{id}", + }, + { + method: "addExtensionToGroup", + args: ["eg1", "e1"], + verb: "POST", + path: "/v1/extension-groups/eg1/extensions/e1", + body: null, + operation: "POST /v1/extension-groups/{id}/extensions/{extension_id}", + }, + { + method: "removeExtensionFromGroup", + args: ["eg1", "e1"], + verb: "DELETE", + path: "/v1/extension-groups/eg1/extensions/e1", + body: null, + operation: "DELETE /v1/extension-groups/{id}/extensions/{extension_id}", + }, + // -- browsers ------------------------------------------------------------ + { + method: "downloadBrowser", + args: [{ browser: "wayfern", version: "152.0.1" }], + verb: "POST", + path: "/v1/browsers/download", + body: { browser: "wayfern", version: "152.0.1" }, + operation: "POST /v1/browsers/download", + }, + { + method: "listBrowserVersions", + args: ["wayfern"], + verb: "GET", + path: "/v1/browsers/wayfern/versions", + body: null, + operation: "GET /v1/browsers/{browser}/versions", + }, + { + method: "isBrowserDownloaded", + args: ["wayfern", "152.0.1"], + verb: "GET", + path: "/v1/browsers/wayfern/versions/152.0.1/downloaded", + body: null, + operation: "GET /v1/browsers/{browser}/versions/{version}/downloaded", + }, +]; + +for (const [index, expected] of CASES.entries()) { + test(`${expected.method} sends the documented request [${index}]`, async () => { + await withClient(async (client, fake) => { + const callable = (client as unknown as Record Promise>)[ + expected.method + ]; + assert.equal(typeof callable, "function", `${expected.method} is not a method`); + await callable.call(client, ...expected.args); + + const sent = fake.last; + assert.equal(sent.method, expected.verb); + assert.equal(sent.path, expected.path); + assert.deepEqual(sent.query, expected.query ?? {}); + assert.deepEqual(sent.json, expected.body); + assert.equal(OPERATIONS.get(expected.operation), expected.method); + }); + }); +} + +test("every wrapped operation has a request test", () => { + const covered = new Set(CASES.map((entry) => entry.method)); + const missing = [...OPERATIONS.values()].filter((name) => !covered.has(name)).sort(); + assert.deepEqual(missing, [], `these wrapped operations have no request test: ${missing}`); +}); + +test("the token travels as a bearer header", async () => { + await withClient(async (client, fake) => { + await client.listProfiles(); + assert.equal(fake.last.headers.authorization, "Bearer test-token-abc123"); + assert.equal(fake.last.headers.accept, "application/json"); + assert.equal( + fake.last.headers["content-type"], + undefined, + "a GET must not claim to carry JSON", + ); + }); +}); + +test("a body is sent as JSON", async () => { + await withClient(async (client, fake) => { + await client.createGroup("Retail"); + assert.equal(fake.last.headers["content-type"], "application/json"); + assert.equal(fake.last.rawBody, '{"name":"Retail"}'); + }); +}); + +test("path ids are escaped", async () => { + await withClient(async (client, fake) => { + await client.getProfile("a/b c?d"); + assert.equal(fake.last.path, "/v1/profiles/a%2Fb%20c%3Fd"); + }); +}); + +test("undefined arguments are left out of the body", async () => { + await withClient(async (client, fake) => { + await client.updateProfile("p1", { name: "Only this", version: undefined }); + assert.deepEqual(fake.last.json, { name: "Only this" }); + }); +}); + +test("an empty string still reaches the app", async () => { + // `proxy_id: ""` is how the app is told to detach a proxy, so it must survive. + await withClient(async (client, fake) => { + await client.updateProfile("p1", { proxy_id: "" }); + assert.deepEqual(fake.last.json, { proxy_id: "" }); + }); +}); + +test("a no-content answer becomes undefined", async () => { + await withClient(async (client, fake) => { + fake.enqueueEmpty(204); + assert.equal(await client.deleteProfile("p1"), undefined); + }); +}); + +test("a JSON answer is returned as sent", async () => { + await withClient(async (client, fake) => { + fake.enqueueJson({ profiles: [{ id: "p1", name: "Shopper" }], total: 1 }); + assert.deepEqual(await client.listProfiles(), { + profiles: [{ id: "p1", name: "Shopper" }], + total: 1, + }); + }); +}); + +test("a bare boolean answer is returned", async () => { + await withClient(async (client, fake) => { + fake.enqueueJson(true); + assert.equal(await client.isBrowserDownloaded("wayfern", "152.0.1"), true); + }); +}); diff --git a/sdk/node/test/session.test.mts b/sdk/node/test/session.test.mts new file mode 100644 index 0000000..b9a73fb --- /dev/null +++ b/sdk/node/test/session.test.mts @@ -0,0 +1,134 @@ +/** `withProfile` launches, hands over the CDP endpoint, and stops. */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { Conflict, DonutError, RunSession } from "../src/index.mts"; +import { withClient } from "./support.mts"; + +const RUN_BODY = { profile_id: "p1", remote_debugging_port: 9222, headless: true }; + +test("the callback gets the CDP endpoint", async () => { + await withClient(async (client, fake) => { + fake.enqueueJson(RUN_BODY); + fake.enqueueEmpty(204); + + const seen = await client.withProfile( + "p1", + { url: "https://example.com", headless: true }, + (session) => { + assert.ok(session instanceof RunSession); + assert.equal(session.remoteDebuggingPort, 9222); + assert.equal(session.headless, true); + assert.equal(session.cdpUrl, "http://127.0.0.1:9222"); + assert.deepEqual(session.response, RUN_BODY); + return session.cdpUrl; + }, + ); + + assert.equal(seen, "http://127.0.0.1:9222"); + assert.deepEqual( + fake.requests.map((sent) => `${sent.method} ${sent.path}`), + ["POST /v1/profiles/p1/run", "POST /v1/profiles/p1/kill"], + ); + assert.deepEqual(fake.requests[0]?.json, { url: "https://example.com", headless: true }); + }); +}); + +test("the browser is stopped when the callback throws", async () => { + await withClient(async (client, fake) => { + fake.enqueueJson(RUN_BODY); + fake.enqueueEmpty(204); + + await assert.rejects( + client.withProfile("p1", {}, () => { + throw new RangeError("the body failed"); + }), + RangeError, + ); + + assert.deepEqual( + fake.requests.map((sent) => sent.path), + ["/v1/profiles/p1/run", "/v1/profiles/p1/kill"], + ); + }); +}); + +test("a failed stop never hides why the callback failed", async () => { + await withClient(async (client, fake) => { + fake.enqueueJson(RUN_BODY); + fake.enqueueError(409, "PROFILE_LOCKED_ELSEWHERE"); + + let captured: RunSession | undefined; + await assert.rejects( + client.withProfile("p1", {}, (session) => { + captured = session; + throw new RangeError("the body failed"); + }), + RangeError, + ); + + assert.ok(captured?.cleanupError instanceof Conflict); + }); +}); + +test("a failed stop is thrown when the callback was fine", async () => { + await withClient(async (client, fake) => { + fake.enqueueJson(RUN_BODY); + fake.enqueueError(503, "the fleet could not be reached"); + + await assert.rejects( + client.withProfile("p1", {}, () => "done"), + DonutError, + ); + }); +}); + +test("a failed launch never runs the callback and stops nothing", async () => { + await withClient(async (client, fake) => { + fake.enqueueError(409, "PROFILE_RUNNING"); + + await assert.rejects( + client.withProfile("p1", {}, () => { + throw new Error("the callback must not run when the launch failed"); + }), + Conflict, + ); + + assert.deepEqual( + fake.requests.map((sent) => sent.path), + ["/v1/profiles/p1/run"], + ); + }); +}); + +test("an async callback is awaited before the browser is stopped", async () => { + await withClient(async (client, fake) => { + fake.enqueueJson(RUN_BODY); + fake.enqueueJson({ profiles: [], total: 0 }); + fake.enqueueEmpty(204); + + await client.withProfile("p1", {}, async () => { + await client.listProfiles(); + }); + + assert.deepEqual( + fake.requests.map((sent) => sent.path), + ["/v1/profiles/p1/run", "/v1/profiles", "/v1/profiles/p1/kill"], + ); + }); +}); + +test("a session also disposes itself", async () => { + // `withProfile` is the portable form, but a runtime with `await using` can + // hold a RunSession directly. + await withClient(async (client, fake) => { + fake.enqueueEmpty(204); + const session = new RunSession(client, "p1", RUN_BODY); + await session[Symbol.asyncDispose](); + assert.deepEqual( + fake.requests.map((sent) => sent.path), + ["/v1/profiles/p1/kill"], + ); + }); +}); diff --git a/sdk/node/test/support.mts b/sdk/node/test/support.mts new file mode 100644 index 0000000..ba53491 --- /dev/null +++ b/sdk/node/test/support.mts @@ -0,0 +1,22 @@ +import { DonutClient } from "../src/index.mts"; +import { FakeDonut } from "./fake-donut.mts"; + +export const TOKEN = "test-token-abc123"; + +/** Start a fake app, point a client at it, and always shut the server down. */ +export async function withClient( + work: (client: DonutClient, fake: FakeDonut) => Promise, +): Promise { + const fake = await new FakeDonut().start(); + try { + const client = new DonutClient({ + token: TOKEN, + port: fake.port, + timeoutMs: 5_000, + env: {}, + }); + return await work(client, fake); + } finally { + await fake.stop(); + } +} diff --git a/sdk/node/tsconfig.json b/sdk/node/tsconfig.json new file mode 100644 index 0000000..4ec81ea --- /dev/null +++ b/sdk/node/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023", "DOM", "ESNext.Disposable"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "exactOptionalPropertyTypes": false, + "noUncheckedIndexedAccess": true, + "declaration": true, + "noEmitOnError": true, + "declarationMap": true, + "sourceMap": true, + "removeComments": false, + "outDir": "dist", + "rootDir": "src", + "types": [], + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": ["src/**/*.mts"] +} diff --git a/sdk/python/README.md b/sdk/python/README.md new file mode 100644 index 0000000..a9d66e6 --- /dev/null +++ b/sdk/python/README.md @@ -0,0 +1,37 @@ +# donutbrowser + +A thin Python client for the [Donut Browser](https://donutbrowser.com) local +REST API. Every method wraps exactly one documented endpoint; nothing is +invented, cached or retried. + +The local API is off by default. Switch it on in the app under **Settings → +Integrations → Local API → "Enable Local API Server"**, then copy the port and +the authentication token from that screen. + +```bash +pip install -e . # from this directory +``` + +```python +from donutbrowser import DonutClient + +with DonutClient(token="...") as client: + with client.run(profile_id, url="https://example.com", headless=True) as session: + print(session.cdp_url) +``` + +The client reads `DONUT_API_TOKEN` and `DONUT_API_PORT` when the token and port +are not passed as arguments. + +Full documentation, including the Node package and a worked agent example, is in +[`sdk/README.md`](../README.md). + +## Tests + +```bash +pip install -e ".[dev]" +pytest +``` + +The suite runs entirely against a fake HTTP server on loopback. It never reaches +the network and never needs the desktop app. diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml new file mode 100644 index 0000000..d4f0677 --- /dev/null +++ b/sdk/python/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "donutbrowser" +version = "0.1.0" +description = "Thin client for the Donut Browser local REST API" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "AGPL-3.0" } +keywords = ["donut-browser", "browser-automation", "anti-detect", "cdp"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Internet :: WWW/HTTP", + "Typing :: Typed", +] +# No runtime dependencies on purpose: this client talks to a loopback server on +# the same machine, so the standard library is enough and installing the SDK can +# never drag a transitive dependency into an automation environment. +dependencies = [] + +[project.optional-dependencies] +dev = ["pytest>=7"] + +[project.urls] +Homepage = "https://donutbrowser.com" +Source = "https://github.com/zhom/donutbrowser" + +[tool.hatch.build.targets.wheel] +packages = ["src/donutbrowser"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/sdk/python/src/donutbrowser/__init__.py b/sdk/python/src/donutbrowser/__init__.py new file mode 100644 index 0000000..ba01bea --- /dev/null +++ b/sdk/python/src/donutbrowser/__init__.py @@ -0,0 +1,59 @@ +"""Donut Browser SDK: a thin client for the app's local REST API. + +The local API is off by default. Switch it on in the app under **Settings, +Integrations, Local API, "Enable Local API Server"**, and copy the port and the +authentication token from that screen. + +:: + + from donutbrowser import DonutClient + + with DonutClient(token="...") as client: + with client.run(profile_id, url="https://example.com") as session: + client.agent_click(profile_id, locator={"role": "button", "name": "Sign in"}) +""" + +from .client import DEFAULT_HOST, DEFAULT_PORT, DonutClient, RunSession +from .coverage import OMITTED, OPERATIONS +from .errors import ( + BadGateway, + Conflict, + DonutAPIError, + DonutConnectionError, + DonutError, + Forbidden, + NotFound, + PaymentRequired, + RateLimited, + RequestTimeout, + ServerError, + ServiceUnavailable, + Unauthorized, + ValidationError, +) + +__version__ = "0.1.0" + +__all__ = [ + "DonutClient", + "RunSession", + "DEFAULT_HOST", + "DEFAULT_PORT", + "OPERATIONS", + "OMITTED", + "DonutError", + "DonutConnectionError", + "DonutAPIError", + "ValidationError", + "Unauthorized", + "PaymentRequired", + "Forbidden", + "NotFound", + "RequestTimeout", + "Conflict", + "RateLimited", + "ServerError", + "BadGateway", + "ServiceUnavailable", + "__version__", +] diff --git a/sdk/python/src/donutbrowser/client.py b/sdk/python/src/donutbrowser/client.py new file mode 100644 index 0000000..1cfa9ec --- /dev/null +++ b/sdk/python/src/donutbrowser/client.py @@ -0,0 +1,1183 @@ +"""A thin client for the Donut Browser local REST API. + +Every method here is one request to one documented path. Nothing is cached, +nothing is retried, and nothing is invented: if a method exists below, the app +publishes that operation in its ``/openapi.json``. + +Only the standard library is used, deliberately. See ``sdk/README.md``. +""" + +from __future__ import annotations + +import http.client +import json +import os +import socket +import threading +from types import TracebackType +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Type, Union +from urllib.parse import quote, urlencode, urlsplit + +from .errors import DonutConnectionError, DonutError, error_for_status +from .models import ( + AgentClick, + AgentTyping, + ApiGroupResponse, + ApiProfileResponse, + ApiProfilesResponse, + ApiProxyResponse, + ApiRemoteSessionsResponse, + ApiVpnExportResponse, + ApiVpnResponse, + BatchRunResponse, + BatchStopResponse, + CookieBotConflictCheck, + CookieBotPresetList, + CookieBotRun, + CookieBotRunPage, + CookieBotRunStarted, + CookieBotSchedule, + CookieBotScheduleDeleted, + CookieBotScheduleList, + CookieBotScheduleSaved, + CookieBotUsage, + DetectedProfilesResponse, + DistributeProxiesResponse, + DownloadBrowserResponse, + Extension, + ExtensionGroup, + Extraction, + ExtractionField, + ImportCookiesResponse, + ImportProfileItem, + ImportProxiesResponse, + LocatorDescription, + LocatorResolution, + PerceptionPage, + PickedElement, + ProfileImportBatchResult, + ProxyPair, + ProxySettings, + RemoteHoursQuota, + RemoteSessionState, + RunProfileResponse, + RunRemoteResponse, + SetCloudSyncResponse, + StopRemoteResponse, + WayfernConfig, +) + +__all__ = ["DonutClient", "RunSession", "DEFAULT_PORT", "DEFAULT_HOST"] + +#: The port the app offers by default in Settings, Integrations, Local API. +DEFAULT_PORT = 10108 + +#: The API binds loopback only. It is never reachable from another machine. +DEFAULT_HOST = "127.0.0.1" + +_JSON = "application/json" + +QueryValue = Union[str, int, bool, None] + + +def _body(**fields: Any) -> Dict[str, Any]: + """Drop every key the caller left unset. + + The app reads a missing key and an explicit ``null`` the same way, so + omitting is always the faithful encoding of "the caller said nothing". + Where a value has to be cleared, the app documents an empty string for it + (``proxy_id=""`` detaches a proxy), and an empty string survives this. + """ + return {name: value for name, value in fields.items() if value is not None} + + +def _query(**fields: QueryValue) -> Dict[str, str]: + encoded: Dict[str, str] = {} + for name, value in fields.items(): + if value is None: + continue + encoded[name] = "true" if value is True else "false" if value is False else str(value) + return encoded + + +def _segment(value: str) -> str: + """Escape one path segment so an id with a slash or a space cannot forge a path.""" + return quote(str(value), safe="") + + +class DonutClient: + """A connection to one running Donut Browser. + + The local API must be switched on first: **Settings, Integrations, Local + API, "Enable Local API Server"**. That screen shows the port and the + authentication token to use here. + + Arguments win over the environment: + + * ``token`` falls back to ``DONUT_API_TOKEN``. + * ``port`` falls back to ``DONUT_API_PORT``, then to ``10108``. + * ``base_url``, when given, overrides ``host`` and ``port`` entirely. + + The client keeps one connection open and is safe to share between threads; + requests on it are serialised. + """ + + def __init__( + self, + base_url: Optional[str] = None, + token: Optional[str] = None, + timeout: float = 30.0, + *, + host: Optional[str] = None, + port: Optional[int] = None, + env: Optional[Mapping[str, str]] = None, + ) -> None: + environment = os.environ if env is None else env + + resolved_token = token if token is not None else environment.get("DONUT_API_TOKEN") + if not resolved_token: + raise DonutError( + "No API token. Pass token=..., or set DONUT_API_TOKEN. The token is " + "shown in the app under Settings, Integrations, Local API." + ) + + if base_url: + parts = urlsplit(base_url if "//" in base_url else f"http://{base_url}") + if parts.scheme not in ("http", "https"): + raise DonutError(f"base_url must be http or https, got {parts.scheme!r}") + self.scheme = parts.scheme + self.host = parts.hostname or DEFAULT_HOST + self.port = parts.port or (443 if parts.scheme == "https" else 80) + self._prefix = parts.path.rstrip("/") + else: + resolved_port = port + if resolved_port is None: + raw_port = environment.get("DONUT_API_PORT") + if raw_port: + try: + resolved_port = int(raw_port) + except ValueError as invalid: + raise DonutError( + f"DONUT_API_PORT is not a number: {raw_port!r}" + ) from invalid + self.scheme = "http" + self.host = host or DEFAULT_HOST + self.port = resolved_port if resolved_port is not None else DEFAULT_PORT + self._prefix = "" + + self.token = resolved_token + self.timeout = timeout + self._lock = threading.Lock() + self._connection: Optional[http.client.HTTPConnection] = None + + @property + def base_url(self) -> str: + return f"{self.scheme}://{self.host}:{self.port}{self._prefix}" + + def __enter__(self) -> "DonutClient": + return self + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + self.close() + + def close(self) -> None: + """Drop the kept-alive connection. Calling a method again reopens one.""" + with self._lock: + if self._connection is not None: + self._connection.close() + self._connection = None + + # ------------------------------------------------------------------ + # Transport + # ------------------------------------------------------------------ + + def _open(self) -> http.client.HTTPConnection: + if self.scheme == "https": + return http.client.HTTPSConnection(self.host, self.port, timeout=self.timeout) + return http.client.HTTPConnection(self.host, self.port, timeout=self.timeout) + + def _request( + self, + method: str, + path: str, + *, + body: Optional[Any] = None, + query: Optional[Mapping[str, str]] = None, + ) -> Any: + target = f"{self._prefix}{path}" + if query: + target = f"{target}?{urlencode(query)}" + + payload = None if body is None else json.dumps(body).encode("utf-8") + headers = {"Authorization": f"Bearer {self.token}", "Accept": _JSON} + if payload is not None: + headers["Content-Type"] = _JSON + + with self._lock: + # One retry, and only for a connection that was already open: a + # kept-alive socket the app closed between calls fails on send, + # and reporting that as "Donut is unreachable" would be a lie. + for attempt in (0, 1): + reused = self._connection is not None + if self._connection is None: + self._connection = self._open() + try: + self._connection.request(method, target, body=payload, headers=headers) + response = self._connection.getresponse() + status = response.status + raw = response.read() + response_headers = dict(response.getheaders()) + if response.will_close: + self._connection.close() + self._connection = None + break + except (http.client.HTTPException, socket.error) as failure: + self._connection.close() + self._connection = None + if reused and attempt == 0: + continue + raise DonutConnectionError( + f"Could not reach Donut Browser at {self.base_url} " + f"({method} {path}): {failure}. Is the app running with " + "Settings, Integrations, Local API switched on?" + ) from failure + + text = raw.decode("utf-8", errors="replace") + if status >= 400: + raise error_for_status( + status, text, method=method, path=path, headers=response_headers + ) + if status == 204 or not text.strip(): + return None + try: + return json.loads(text) + except ValueError as invalid: + raise DonutError( + f"{method} {path} answered {status} with a body that is not JSON: {text[:200]!r}" + ) from invalid + + # ------------------------------------------------------------------ + # Profiles + # ------------------------------------------------------------------ + + def list_profiles(self) -> ApiProfilesResponse: + """GET /v1/profiles""" + return self._request("GET", "/v1/profiles") + + def get_profile(self, profile_id: str) -> ApiProfileResponse: + """GET /v1/profiles/{id}""" + return self._request("GET", f"/v1/profiles/{_segment(profile_id)}") + + def create_profile( + self, + *, + name: str, + browser: str, + version: Optional[str] = None, + proxy_id: Optional[str] = None, + vpn_id: Optional[str] = None, + launch_hook: Optional[str] = None, + release_type: Optional[str] = None, + wayfern_config: Optional[WayfernConfig] = None, + group_id: Optional[str] = None, + tags: Optional[Sequence[str]] = None, + ephemeral: Optional[bool] = None, + temporary: Optional[bool] = None, + ) -> ApiProfileResponse: + """POST /v1/profiles + + ``browser`` must be ``"wayfern"``; anything else is refused with 400. + ``version`` must already be downloaded, so omit it (or pass + ``"latest"``) to take the newest local build. + """ + return self._request( + "POST", + "/v1/profiles", + body=_body( + name=name, + browser=browser, + version=version, + proxy_id=proxy_id, + vpn_id=vpn_id, + launch_hook=launch_hook, + release_type=release_type, + wayfern_config=wayfern_config, + group_id=group_id, + tags=list(tags) if tags is not None else None, + ephemeral=ephemeral, + temporary=temporary, + ), + ) + + def update_profile( + self, + profile_id: str, + *, + name: Optional[str] = None, + version: Optional[str] = None, + proxy_id: Optional[str] = None, + vpn_id: Optional[str] = None, + launch_hook: Optional[str] = None, + release_type: Optional[str] = None, + group_id: Optional[str] = None, + tags: Optional[Sequence[str]] = None, + extension_group_id: Optional[str] = None, + proxy_bypass_rules: Optional[Sequence[str]] = None, + sync_mode: Optional[str] = None, + clear_on_close: Optional[bool] = None, + ) -> ApiProfileResponse: + """PUT /v1/profiles/{id} + + A profile's browser engine is fixed at creation, so there is no + ``browser`` argument. Pass ``proxy_id=""`` or ``vpn_id=""`` to detach + one; leaving either unset changes nothing. + """ + return self._request( + "PUT", + f"/v1/profiles/{_segment(profile_id)}", + body=_body( + name=name, + version=version, + proxy_id=proxy_id, + vpn_id=vpn_id, + launch_hook=launch_hook, + release_type=release_type, + group_id=group_id, + tags=list(tags) if tags is not None else None, + extension_group_id=extension_group_id, + proxy_bypass_rules=( + list(proxy_bypass_rules) if proxy_bypass_rules is not None else None + ), + sync_mode=sync_mode, + clear_on_close=clear_on_close, + ), + ) + + def delete_profile(self, profile_id: str) -> None: + """DELETE /v1/profiles/{id}""" + self._request("DELETE", f"/v1/profiles/{_segment(profile_id)}") + + def run_profile( + self, + profile_id: str, + *, + url: Optional[str] = None, + headless: Optional[bool] = None, + ) -> RunProfileResponse: + """POST /v1/profiles/{id}/run + + Prefer :meth:`run`, which stops the browser again when the block ends. + """ + return self._request( + "POST", + f"/v1/profiles/{_segment(profile_id)}/run", + body=_body(url=url, headless=headless), + ) + + def run_profile_remote( + self, profile_id: str, *, url: Optional[str] = None + ) -> RunRemoteResponse: + """POST /v1/profiles/{id}/run-remote""" + return self._request( + "POST", + f"/v1/profiles/{_segment(profile_id)}/run-remote", + body=_body(url=url), + ) + + def set_profile_cloud_sync(self, profile_id: str, *, mode: str) -> SetCloudSyncResponse: + """POST /v1/profiles/{id}/cloud-sync + + ``mode`` is ``"Disabled"``, ``"Regular"`` or ``"Encrypted"``. An + encrypted profile cannot be launched remotely: its key never leaves + this machine, so a remote host would download ciphertext. + """ + return self._request( + "POST", + f"/v1/profiles/{_segment(profile_id)}/cloud-sync", + body={"mode": mode}, + ) + + def open_url(self, profile_id: str, url: str) -> None: + """POST /v1/profiles/{id}/open-url""" + self._request( + "POST", + f"/v1/profiles/{_segment(profile_id)}/open-url", + body={"url": url}, + ) + + def kill_profile(self, profile_id: str) -> None: + """POST /v1/profiles/{id}/kill + + A 503 here means the fleet could not be reached and the remote browser + is *still running*, not that it stopped. + """ + self._request("POST", f"/v1/profiles/{_segment(profile_id)}/kill") + + def batch_run_profiles( + self, + profile_ids: Sequence[str], + *, + url: Optional[str] = None, + headless: Optional[bool] = None, + ) -> BatchRunResponse: + """POST /v1/profiles/batch/run + + Answers 200 even when some profiles failed; read ``results[].ok``. + """ + return self._request( + "POST", + "/v1/profiles/batch/run", + body=_body(profile_ids=list(profile_ids), url=url, headless=headless), + ) + + def batch_stop_profiles(self, profile_ids: Sequence[str]) -> BatchStopResponse: + """POST /v1/profiles/batch/stop""" + return self._request( + "POST", + "/v1/profiles/batch/stop", + body={"profile_ids": list(profile_ids)}, + ) + + def detect_import_profiles(self, *, folder: Optional[str] = None) -> DetectedProfilesResponse: + """GET /v1/profiles/import/detect + + Without ``folder`` the app scans the default browser locations. + """ + return self._request( + "GET", "/v1/profiles/import/detect", query=_query(folder=folder) + ) + + def import_profiles( + self, + items: Iterable[ImportProfileItem], + *, + group_id: Optional[str] = None, + duplicate_strategy: Optional[str] = None, + wayfern_config: Optional[WayfernConfig] = None, + ) -> ProfileImportBatchResult: + """POST /v1/profiles/import + + ``duplicate_strategy`` is ``"skip"`` or ``"rename"`` (the default). + Each item is isolated: one failure does not stop the rest. + """ + return self._request( + "POST", + "/v1/profiles/import", + body=_body( + items=[dict(item) for item in items], + group_id=group_id, + duplicate_strategy=duplicate_strategy, + wayfern_config=wayfern_config, + ), + ) + + def import_profile_cookies(self, profile_id: str, *, content: str) -> ImportCookiesResponse: + """POST /v1/profiles/{id}/cookies/import + + ``content`` is a raw cookie file. The format is detected: a JSON array + in the Puppeteer style, or a Netscape ``cookies.txt``. + """ + return self._request( + "POST", + f"/v1/profiles/{_segment(profile_id)}/cookies/import", + body={"content": content}, + ) + + def distribute_proxies(self, pairs: Sequence[ProxyPair]) -> DistributeProxiesResponse: + """POST /v1/profiles/distribute-proxies + + Applies one proxy per profile. Configuration rather than automation, so + it costs no automation quota. Answers 200 even when some pairs failed: + read ``results[].ok``, and note that a profile whose browser is running + is refused rather than moved. + """ + return self._request( + "POST", + "/v1/profiles/distribute-proxies", + body={"pairs": [dict(pair) for pair in pairs]}, + ) + + # ------------------------------------------------------------------ + # Agent: reading and driving a running profile + # ------------------------------------------------------------------ + + def agent_perceive( + self, + profile_id: str, + *, + max_bytes: Optional[int] = None, + budget_ms: Optional[int] = None, + max_nodes: Optional[int] = None, + include_text: Optional[bool] = None, + viewport_only: Optional[bool] = None, + text_order: Optional[str] = None, + cursor: Optional[str] = None, + ) -> PerceptionPage: + """POST /v1/profiles/{id}/agent/perceive + + When the answer says ``truncated``, pass its ``cursor`` back to + continue where it stopped. + """ + return self._request( + "POST", + f"/v1/profiles/{_segment(profile_id)}/agent/perceive", + body=_body( + max_bytes=max_bytes, + budget_ms=budget_ms, + max_nodes=max_nodes, + include_text=include_text, + viewport_only=viewport_only, + text_order=text_order, + cursor=cursor, + ), + ) + + def agent_resolve_locator( + self, + profile_id: str, + *, + locator: LocatorDescription, + candidate_limit: Optional[int] = None, + ) -> LocatorResolution: + """POST /v1/profiles/{id}/agent/resolve-locator + + Succeeds only when the locator matches exactly one element. + """ + return self._request( + "POST", + f"/v1/profiles/{_segment(profile_id)}/agent/resolve-locator", + body=_body(locator=dict(locator), candidate_limit=candidate_limit), + ) + + def agent_click( + self, + profile_id: str, + *, + locator: LocatorDescription, + button: Optional[str] = None, + click_count: Optional[int] = None, + ) -> AgentClick: + """POST /v1/profiles/{id}/agent/click + + ``button`` is ``"left"`` (the default), ``"middle"``, ``"right"``, + ``"back"`` or ``"forward"``. + """ + return self._request( + "POST", + f"/v1/profiles/{_segment(profile_id)}/agent/click", + body=_body(locator=dict(locator), button=button, click_count=click_count), + ) + + def agent_type( + self, + profile_id: str, + *, + locator: LocatorDescription, + text: str, + clear_first: Optional[bool] = None, + typos: Optional[bool] = None, + wpm: Optional[float] = None, + ) -> AgentTyping: + """POST /v1/profiles/{id}/agent/type + + ``wpm`` is honoured by the fallback engine only; a recent Wayfern types + at the profile's own rhythm. + """ + return self._request( + "POST", + f"/v1/profiles/{_segment(profile_id)}/agent/type", + body=_body( + locator=dict(locator), + text=text, + clear_first=clear_first, + typos=typos, + wpm=wpm, + ), + ) + + def agent_extract( + self, + profile_id: str, + *, + container: LocatorDescription, + field_map: Sequence[ExtractionField], + next_page: Optional[LocatorDescription] = None, + max_pages: Optional[int] = None, + max_rows: Optional[int] = None, + max_bytes: Optional[int] = None, + max_nodes: Optional[int] = None, + time_budget_ms: Optional[int] = None, + ) -> Extraction: + """POST /v1/profiles/{id}/agent/extract + + A container that matches nothing is a result with ``stopReason`` set + to ``"no-container"``, not an error. + """ + return self._request( + "POST", + f"/v1/profiles/{_segment(profile_id)}/agent/extract", + body=_body( + container=dict(container), + field_map=[dict(field) for field in field_map], + next_page=dict(next_page) if next_page is not None else None, + max_pages=max_pages, + max_rows=max_rows, + max_bytes=max_bytes, + max_nodes=max_nodes, + time_budget_ms=time_budget_ms, + ), + ) + + def agent_pick(self, profile_id: str, *, timeout_ms: Optional[int] = None) -> PickedElement: + """POST /v1/profiles/{id}/agent/pick + + Arms a picker in the visible browser and waits for a human to click + something. Nothing picked inside ``timeout_ms`` raises + :class:`~donutbrowser.errors.RequestTimeout`. + """ + return self._request( + "POST", + f"/v1/profiles/{_segment(profile_id)}/agent/pick", + body=_body(timeout_ms=timeout_ms), + ) + + # ------------------------------------------------------------------ + # Remote sessions + # ------------------------------------------------------------------ + + def list_remote_sessions(self) -> ApiRemoteSessionsResponse: + """GET /v1/remote-sessions""" + return self._request("GET", "/v1/remote-sessions") + + def get_remote_session(self, session_id: str) -> RemoteSessionState: + """GET /v1/remote-sessions/{id}""" + return self._request("GET", f"/v1/remote-sessions/{_segment(session_id)}") + + def stop_remote_session(self, session_id: str) -> StopRemoteResponse: + """DELETE /v1/remote-sessions/{id}""" + return self._request("DELETE", f"/v1/remote-sessions/{_segment(session_id)}") + + def remote_session_cdp_url(self, session_id: str) -> str: + """The websocket address of ``GET /v1/remote-sessions/{id}/cdp``. + + That path is a WebSocket upgrade, not a request this client can make, + so it builds the address and leaves the socket to a websocket library. + Send the same ``Authorization: Bearer`` header on the handshake. + """ + scheme = "wss" if self.scheme == "https" else "ws" + return ( + f"{scheme}://{self.host}:{self.port}{self._prefix}" + f"/v1/remote-sessions/{_segment(session_id)}/cdp" + ) + + def get_remote_hours(self) -> RemoteHoursQuota: + """GET /v1/remote-hours""" + return self._request("GET", "/v1/remote-hours") + + # ------------------------------------------------------------------ + # Cookie bot + # ------------------------------------------------------------------ + + def list_cookie_bot_schedules(self, *, scope: Optional[str] = None) -> CookieBotScheduleList: + """GET /v1/cookie-bot/schedules + + ``scope`` is ``"mine"`` (the default) or ``"team"``. + """ + return self._request( + "GET", "/v1/cookie-bot/schedules", query=_query(scope=scope) + ) + + def get_cookie_bot_schedule(self, profile_id: str) -> CookieBotSchedule: + """GET /v1/cookie-bot/schedules/{profile_id}""" + return self._request("GET", f"/v1/cookie-bot/schedules/{_segment(profile_id)}") + + def set_cookie_bot_schedule( + self, + profile_id: str, + *, + enabled: bool, + run_at_minute: int, + days_mask: int, + timezone: str, + preset: str, + max_minutes: int, + profile_name: Optional[str] = None, + platform: Optional[str] = None, + sites: Optional[Sequence[str]] = None, + jitter_seconds: Optional[int] = None, + acknowledge_conflict: Optional[bool] = None, + ) -> CookieBotScheduleSaved: + """PUT /v1/cookie-bot/schedules/{profile_id} + + ``run_at_minute`` is minutes past local midnight (0 to 1439) and + ``days_mask`` is a weekday bitmask with bit 0 as Monday. A teammate + already enrolling this profile makes the write 409 until + ``acknowledge_conflict=True``. + """ + return self._request( + "PUT", + f"/v1/cookie-bot/schedules/{_segment(profile_id)}", + body=_body( + profile_name=profile_name, + platform=platform, + enabled=enabled, + run_at_minute=run_at_minute, + days_mask=days_mask, + timezone=timezone, + preset=preset, + max_minutes=max_minutes, + sites=list(sites) if sites is not None else None, + jitter_seconds=jitter_seconds, + acknowledge_conflict=acknowledge_conflict, + ), + ) + + def delete_cookie_bot_schedule(self, profile_id: str) -> CookieBotScheduleDeleted: + """DELETE /v1/cookie-bot/schedules/{profile_id}""" + return self._request("DELETE", f"/v1/cookie-bot/schedules/{_segment(profile_id)}") + + def get_cookie_bot_conflicts( + self, + profile_id: str, + *, + run_at_minute: Optional[int] = None, + timezone: Optional[str] = None, + days_mask: Optional[int] = None, + ) -> CookieBotConflictCheck: + """GET /v1/cookie-bot/conflicts + + A dry run: asks who else enrols this profile, without writing. + """ + return self._request( + "GET", + "/v1/cookie-bot/conflicts", + query=_query( + profile_id=profile_id, + run_at_minute=run_at_minute, + timezone=timezone, + days_mask=days_mask, + ), + ) + + def list_cookie_bot_runs( + self, + *, + profile_id: Optional[str] = None, + scope: Optional[str] = None, + limit: Optional[int] = None, + before: Optional[str] = None, + ) -> CookieBotRunPage: + """GET /v1/cookie-bot/runs + + Newest first. ``before`` is the ``next_before`` of the previous page. + """ + return self._request( + "GET", + "/v1/cookie-bot/runs", + query=_query(profile_id=profile_id, scope=scope, limit=limit, before=before), + ) + + def start_cookie_bot_run( + self, *, profile_id: str, max_minutes: Optional[int] = None + ) -> CookieBotRunStarted: + """POST /v1/cookie-bot/runs + + Answers 202: the run keeps going for minutes after this returns. The + profile must already have a schedule, which is where the preset and + the site list live. + """ + return self._request( + "POST", + "/v1/cookie-bot/runs", + body=_body(profile_id=profile_id, max_minutes=max_minutes), + ) + + def cancel_cookie_bot_run(self, run_id: str) -> CookieBotRun: + """DELETE /v1/cookie-bot/runs/{run_id}""" + return self._request("DELETE", f"/v1/cookie-bot/runs/{_segment(run_id)}") + + def list_cookie_bot_presets(self) -> CookieBotPresetList: + """GET /v1/cookie-bot/presets""" + return self._request("GET", "/v1/cookie-bot/presets") + + def get_cookie_bot_usage(self, *, period: Optional[str] = None) -> CookieBotUsage: + """GET /v1/cookie-bot/usage + + ``period`` is ``YYYY-MM``, defaulting to the current UTC month. + """ + return self._request("GET", "/v1/cookie-bot/usage", query=_query(period=period)) + + # ------------------------------------------------------------------ + # Groups and tags + # ------------------------------------------------------------------ + + def list_groups(self) -> List[ApiGroupResponse]: + """GET /v1/groups""" + return self._request("GET", "/v1/groups") + + def get_group(self, group_id: str) -> ApiGroupResponse: + """GET /v1/groups/{id}""" + return self._request("GET", f"/v1/groups/{_segment(group_id)}") + + def create_group(self, *, name: str) -> ApiGroupResponse: + """POST /v1/groups""" + return self._request("POST", "/v1/groups", body={"name": name}) + + def update_group(self, group_id: str, *, name: str) -> ApiGroupResponse: + """PUT /v1/groups/{id}""" + return self._request("PUT", f"/v1/groups/{_segment(group_id)}", body={"name": name}) + + def delete_group(self, group_id: str) -> None: + """DELETE /v1/groups/{id}""" + self._request("DELETE", f"/v1/groups/{_segment(group_id)}") + + def list_tags(self) -> List[str]: + """GET /v1/tags""" + return self._request("GET", "/v1/tags") + + # ------------------------------------------------------------------ + # Proxies + # ------------------------------------------------------------------ + + def list_proxies(self) -> List[ApiProxyResponse]: + """GET /v1/proxies""" + return self._request("GET", "/v1/proxies") + + def get_proxy(self, proxy_id: str) -> ApiProxyResponse: + """GET /v1/proxies/{id}""" + return self._request("GET", f"/v1/proxies/{_segment(proxy_id)}") + + def create_proxy(self, *, name: str, proxy_settings: ProxySettings) -> ApiProxyResponse: + """POST /v1/proxies""" + return self._request( + "POST", + "/v1/proxies", + body={"name": name, "proxy_settings": dict(proxy_settings)}, + ) + + def update_proxy( + self, + proxy_id: str, + *, + name: Optional[str] = None, + proxy_settings: Optional[ProxySettings] = None, + ) -> ApiProxyResponse: + """PUT /v1/proxies/{id}""" + return self._request( + "PUT", + f"/v1/proxies/{_segment(proxy_id)}", + body=_body( + name=name, + proxy_settings=dict(proxy_settings) if proxy_settings is not None else None, + ), + ) + + def delete_proxy(self, proxy_id: str) -> None: + """DELETE /v1/proxies/{id}""" + self._request("DELETE", f"/v1/proxies/{_segment(proxy_id)}") + + def import_proxies( + self, + *, + format: str, + content: str, + name_prefix: Optional[str] = None, + ) -> ImportProxiesResponse: + """POST /v1/proxies/import + + ``format`` is ``"txt"`` (one proxy per line) or ``"json"`` (a Donut + proxy export). + """ + return self._request( + "POST", + "/v1/proxies/import", + body=_body(format=format, content=content, name_prefix=name_prefix), + ) + + # ------------------------------------------------------------------ + # VPNs + # ------------------------------------------------------------------ + + def list_vpns(self) -> List[ApiVpnResponse]: + """GET /v1/vpns""" + return self._request("GET", "/v1/vpns") + + def get_vpn(self, vpn_id: str) -> ApiVpnResponse: + """GET /v1/vpns/{id}""" + return self._request("GET", f"/v1/vpns/{_segment(vpn_id)}") + + def export_vpn(self, vpn_id: str) -> ApiVpnExportResponse: + """GET /v1/vpns/{id}/export + + Returns the decrypted ``.conf`` text. Treat it as a secret. + """ + return self._request("GET", f"/v1/vpns/{_segment(vpn_id)}/export") + + def import_vpn( + self, *, content: str, filename: str, name: Optional[str] = None + ) -> ApiVpnResponse: + """POST /v1/vpns/import""" + return self._request( + "POST", + "/v1/vpns/import", + body=_body(content=content, filename=filename, name=name), + ) + + def create_vpn(self, *, name: str, vpn_type: str, config_data: str) -> ApiVpnResponse: + """POST /v1/vpns + + ``vpn_type`` must be ``"WireGuard"``. + """ + return self._request( + "POST", + "/v1/vpns", + body={"name": name, "vpn_type": vpn_type, "config_data": config_data}, + ) + + def update_vpn(self, vpn_id: str, *, name: str) -> ApiVpnResponse: + """PUT /v1/vpns/{id}""" + return self._request("PUT", f"/v1/vpns/{_segment(vpn_id)}", body={"name": name}) + + def delete_vpn(self, vpn_id: str) -> None: + """DELETE /v1/vpns/{id}""" + self._request("DELETE", f"/v1/vpns/{_segment(vpn_id)}") + + # ------------------------------------------------------------------ + # Extensions + # ------------------------------------------------------------------ + + def list_extensions(self) -> List[Extension]: + """GET /v1/extensions""" + return self._request("GET", "/v1/extensions") + + def get_extension(self, extension_id: str) -> Extension: + """GET /v1/extensions/{id}""" + return self._request("GET", f"/v1/extensions/{_segment(extension_id)}") + + def create_extension( + self, + *, + name: Optional[str] = None, + file_name: Optional[str] = None, + file_data_base64: Optional[str] = None, + source_path: Optional[str] = None, + link: Optional[bool] = None, + ) -> Extension: + """POST /v1/extensions + + Either upload bytes (``file_name`` plus ``file_data_base64``) or point + at a path on this machine (``source_path``). Answers 201. + """ + return self._request( + "POST", + "/v1/extensions", + body=_body( + name=name, + file_name=file_name, + file_data_base64=file_data_base64, + source_path=source_path, + link=link, + ), + ) + + def update_extension( + self, + extension_id: str, + *, + name: Optional[str] = None, + file_name: Optional[str] = None, + file_data_base64: Optional[str] = None, + source_path: Optional[str] = None, + link: Optional[bool] = None, + ) -> Extension: + """PUT /v1/extensions/{id}""" + return self._request( + "PUT", + f"/v1/extensions/{_segment(extension_id)}", + body=_body( + name=name, + file_name=file_name, + file_data_base64=file_data_base64, + source_path=source_path, + link=link, + ), + ) + + def delete_extension(self, extension_id: str) -> None: + """DELETE /v1/extensions/{id}""" + self._request("DELETE", f"/v1/extensions/{_segment(extension_id)}") + + def list_extension_groups(self) -> List[ExtensionGroup]: + """GET /v1/extension-groups""" + return self._request("GET", "/v1/extension-groups") + + def get_extension_group(self, group_id: str) -> ExtensionGroup: + """GET /v1/extension-groups/{id}""" + return self._request("GET", f"/v1/extension-groups/{_segment(group_id)}") + + def create_extension_group(self, *, name: str) -> ExtensionGroup: + """POST /v1/extension-groups. Answers 201.""" + return self._request("POST", "/v1/extension-groups", body={"name": name}) + + def update_extension_group( + self, + group_id: str, + *, + name: Optional[str] = None, + extension_ids: Optional[Sequence[str]] = None, + ) -> ExtensionGroup: + """PUT /v1/extension-groups/{id} + + ``extension_ids`` replaces the whole membership list. To change one + member, use :meth:`add_extension_to_group` or + :meth:`remove_extension_from_group`. + """ + return self._request( + "PUT", + f"/v1/extension-groups/{_segment(group_id)}", + body=_body( + name=name, + extension_ids=list(extension_ids) if extension_ids is not None else None, + ), + ) + + def delete_extension_group(self, group_id: str) -> None: + """DELETE /v1/extension-groups/{id}""" + self._request("DELETE", f"/v1/extension-groups/{_segment(group_id)}") + + def add_extension_to_group(self, group_id: str, extension_id: str) -> ExtensionGroup: + """POST /v1/extension-groups/{id}/extensions/{extension_id}""" + return self._request( + "POST", + f"/v1/extension-groups/{_segment(group_id)}/extensions/{_segment(extension_id)}", + ) + + def remove_extension_from_group(self, group_id: str, extension_id: str) -> ExtensionGroup: + """DELETE /v1/extension-groups/{id}/extensions/{extension_id}""" + return self._request( + "DELETE", + f"/v1/extension-groups/{_segment(group_id)}/extensions/{_segment(extension_id)}", + ) + + # ------------------------------------------------------------------ + # Browsers + # ------------------------------------------------------------------ + + def download_browser(self, *, browser: str, version: str) -> DownloadBrowserResponse: + """POST /v1/browsers/download + + Returns once the build is on disk, so give this a long ``timeout``. + A 409 means the same version is already downloading. + """ + return self._request( + "POST", + "/v1/browsers/download", + body={"browser": browser, "version": version}, + ) + + def list_browser_versions(self, browser: str) -> List[str]: + """GET /v1/browsers/{browser}/versions""" + return self._request("GET", f"/v1/browsers/{_segment(browser)}/versions") + + def is_browser_downloaded(self, browser: str, version: str) -> bool: + """GET /v1/browsers/{browser}/versions/{version}/downloaded""" + return self._request( + "GET", + f"/v1/browsers/{_segment(browser)}/versions/{_segment(version)}/downloaded", + ) + + # ------------------------------------------------------------------ + # Convenience + # ------------------------------------------------------------------ + + def run( + self, + profile_id: str, + *, + url: Optional[str] = None, + headless: Optional[bool] = None, + ) -> "RunSession": + """Launch a profile for the length of a ``with`` block, then stop it. + + :: + + with client.run(profile_id, url="https://example.com", headless=True) as session: + print(session.cdp_url) + + The browser starts when the block is entered and is stopped when it + ends, including when the body raises. + """ + return RunSession(self, profile_id, url=url, headless=headless) + + +class RunSession: + """A profile launched for one ``with`` block. + + Nothing starts until the block is entered, so a session that is built but + never entered leaves no browser behind. + """ + + def __init__( + self, + client: DonutClient, + profile_id: str, + *, + url: Optional[str] = None, + headless: Optional[bool] = None, + ) -> None: + self.client = client + self.profile_id = profile_id + self._url = url + self._headless = headless + + #: The whole body of ``POST /v1/profiles/{id}/run``, once entered. + self.response: Optional[RunProfileResponse] = None + #: The browser's CDP port, once entered. + self.remote_debugging_port: Optional[int] = None + #: Whether the browser actually started headless. + self.headless: Optional[bool] = None + #: A failure while stopping the browser, kept rather than raised when + #: the block itself was already failing. + self.cleanup_error: Optional[DonutError] = None + + @property + def cdp_url(self) -> str: + """The browser's DevTools endpoint, e.g. ``http://127.0.0.1:9222``. + + ``GET {cdp_url}/json/version`` returns the ``webSocketDebuggerUrl`` a + CDP library connects to. + """ + if self.remote_debugging_port is None: + raise DonutError("The session is not running: enter the `with` block first.") + return f"http://{self.client.host}:{self.remote_debugging_port}" + + def __enter__(self) -> "RunSession": + response = self.client.run_profile( + self.profile_id, url=self._url, headless=self._headless + ) + self.response = response + self.remote_debugging_port = response["remote_debugging_port"] + self.headless = response["headless"] + return self + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> bool: + try: + self.client.kill_profile(self.profile_id) + except DonutError as failure: + self.cleanup_error = failure + # A failure to stop must never hide why the block failed. When the + # block was fine, the failure is the only news there is, so it is + # raised; otherwise it stays readable on `cleanup_error`. + if exc_type is None: + raise + return False diff --git a/sdk/python/src/donutbrowser/coverage.py b/sdk/python/src/donutbrowser/coverage.py new file mode 100644 index 0000000..d37ff60 --- /dev/null +++ b/sdk/python/src/donutbrowser/coverage.py @@ -0,0 +1,113 @@ +"""Which app operation each client method wraps. + +This table is the SDK's half of a two-sided check. ``sdk/api-paths.json`` holds +every operation the desktop app publishes, generated from +``src-tauri/src/api_server.rs``. The test suite asserts the two agree exactly in +both directions, so: + +* an endpoint added to the app fails the SDK tests until it is wrapped here, or + listed in :data:`OMITTED` with a reason, and +* an entry here that the app no longer publishes fails too. + +The same table is mirrored in the Node package, and the same snapshot proves it. +""" + +from __future__ import annotations + +from typing import Dict, Tuple + +__all__ = ["OPERATIONS", "OMITTED"] + +Operation = Tuple[str, str] + +#: ``(method, path template)`` to the name of the :class:`~donutbrowser.DonutClient` +#: method that calls it. +OPERATIONS: Dict[Operation, str] = { + ("POST", "/v1/browsers/download"): "download_browser", + ("GET", "/v1/browsers/{browser}/versions"): "list_browser_versions", + ("GET", "/v1/browsers/{browser}/versions/{version}/downloaded"): "is_browser_downloaded", + ("GET", "/v1/cookie-bot/conflicts"): "get_cookie_bot_conflicts", + ("GET", "/v1/cookie-bot/presets"): "list_cookie_bot_presets", + ("GET", "/v1/cookie-bot/runs"): "list_cookie_bot_runs", + ("POST", "/v1/cookie-bot/runs"): "start_cookie_bot_run", + ("DELETE", "/v1/cookie-bot/runs/{run_id}"): "cancel_cookie_bot_run", + ("GET", "/v1/cookie-bot/schedules"): "list_cookie_bot_schedules", + ("DELETE", "/v1/cookie-bot/schedules/{profile_id}"): "delete_cookie_bot_schedule", + ("GET", "/v1/cookie-bot/schedules/{profile_id}"): "get_cookie_bot_schedule", + ("PUT", "/v1/cookie-bot/schedules/{profile_id}"): "set_cookie_bot_schedule", + ("GET", "/v1/cookie-bot/usage"): "get_cookie_bot_usage", + ("GET", "/v1/extension-groups"): "list_extension_groups", + ("POST", "/v1/extension-groups"): "create_extension_group", + ("DELETE", "/v1/extension-groups/{id}"): "delete_extension_group", + ("GET", "/v1/extension-groups/{id}"): "get_extension_group", + ("PUT", "/v1/extension-groups/{id}"): "update_extension_group", + ( + "DELETE", + "/v1/extension-groups/{id}/extensions/{extension_id}", + ): "remove_extension_from_group", + ("POST", "/v1/extension-groups/{id}/extensions/{extension_id}"): "add_extension_to_group", + ("GET", "/v1/extensions"): "list_extensions", + ("POST", "/v1/extensions"): "create_extension", + ("DELETE", "/v1/extensions/{id}"): "delete_extension", + ("GET", "/v1/extensions/{id}"): "get_extension", + ("PUT", "/v1/extensions/{id}"): "update_extension", + ("GET", "/v1/groups"): "list_groups", + ("POST", "/v1/groups"): "create_group", + ("DELETE", "/v1/groups/{id}"): "delete_group", + ("GET", "/v1/groups/{id}"): "get_group", + ("PUT", "/v1/groups/{id}"): "update_group", + ("GET", "/v1/profiles"): "list_profiles", + ("POST", "/v1/profiles"): "create_profile", + ("POST", "/v1/profiles/batch/run"): "batch_run_profiles", + ("POST", "/v1/profiles/batch/stop"): "batch_stop_profiles", + ("POST", "/v1/profiles/distribute-proxies"): "distribute_proxies", + ("POST", "/v1/profiles/import"): "import_profiles", + ("GET", "/v1/profiles/import/detect"): "detect_import_profiles", + ("DELETE", "/v1/profiles/{id}"): "delete_profile", + ("GET", "/v1/profiles/{id}"): "get_profile", + ("PUT", "/v1/profiles/{id}"): "update_profile", + ("POST", "/v1/profiles/{id}/agent/click"): "agent_click", + ("POST", "/v1/profiles/{id}/agent/extract"): "agent_extract", + ("POST", "/v1/profiles/{id}/agent/perceive"): "agent_perceive", + ("POST", "/v1/profiles/{id}/agent/pick"): "agent_pick", + ("POST", "/v1/profiles/{id}/agent/resolve-locator"): "agent_resolve_locator", + ("POST", "/v1/profiles/{id}/agent/type"): "agent_type", + ("POST", "/v1/profiles/{id}/cloud-sync"): "set_profile_cloud_sync", + ("POST", "/v1/profiles/{id}/cookies/import"): "import_profile_cookies", + ("POST", "/v1/profiles/{id}/kill"): "kill_profile", + ("POST", "/v1/profiles/{id}/open-url"): "open_url", + ("POST", "/v1/profiles/{id}/run"): "run_profile", + ("POST", "/v1/profiles/{id}/run-remote"): "run_profile_remote", + ("GET", "/v1/proxies"): "list_proxies", + ("POST", "/v1/proxies"): "create_proxy", + ("POST", "/v1/proxies/import"): "import_proxies", + ("DELETE", "/v1/proxies/{id}"): "delete_proxy", + ("GET", "/v1/proxies/{id}"): "get_proxy", + ("PUT", "/v1/proxies/{id}"): "update_proxy", + ("GET", "/v1/remote-hours"): "get_remote_hours", + ("GET", "/v1/remote-sessions"): "list_remote_sessions", + ("DELETE", "/v1/remote-sessions/{id}"): "stop_remote_session", + ("GET", "/v1/remote-sessions/{id}"): "get_remote_session", + ("GET", "/v1/tags"): "list_tags", + ("GET", "/v1/vpns"): "list_vpns", + ("POST", "/v1/vpns"): "create_vpn", + ("POST", "/v1/vpns/import"): "import_vpn", + ("DELETE", "/v1/vpns/{id}"): "delete_vpn", + ("GET", "/v1/vpns/{id}"): "get_vpn", + ("PUT", "/v1/vpns/{id}"): "update_vpn", + ("GET", "/v1/vpns/{id}/export"): "export_vpn", +} + +#: Operations this SDK deliberately does not call, and why. +OMITTED: Dict[Operation, str] = { + ( + "GET", + "/v1/remote-sessions/{id}/cdp", + ): ( + "A WebSocket upgrade, not a request. An HTTP client cannot speak it, and " + "bundling a websocket implementation would end this package's zero-dependency " + "promise for one endpoint. DonutClient.remote_session_cdp_url() builds the " + "ws:// address so a websocket library of the caller's choosing can connect, " + "sending the same Authorization: Bearer header on the handshake." + ), +} diff --git a/sdk/python/src/donutbrowser/errors.py b/sdk/python/src/donutbrowser/errors.py new file mode 100644 index 0000000..93fa8e4 --- /dev/null +++ b/sdk/python/src/donutbrowser/errors.py @@ -0,0 +1,240 @@ +"""Exceptions raised by the Donut Browser SDK. + +The local REST API answers with a plain-text body and one of a small set of +statuses. Each status means one thing, so each gets its own exception and a +caller can branch on the class instead of on a number: + +=== ========================== ================================== +403 ``Forbidden`` Terms not accepted, or not signed in +400 ``ValidationError`` Malformed request, duplicate name +401 ``Unauthorized`` Missing or wrong bearer token +402 ``PaymentRequired`` Automation needs an active paid plan +404 ``NotFound`` No such profile, group, proxy, ... +408 ``RequestTimeout`` ``agent/pick`` waited and nothing was picked +409 ``Conflict`` Something else holds the profile right now +429 ``RateLimited`` Automation quota spent; see ``retry_after`` +500 ``ServerError`` Internal failure +502 ``BadGateway`` The browser or relay answered wrongly +503 ``ServiceUnavailable`` Cloud, fleet or lock service unreachable +=== ========================== ================================== + +Some bodies are the structured ``{"code": ..., "params": {...}}`` strings the +desktop app shares with its own frontend. When one arrives, ``code`` and +``params`` are filled in; otherwise ``code`` is ``None`` and ``body`` holds the +diagnostic text as sent. +""" + +from __future__ import annotations + +import json +from typing import Any, Mapping, Optional + +__all__ = [ + "DonutError", + "DonutConnectionError", + "DonutAPIError", + "ValidationError", + "Unauthorized", + "PaymentRequired", + "Forbidden", + "NotFound", + "RequestTimeout", + "Conflict", + "RateLimited", + "ServerError", + "BadGateway", + "ServiceUnavailable", + "error_for_status", +] + + +class DonutError(Exception): + """Base class for everything this package raises.""" + + +class DonutConnectionError(DonutError): + """The app could not be reached at all. + + Usually means the local API is switched off, is listening on another port, + or the desktop app is not running. + """ + + +class DonutAPIError(DonutError): + """The app answered, and the answer was an error status.""" + + #: HTTP status this class is raised for. ``None`` on the base class, which + #: catches every status without a more specific subclass. + status: Optional[int] = None + + def __init__( + self, + status: int, + body: str, + *, + method: str = "", + path: str = "", + headers: Optional[Mapping[str, str]] = None, + ) -> None: + self.status = status + self.body = body + self.method = method + self.path = path + self.headers = dict(headers or {}) + self.code: Optional[str] = None + self.params: dict[str, Any] = {} + + stripped = body.strip() + if stripped.startswith("{"): + try: + decoded = json.loads(stripped) + except ValueError: + decoded = None + if isinstance(decoded, dict) and isinstance(decoded.get("code"), str): + self.code = decoded["code"] + params = decoded.get("params") + if isinstance(params, dict): + self.params = params + + where = f"{method} {path}".strip() + detail = self.code or stripped or "(empty body)" + super().__init__(f"{status} on {where}: {detail}" if where else f"{status}: {detail}") + + +class ValidationError(DonutAPIError): + """400: the request was malformed, duplicated a name, or named something unsupported.""" + + status = 400 + + +class Unauthorized(DonutAPIError): + """401: no bearer token, the wrong one, or the local API has no token stored.""" + + status = 401 + + +class PaymentRequired(DonutAPIError): + """402: this action needs an active paid plan, or the proxy behind it lapsed.""" + + status = 402 + + +class Forbidden(DonutAPIError): + """403: the Wayfern terms are not accepted, or this desktop is not signed in.""" + + status = 403 + + +class NotFound(DonutAPIError): + """404: no entity with that id.""" + + status = 404 + + +class RequestTimeout(DonutAPIError): + """408: ``agent/pick`` waited its whole timeout and nothing was picked.""" + + status = 408 + + +class Conflict(DonutAPIError): + """409: something else holds the profile — a browser, a teammate, a remote session.""" + + status = 409 + + +class RateLimited(DonutAPIError): + """429: the shared automation quota is spent. + + ``retry_after`` is the number of seconds the server asked the caller to + wait, taken from the ``Retry-After`` response header. It is ``None`` only + when the header is missing or unreadable. + """ + + status = 429 + + def __init__( + self, + status: int, + body: str, + *, + method: str = "", + path: str = "", + headers: Optional[Mapping[str, str]] = None, + ) -> None: + super().__init__(status, body, method=method, path=path, headers=headers) + self.retry_after: Optional[int] = None + raw = next( + (value for key, value in self.headers.items() if key.lower() == "retry-after"), + None, + ) + if raw is not None: + try: + self.retry_after = int(str(raw).strip()) + except ValueError: + self.retry_after = None + + +class ServerError(DonutAPIError): + """500 and the other 5xx: the app, the fleet or an upstream failed. + + ``BadGateway`` and ``ServiceUnavailable`` derive from this, so one + ``except ServerError`` catches every server-side failure. + """ + + status = 500 + + +class BadGateway(ServerError): + """502: the browser or the relay did not answer the way it documents.""" + + status = 502 + + +class ServiceUnavailable(ServerError): + """503: Donut cloud, the remote fleet, or the profile lock service is unreachable. + + Whatever was running keeps running: a 503 from ``kill`` or from stopping a + remote session means the browser is still up, not that it stopped. + """ + + status = 503 + + +_BY_STATUS: dict[int, type[DonutAPIError]] = { + cls.status: cls + for cls in ( + ValidationError, + Unauthorized, + PaymentRequired, + Forbidden, + NotFound, + RequestTimeout, + Conflict, + RateLimited, + ServerError, + BadGateway, + ServiceUnavailable, + ) + if cls.status is not None +} + + +def error_for_status( + status: int, + body: str, + *, + method: str = "", + path: str = "", + headers: Optional[Mapping[str, str]] = None, +) -> DonutAPIError: + """Build the exception that belongs to ``status``. + + A status with no class of its own becomes a plain :class:`DonutAPIError`, + so a future status added to the app still raises something a caller can + catch rather than escaping as a decode failure. + """ + cls = _BY_STATUS.get(status) + if cls is None: + cls = ServerError if status >= 500 else DonutAPIError + return cls(status, body, method=method, path=path, headers=headers) diff --git a/sdk/python/src/donutbrowser/models.py b/sdk/python/src/donutbrowser/models.py new file mode 100644 index 0000000..1856c1e --- /dev/null +++ b/sdk/python/src/donutbrowser/models.py @@ -0,0 +1,763 @@ +"""Response shapes, spelled exactly the way the local API sends them. + +Every entry here mirrors a ``ToSchema`` struct in ``src-tauri`` field for field. +A Rust ``Option`` becomes a key that may be absent, expressed with the +``total=False`` half of each pair of classes, so ``dict.get`` is the honest way +to read one. + +Two spellings live side by side because the app sends both. Most bodies are +snake_case; the browser-facing agent types (``LocatorDescription``, +``LocatorCandidate``, ``PerceptionPage`` and friends) carry the browser's own +camelCase, because they are handed through from the browser rather than +restated. ``AgentClick`` and ``AgentTyping`` are the exceptions inside the +agent surface: they are snake_case with a single ``match`` key. The types below +follow the wire rather than tidying it, so a value read from one call can be +passed straight into the next. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, TypedDict + +__all__ = [ + "ApiProfile", + "ApiProfilesResponse", + "ApiProfileResponse", + "ApiGroupResponse", + "ApiProxyResponse", + "ApiVpnResponse", + "ApiVpnExportResponse", + "DownloadBrowserResponse", + "RunProfileResponse", + "RunRemoteResponse", + "StopRemoteResponse", + "SetCloudSyncResponse", + "RemoteSessionState", + "ApiRemoteSessionsResponse", + "RemoteHoursBreakdown", + "RemoteHoursMember", + "RemoteHoursQuota", + "CookieBotSlot", + "CookieBotSchedule", + "CookieBotScheduleList", + "CookieBotConflict", + "CookieBotScheduleSaved", + "CookieBotConflictCheck", + "CookieBotScheduleDeleted", + "CookieBotRun", + "CookieBotRunPage", + "CookieBotRunStarted", + "CookieBotPreset", + "CookieBotPresetList", + "CookieBotUsageMember", + "CookieBotUsageProfile", + "CookieBotUsage", + "BatchRunResult", + "BatchRunResponse", + "BatchStopResult", + "BatchStopResponse", + "ProxyPair", + "ProxyAssignmentResult", + "DistributeProxiesResponse", + "ImportCookiesResponse", + "ImportProxiesResponse", + "DetectedProfile", + "DetectedProfilesResponse", + "ImportProfileItem", + "ProfileImportItemResult", + "ProfileImportBatchResult", + "Extension", + "ExtensionGroup", + "LocatorAttribute", + "LocatorDescription", + "LocatorBounds", + "LocatorCandidate", + "LocatorResolution", + "PerceptionNode", + "PerceptionFrame", + "PerceptionStats", + "PerceptionPage", + "ExtractionField", + "ExtractionRow", + "Extraction", + "PickedElement", + "AgentClick", + "AgentTyping", +] + +# The app's own JSON for a proxy's settings. Declared `Object` in the OpenAPI +# document rather than a struct, so it is passed through untouched. +ProxySettings = Dict[str, Any] + +# A Wayfern fingerprint/config blob. Also declared `Object` in the document. +WayfernConfig = Dict[str, Any] + + +class _ApiProfileRequired(TypedDict): + id: str + name: str + browser: str + version: str + release_type: str + tags: List[str] + is_running: bool + proxy_bypass_rules: List[str] + ephemeral: bool + temporary: bool + clear_on_close: bool + sync_mode: str + cloud_sync_enabled: bool + is_cross_os: bool + + +class ApiProfile(_ApiProfileRequired, total=False): + proxy_id: str + launch_hook: str + process_id: int + last_launch: int + group_id: str + vpn_id: str + extension_group_id: str + host_os: str + fingerprint_os: str + + +class ApiProfilesResponse(TypedDict): + profiles: List[ApiProfile] + total: int + + +class ApiProfileResponse(TypedDict): + profile: ApiProfile + + +class ApiGroupResponse(TypedDict): + id: str + name: str + profile_count: int + + +class ApiProxyResponse(TypedDict): + id: str + name: str + proxy_settings: ProxySettings + + +class _ApiVpnRequired(TypedDict): + id: str + name: str + vpn_type: str + created_at: int + + +class ApiVpnResponse(_ApiVpnRequired, total=False): + last_used: int + + +class ApiVpnExportResponse(TypedDict): + id: str + name: str + vpn_type: str + config_data: str + + +class DownloadBrowserResponse(TypedDict): + browser: str + version: str + status: str + + +class RunProfileResponse(TypedDict): + profile_id: str + remote_debugging_port: int + headless: bool + + +class RunRemoteResponse(TypedDict): + profile_id: str + session_id: str + platform: str + status: str + + +class StopRemoteResponse(TypedDict): + session_id: str + status: str + billed_seconds: int + + +class _SetCloudSyncRequired(TypedDict): + profile_id: str + mode: str + remote_launchable: bool + + +class SetCloudSyncResponse(_SetCloudSyncRequired, total=False): + remote_blocked_reason: str + + +class _RemoteSessionStateRequired(TypedDict): + session_id: str + state: str + + +class RemoteSessionState(_RemoteSessionStateRequired, total=False): + profile_id: str + platform: str + cdp_ready: bool + kind: str + run_id: str + team_id: str + started_at: str + ended_at: str + close_reason: str + billed_seconds: int + + +class ApiRemoteSessionsResponse(TypedDict): + sessions: List[RemoteSessionState] + + +class RemoteHoursBreakdown(TypedDict, total=False): + interactive_hours: float + bot_hours: float + + +class _RemoteHoursMemberRequired(TypedDict): + user_id: str + email: str + + +class RemoteHoursMember(_RemoteHoursMemberRequired, total=False): + role: str + used_hours: float + interactive_hours: float + bot_hours: float + + +class _RemoteHoursQuotaRequired(TypedDict): + granted_hours: float + remaining_hours: float + + +class RemoteHoursQuota(_RemoteHoursQuotaRequired, total=False): + used_hours: float + period_start: str + period_end: str + scope: str + team_id: str + seats: int + per_seat_hours: float + breakdown: RemoteHoursBreakdown + members: List[RemoteHoursMember] + + +class CookieBotSlot(TypedDict, total=False): + run_at_minute: int + days_mask: int + + +class _CookieBotScheduleRequired(TypedDict): + profile_id: str + profile_name: str + platform: str + enabled: bool + run_at_minute: int + days_mask: int + timezone: str + preset: str + max_minutes: int + + +class CookieBotSchedule(_CookieBotScheduleRequired, total=False): + slots: List[CookieBotSlot] + template_id: str + sites: List[str] + jitter_seconds: int + sync_enabled: bool + encrypted_sync: bool + has_proxy: bool + proxy_remote_reachable: bool + touch_fingerprint: bool + sticky_exit: bool + profile_state_at: str + blocked_by: str + next_run_at: str + last_run_at: str + last_run_id: str + owner_user_id: str + owner_email: str + updated_at: str + + +class CookieBotScheduleList(TypedDict, total=False): + schedules: List[CookieBotSchedule] + team_id: str + scope: str + + +class _CookieBotConflictRequired(TypedDict): + user_id: str + email: str + run_at_minute: int + timezone: str + days_mask: int + enabled: bool + + +class CookieBotConflict(_CookieBotConflictRequired, total=False): + overlaps: bool + + +class _CookieBotScheduleSavedRequired(TypedDict): + schedule: CookieBotSchedule + + +class CookieBotScheduleSaved(_CookieBotScheduleSavedRequired, total=False): + conflicts: List[CookieBotConflict] + + +class _CookieBotConflictCheckRequired(TypedDict): + profile_id: str + + +class CookieBotConflictCheck(_CookieBotConflictCheckRequired, total=False): + conflicts: List[CookieBotConflict] + + +class CookieBotScheduleDeleted(TypedDict): + profile_id: str + deleted: bool + + +class _CookieBotRunRequired(TypedDict): + id: str + profile_id: str + trigger: str + status: str + scheduled_for: str + + +class CookieBotRun(_CookieBotRunRequired, total=False): + profile_name: str + user_id: str + email: str + team_id: str + dispatch_after: str + started_at: str + ended_at: str + max_minutes: int + chunks_total: int + chunk_index: int + sites_total: int + sites_visited: int + sites_failed: int + consent_dismissed: int + billed_seconds: int + outcome_code: str + session_id: str + + +class CookieBotRunPage(TypedDict, total=False): + runs: List[CookieBotRun] + next_before: str + + +class _CookieBotRunStartedRequired(TypedDict): + run: CookieBotRun + + +class CookieBotRunStarted(_CookieBotRunStartedRequired, total=False): + session_id: str + + +class _CookieBotPresetRequired(TypedDict): + id: str + + +class CookieBotPreset(_CookieBotPresetRequired, total=False): + typical_minutes: int + recommended: bool + name: str + description: str + + +class CookieBotPresetList(TypedDict, total=False): + presets: List[CookieBotPreset] + default_preset: str + # `templates` and `limits` are whatever the server publishes; the app + # forwards them without narrowing, so neither is spelled out here. + templates: List[Dict[str, Any]] + limits: Dict[str, Any] + + +class _CookieBotUsageMemberRequired(TypedDict): + user_id: str + email: str + + +class CookieBotUsageMember(_CookieBotUsageMemberRequired, total=False): + role: str + interactive_hours: float + bot_hours: float + used_hours: float + sessions: int + bot_runs: int + bot_runs_failed: int + + +class _CookieBotUsageProfileRequired(TypedDict): + profile_id: str + + +class CookieBotUsageProfile(_CookieBotUsageProfileRequired, total=False): + profile_name: str + owner_email: str + bot_hours: float + runs: int + runs_failed: int + last_run_at: str + last_status: str + + +class _CookieBotUsageRequired(TypedDict): + period: str + + +class CookieBotUsage(_CookieBotUsageRequired, total=False): + period_start: str + period_end: str + team_id: str + seats: int + granted_hours: float + used_hours: float + remaining_hours: float + members: List[CookieBotUsageMember] + profiles: List[CookieBotUsageProfile] + + +class _BatchRunResultRequired(TypedDict): + profile_id: str + ok: bool + + +class BatchRunResult(_BatchRunResultRequired, total=False): + remote_debugging_port: int + error: str + + +class BatchRunResponse(TypedDict): + results: List[BatchRunResult] + + +class _BatchStopResultRequired(TypedDict): + profile_id: str + ok: bool + + +class BatchStopResult(_BatchStopResultRequired, total=False): + error: str + + +class BatchStopResponse(TypedDict): + results: List[BatchStopResult] + + +class _ProxyAssignmentResultRequired(TypedDict): + profile_id: str + proxy_id: str + ok: bool + + +class ProxyAssignmentResult(_ProxyAssignmentResultRequired, total=False): + """``error`` is a ``{"code": ...}`` payload when ``ok`` is false.""" + + error: str + + +class ProxyPair(TypedDict): + """One profile, one proxy. The distribution applies exactly these pairs.""" + + profile_id: str + proxy_id: str + + +class DistributeProxiesResponse(TypedDict): + results: List[ProxyAssignmentResult] + + +class ImportCookiesResponse(TypedDict): + cookies_imported: int + cookies_replaced: int + errors: List[str] + + +class ImportProxiesResponse(TypedDict): + imported_count: int + skipped_count: int + errors: List[str] + proxies: List[ApiProxyResponse] + + +class DetectedProfile(TypedDict): + browser: str + mapped_browser: str + name: str + path: str + description: str + + +class DetectedProfilesResponse(TypedDict): + profiles: List[DetectedProfile] + total: int + + +class _ImportProfileItemRequired(TypedDict): + source_path: str + new_profile_name: str + + +class ImportProfileItem(_ImportProfileItemRequired, total=False): + """One item of ``import_profiles``. + + ``browser_type`` defaults to the app's own default when absent, and it is + load-bearing: it picks which keychain entry unlocks the source's cookies + and passwords. + """ + + browser_type: str + proxy_id: str + vpn_id: str + allow_running: bool + + +class _ProfileImportItemResultRequired(TypedDict): + name: str + source_path: str + status: str + + +class ProfileImportItemResult(_ProfileImportItemResultRequired, total=False): + profile_id: str + error: str + report: Dict[str, Any] + + +class ProfileImportBatchResult(TypedDict): + imported_count: int + skipped_count: int + failed_count: int + results: List[ProfileImportItemResult] + + +class _ExtensionRequired(TypedDict): + id: str + name: str + file_name: str + file_type: str + browser_compatibility: List[str] + created_at: int + updated_at: int + source_kind: str + + +class Extension(_ExtensionRequired, total=False): + manifest_name: str + sync_enabled: bool + last_sync: int + version: str + description: str + author: str + homepage_url: str + linked_path: str + + +class _ExtensionGroupRequired(TypedDict): + id: str + name: str + extension_ids: List[str] + created_at: int + updated_at: int + + +class ExtensionGroup(_ExtensionGroupRequired, total=False): + sync_enabled: bool + last_sync: int + + +class LocatorAttribute(TypedDict): + name: str + value: str + + +class LocatorDescription(TypedDict, total=False): + """How an element is named without a CSS selector. + + At least one key must be set. Keys are the browser's own camelCase; the + app also accepts ``name_contains`` and ``text_contains`` on input, but a + locator handed back by ``agent_pick`` uses the spellings below, so reusing + one verbatim is the reliable path. + """ + + role: str + name: str + nameContains: str + text: str + textContains: str + attributes: List[LocatorAttribute] + + +class LocatorBounds(TypedDict): + x: float + y: float + width: float + height: float + + +class _LocatorCandidateRequired(TypedDict): + role: str + name: str + text: str + signature: str + bounds: LocatorBounds + + +class LocatorCandidate(_LocatorCandidateRequired, total=False): + backendNodeId: int + value: str + url: str + attributes: List[LocatorAttribute] + + +class _LocatorResolutionRequired(TypedDict): + matchCount: int + # `match` is the key the app sends. It is a soft keyword in Python, so it + # is spelled here exactly as it arrives. + match: LocatorCandidate + locator: LocatorDescription + engine: str + + +class LocatorResolution(_LocatorResolutionRequired, total=False): + backendNodeId: int + + +class _PerceptionNodeRequired(TypedDict): + id: str + frameId: str + role: str + x: float + y: float + width: float + height: float + inViewport: bool + visible: bool + focused: bool + disabled: bool + + +class PerceptionNode(_PerceptionNodeRequired, total=False): + parentId: str + name: str + text: str + value: str + checked: str + expanded: bool + scrollable: bool + scrollContainerId: str + + +class _PerceptionFrameRequired(TypedDict): + frameId: str + url: str + crossOrigin: bool + + +class PerceptionFrame(_PerceptionFrameRequired, total=False): + parentFrameId: str + + +class PerceptionStats(TypedDict): + totalNodes: int + returnedNodes: int + bytes: int + elapsedMs: int + framesVisited: int + framesFailed: int + + +class _PerceptionPageRequired(TypedDict): + snapshotId: str + nodes: List[PerceptionNode] + frames: List[PerceptionFrame] + text: str + truncated: bool + stats: PerceptionStats + engine: str + + +class PerceptionPage(_PerceptionPageRequired, total=False): + cursor: str + + +class _ExtractionFieldRequired(TypedDict): + key: str + locator: LocatorDescription + source: str + + +class ExtractionField(_ExtractionFieldRequired, total=False): + """One output column. ``attribute`` is required when ``source`` is ``"attribute"``.""" + + attribute: str + + +class ExtractionRow(TypedDict): + index: int + page: int + values: Dict[str, Any] + + +class Extraction(TypedDict): + rows: List[ExtractionRow] + rowCount: int + pageCount: int + byteSize: int + truncated: bool + stopReason: str + engine: str + + +class PickedElement(TypedDict): + backendNodeId: int + locator: LocatorDescription + matchCount: int + node: LocatorCandidate + engine: str + + +class AgentClick(TypedDict): + """What a click did. Note the snake_case body and the ``match`` key.""" + + clicked: bool + match: LocatorCandidate + engine: str + navigated: bool + + +class _AgentTypingRequired(TypedDict): + typed: bool + characters: int + duration_ms: float + engine: str + match: LocatorCandidate + + +class AgentTyping(_AgentTypingRequired, total=False): + """What a typing call did. + + ``corrections`` is absent on the fallback engine, which does not count its + own mistypes. + """ + + corrections: int diff --git a/sdk/python/src/donutbrowser/py.typed b/sdk/python/src/donutbrowser/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/sdk/python/tests/conftest.py b/sdk/python/tests/conftest.py new file mode 100644 index 0000000..c11e5c0 --- /dev/null +++ b/sdk/python/tests/conftest.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Iterator + +import pytest + +# Run against the working tree without an install step, so `pytest` works +# straight after a checkout. +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from donutbrowser import DonutClient # noqa: E402 +from fake_donut import FakeDonut # noqa: E402 + +TOKEN = "test-token-abc123" + + +@pytest.fixture +def fake() -> Iterator[FakeDonut]: + server = FakeDonut().start() + try: + yield server + finally: + server.stop() + + +@pytest.fixture +def client(fake: FakeDonut) -> Iterator[DonutClient]: + with DonutClient(token=TOKEN, port=fake.port, timeout=5.0, env={}) as connected: + yield connected diff --git a/sdk/python/tests/fake_donut.py b/sdk/python/tests/fake_donut.py new file mode 100644 index 0000000..66ba8df --- /dev/null +++ b/sdk/python/tests/fake_donut.py @@ -0,0 +1,144 @@ +"""A stand-in for the desktop app's local REST API. + +It records what the client sent, byte for byte, and answers with whatever the +test queued. Nothing here reaches the network: it binds an ephemeral loopback +port and is torn down with the test. +""" + +from __future__ import annotations + +import json +import threading +from dataclasses import dataclass, field +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import parse_qsl, urlsplit + + +@dataclass +class RecordedRequest: + method: str + target: str + headers: Dict[str, str] + body: bytes + + @property + def path(self) -> str: + return urlsplit(self.target).path + + @property + def query(self) -> Dict[str, str]: + return dict(parse_qsl(urlsplit(self.target).query, keep_blank_values=True)) + + @property + def json(self) -> Any: + if not self.body: + return None + return json.loads(self.body.decode("utf-8")) + + def header(self, name: str) -> Optional[str]: + for key, value in self.headers.items(): + if key.lower() == name.lower(): + return value + return None + + +@dataclass +class QueuedResponse: + status: int = 200 + body: str = "" + headers: Tuple[Tuple[str, str], ...] = () + content_type: str = "application/json" + + +@dataclass +class FakeDonut: + """Queue responses, then read :attr:`requests` back.""" + + requests: List[RecordedRequest] = field(default_factory=list) + responses: List[QueuedResponse] = field(default_factory=list) + _server: Optional[ThreadingHTTPServer] = None + _thread: Optional[threading.Thread] = None + + def enqueue_json(self, payload: Any, status: int = 200) -> None: + self.responses.append(QueuedResponse(status=status, body=json.dumps(payload))) + + def enqueue_empty(self, status: int = 204) -> None: + self.responses.append(QueuedResponse(status=status, body="")) + + def enqueue_error( + self, + status: int, + body: str = "", + headers: Tuple[Tuple[str, str], ...] = (), + ) -> None: + self.responses.append( + QueuedResponse(status=status, body=body, headers=headers, content_type="text/plain") + ) + + @property + def port(self) -> int: + assert self._server is not None, "the fake server is not running" + return self._server.server_address[1] + + @property + def last(self) -> RecordedRequest: + assert self.requests, "the client sent nothing" + return self.requests[-1] + + def start(self) -> "FakeDonut": + fake = self + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *_args: Any) -> None: + """Keep the test output clean.""" + + def _handle(self) -> None: + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) if length else b"" + fake.requests.append( + RecordedRequest( + method=self.command, + target=self.path, + headers={key: value for key, value in self.headers.items()}, + body=body, + ) + ) + + queued = fake.responses.pop(0) if fake.responses else QueuedResponse(body="{}") + payload = queued.body.encode("utf-8") + self.send_response(queued.status) + for name, value in queued.headers: + self.send_header(name, value) + if payload: + self.send_header("Content-Type", queued.content_type) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + if payload: + self.wfile.write(payload) + + do_GET = _handle + do_POST = _handle + do_PUT = _handle + do_DELETE = _handle + do_PATCH = _handle + + self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + # A short poll interval so `shutdown()` returns promptly: the default + # 0.5s would add half a second to the teardown of every single test. + self._thread = threading.Thread( + target=self._server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True + ) + self._thread.start() + return self + + def stop(self) -> None: + if self._server is not None: + self._server.shutdown() + self._server.server_close() + self._server = None + if self._thread is not None: + self._thread.join(timeout=5) + self._thread = None diff --git a/sdk/python/tests/test_configuration.py b/sdk/python/tests/test_configuration.py new file mode 100644 index 0000000..96916bc --- /dev/null +++ b/sdk/python/tests/test_configuration.py @@ -0,0 +1,83 @@ +"""Where the token and the port come from, and in what order.""" + +from __future__ import annotations + +import pytest +from fake_donut import FakeDonut + +from donutbrowser import DEFAULT_HOST, DEFAULT_PORT, DonutClient, DonutError + + +def test_arguments_are_used_as_given() -> None: + client = DonutClient(token="from-argument", port=12345, env={}) + assert client.token == "from-argument" + assert client.port == 12345 + assert client.host == DEFAULT_HOST + assert client.base_url == "http://127.0.0.1:12345" + + +def test_the_environment_fills_in_what_was_not_passed() -> None: + client = DonutClient(env={"DONUT_API_TOKEN": "from-env", "DONUT_API_PORT": "13579"}) + assert client.token == "from-env" + assert client.port == 13579 + + +def test_arguments_win_over_the_environment() -> None: + client = DonutClient( + token="from-argument", + port=111, + env={"DONUT_API_TOKEN": "from-env", "DONUT_API_PORT": "222"}, + ) + assert client.token == "from-argument" + assert client.port == 111 + + +def test_the_port_falls_back_to_the_app_default() -> None: + client = DonutClient(env={"DONUT_API_TOKEN": "t"}) + assert client.port == DEFAULT_PORT == 10108 + + +def test_a_base_url_overrides_host_and_port() -> None: + client = DonutClient( + base_url="http://127.0.0.1:9999/donut", + token="t", + env={"DONUT_API_PORT": "222"}, + ) + assert client.port == 9999 + assert client.base_url == "http://127.0.0.1:9999/donut" + + +def test_a_base_url_prefix_is_kept_on_every_path(fake: FakeDonut) -> None: + with DonutClient( + base_url=f"http://127.0.0.1:{fake.port}/donut", token="t", timeout=5.0, env={} + ) as client: + client.list_profiles() + assert fake.last.path == "/donut/v1/profiles" + + +def test_an_unusable_port_in_the_environment_is_reported() -> None: + with pytest.raises(DonutError) as raised: + DonutClient(env={"DONUT_API_TOKEN": "t", "DONUT_API_PORT": "not-a-number"}) + assert "DONUT_API_PORT" in str(raised.value) + + +def test_an_unsupported_scheme_is_refused() -> None: + with pytest.raises(DonutError): + DonutClient(base_url="ftp://127.0.0.1:9999", token="t", env={}) + + +def test_the_websocket_address_is_built_from_the_same_base() -> None: + client = DonutClient(token="t", port=10108, env={}) + assert ( + client.remote_session_cdp_url("s 1") + == "ws://127.0.0.1:10108/v1/remote-sessions/s%201/cdp" + ) + + +def test_a_reopened_client_still_works(fake: FakeDonut) -> None: + """`close()` drops the socket; the next call has to open a new one.""" + with DonutClient(token="t", port=fake.port, timeout=5.0, env={}) as client: + client.list_profiles() + client.close() + client.list_profiles() + assert len(fake.requests) == 2 diff --git a/sdk/python/tests/test_coverage.py b/sdk/python/tests/test_coverage.py new file mode 100644 index 0000000..63e3acb --- /dev/null +++ b/sdk/python/tests/test_coverage.py @@ -0,0 +1,73 @@ +"""The SDK cannot silently drift from the app's API. + +``sdk/api-paths.json`` is generated from ``src-tauri/src/api_server.rs`` and +lists every operation the desktop app publishes. These tests hold it against +the SDK's own table in both directions, so a new endpoint in the app fails here +until it is wrapped or deliberately omitted with a reason. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, Set, Tuple + +from donutbrowser import DonutClient +from donutbrowser.coverage import OMITTED, OPERATIONS + +SNAPSHOT = Path(__file__).resolve().parents[2] / "api-paths.json" + + +def published() -> Set[Tuple[str, str]]: + document: Dict[str, Any] = json.loads(SNAPSHOT.read_text(encoding="utf-8")) + return { + (operation["method"], operation["path"]) for operation in document["operations"] + } + + +def test_the_snapshot_is_readable_and_not_empty() -> None: + document = json.loads(SNAPSHOT.read_text(encoding="utf-8")) + assert document["source"] == "src-tauri/src/api_server.rs" + assert document["operation_count"] == len(document["operations"]) + assert document["operation_count"] > 0 + assert len(published()) == document["operation_count"], "the app has two identical operations" + + +def test_every_published_operation_is_wrapped_or_omitted() -> None: + known = set(OPERATIONS) | set(OMITTED) + missing = sorted(published() - known) + assert not missing, ( + "the app publishes operations this SDK does not handle: " + f"{missing}. Wrap each one, or add it to coverage.OMITTED with a reason." + ) + + +def test_the_sdk_claims_nothing_the_app_does_not_publish() -> None: + stale = sorted((set(OPERATIONS) | set(OMITTED)) - published()) + assert not stale, ( + "this SDK handles operations the app no longer publishes: " + f"{stale}. Regenerate the snapshot with sdk/tools/extract-api-paths.py, " + "then drop or fix each entry." + ) + + +def test_an_operation_is_either_wrapped_or_omitted_but_not_both() -> None: + both = sorted(set(OPERATIONS) & set(OMITTED)) + assert not both, f"listed twice: {both}" + + +def test_every_omission_gives_a_reason() -> None: + for operation, reason in OMITTED.items(): + assert len(reason.strip()) > 40, f"{operation} is omitted without a real reason" + + +def test_every_wrapped_operation_names_a_real_method() -> None: + for operation, method_name in OPERATIONS.items(): + attribute = getattr(DonutClient, method_name, None) + assert callable(attribute), f"{operation} names {method_name}, which is not a method" + + +def test_no_two_operations_share_a_method() -> None: + names = list(OPERATIONS.values()) + duplicates = sorted({name for name in names if names.count(name) > 1}) + assert not duplicates, f"one method is claimed by several operations: {duplicates}" diff --git a/sdk/python/tests/test_errors.py b/sdk/python/tests/test_errors.py new file mode 100644 index 0000000..9ac9168 --- /dev/null +++ b/sdk/python/tests/test_errors.py @@ -0,0 +1,168 @@ +"""Each status the app documents raises its own exception.""" + +from __future__ import annotations + +import json + +import pytest +from fake_donut import FakeDonut, QueuedResponse + +from donutbrowser import ( + BadGateway, + Conflict, + DonutAPIError, + DonutClient, + DonutConnectionError, + DonutError, + Forbidden, + NotFound, + PaymentRequired, + RateLimited, + RequestTimeout, + ServerError, + ServiceUnavailable, + Unauthorized, + ValidationError, +) + +STATUS_TO_ERROR = [ + (400, ValidationError), + (401, Unauthorized), + (402, PaymentRequired), + (403, Forbidden), + (404, NotFound), + (408, RequestTimeout), + (409, Conflict), + (429, RateLimited), + (500, ServerError), + (502, BadGateway), + (503, ServiceUnavailable), +] + + +@pytest.mark.parametrize("status,expected", STATUS_TO_ERROR) +def test_status_maps_to_its_exception( + client: DonutClient, fake: FakeDonut, status: int, expected: type +) -> None: + fake.enqueue_error(status, "something went wrong") + with pytest.raises(expected) as raised: + client.list_profiles() + assert raised.value.status == status + assert raised.value.body == "something went wrong" + assert raised.value.method == "GET" + assert raised.value.path == "/v1/profiles" + + +def test_every_error_is_a_donut_error(client: DonutClient, fake: FakeDonut) -> None: + fake.enqueue_error(404, "PROFILE_NOT_FOUND") + with pytest.raises(DonutError): + client.get_profile("nope") + + +def test_the_five_hundreds_share_one_base(client: DonutClient, fake: FakeDonut) -> None: + """`except ServerError` has to catch 502 and 503 as well as 500.""" + for status in (500, 502, 503): + fake.enqueue_error(status, "upstream") + with pytest.raises(ServerError): + client.list_profiles() + + +def test_rate_limited_carries_retry_after(client: DonutClient, fake: FakeDonut) -> None: + fake.enqueue_error( + 429, + "automation request rate limit exceeded", + headers=(("Retry-After", "42"),), + ) + with pytest.raises(RateLimited) as raised: + client.run_profile("p1") + assert raised.value.retry_after == 42 + + +def test_rate_limited_without_the_header_is_still_raised( + client: DonutClient, fake: FakeDonut +) -> None: + fake.enqueue_error(429, "slow down") + with pytest.raises(RateLimited) as raised: + client.run_profile("p1") + assert raised.value.retry_after is None + + +def test_an_unreadable_retry_after_does_not_break_the_error( + client: DonutClient, fake: FakeDonut +) -> None: + fake.enqueue_error(429, "slow down", headers=(("Retry-After", "Wed, 21 Oct 2026 07:28:00 GMT"),)) + with pytest.raises(RateLimited) as raised: + client.run_profile("p1") + assert raised.value.retry_after is None + + +def test_a_structured_code_body_is_decoded(client: DonutClient, fake: FakeDonut) -> None: + """The app shares `{"code": ...}` strings with its own frontend.""" + fake.enqueue_error(400, json.dumps({"code": "NAME_CANNOT_BE_EMPTY"})) + with pytest.raises(ValidationError) as raised: + client.create_group(name="") + assert raised.value.code == "NAME_CANNOT_BE_EMPTY" + assert raised.value.params == {} + + +def test_a_structured_code_body_keeps_its_params(client: DonutClient, fake: FakeDonut) -> None: + fake.enqueue_error(409, json.dumps({"code": "PROFILE_LOCKED_BY_MEMBER", "params": {"n": "5"}})) + with pytest.raises(Conflict) as raised: + client.run_profile("p1") + assert raised.value.code == "PROFILE_LOCKED_BY_MEMBER" + assert raised.value.params == {"n": "5"} + + +def test_a_plain_text_body_leaves_code_unset(client: DonutClient, fake: FakeDonut) -> None: + fake.enqueue_error(400, "invalid browser") + with pytest.raises(ValidationError) as raised: + client.create_profile(name="x", browser="chromium") + assert raised.value.code is None + assert raised.value.body == "invalid browser" + + +def test_an_undocumented_status_still_raises_something_catchable( + client: DonutClient, fake: FakeDonut +) -> None: + fake.enqueue_error(418, "teapot") + with pytest.raises(DonutAPIError) as raised: + client.list_profiles() + assert raised.value.status == 418 + + +def test_an_undocumented_server_status_is_a_server_error( + client: DonutClient, fake: FakeDonut +) -> None: + fake.enqueue_error(504, "gateway timeout") + with pytest.raises(ServerError): + client.list_profiles() + + +def test_the_message_names_the_call(client: DonutClient, fake: FakeDonut) -> None: + fake.enqueue_error(404, "Profile not found") + with pytest.raises(NotFound) as raised: + client.get_profile("missing") + assert "404" in str(raised.value) + assert "GET /v1/profiles/missing" in str(raised.value) + + +def test_an_unreachable_app_is_not_an_api_error(fake: FakeDonut) -> None: + port = fake.port + fake.stop() + with DonutClient(token="t", port=port, timeout=2.0, env={}) as client: + with pytest.raises(DonutConnectionError) as raised: + client.list_profiles() + assert "Local API" in str(raised.value) + + +def test_a_missing_token_fails_before_any_request() -> None: + with pytest.raises(DonutError) as raised: + DonutClient(env={}) + assert "DONUT_API_TOKEN" in str(raised.value) + + +def test_a_non_json_answer_is_reported_as_such(client: DonutClient, fake: FakeDonut) -> None: + fake.responses.append(QueuedResponse(status=200, body="nope")) + with pytest.raises(DonutError) as raised: + client.list_profiles() + assert "not JSON" in str(raised.value) diff --git a/sdk/python/tests/test_requests.py b/sdk/python/tests/test_requests.py new file mode 100644 index 0000000..ed7f78c --- /dev/null +++ b/sdk/python/tests/test_requests.py @@ -0,0 +1,721 @@ +"""Every client method sends exactly the request the app documents. + +The table below is the whole public surface. Each row names a method, the +arguments to call it with, and the request that must appear on the wire: the +verb, the concrete path, the query string and the JSON body. ``operation`` is +the path template the app publishes, which ties this file to +``donutbrowser.coverage.OPERATIONS`` and, through it, to ``sdk/api-paths.json``. +""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional, Tuple + +import pytest +from fake_donut import FakeDonut + +from donutbrowser import DonutClient +from donutbrowser.coverage import OPERATIONS + +Case = Tuple[ + str, # client method + Tuple[Any, ...], # positional arguments + Dict[str, Any], # keyword arguments + str, # expected verb + str, # expected concrete path + Optional[Dict[str, Any]], # expected JSON body, or None for no body + Dict[str, str], # expected query string + str, # operation template, as published by the app +] + +LOCATOR = {"role": "button", "name": "Sign in"} + +CASES: List[Case] = [ + # -- profiles ---------------------------------------------------------- + ("list_profiles", (), {}, "GET", "/v1/profiles", None, {}, "/v1/profiles"), + ("get_profile", ("p1",), {}, "GET", "/v1/profiles/p1", None, {}, "/v1/profiles/{id}"), + ( + "create_profile", + (), + {"name": "Shopper", "browser": "wayfern", "tags": ["eu"], "ephemeral": True}, + "POST", + "/v1/profiles", + {"name": "Shopper", "browser": "wayfern", "tags": ["eu"], "ephemeral": True}, + {}, + "/v1/profiles", + ), + ( + "create_profile", + (), + {"name": "Bare", "browser": "wayfern"}, + "POST", + "/v1/profiles", + {"name": "Bare", "browser": "wayfern"}, + {}, + "/v1/profiles", + ), + ( + "update_profile", + ("p1",), + {"name": "Renamed", "proxy_id": "", "clear_on_close": False}, + "PUT", + "/v1/profiles/p1", + {"name": "Renamed", "proxy_id": "", "clear_on_close": False}, + {}, + "/v1/profiles/{id}", + ), + ("delete_profile", ("p1",), {}, "DELETE", "/v1/profiles/p1", None, {}, "/v1/profiles/{id}"), + ( + "run_profile", + ("p1",), + {"url": "https://example.com", "headless": True}, + "POST", + "/v1/profiles/p1/run", + {"url": "https://example.com", "headless": True}, + {}, + "/v1/profiles/{id}/run", + ), + ( + "run_profile_remote", + ("p1",), + {"url": "https://example.com"}, + "POST", + "/v1/profiles/p1/run-remote", + {"url": "https://example.com"}, + {}, + "/v1/profiles/{id}/run-remote", + ), + ( + "set_profile_cloud_sync", + ("p1",), + {"mode": "Regular"}, + "POST", + "/v1/profiles/p1/cloud-sync", + {"mode": "Regular"}, + {}, + "/v1/profiles/{id}/cloud-sync", + ), + ( + "open_url", + ("p1", "https://example.com/page"), + {}, + "POST", + "/v1/profiles/p1/open-url", + {"url": "https://example.com/page"}, + {}, + "/v1/profiles/{id}/open-url", + ), + ( + "kill_profile", + ("p1",), + {}, + "POST", + "/v1/profiles/p1/kill", + None, + {}, + "/v1/profiles/{id}/kill", + ), + ( + "batch_run_profiles", + (["p1", "p2"],), + {"headless": False}, + "POST", + "/v1/profiles/batch/run", + {"profile_ids": ["p1", "p2"], "headless": False}, + {}, + "/v1/profiles/batch/run", + ), + ( + "batch_stop_profiles", + (["p1", "p2"],), + {}, + "POST", + "/v1/profiles/batch/stop", + {"profile_ids": ["p1", "p2"]}, + {}, + "/v1/profiles/batch/stop", + ), + ( + "distribute_proxies", + ([{"profile_id": "p1", "proxy_id": "x1"}, {"profile_id": "p2", "proxy_id": "x2"}],), + {}, + "POST", + "/v1/profiles/distribute-proxies", + { + "pairs": [ + {"profile_id": "p1", "proxy_id": "x1"}, + {"profile_id": "p2", "proxy_id": "x2"}, + ] + }, + {}, + "/v1/profiles/distribute-proxies", + ), + ( + "detect_import_profiles", + (), + {"folder": "/Users/x/Chrome"}, + "GET", + "/v1/profiles/import/detect", + None, + {"folder": "/Users/x/Chrome"}, + "/v1/profiles/import/detect", + ), + ( + "detect_import_profiles", + (), + {}, + "GET", + "/v1/profiles/import/detect", + None, + {}, + "/v1/profiles/import/detect", + ), + ( + "import_profiles", + ([{"source_path": "/tmp/src", "new_profile_name": "Imported"}],), + {"duplicate_strategy": "skip"}, + "POST", + "/v1/profiles/import", + { + "items": [{"source_path": "/tmp/src", "new_profile_name": "Imported"}], + "duplicate_strategy": "skip", + }, + {}, + "/v1/profiles/import", + ), + ( + "import_profile_cookies", + ("p1",), + {"content": "[]"}, + "POST", + "/v1/profiles/p1/cookies/import", + {"content": "[]"}, + {}, + "/v1/profiles/{id}/cookies/import", + ), + # -- agent ------------------------------------------------------------- + ( + "agent_perceive", + ("p1",), + {"viewport_only": True, "max_bytes": 2048}, + "POST", + "/v1/profiles/p1/agent/perceive", + {"max_bytes": 2048, "viewport_only": True}, + {}, + "/v1/profiles/{id}/agent/perceive", + ), + ( + "agent_perceive", + ("p1",), + {}, + "POST", + "/v1/profiles/p1/agent/perceive", + {}, + {}, + "/v1/profiles/{id}/agent/perceive", + ), + ( + "agent_resolve_locator", + ("p1",), + {"locator": LOCATOR, "candidate_limit": 5}, + "POST", + "/v1/profiles/p1/agent/resolve-locator", + {"locator": LOCATOR, "candidate_limit": 5}, + {}, + "/v1/profiles/{id}/agent/resolve-locator", + ), + ( + "agent_click", + ("p1",), + {"locator": LOCATOR, "button": "right", "click_count": 2}, + "POST", + "/v1/profiles/p1/agent/click", + {"locator": LOCATOR, "button": "right", "click_count": 2}, + {}, + "/v1/profiles/{id}/agent/click", + ), + ( + "agent_type", + ("p1",), + {"locator": LOCATOR, "text": "hello", "clear_first": False, "wpm": 55.0}, + "POST", + "/v1/profiles/p1/agent/type", + {"locator": LOCATOR, "text": "hello", "clear_first": False, "wpm": 55.0}, + {}, + "/v1/profiles/{id}/agent/type", + ), + ( + "agent_extract", + ("p1",), + { + "container": {"role": "listitem"}, + "field_map": [{"key": "title", "locator": {"role": "heading"}, "source": "text"}], + "max_pages": 3, + }, + "POST", + "/v1/profiles/p1/agent/extract", + { + "container": {"role": "listitem"}, + "field_map": [{"key": "title", "locator": {"role": "heading"}, "source": "text"}], + "max_pages": 3, + }, + {}, + "/v1/profiles/{id}/agent/extract", + ), + ( + "agent_pick", + ("p1",), + {"timeout_ms": 15000}, + "POST", + "/v1/profiles/p1/agent/pick", + {"timeout_ms": 15000}, + {}, + "/v1/profiles/{id}/agent/pick", + ), + # -- remote sessions --------------------------------------------------- + ( + "list_remote_sessions", + (), + {}, + "GET", + "/v1/remote-sessions", + None, + {}, + "/v1/remote-sessions", + ), + ( + "get_remote_session", + ("s1",), + {}, + "GET", + "/v1/remote-sessions/s1", + None, + {}, + "/v1/remote-sessions/{id}", + ), + ( + "stop_remote_session", + ("s1",), + {}, + "DELETE", + "/v1/remote-sessions/s1", + None, + {}, + "/v1/remote-sessions/{id}", + ), + ("get_remote_hours", (), {}, "GET", "/v1/remote-hours", None, {}, "/v1/remote-hours"), + # -- cookie bot -------------------------------------------------------- + ( + "list_cookie_bot_schedules", + (), + {"scope": "team"}, + "GET", + "/v1/cookie-bot/schedules", + None, + {"scope": "team"}, + "/v1/cookie-bot/schedules", + ), + ( + "get_cookie_bot_schedule", + ("p1",), + {}, + "GET", + "/v1/cookie-bot/schedules/p1", + None, + {}, + "/v1/cookie-bot/schedules/{profile_id}", + ), + ( + "set_cookie_bot_schedule", + ("p1",), + { + "enabled": True, + "run_at_minute": 120, + "days_mask": 31, + "timezone": "Europe/Berlin", + "preset": "steady", + "max_minutes": 45, + "sites": ["https://example.com"], + "acknowledge_conflict": True, + }, + "PUT", + "/v1/cookie-bot/schedules/p1", + { + "enabled": True, + "run_at_minute": 120, + "days_mask": 31, + "timezone": "Europe/Berlin", + "preset": "steady", + "max_minutes": 45, + "sites": ["https://example.com"], + "acknowledge_conflict": True, + }, + {}, + "/v1/cookie-bot/schedules/{profile_id}", + ), + ( + "delete_cookie_bot_schedule", + ("p1",), + {}, + "DELETE", + "/v1/cookie-bot/schedules/p1", + None, + {}, + "/v1/cookie-bot/schedules/{profile_id}", + ), + ( + "get_cookie_bot_conflicts", + ("p1",), + {"run_at_minute": 90, "timezone": "UTC", "days_mask": 7}, + "GET", + "/v1/cookie-bot/conflicts", + None, + {"profile_id": "p1", "run_at_minute": "90", "timezone": "UTC", "days_mask": "7"}, + "/v1/cookie-bot/conflicts", + ), + ( + "list_cookie_bot_runs", + (), + {"profile_id": "p1", "limit": 10, "before": "cursor-1"}, + "GET", + "/v1/cookie-bot/runs", + None, + {"profile_id": "p1", "limit": "10", "before": "cursor-1"}, + "/v1/cookie-bot/runs", + ), + ( + "start_cookie_bot_run", + (), + {"profile_id": "p1", "max_minutes": 30}, + "POST", + "/v1/cookie-bot/runs", + {"profile_id": "p1", "max_minutes": 30}, + {}, + "/v1/cookie-bot/runs", + ), + ( + "cancel_cookie_bot_run", + ("r1",), + {}, + "DELETE", + "/v1/cookie-bot/runs/r1", + None, + {}, + "/v1/cookie-bot/runs/{run_id}", + ), + ( + "list_cookie_bot_presets", + (), + {}, + "GET", + "/v1/cookie-bot/presets", + None, + {}, + "/v1/cookie-bot/presets", + ), + ( + "get_cookie_bot_usage", + (), + {"period": "2026-08"}, + "GET", + "/v1/cookie-bot/usage", + None, + {"period": "2026-08"}, + "/v1/cookie-bot/usage", + ), + # -- groups and tags --------------------------------------------------- + ("list_groups", (), {}, "GET", "/v1/groups", None, {}, "/v1/groups"), + ("get_group", ("g1",), {}, "GET", "/v1/groups/g1", None, {}, "/v1/groups/{id}"), + ("create_group", (), {"name": "Retail"}, "POST", "/v1/groups", {"name": "Retail"}, {}, "/v1/groups"), + ( + "update_group", + ("g1",), + {"name": "Retail EU"}, + "PUT", + "/v1/groups/g1", + {"name": "Retail EU"}, + {}, + "/v1/groups/{id}", + ), + ("delete_group", ("g1",), {}, "DELETE", "/v1/groups/g1", None, {}, "/v1/groups/{id}"), + ("list_tags", (), {}, "GET", "/v1/tags", None, {}, "/v1/tags"), + # -- proxies ----------------------------------------------------------- + ("list_proxies", (), {}, "GET", "/v1/proxies", None, {}, "/v1/proxies"), + ("get_proxy", ("x1",), {}, "GET", "/v1/proxies/x1", None, {}, "/v1/proxies/{id}"), + ( + "create_proxy", + (), + {"name": "EU", "proxy_settings": {"proxy_type": "http", "host": "h", "port": 8080}}, + "POST", + "/v1/proxies", + {"name": "EU", "proxy_settings": {"proxy_type": "http", "host": "h", "port": 8080}}, + {}, + "/v1/proxies", + ), + ( + "update_proxy", + ("x1",), + {"name": "EU 2"}, + "PUT", + "/v1/proxies/x1", + {"name": "EU 2"}, + {}, + "/v1/proxies/{id}", + ), + ("delete_proxy", ("x1",), {}, "DELETE", "/v1/proxies/x1", None, {}, "/v1/proxies/{id}"), + ( + "import_proxies", + (), + {"format": "txt", "content": "h:1:u:p", "name_prefix": "EU"}, + "POST", + "/v1/proxies/import", + {"format": "txt", "content": "h:1:u:p", "name_prefix": "EU"}, + {}, + "/v1/proxies/import", + ), + # -- vpns -------------------------------------------------------------- + ("list_vpns", (), {}, "GET", "/v1/vpns", None, {}, "/v1/vpns"), + ("get_vpn", ("v1",), {}, "GET", "/v1/vpns/v1", None, {}, "/v1/vpns/{id}"), + ("export_vpn", ("v1",), {}, "GET", "/v1/vpns/v1/export", None, {}, "/v1/vpns/{id}/export"), + ( + "import_vpn", + (), + {"content": "[Interface]", "filename": "eu.conf"}, + "POST", + "/v1/vpns/import", + {"content": "[Interface]", "filename": "eu.conf"}, + {}, + "/v1/vpns/import", + ), + ( + "create_vpn", + (), + {"name": "EU", "vpn_type": "WireGuard", "config_data": "[Interface]"}, + "POST", + "/v1/vpns", + {"name": "EU", "vpn_type": "WireGuard", "config_data": "[Interface]"}, + {}, + "/v1/vpns", + ), + ( + "update_vpn", + ("v1",), + {"name": "EU 2"}, + "PUT", + "/v1/vpns/v1", + {"name": "EU 2"}, + {}, + "/v1/vpns/{id}", + ), + ("delete_vpn", ("v1",), {}, "DELETE", "/v1/vpns/v1", None, {}, "/v1/vpns/{id}"), + # -- extensions -------------------------------------------------------- + ("list_extensions", (), {}, "GET", "/v1/extensions", None, {}, "/v1/extensions"), + ("get_extension", ("e1",), {}, "GET", "/v1/extensions/e1", None, {}, "/v1/extensions/{id}"), + ( + "create_extension", + (), + {"name": "Blocker", "file_name": "b.crx", "file_data_base64": "AAAA"}, + "POST", + "/v1/extensions", + {"name": "Blocker", "file_name": "b.crx", "file_data_base64": "AAAA"}, + {}, + "/v1/extensions", + ), + ( + "update_extension", + ("e1",), + {"name": "Blocker 2", "link": True}, + "PUT", + "/v1/extensions/e1", + {"name": "Blocker 2", "link": True}, + {}, + "/v1/extensions/{id}", + ), + ( + "delete_extension", + ("e1",), + {}, + "DELETE", + "/v1/extensions/e1", + None, + {}, + "/v1/extensions/{id}", + ), + ( + "list_extension_groups", + (), + {}, + "GET", + "/v1/extension-groups", + None, + {}, + "/v1/extension-groups", + ), + ( + "get_extension_group", + ("eg1",), + {}, + "GET", + "/v1/extension-groups/eg1", + None, + {}, + "/v1/extension-groups/{id}", + ), + ( + "create_extension_group", + (), + {"name": "Adblock set"}, + "POST", + "/v1/extension-groups", + {"name": "Adblock set"}, + {}, + "/v1/extension-groups", + ), + ( + "update_extension_group", + ("eg1",), + {"extension_ids": ["e1", "e2"]}, + "PUT", + "/v1/extension-groups/eg1", + {"extension_ids": ["e1", "e2"]}, + {}, + "/v1/extension-groups/{id}", + ), + ( + "delete_extension_group", + ("eg1",), + {}, + "DELETE", + "/v1/extension-groups/eg1", + None, + {}, + "/v1/extension-groups/{id}", + ), + ( + "add_extension_to_group", + ("eg1", "e1"), + {}, + "POST", + "/v1/extension-groups/eg1/extensions/e1", + None, + {}, + "/v1/extension-groups/{id}/extensions/{extension_id}", + ), + ( + "remove_extension_from_group", + ("eg1", "e1"), + {}, + "DELETE", + "/v1/extension-groups/eg1/extensions/e1", + None, + {}, + "/v1/extension-groups/{id}/extensions/{extension_id}", + ), + # -- browsers ---------------------------------------------------------- + ( + "download_browser", + (), + {"browser": "wayfern", "version": "152.0.1"}, + "POST", + "/v1/browsers/download", + {"browser": "wayfern", "version": "152.0.1"}, + {}, + "/v1/browsers/download", + ), + ( + "list_browser_versions", + ("wayfern",), + {}, + "GET", + "/v1/browsers/wayfern/versions", + None, + {}, + "/v1/browsers/{browser}/versions", + ), + ( + "is_browser_downloaded", + ("wayfern", "152.0.1"), + {}, + "GET", + "/v1/browsers/wayfern/versions/152.0.1/downloaded", + None, + {}, + "/v1/browsers/{browser}/versions/{version}/downloaded", + ), +] + + +@pytest.mark.parametrize( + "case", CASES, ids=[f"{case[0]}[{index}]" for index, case in enumerate(CASES)] +) +def test_method_sends_the_documented_request( + client: DonutClient, fake: FakeDonut, case: Case +) -> None: + name, args, kwargs, verb, path, body, query, operation = case + + getattr(client, name)(*args, **kwargs) + + sent = fake.last + assert sent.method == verb + assert sent.path == path + assert sent.query == query + assert sent.json == body + assert OPERATIONS[(verb, operation)] == name + + +def test_every_client_method_is_exercised_here() -> None: + """No method may be added to the table of operations without a case above.""" + covered = {case[0] for case in CASES} + missing = sorted(set(OPERATIONS.values()) - covered) + assert not missing, f"these wrapped operations have no request test: {missing}" + + +def test_the_token_travels_as_a_bearer_header(client: DonutClient, fake: FakeDonut) -> None: + client.list_profiles() + sent = fake.last + assert sent.header("Authorization") == "Bearer test-token-abc123" + assert sent.header("Accept") == "application/json" + assert sent.header("Content-Type") is None, "a GET must not claim to carry JSON" + + +def test_a_body_is_sent_as_json(client: DonutClient, fake: FakeDonut) -> None: + client.create_group(name="Retail") + sent = fake.last + assert sent.header("Content-Type") == "application/json" + assert json.loads(sent.body.decode()) == {"name": "Retail"} + + +def test_path_ids_are_escaped(client: DonutClient, fake: FakeDonut) -> None: + """An id can never break out of its own path segment.""" + client.get_profile("a/b c?d") + assert fake.last.path == "/v1/profiles/a%2Fb%20c%3Fd" + + +def test_none_arguments_are_left_out_of_the_body(client: DonutClient, fake: FakeDonut) -> None: + client.update_profile("p1", name="Only this") + assert fake.last.json == {"name": "Only this"} + + +def test_an_empty_string_still_reaches_the_app(client: DonutClient, fake: FakeDonut) -> None: + """`proxy_id=""` is how the app is told to detach a proxy, so it must survive.""" + client.update_profile("p1", proxy_id="") + assert fake.last.json == {"proxy_id": ""} + + +def test_a_no_content_answer_becomes_none(client: DonutClient, fake: FakeDonut) -> None: + fake.enqueue_empty(204) + assert client.delete_profile("p1") is None + + +def test_a_json_answer_is_returned_as_sent(client: DonutClient, fake: FakeDonut) -> None: + fake.enqueue_json({"profiles": [{"id": "p1", "name": "Shopper"}], "total": 1}) + assert client.list_profiles() == { + "profiles": [{"id": "p1", "name": "Shopper"}], + "total": 1, + } + + +def test_a_bare_boolean_answer_is_returned(client: DonutClient, fake: FakeDonut) -> None: + fake.enqueue_json(True) + assert client.is_browser_downloaded("wayfern", "152.0.1") is True diff --git a/sdk/python/tests/test_session.py b/sdk/python/tests/test_session.py new file mode 100644 index 0000000..b1bb472 --- /dev/null +++ b/sdk/python/tests/test_session.py @@ -0,0 +1,92 @@ +"""`with client.run(...)` launches, hands over the CDP endpoint, and stops.""" + +from __future__ import annotations + +import pytest +from fake_donut import FakeDonut + +from donutbrowser import Conflict, DonutClient, DonutError + +RUN_BODY = {"profile_id": "p1", "remote_debugging_port": 9222, "headless": True} + + +def test_the_block_gets_the_cdp_endpoint(client: DonutClient, fake: FakeDonut) -> None: + fake.enqueue_json(RUN_BODY) + fake.enqueue_empty(204) + + with client.run("p1", url="https://example.com", headless=True) as session: + assert session.remote_debugging_port == 9222 + assert session.headless is True + assert session.cdp_url == "http://127.0.0.1:9222" + assert session.response == RUN_BODY + + assert [(sent.method, sent.path) for sent in fake.requests] == [ + ("POST", "/v1/profiles/p1/run"), + ("POST", "/v1/profiles/p1/kill"), + ] + assert fake.requests[0].json == {"url": "https://example.com", "headless": True} + + +def test_nothing_launches_until_the_block_is_entered( + client: DonutClient, fake: FakeDonut +) -> None: + session = client.run("p1") + assert session.remote_debugging_port is None + assert fake.requests == [] + + +def test_the_browser_is_stopped_when_the_block_raises( + client: DonutClient, fake: FakeDonut +) -> None: + fake.enqueue_json(RUN_BODY) + fake.enqueue_empty(204) + + with pytest.raises(ZeroDivisionError): + with client.run("p1"): + raise ZeroDivisionError("the body failed") + + assert [sent.path for sent in fake.requests] == [ + "/v1/profiles/p1/run", + "/v1/profiles/p1/kill", + ] + + +def test_a_failed_stop_never_hides_why_the_block_failed( + client: DonutClient, fake: FakeDonut +) -> None: + fake.enqueue_json(RUN_BODY) + fake.enqueue_error(409, "PROFILE_LOCKED_ELSEWHERE") + + session = client.run("p1") + with pytest.raises(ZeroDivisionError): + with session: + raise ZeroDivisionError("the body failed") + + assert isinstance(session.cleanup_error, Conflict) + + +def test_a_failed_stop_is_raised_when_the_block_was_fine( + client: DonutClient, fake: FakeDonut +) -> None: + fake.enqueue_json(RUN_BODY) + fake.enqueue_error(503, "the fleet could not be reached") + + with pytest.raises(DonutError): + with client.run("p1"): + pass + + +def test_a_failed_launch_stops_nothing(client: DonutClient, fake: FakeDonut) -> None: + fake.enqueue_error(409, "PROFILE_RUNNING") + + with pytest.raises(Conflict): + with client.run("p1"): + pytest.fail("the block must not run when the launch failed") + + assert [sent.path for sent in fake.requests] == ["/v1/profiles/p1/run"] + + +def test_the_cdp_url_is_refused_before_the_block(client: DonutClient) -> None: + session = client.run("p1") + with pytest.raises(DonutError): + _ = session.cdp_url diff --git a/sdk/tools/extract-api-paths.py b/sdk/tools/extract-api-paths.py new file mode 100644 index 0000000..dc4bb13 --- /dev/null +++ b/sdk/tools/extract-api-paths.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Regenerate sdk/api-paths.json from the Rust REST server. + +The served /openapi.json comes from the hand-maintained `ApiDoc` derive in +`src-tauri/src/api_server.rs`, not from the axum router, so this script reads +the same two things the document is built from: + + * every `#[utoipa::path(...)]` annotation (its verb and path), and + * the `paths(...)` list inside `#[openapi(...)]`. + +An annotation that is not in `paths(...)` never reaches the served document, so +the two lists are compared here and a difference fails the run. The result is a +snapshot both SDK test suites read to prove they cover the whole API. + +Usage (from anywhere): + python3 sdk/tools/extract-api-paths.py +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +SOURCE = REPO_ROOT / "src-tauri" / "src" / "api_server.rs" +SNAPSHOT = REPO_ROOT / "sdk" / "api-paths.json" + +VERBS = ("get", "post", "put", "delete", "patch", "head", "options") + + +def read_annotations(lines: list[str]) -> list[dict[str, str]]: + """Every `#[utoipa::path(...)]` block, paired with the fn it decorates.""" + operations: list[dict[str, str]] = [] + index = 0 + while index < len(lines): + if lines[index].strip() != "#[utoipa::path(": + index += 1 + continue + + depth = 0 + end = index + while end < len(lines): + depth += lines[end].count("(") - lines[end].count(")") + if depth == 0 and end > index: + break + end += 1 + block = lines[index : end + 1] + + method = next( + (line.strip().rstrip(",") for line in block if line.strip().rstrip(",") in VERBS), + None, + ) + path_match = next( + (re.search(r'path\s*=\s*"([^"]+)"', line) for line in block if "path = " in line), + None, + ) + name_match = None + for line in lines[end + 1 : end + 4]: + name_match = re.search(r"\bfn\s+(\w+)\s*\(", line) + if name_match: + break + + if method is None or path_match is None or name_match is None: + raise SystemExit( + f"{SOURCE}:{index + 1}: could not read a verb, a path and a fn name " + "out of this #[utoipa::path] block" + ) + + operations.append( + { + "operation_id": name_match.group(1), + "method": method.upper(), + "path": path_match.group(1), + } + ) + index = end + 1 + + return operations + + +def read_apidoc_paths(text: str) -> list[str]: + """The operation ids listed in `#[openapi(paths(...))]`.""" + start = text.index("#[openapi(") + listed = text.index("paths(", start) + len("paths(") + depth = 1 + end = listed + while depth: + if text[end] == "(": + depth += 1 + elif text[end] == ")": + depth -= 1 + if depth == 0: + break + end += 1 + body = re.sub(r"//[^\n]*", "", text[listed:end]) + return [item.strip() for item in body.split(",") if item.strip()] + + +def main() -> int: + text = SOURCE.read_text(encoding="utf-8") + annotated = read_annotations(text.split("\n")) + listed = read_apidoc_paths(text) + + annotated_ids = {operation["operation_id"] for operation in annotated} + listed_ids = set(listed) + + unpublished = sorted(annotated_ids - listed_ids) + unknown = sorted(listed_ids - annotated_ids) + if unpublished or unknown: + for name in unpublished: + print( + f"error: {name} carries a #[utoipa::path] but is missing from " + "ApiDoc paths(...), so it is absent from the served spec", + file=sys.stderr, + ) + for name in unknown: + print( + f"error: ApiDoc paths(...) lists {name}, which has no " + "#[utoipa::path] annotation in this file", + file=sys.stderr, + ) + return 1 + + operations = sorted(annotated, key=lambda op: (op["path"], op["method"])) + snapshot = { + "source": "src-tauri/src/api_server.rs", + "regenerate_with": "python3 sdk/tools/extract-api-paths.py", + "description": ( + "Every operation the desktop app publishes in its /openapi.json. The " + "SDK test suites assert this list and their own coverage tables match " + "exactly, so an endpoint added to the app fails the SDK tests until it " + "is either wrapped or deliberately listed as omitted." + ), + "operation_count": len(operations), + "operations": operations, + } + SNAPSHOT.write_text(json.dumps(snapshot, indent=2) + "\n", encoding="utf-8") + print(f"wrote {SNAPSHOT.relative_to(REPO_ROOT)} with {len(operations)} operations") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e9b4128..a242b62 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -28,6 +28,17 @@ dependencies = [ "inout 0.2.2", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + [[package]] name = "aes" version = "0.9.3" @@ -39,6 +50,20 @@ dependencies = [ "cpufeatures 0.3.1", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", + "subtle", +] + [[package]] name = "aes-gcm" version = "0.11.1" @@ -46,11 +71,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" dependencies = [ "aead 0.6.1", - "aes", + "aes 0.9.3", "cipher 0.5.2", - "ctr", + "ctr 0.10.1", "ctutils", - "ghash", + "ghash 0.6.0", ] [[package]] @@ -477,30 +502,6 @@ dependencies = [ "arrayvec", ] -[[package]] -name = "aws-lc-rs" -version = "1.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" -dependencies = [ - "aws-lc-sys", - "untrusted 0.7.1", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - [[package]] name = "axum" version = "0.8.9" @@ -714,7 +715,7 @@ dependencies = [ "aead 0.5.2", "base64 0.22.1", "blake2 0.10.6", - "chacha20poly1305 0.10.1", + "chacha20poly1305", "hex", "hmac 0.12.1", "ip_network", @@ -726,7 +727,7 @@ dependencies = [ "rand_core 0.6.4", "ring", "tracing", - "untrusted 0.9.0", + "untrusted", "x25519-dalek", ] @@ -1006,7 +1007,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cipher 0.5.2", "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -1020,22 +1020,10 @@ dependencies = [ "aead 0.5.2", "chacha20 0.9.1", "cipher 0.4.4", - "poly1305 0.8.0", + "poly1305", "zeroize", ] -[[package]] -name = "chacha20poly1305" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" -dependencies = [ - "aead 0.6.1", - "chacha20 0.10.2", - "cipher 0.5.2", - "poly1305 0.9.1", -] - [[package]] name = "chrono" version = "0.4.45" @@ -1131,15 +1119,6 @@ dependencies = [ "error-code", ] -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "cmov" version = "0.5.4" @@ -1177,6 +1156,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -1418,6 +1403,15 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "ctr" version = "0.10.1" @@ -1568,6 +1562,16 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1616,7 +1620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] @@ -1725,8 +1729,8 @@ dependencies = [ name = "donutbrowser" version = "0.30.0" dependencies = [ - "aes", - "aes-gcm", + "aes 0.9.3", + "aes-gcm 0.11.1", "argon2", "async-socks5", "async-trait", @@ -1752,6 +1756,7 @@ dependencies = [ "hyper", "hyper-util", "image", + "jsonc-parser", "lazy_static", "libc", "log", @@ -1759,6 +1764,7 @@ dependencies = [ "maxminddb", "mime_guess", "msi-extract", + "native-tls", "nix", "objc2", "objc2-app-kit", @@ -1798,9 +1804,10 @@ dependencies = [ "tempfile", "thiserror 2.0.20", "tokio", + "tokio-native-tls", "tokio-tungstenite 0.30.0", "tokio-util", - "toml 1.1.4+spec-1.1.0", + "toml_edit 0.25.13+spec-1.1.0", "tower", "tower-http 0.7.1", "url", @@ -1891,6 +1898,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + [[package]] name = "either" version = "1.18.0" @@ -2282,12 +2299,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "futures" version = "0.3.34" @@ -2543,13 +2554,23 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval 0.6.2", +] + [[package]] name = "ghash" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" dependencies = [ - "polyval", + "polyval 0.7.3", ] [[package]] @@ -2856,6 +2877,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + [[package]] name = "hkdf" version = "0.13.0" @@ -3511,6 +3541,15 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "jsonc-parser" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0560e3f9a9a03ea6b6e90b41138c5db9e21526c99eb192c1a26c68176593285" +dependencies = [ + "serde_json", +] + [[package]] name = "jsonptr" version = "0.6.3" @@ -3756,12 +3795,12 @@ dependencies = [ [[package]] name = "md-5" -version = "0.11.0" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest 0.11.3", + "digest 0.10.7", ] [[package]] @@ -4678,6 +4717,16 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -4748,16 +4797,6 @@ dependencies = [ "universal-hash 0.5.1", ] -[[package]] -name = "poly1305" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" -dependencies = [ - "cpufeatures 0.3.1", - "universal-hash 0.6.1", -] - [[package]] name = "polycool" version = "0.4.0" @@ -4767,6 +4806,18 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", +] + [[package]] name = "polyval" version = "0.7.3" @@ -5323,10 +5374,23 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted 0.9.0", + "untrusted", "windows-sys 0.52.0", ] +[[package]] +name = "ring-compat" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccce7bae150b815f0811db41b8312fcb74bffa4cab9cee5429ee00f356dd5bd4" +dependencies = [ + "aead 0.5.2", + "ed25519", + "generic-array", + "pkcs8", + "ring", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -5435,7 +5499,7 @@ checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", - "untrusted 0.9.0", + "untrusted", ] [[package]] @@ -5527,13 +5591,13 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "sealed" -version = "0.7.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b68e2ea526d9fb32f23ca8894fb5da9e743f34c2f41701f0501dc8a25c4b343" +checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 2.0.119", ] [[package]] @@ -5542,11 +5606,11 @@ version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" dependencies = [ - "aes", + "aes 0.9.3", "cbc", "futures-util", "getrandom 0.4.3", - "hkdf", + "hkdf 0.13.0", "hybrid-array", "num", "once_cell", @@ -5883,11 +5947,11 @@ dependencies = [ [[package]] name = "shadowsocks" -version = "1.25.0" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2065b026dbe4f47048eca384adf07f693bf901050d80abd3adb7b2422709b2d" +checksum = "482831bf9d55acf3c98e211b6c852c3dfdf1d1b0d23fdf1d887c5a4b2acad4e4" dependencies = [ - "base64 0.23.1", + "base64 0.22.1", "blake3", "byte_string", "bytes", @@ -5916,18 +5980,18 @@ dependencies = [ [[package]] name = "shadowsocks-crypto" -version = "0.8.0" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b404a62ebea5003f44b9a2748dee7555c5f9ae39896508e466a2a91ee6fa5df5" +checksum = "3d038a3d17586f1c1ab3c1c3b9e4d5ef8fba98fb3890ad740c8487038b2e2ca5" dependencies = [ - "aes-gcm", - "aws-lc-rs", + "aes-gcm 0.10.3", "cfg-if", - "chacha20poly1305 0.11.0", - "hkdf", + "chacha20poly1305", + "hkdf 0.12.4", "md-5", - "rand 0.10.2", - "sha1 0.11.0", + "rand 0.9.5", + "ring-compat", + "sha1 0.10.7", ] [[package]] @@ -5978,6 +6042,12 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" + [[package]] name = "simd-adler32" version = "0.3.10" @@ -6113,13 +6183,23 @@ dependencies = [ [[package]] name = "spin" -version = "0.12.3" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0134f9043ed38b087ac4f7d4af44c79e2c9e5094421fe3164f435ce585953b10" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -6807,7 +6887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -7176,6 +7256,7 @@ dependencies = [ "indexmap 2.14.2", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", + "toml_writer", "winnow 1.0.4", ] @@ -7513,12 +7594,6 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - [[package]] name = "untrusted" version = "0.9.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a58ec60..17a158e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,6 +30,8 @@ resvg = "0.48" [dependencies] serde_json = "1" +# Runtime as well as build time: the per-profile window badge is rendered at launch. +resvg = "0.48" serde = { version = "1", features = ["derive"] } tauri = { version = "2", features = ["tray-icon", "image-png"] } tauri-plugin-opener = "2" @@ -47,6 +49,16 @@ env_logger = "0.11" directories = "6" reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "stream", "socks", "charset", "http2", "system-proxy"] } +# The `httpstls` upstream type wraps the hop to the proxy in TLS before a single +# byte of CONNECT or Proxy-Authorization is written. native-tls (not rustls) on +# purpose: reqwest above already terminates its proxy TLS through native-tls, so +# both the browser tunnel and the check-button probe consult the same platform +# trust store. A rustls tunnel plus a native-tls probe would mean a proxy that +# passes the check and then fails in the browser. Both crates already build +# today as transitive deps of reqwest and tokio-tungstenite; this adds an edge, +# not a crate. +native-tls = "0.2" +tokio-native-tls = "0.3" tokio = { version = "1", features = ["full", "sync"] } tokio-util = "0.7" sysinfo = "0.39" @@ -88,7 +100,11 @@ cbc = "0.2" ring = "0.17" subtle = "2" sha2 = "0.11" -shadowsocks = { version = "1.24", default-features = false, features = ["aead-cipher"] } +# Held below 1.25 on purpose. From 1.25 the `aead-cipher` feature hard-enables the +# aws-lc crypto backend (a C/assembly library needing cmake, and NASM on Windows) +# alongside the `ring` this crate already ships through boringtun. Moving up is a +# build-toolchain decision for every platform, not a routine dependency refresh. +shadowsocks = { version = ">=1.24, <1.25", default-features = false, features = ["aead-cipher"] } hyper = { version = "1.10", features = ["full"] } hyper-util = { version = "0.1", features = ["full"] } http-body-util = "0.1" @@ -100,7 +116,6 @@ async-socks5 = "0.6" tokio-tungstenite = { version = "0.30", features = ["native-tls"] } rusqlite = { version = "0.40", features = ["bundled"] } serde_yaml = "0.9" -toml = "1.1" thiserror = "2.0" regex-lite = "0.1" tempfile = "3" @@ -116,6 +131,8 @@ image = "0.25" dirs = "6" crossbeam-channel = "0.5" sys-locale = "0.3" +jsonc-parser = { version = "0.33", features = ["cst", "serde_json"] } +toml_edit = "0.25" [target.'cfg(unix)'.dependencies] nix = { version = "0.31", features = ["signal", "process"] } diff --git a/src-tauri/src/agent.rs b/src-tauri/src/agent.rs new file mode 100644 index 0000000..637b648 --- /dev/null +++ b/src-tauri/src/agent.rs @@ -0,0 +1,1229 @@ +//! Agent-run transport. +//! +//! An agent run is a goal the cloud pursues on one profile, either on this +//! desktop (`desktop`) or on a leased host (`fleet`). NONE of the reasoning +//! lives here: everything a run actually does is the cloud API's, and this side +//! never sees it. +//! +//! This module is the wire plus the two things a client is uniquely able to +//! check: that the profile the run names is actually on this machine, and that +//! the goal is not empty. Everything else is asked for and rendered back. +//! +//! It also owns the bridge that turns the run's `text/event-stream` into Tauri +//! events, because a run's steps are only observable through that stream — the +//! create call answers `queued` and nothing more. + +use crate::cloud_errors::{self, BackendFailure, FailureCodes}; +use crate::remote_session::{jittered, reconnect_delay, SseDecoder}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; +use tauri::AppHandle; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); + +/// How long a live run may say nothing before the socket is treated as gone. +/// +/// Longer than the cookie-bot equivalent on purpose: a single agent step can be +/// a slow page load followed by a model call, and cutting a healthy stream +/// mid-thought would replay the whole transcript for nothing. +const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(120); + +/// Failure codes for the run routes. +const RUN_CODES: FailureCodes = FailureCodes { + bad_request: "AGENT_GOAL_INVALID", + forbidden: "AGENT_NOT_ENTITLED", + not_found: "AGENT_RUN_NOT_FOUND", + conflict: "AGENT_RUN_NOT_CANCELLABLE", +}; + +/// Failure codes for the recipe routes. +/// +/// Deliberately not the run set: a 404 here is a recipe someone deleted from +/// another device, not a run id that was never the caller's, and telling a user +/// editing a saved list that "that run does not exist" is worse than saying +/// nothing. The backend sends its own `{"code":…}` body on every refusal it has +/// a name for, so these only decide a bodyless status. +const RECIPE_CODES: FailureCodes = FailureCodes { + bad_request: "AGENT_RECIPE_INVALID", + forbidden: "AGENT_NOT_ENTITLED", + not_found: "AGENT_RECIPE_NOT_FOUND", + conflict: "AGENT_RECIPE_INVALID", +}; + +/// Every agent call fails as a code the frontend can translate. +/// +/// There is no `Other(String)` carrying backend English: a raw message reaches +/// the user untranslated, which is the bug pattern the `{"code":…}` convention +/// exists to block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentError(pub BackendFailure); + +impl AgentError { + pub fn code(&self) -> &str { + &self.0.code + } + + pub fn status(&self) -> u16 { + self.0.status + } + + /// The `{"code":…,"params":{…}}` string a Tauri command returns. + pub fn to_error_json(&self) -> String { + self.0.to_error_json() + } +} + +impl std::fmt::Display for AgentError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.to_error_json()) + } +} + +impl From for AgentError { + fn from(failure: BackendFailure) -> Self { + Self(failure) + } +} + +// --- Wire types ------------------------------------------------------------- +// +// The backend speaks camelCase on this surface, so every type here does too and +// the same spelling reaches the frontend unchanged. One shape end to end means +// a contract change is a single edit rather than two translations of it. + +/// Where the run drives a browser. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AgentTarget { + /// This machine, through the desktop's own automation surface. + Desktop, + /// A leased host of the profile's operating system. + Fleet, +} + +impl AgentTarget { + fn as_str(self) -> &'static str { + match self { + Self::Desktop => "desktop", + Self::Fleet => "fleet", + } + } +} + +/// Ceilings a run stops at rather than running until the money does. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentBudgets { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_steps: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_wall_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, +} + +impl AgentBudgets { + fn is_empty(&self) -> bool { + self.max_steps.is_none() && self.max_wall_ms.is_none() && self.max_tokens.is_none() + } +} + +/// One run, as the server describes it. +/// +/// Every field but the id defaults. A run view that fails to decode is a blank +/// page where a working one should be, and the desktop is a renderer here: a +/// field the server adds, drops or renames must cost one missing line, never +/// the whole surface. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentRunView { + pub id: String, + #[serde(default)] + pub profile_id: String, + #[serde(default)] + pub target: String, + #[serde(default)] + pub platform: Option, + #[serde(default)] + pub goal: String, + #[serde(default)] + pub status: String, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub effort: Option, + #[serde(default)] + pub budgets: Option, + #[serde(default)] + pub allowed_hosts: Option>, + #[serde(default)] + pub remote_session_id: Option, + #[serde(default)] + pub close_reason: Option, + #[serde(default)] + pub error_code: Option, + #[serde(default)] + pub result: Option, + #[serde(default)] + pub tokens_in: Option, + #[serde(default)] + pub tokens_out: Option, + #[serde(default)] + pub cost_usd: Option, + #[serde(default)] + pub steps: Option, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub started_at: Option, + #[serde(default)] + pub ended_at: Option, + #[serde(default)] + pub updated_at: Option, +} + +/// One entry in a run's transcript. +/// +/// `rest` keeps whatever the step kind carries — a thought's text, a tool's +/// name and arguments, an error's message. Flattened rather than enumerated +/// because the tool set is the server's and grows without a desktop release; +/// dropping the unknown half would render a tool step with nothing in it. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentStep { + #[serde(default)] + pub index: u32, + #[serde(default)] + pub at: Option, + #[serde(default)] + pub kind: String, + #[serde(flatten)] + pub rest: serde_json::Map, +} + +/// A run plus everything it has done so far. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentRunDetail { + #[serde(flatten)] + pub run: AgentRunView, + #[serde(default)] + pub transcript: Vec, +} + +/// One page of runs, newest first. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentRunPage { + #[serde(default)] + pub runs: Vec, + #[serde(default)] + pub next_cursor: Option, +} + +/// One step of a recipe, in the shape the API validates. +/// +/// Kept as raw JSON rather than a mirrored enum: the server owns the schema +/// and refuses anything it does not recognise, so a second copy of the rules +/// here would only add a way for the two to disagree. What this side does +/// check is the shape a client can get wrong silently — a step must be an +/// object naming a `type` the server knows, and a targeted step must carry +/// exactly one of `selector` or `locator`. +pub type RecipeStep = serde_json::Value; + +/// The step kinds the API accepts, in its own spelling. +const RECIPE_STEP_TYPES: [&str; 9] = [ + "navigate", + "click", + "type", + "waitFor", + "extract", + "pressKey", + "scroll", + "screenshot", + "sleep", +]; + +/// The kinds that name an element, and so must carry exactly one target. +const RECIPE_TARGETED_TYPES: [&str; 4] = ["click", "type", "waitFor", "extract"]; + +/// A recipe is a task, not a program: the API's own ceiling. +const MAX_RECIPE_STEPS: usize = 200; + +/// A saved goal: a name and the steps it expands to. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentRecipe { + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub steps: Vec, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, +} + +/// Either spelling of the recipe list. +/// +/// A bare array and a `{"recipes":[…]}` envelope are both plausible readings of +/// the same route, and guessing wrong blanks the library with a decode error +/// rather than showing the user their own saved goals. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum RecipeList { + Enveloped { recipes: Vec }, + Bare(Vec), +} + +impl RecipeList { + fn into_vec(self) -> Vec { + match self { + Self::Enveloped { recipes } => recipes, + Self::Bare(recipes) => recipes, + } + } +} + +/// What the desktop asks for when it starts a run. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StartAgentRunInput { + pub profile_id: String, + pub target: AgentTarget, + pub goal: String, + /// Which operating system the leased host must run. Ignored for a desktop + /// run, and resolved from the profile when a fleet run omits it. + #[serde(default)] + pub platform: Option, + #[serde(default)] + pub effort: Option, + #[serde(default)] + pub budgets: Option, + #[serde(default)] + pub allowed_hosts: Option>, +} + +// --- Local preconditions ---------------------------------------------------- + +/// The longest goal the desktop will send. +/// +/// The server has its own ceiling; this one exists so a pasted document is +/// refused here instead of spending a round trip to be told so. +pub const MAX_GOAL_CHARS: usize = 4000; + +/// Refuse a run the server would certainly refuse, before it costs anything. +/// +/// Only the two things a client can actually know: the profile is on this +/// machine, and the goal says something. Everything else — entitlement, budget +/// ceilings, concurrency — is the server's to judge, and second-guessing it +/// here is how a client ends up refusing work the account is entitled to. +fn validate_goal(goal: &str) -> Result { + let trimmed = goal.trim(); + if trimmed.is_empty() || trimmed.chars().count() > MAX_GOAL_CHARS { + return Err(serde_json::json!({ "code": "AGENT_GOAL_INVALID" }).to_string()); + } + Ok(trimmed.to_string()) +} + +/// The local profile a run refers to. +fn local_profile(profile_id: &str) -> Result { + let profiles = crate::profile::manager::ProfileManager::instance() + .list_profiles() + .map_err(|e| { + log::warn!("Agent run refused: profiles could not be read: {e}"); + serde_json::json!({ "code": "INTERNAL_ERROR" }).to_string() + })?; + profiles + .into_iter() + .find(|p| p.id.to_string() == profile_id) + .ok_or_else(|| serde_json::json!({ "code": "PROFILE_NOT_FOUND" }).to_string()) +} + +/// Which operating system a fleet run needs a host for. +/// +/// The caller's choice when it made one, else the profile's own OS: a leased +/// host has to be the machine the profile was built for, so falling back to it +/// is the answer rather than a refusal the user cannot act on. +fn fleet_platform( + requested: Option<&str>, + profile: &crate::profile::types::BrowserProfile, +) -> Result { + let resolved = requested + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .or_else(|| profile.host_os.clone()) + .unwrap_or_default(); + if crate::cookie_bot::BOT_PLATFORMS.contains(&resolved.as_str()) { + return Ok(resolved); + } + Err( + serde_json::json!({ + "code": "REMOTE_PLATFORM_UNSUPPORTED", + "params": { "platform": resolved }, + }) + .to_string(), + ) +} + +// --- Routes ----------------------------------------------------------------- + +fn base() -> String { + format!("{}/api/agent", crate::cloud_auth::CLOUD_API_URL) +} + +/// Start a run. Answers the created run, normally `queued`. +pub async fn start_run(input: StartAgentRunInput) -> Result { + let goal = validate_goal(&input.goal)?; + let profile = local_profile(&input.profile_id)?; + + let mut body = serde_json::Map::new(); + body.insert( + "profileId".to_string(), + serde_json::Value::String(input.profile_id.clone()), + ); + body.insert( + "target".to_string(), + serde_json::Value::String(input.target.as_str().to_string()), + ); + body.insert("goal".to_string(), serde_json::Value::String(goal)); + if input.target == AgentTarget::Fleet { + body.insert( + "platform".to_string(), + serde_json::Value::String(fleet_platform(input.platform.as_deref(), &profile)?), + ); + } + if let Some(effort) = input + .effort + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + body.insert( + "effort".to_string(), + serde_json::Value::String(effort.to_string()), + ); + } + if let Some(budgets) = input.budgets.filter(|b| !b.is_empty()) { + body.insert( + "budgets".to_string(), + serde_json::to_value(budgets).unwrap_or(serde_json::Value::Null), + ); + } + // An empty list is not the same as no list: `[]` would mean "navigate + // nowhere", so only a non-empty allowlist is sent at all. + let hosts = normalise_hosts(input.allowed_hosts.as_deref()); + if !hosts.is_empty() { + body.insert( + "allowedHosts".to_string(), + serde_json::Value::Array(hosts.into_iter().map(serde_json::Value::String).collect()), + ); + } + + request( + reqwest::Method::POST, + format!("{}/runs", base()), + Vec::new(), + Some(serde_json::Value::Object(body)), + RUN_CODES, + ) + .await + .map_err(|e| agent_error("run start", e)) +} + +/// Trim, lowercase and de-duplicate an allowlist, dropping blanks. +/// +/// Hosts are compared case-insensitively by every browser, so `Example.com` and +/// `example.com` reaching the server as two entries only makes the refusal +/// message longer. +pub fn normalise_hosts(hosts: Option<&[String]>) -> Vec { + let mut seen = Vec::new(); + for host in hosts.unwrap_or_default() { + let host = host.trim().to_lowercase(); + if host.is_empty() || seen.contains(&host) { + continue; + } + seen.push(host); + } + seen +} + +/// One page of runs, newest first. +pub async fn list_runs(limit: Option, cursor: Option<&str>) -> Result { + let mut query = Vec::new(); + if let Some(n) = limit { + query.push(("limit".to_string(), n.to_string())); + } + if let Some(c) = cursor.map(str::trim).filter(|s| !s.is_empty()) { + query.push(("cursor".to_string(), c.to_string())); + } + request( + reqwest::Method::GET, + format!("{}/runs", base()), + query, + None, + RUN_CODES, + ) + .await + .map_err(|e| agent_error("run list", e)) +} + +/// One run and everything it has done. +pub async fn get_run(run_id: &str) -> Result { + request( + reqwest::Method::GET, + format!("{}/runs/{}", base(), urlencoding::encode(run_id)), + Vec::new(), + None, + RUN_CODES, + ) + .await + .map_err(|e| agent_error("run read", e)) +} + +/// Stop a run that has not finished. +pub async fn cancel_run(run_id: &str) -> Result { + request( + reqwest::Method::POST, + format!("{}/runs/{}/cancel", base(), urlencoding::encode(run_id)), + Vec::new(), + Some(serde_json::Value::Object(serde_json::Map::new())), + RUN_CODES, + ) + .await + .map_err(|e| agent_error("run cancel", e)) +} + +/// Every saved goal this account has. +pub async fn list_recipes() -> Result, String> { + let list: RecipeList = request( + reqwest::Method::GET, + format!("{}/recipes", base()), + Vec::new(), + None, + RECIPE_CODES, + ) + .await + .map_err(|e| agent_error("recipe list", e))?; + Ok(list.into_vec()) +} + +/// Save a new one. +pub async fn create_recipe(name: &str, steps: &[RecipeStep]) -> Result { + let (name, steps) = validate_recipe(name, steps)?; + request( + reqwest::Method::POST, + format!("{}/recipes", base()), + Vec::new(), + Some(serde_json::json!({ "name": name, "steps": steps })), + RECIPE_CODES, + ) + .await + .map_err(|e| agent_error("recipe create", e)) +} + +/// Rename one, replace its steps, or both. +pub async fn update_recipe( + id: &str, + name: &str, + steps: &[RecipeStep], +) -> Result { + let (name, steps) = validate_recipe(name, steps)?; + request( + reqwest::Method::PATCH, + format!("{}/recipes/{}", base(), urlencoding::encode(id)), + Vec::new(), + Some(serde_json::json!({ "name": name, "steps": steps })), + RECIPE_CODES, + ) + .await + .map_err(|e| agent_error("recipe update", e)) +} + +/// Delete one. +pub async fn delete_recipe(id: &str) -> Result { + let outcome: RecipeDeleted = request( + reqwest::Method::DELETE, + format!("{}/recipes/{}", base(), urlencoding::encode(id)), + Vec::new(), + None, + RECIPE_CODES, + ) + .await + .map_err(|e| agent_error("recipe delete", e))?; + Ok(outcome.deleted.unwrap_or(true)) +} + +#[derive(Debug, Default, Deserialize)] +struct RecipeDeleted { + #[serde(default)] + deleted: Option, +} + +/// A recipe needs a name and at least one step. +/// +/// `NAME_CANNOT_BE_EMPTY` rather than an agent-specific code: it is the same +/// refusal the user already meets when naming a group or a proxy, and it is +/// already translated everywhere. +fn validate_recipe(name: &str, steps: &[RecipeStep]) -> Result<(String, Vec), String> { + let name = name.trim().to_string(); + if name.is_empty() { + return Err(serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }).to_string()); + } + if steps.is_empty() || steps.len() > MAX_RECIPE_STEPS { + return Err(serde_json::json!({ "code": "AGENT_RECIPE_INVALID" }).to_string()); + } + for step in steps { + let Some(object) = step.as_object() else { + return Err(serde_json::json!({ "code": "AGENT_RECIPE_INVALID" }).to_string()); + }; + let Some(kind) = object.get("type").and_then(serde_json::Value::as_str) else { + return Err(serde_json::json!({ "code": "AGENT_RECIPE_INVALID" }).to_string()); + }; + if !RECIPE_STEP_TYPES.contains(&kind) { + return Err(serde_json::json!({ "code": "AGENT_RECIPE_INVALID" }).to_string()); + } + if RECIPE_TARGETED_TYPES.contains(&kind) { + let has_selector = object + .get("selector") + .and_then(serde_json::Value::as_str) + .is_some_and(|selector| !selector.trim().is_empty()); + let has_locator = object + .get("locator") + .is_some_and(|locator| locator.as_object().is_some_and(|fields| !fields.is_empty())); + // Exactly one, which is the rule the API enforces: both is ambiguous and + // neither names nothing. + if has_selector == has_locator { + return Err(serde_json::json!({ "code": "AGENT_RECIPE_INVALID" }).to_string()); + } + } + } + Ok((name, steps.to_vec())) +} + +/// Turn an agent failure into the code the frontend translates. +fn agent_error(context: &str, err: AgentError) -> String { + log::warn!( + "Agent {context} failed: {} (HTTP {})", + err.code(), + err.status() + ); + err.to_error_json() +} + +// --- Step stream ------------------------------------------------------------ + +/// One step was appended to a run. Payload: the SSE frame's own JSON. +pub const EVENT_AGENT_STEP: &str = "agent-run-step"; +/// A run changed status. Payload: the SSE frame's own JSON. +pub const EVENT_AGENT_STATUS: &str = "agent-run-status"; +/// Whether steps are currently arriving. Payload: `{connected, runId, reason}`. +pub const EVENT_AGENT_STREAM: &str = "agent-run-stream"; + +static STREAM_RUNNING: AtomicBool = AtomicBool::new(false); +static WATCHED_RUN: Mutex> = Mutex::new(None); +static STREAM_TASK: Mutex>> = Mutex::new(None); + +/// Granularity of the cancellable sleep, so a stop is not held up by a backoff. +const SHUTDOWN_POLL: Duration = Duration::from_millis(250); + +/// A run that cannot move again on its own. +/// +/// The stream ends after one of these, and a client that reconnected anyway +/// would replay a finished transcript on a loop for as long as the page stayed +/// open. +pub fn is_terminal_status(status: &str) -> bool { + matches!(status, "succeeded" | "failed" | "cancelled") +} + +/// Which run the desktop is currently streaming, if any. +pub fn watched_run() -> Option { + if !STREAM_RUNNING.load(Ordering::SeqCst) { + return None; + } + WATCHED_RUN.lock().ok().and_then(|slot| slot.clone()) +} + +/// Start streaming one run's steps. +/// +/// Idempotent for the run already being watched; asking for a different one +/// replaces the stream rather than opening a second socket, because the page +/// only ever shows one run at a time and two sockets would double every step. +pub fn start_run_events(app: AppHandle, run_id: String) { + if watched_run().as_deref() == Some(run_id.as_str()) { + return; + } + stop_run_events(); + + if let Ok(mut slot) = WATCHED_RUN.lock() { + *slot = Some(run_id.clone()); + } + STREAM_RUNNING.store(true, Ordering::SeqCst); + let handle = tauri::async_runtime::spawn(async move { + run_step_events(app, run_id).await; + }); + if let Ok(mut slot) = STREAM_TASK.lock() { + *slot = Some(handle); + } +} + +/// Stop streaming. Safe to call when nothing is running. +pub fn stop_run_events() { + if let Ok(mut slot) = WATCHED_RUN.lock() { + *slot = None; + } + if !STREAM_RUNNING.swap(false, Ordering::SeqCst) { + return; + } + if let Ok(mut slot) = STREAM_TASK.lock() { + if let Some(handle) = slot.take() { + handle.abort(); + } + } +} + +async fn run_step_events(app: AppHandle, run_id: String) { + let mut attempt = 0u32; + + while STREAM_RUNNING.load(Ordering::SeqCst) && watched_run().as_deref() == Some(run_id.as_str()) { + match connect_run_events(&run_id).await { + Ok(response) => { + attempt = 0; + emit_stream_status(&app, &run_id, true, None); + match consume_run_events(&app, response).await { + Ok(saw_terminal) => { + emit_stream_status(&app, &run_id, false, None); + if saw_terminal { + // The run is over and the transcript will not grow again. + // Reconnecting would replay it on a loop. + log::info!("Agent run {run_id} reached a terminal status; stream closed"); + break; + } + log::info!("Agent run {run_id} stream closed by the backend"); + } + Err(reason) => { + log::warn!("Agent run {run_id} stream ended: {reason}"); + emit_stream_status(&app, &run_id, false, Some(&reason)); + } + } + } + Err(reason) => { + log::warn!("Agent run {run_id} stream could not connect: {reason}"); + emit_stream_status(&app, &run_id, false, Some(&reason)); + } + } + + if !STREAM_RUNNING.load(Ordering::SeqCst) { + break; + } + let delay = jittered(reconnect_delay(attempt)); + attempt = attempt.saturating_add(1); + sleep_unless_stopped(delay).await; + } + + // Only clear the slot when this task still owns it: a newer `start_run_events` + // may already have installed its own run id, and blanking that would make the + // status command lie about a stream that is very much alive. + if let Ok(mut slot) = WATCHED_RUN.lock() { + if slot.as_deref() == Some(run_id.as_str()) { + *slot = None; + STREAM_RUNNING.store(false, Ordering::SeqCst); + } + } +} + +async fn sleep_unless_stopped(total: Duration) { + let mut slept = Duration::ZERO; + while slept < total && STREAM_RUNNING.load(Ordering::SeqCst) { + let step = SHUTDOWN_POLL.min(total - slept); + tokio::time::sleep(step).await; + slept += step; + } +} + +async fn connect_run_events(run_id: &str) -> Result { + let endpoint = format!("{}/runs/{}/events", base(), urlencoding::encode(run_id)); + + crate::cloud_auth::CLOUD_AUTH + .api_call_with_retry(|token| { + let endpoint = endpoint.clone(); + async move { + // Through api_call_with_retry so a token that expired during a long run + // is refreshed on the reconnect instead of leaving the page silent. + let response = crate::remote_session::stream_client() + .get(&endpoint) + .bearer_auth(token) + .header(reqwest::header::ACCEPT, "text/event-stream") + .header(reqwest::header::CACHE_CONTROL, "no-cache") + .send() + .await + .map_err(|e| format!("reach backend: {e}"))?; + + let status = response.status().as_u16(); + if !(200..300).contains(&status) { + let text = response.text().await.unwrap_or_default(); + return Err(format!("({status}) {text}")); + } + Ok(response) + } + }) + .await + .map_err(|e| cloud_errors::classify_message(&e, RUN_CODES).code) +} + +/// Read frames until the socket closes. `true` when the run finished first. +async fn consume_run_events(app: &AppHandle, response: reqwest::Response) -> Result { + use futures_util::StreamExt; + + let mut stream = response.bytes_stream(); + let mut decoder = SseDecoder::new(); + let mut saw_terminal = false; + + loop { + if !STREAM_RUNNING.load(Ordering::SeqCst) { + return Ok(saw_terminal); + } + + let next = tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()).await; + let chunk = match next { + // No ping. The socket is gone even though nothing errored, which is what + // a machine returning from sleep sees. + Err(_) => return Err("no heartbeat within the idle timeout".to_string()), + Ok(None) => return Ok(saw_terminal), + Ok(Some(Err(e))) => return Err(format!("stream error: {e}")), + Ok(Some(Ok(bytes))) => bytes, + }; + + for frame in decoder.push(&chunk) { + let Some((target, payload)) = route_frame(frame.event.as_deref(), &frame.data) else { + continue; + }; + if target == EVENT_AGENT_STATUS && payload_is_terminal(&payload) { + saw_terminal = true; + } + emit(app, target, payload); + } + } +} + +fn payload_is_terminal(payload: &serde_json::Value) -> bool { + payload + .get("status") + .and_then(serde_json::Value::as_str) + .is_some_and(is_terminal_status) +} + +/// Turn one decoded frame into the Tauri event and payload it becomes. +/// +/// The backend names its frames (`step`, `status`, `ping`) AND repeats the name +/// inside the JSON as `type`. Either is honoured, because a proxy that strips +/// the `event:` line would otherwise turn every step into a dropped frame and +/// the page would sit empty under a run that is visibly progressing. +pub fn route_frame(event: Option<&str>, data: &str) -> Option<(&'static str, serde_json::Value)> { + if matches!(event, Some("ping" | "heartbeat" | "keepalive")) { + return None; + } + + let payload = match serde_json::from_str::(data) { + Ok(value) => value, + Err(e) => { + log::warn!("Ignoring malformed agent event: {e}"); + return None; + } + }; + let object = payload.as_object()?; + + let kind = object + .get("type") + .and_then(serde_json::Value::as_str) + .or(event) + .unwrap_or_default(); + + match kind { + "step" => Some((EVENT_AGENT_STEP, payload)), + "status" => Some((EVENT_AGENT_STATUS, payload)), + "ping" | "heartbeat" | "keepalive" => None, + other => { + log::warn!("Ignoring agent frame of unknown kind: {other}"); + None + } + } +} + +fn emit(app: &AppHandle, target: &str, payload: serde_json::Value) { + use tauri::Emitter; + if let Err(e) = app.emit(target, payload) { + log::warn!("Failed to emit {target}: {e}"); + } +} + +fn emit_stream_status(app: &AppHandle, run_id: &str, connected: bool, reason: Option<&str>) { + emit( + app, + EVENT_AGENT_STREAM, + serde_json::json!({ "connected": connected, "runId": run_id, "reason": reason }), + ); +} + +// --- Transport -------------------------------------------------------------- + +fn http() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .connect_timeout(CONNECT_TIMEOUT) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) + }) +} + +/// One request, one place. +/// +/// Goes through `api_call_with_retry` so an expired access token is refreshed +/// and the call retried once — otherwise a user whose token aged out mid-run +/// sees "not signed in" on a machine that is signed in. +async fn request( + method: reqwest::Method, + url: String, + query: Vec<(String, String)>, + body: Option, + codes: FailureCodes, +) -> Result { + crate::cloud_auth::CLOUD_AUTH + .api_call_with_retry(|token| { + let method = method.clone(); + let url = url.clone(); + let query = query.clone(); + let body = body.clone(); + async move { + // Percent-encoded here rather than left to the HTTP client: a cursor + // carrying a `&` must not be able to smuggle a second parameter into + // the request. One implementation, shared with the cookie-bot wire. + let url = crate::cookie_bot::with_query(&url, &query); + let mut builder = http().request(method, &url).bearer_auth(token); + if let Some(payload) = body { + builder = builder.json(&payload); + } + + let response = builder + .send() + .await + .map_err(|e| format!("reach backend: {e}"))?; + + let status = response.status().as_u16(); + if !(200..300).contains(&status) { + let text = response.text().await.unwrap_or_default(); + // Encode the status so api_call_with_retry can spot a 401 and + // classify_message can recover the code afterwards. + return Err(format!("({status}) {text}")); + } + + response + .json::() + .await + .map_err(|e| format!("decode response: {e}")) + } + }) + .await + .map_err(|e| AgentError(cloud_errors::classify_message(&e, codes))) +} + +// --- Tauri commands --------------------------------------------------------- +// +// Defined here rather than in `lib.rs` because every local precondition they +// have lives in this file. They are registered in `generate_handler!` as +// `agent::…`; unregistered they are unreachable and the page fails at runtime +// with "command not found" rather than at build time. + +/// Start a run against a profile this machine holds. +#[tauri::command] +pub async fn start_agent_run(input: StartAgentRunInput) -> Result { + start_run(input).await +} + +/// One page of runs, newest first. +#[tauri::command] +pub async fn get_agent_runs( + limit: Option, + cursor: Option, +) -> Result { + list_runs(limit, cursor.as_deref()).await +} + +/// One run and its transcript. +#[tauri::command] +pub async fn get_agent_run(run_id: String) -> Result { + get_run(&run_id).await +} + +/// Stop a run that has not finished. +#[tauri::command] +pub async fn cancel_agent_run(run_id: String) -> Result { + cancel_run(&run_id).await +} + +/// Every saved goal. +#[tauri::command] +pub async fn get_agent_recipes() -> Result, String> { + list_recipes().await +} + +/// Save a new one. +#[tauri::command] +pub async fn create_agent_recipe( + name: String, + steps: Vec, +) -> Result { + create_recipe(&name, &steps).await +} + +/// Rename one or replace its steps. +#[tauri::command] +pub async fn update_agent_recipe( + id: String, + name: String, + steps: Vec, +) -> Result { + update_recipe(&id, &name, &steps).await +} + +/// Delete one. +#[tauri::command] +pub async fn delete_agent_recipe(id: String) -> Result { + delete_recipe(&id).await +} + +/// Stream one run's steps. Idempotent for the run already being watched. +#[tauri::command] +pub fn start_agent_run_events(app_handle: AppHandle, run_id: String) { + start_run_events(app_handle, run_id); +} + +/// Stop streaming. Safe when nothing is running. +#[tauri::command] +pub fn stop_agent_run_events() { + stop_run_events(); +} + +/// Which run is being streamed, if any. +/// +/// A page that mounts after the stream started has no `agent-run-stream` event +/// to read, so this is how it decides whether to trust the live steps or fall +/// back to re-reading the transcript. +#[tauri::command] +pub fn get_agent_run_events_status() -> Option { + watched_run() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_blank_goal_is_refused_before_anything_is_spent() { + for goal in ["", " ", "\n\t "] { + let err = validate_goal(goal).unwrap_err(); + assert!(err.contains("AGENT_GOAL_INVALID"), "{goal:?} -> {err}"); + } + assert_eq!(validate_goal(" buy milk ").unwrap(), "buy milk"); + } + + #[test] + fn a_goal_longer_than_the_ceiling_is_refused_here_rather_than_by_the_server() { + let long = "a".repeat(MAX_GOAL_CHARS + 1); + assert!(validate_goal(&long) + .unwrap_err() + .contains("AGENT_GOAL_INVALID")); + // Counted in characters, not bytes: a goal written in Japanese is not three + // times shorter than the same goal written in English. + let wide = "あ".repeat(MAX_GOAL_CHARS); + assert_eq!( + validate_goal(&wide).unwrap().chars().count(), + MAX_GOAL_CHARS + ); + } + + #[test] + fn an_allowlist_is_lowercased_deduplicated_and_stripped_of_blanks() { + let hosts = vec![ + "Example.com".to_string(), + " example.com ".to_string(), + "".to_string(), + " ".to_string(), + "docs.example.com".to_string(), + ]; + assert_eq!( + normalise_hosts(Some(&hosts)), + vec!["example.com".to_string(), "docs.example.com".to_string()] + ); + assert!(normalise_hosts(None).is_empty()); + } + + #[test] + fn a_recipe_needs_a_name_and_steps_the_api_will_accept() { + let navigate = serde_json::json!({"type": "navigate", "url": "https://example.com"}); + assert!(validate_recipe(" ", std::slice::from_ref(&navigate)) + .unwrap_err() + .contains("NAME_CANNOT_BE_EMPTY")); + assert!(validate_recipe("nightly", &[]) + .unwrap_err() + .contains("AGENT_RECIPE_INVALID")); + + // A step is an object naming a kind the API knows. The old shape — a bare + // line of prose — is exactly what the API refuses, so it is refused here + // rather than sent and rejected. + for rejected in [ + serde_json::json!("open the shop"), + serde_json::json!({"url": "https://example.com"}), + serde_json::json!({"type": "teleport", "url": "https://example.com"}), + ] { + assert!( + validate_recipe("nightly", std::slice::from_ref(&rejected)) + .unwrap_err() + .contains("AGENT_RECIPE_INVALID"), + "{rejected} must be refused" + ); + } + + // A step that names an element carries exactly one target. + let neither = serde_json::json!({"type": "click"}); + let both = serde_json::json!({ + "type": "click", + "selector": "#buy", + "locator": {"role": "button"} + }); + for rejected in [neither, both] { + assert!(validate_recipe("nightly", std::slice::from_ref(&rejected)) + .unwrap_err() + .contains("AGENT_RECIPE_INVALID")); + } + + let click = serde_json::json!({"type": "click", "locator": {"role": "button", "name": "Buy"}}); + let (name, steps) = validate_recipe(" nightly ", &[navigate.clone(), click.clone()]).unwrap(); + assert_eq!(name, "nightly"); + assert_eq!(steps, vec![navigate, click]); + } + + #[test] + fn a_fleet_run_falls_back_to_the_profile_operating_system() { + let profile = crate::profile::types::BrowserProfile { + host_os: Some("windows".to_string()), + ..Default::default() + }; + assert_eq!(fleet_platform(None, &profile).unwrap(), "windows"); + assert_eq!(fleet_platform(Some(" "), &profile).unwrap(), "windows"); + assert_eq!(fleet_platform(Some("linux"), &profile).unwrap(), "linux"); + + let android = crate::profile::types::BrowserProfile { + host_os: Some("android".to_string()), + ..Default::default() + }; + let err = fleet_platform(None, &android).unwrap_err(); + assert!(err.contains("REMOTE_PLATFORM_UNSUPPORTED"), "{err}"); + assert!(err.contains("android"), "{err}"); + } + + #[test] + fn only_a_finished_run_ends_the_stream() { + for status in ["succeeded", "failed", "cancelled"] { + assert!(is_terminal_status(status), "{status}"); + } + for status in ["queued", "running", ""] { + assert!(!is_terminal_status(status), "{status}"); + } + } + + #[test] + fn steps_and_statuses_route_by_the_json_type_or_the_sse_name() { + let step = r#"{"type":"step","runId":"r1","step":{"index":0,"kind":"thought"}}"#; + assert_eq!(route_frame(Some("step"), step).unwrap().0, EVENT_AGENT_STEP); + // A proxy that strips the event line must not cost the frontend its steps. + assert_eq!(route_frame(None, step).unwrap().0, EVENT_AGENT_STEP); + // ...and neither must a backend that stops repeating the type inside. + assert_eq!( + route_frame(Some("step"), r#"{"runId":"r1"}"#).unwrap().0, + EVENT_AGENT_STEP + ); + + let status = r#"{"type":"status","runId":"r1","status":"running"}"#; + assert_eq!( + route_frame(Some("status"), status).unwrap().0, + EVENT_AGENT_STATUS + ); + } + + #[test] + fn pings_and_nonsense_are_dropped_rather_than_emitted() { + assert!(route_frame(Some("ping"), "").is_none()); + assert!(route_frame(None, r#"{"type":"ping"}"#).is_none()); + assert!(route_frame(Some("step"), "not json").is_none()); + assert!(route_frame(None, r#"{"type":"something-new"}"#).is_none()); + } + + #[test] + fn a_terminal_status_frame_is_recognised_from_its_payload() { + let (_, payload) = + route_frame(Some("status"), r#"{"type":"status","status":"succeeded"}"#).unwrap(); + assert!(payload_is_terminal(&payload)); + let (_, running) = + route_frame(Some("status"), r#"{"type":"status","status":"running"}"#).unwrap(); + assert!(!payload_is_terminal(&running)); + } + + #[test] + fn a_run_view_survives_a_server_that_sends_almost_nothing() { + // The desktop renders this; a decode failure is a blank page where a + // working run should be. + let view: AgentRunView = serde_json::from_str(r#"{"id":"run-1"}"#).unwrap(); + assert_eq!(view.id, "run-1"); + assert_eq!(view.status, ""); + assert!(view.budgets.is_none()); + } + + #[test] + fn a_step_keeps_the_fields_this_release_has_never_heard_of() { + let step: AgentStep = serde_json::from_str( + r##"{"index":3,"at":"2026-01-01T00:00:00Z","kind":"tool","tool":"click","selector":"#buy"}"##, + ) + .unwrap(); + assert_eq!(step.index, 3); + assert_eq!(step.kind, "tool"); + assert_eq!(step.rest.get("tool").unwrap(), "click"); + assert_eq!(step.rest.get("selector").unwrap(), "#buy"); + } + + #[test] + fn the_recipe_list_decodes_bare_or_enveloped() { + let bare: RecipeList = + serde_json::from_str(r#"[{"id":"a","name":"A","steps":["x"]}]"#).unwrap(); + assert_eq!(bare.into_vec().len(), 1); + let enveloped: RecipeList = + serde_json::from_str(r#"{"recipes":[{"id":"a","name":"A","steps":["x"]}]}"#).unwrap(); + assert_eq!(enveloped.into_vec()[0].id, "a"); + } + + #[test] + fn budgets_only_travel_when_the_user_set_one() { + assert!(AgentBudgets::default().is_empty()); + assert!(!AgentBudgets { + max_steps: Some(10), + ..Default::default() + } + .is_empty()); + // `skip_serializing_if` keeps an unset ceiling out of the body entirely, + // so the server applies its own default rather than reading a null as zero. + let json = serde_json::to_string(&AgentBudgets { + max_steps: Some(10), + ..Default::default() + }) + .unwrap(); + assert_eq!(json, r#"{"maxSteps":10}"#); + } +} diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index c290d60..9d9b7d7 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -45,6 +45,10 @@ pub struct ApiProfile { /// `PUT /v1/profiles/{id}`; exposed here so a caller can read back what it /// set instead of having to go through the desktop app. pub extension_group_id: Option, + /// Browsing data is kept in memory only. + pub ephemeral: bool, + /// Created for one automation run: deleted when its browser stops. + pub temporary: bool, pub clear_on_close: bool, /// Cloud sync mode: `"Disabled"`, `"Regular"` or `"Encrypted"`. /// Settable via `PUT /v1/profiles/{id}`; exposed here so a caller can read @@ -89,6 +93,8 @@ impl From<&crate::profile::types::BrowserProfile> for ApiProfile { proxy_bypass_rules: profile.proxy_bypass_rules.clone(), vpn_id: profile.vpn_id.clone(), extension_group_id: profile.extension_group_id.clone(), + ephemeral: profile.ephemeral, + temporary: profile.temporary, clear_on_close: profile.clear_on_close, sync_mode: format!("{:?}", profile.sync_mode), cloud_sync_enabled: profile.is_sync_enabled(), @@ -121,7 +127,11 @@ pub struct CreateProfileRequest { /// downloaded; the create path does not fetch new versions. #[serde(default)] pub version: Option, + /// Optional stored-proxy id. Omit it, send `null`, or send an empty string + /// for a profile with no proxy. Mutually exclusive with `vpn_id`. pub proxy_id: Option, + /// Optional stored-VPN id. Omit it, send `null`, or send an empty string for + /// a profile with no VPN. Mutually exclusive with `proxy_id`. pub vpn_id: Option, pub launch_hook: Option, pub release_type: Option, @@ -133,6 +143,13 @@ pub struct CreateProfileRequest { pub wayfern_config: Option, pub group_id: Option, pub tags: Option>, + /// Keep the profile's browsing data in memory only, so nothing it browses + /// reaches real disk. Defaults to false. + pub ephemeral: Option, + /// A profile for one automation run: implies `ephemeral`, is destroyed when + /// its browser stops, and is swept at startup if it outlived a crash. + /// Defaults to false. + pub temporary: Option, } #[derive(Debug, Serialize, Deserialize, ToSchema)] @@ -142,7 +159,11 @@ pub struct UpdateProfileRequest { // would invalidate the generated fingerprint and on-disk profile dir). // Accepting it here only to silently ignore it misled API clients. pub version: Option, + /// Omitted or `null` leaves the proxy assignment unchanged; an empty string + /// detaches it. Assigning a proxy clears any assigned VPN. pub proxy_id: Option, + /// Omitted or `null` leaves the VPN assignment unchanged; an empty string + /// detaches it. Assigning a VPN clears any assigned proxy. pub vpn_id: Option, pub launch_hook: Option, pub release_type: Option, @@ -335,8 +356,8 @@ struct ApiRemoteSessionsResponse { struct SetCookieBotScheduleRequest { /// Defaults to the profile's local name. profile_name: Option, - /// `windows` or `macos`. Defaults to the profile's own operating system, and - /// must match it when supplied. + /// `windows`, `macos` or `linux`. Defaults to the profile's own operating + /// system, and must match it when supplied. platform: Option, /// Whether the nightly run is armed. A disabled schedule keeps its settings. enabled: bool, @@ -500,6 +521,17 @@ struct BatchRunResponse { results: Vec, } +#[derive(Debug, Deserialize, ToSchema)] +struct DistributeProxiesRequest { + /// Profile/proxy pairs to apply, one proxy per profile. + pairs: Vec, +} + +#[derive(Debug, Serialize, ToSchema)] +struct DistributeProxiesResponse { + results: Vec, +} + #[derive(Debug, Deserialize, ToSchema)] struct BatchStopRequest { /// Profile IDs to stop. @@ -596,6 +628,7 @@ struct ImportProxiesResponse { kill_profile, batch_run_profiles, batch_stop_profiles, + distribute_proxies, detect_import_profiles, import_profiles_api, import_profile_cookies, @@ -633,6 +666,12 @@ struct ImportProxiesResponse { download_browser_api, get_browser_versions, check_browser_downloaded, + agent_perceive_api, + agent_resolve_locator_api, + agent_click_api, + agent_type_api, + agent_extract_api, + agent_pick_api, ), components(schemas( ApiProfile, @@ -687,6 +726,10 @@ struct ImportProxiesResponse { BatchStopRequest, BatchStopResult, BatchStopResponse, + DistributeProxiesRequest, + DistributeProxiesResponse, + crate::proxy_distribution::ProxyPair, + crate::proxy_distribution::ProxyAssignmentResult, OpenUrlRequest, ImportCookiesRequest, ImportCookiesResponse, @@ -707,6 +750,28 @@ struct ImportProxiesResponse { crate::profile_importer::ProfileImportItemResult, crate::profile_importer::ProfileImportBatchResult, crate::profile_import::report::ProfileImportReport, + crate::wayfern_cdp::Engine, + crate::wayfern_cdp::LocatorAttribute, + crate::wayfern_cdp::LocatorDescription, + crate::wayfern_cdp::LocatorBounds, + crate::wayfern_cdp::LocatorCandidate, + crate::wayfern_cdp::LocatorResolution, + crate::wayfern_cdp::PerceptionRequest, + crate::wayfern_cdp::PerceptionNode, + crate::wayfern_cdp::PerceptionFrame, + crate::wayfern_cdp::PerceptionStats, + crate::wayfern_cdp::PerceptionPage, + crate::wayfern_cdp::ExtractionField, + crate::wayfern_cdp::ExtractionRequest, + crate::wayfern_cdp::ExtractionRow, + crate::wayfern_cdp::Extraction, + crate::wayfern_cdp::PickedElement, + crate::mcp_server::AgentResolveRequest, + crate::mcp_server::AgentClickRequest, + crate::mcp_server::AgentClick, + crate::mcp_server::AgentTypeRequest, + crate::mcp_server::AgentTyping, + crate::mcp_server::AgentPickRequest, )), tags( (name = "profiles", description = "Profile management endpoints"), @@ -719,6 +784,7 @@ struct ImportProxiesResponse { (name = "cookies", description = "Cookie management endpoints"), (name = "remote-sessions", description = "Sessions running on the leased remote fleet"), (name = "cookie-bot", description = "Scheduled cookie-warming runs on the remote fleet"), + (name = "agent", description = "Native page perception, locators, extraction and humanized input for a running profile"), ), modifiers(&SecurityAddon), )] @@ -897,6 +963,7 @@ fn build_v1_router() -> Router { .routes(routes!(kill_profile)) .routes(routes!(batch_run_profiles)) .routes(routes!(batch_stop_profiles)) + .routes(routes!(distribute_proxies)) .routes(routes!(detect_import_profiles)) .routes(routes!(import_profiles_api)) .routes(routes!(import_profile_cookies)) @@ -923,6 +990,12 @@ fn build_v1_router() -> Router { .routes(routes!(download_browser_api)) .routes(routes!(get_browser_versions)) .routes(routes!(check_browser_downloaded)) + .routes(routes!(agent_perceive_api)) + .routes(routes!(agent_resolve_locator_api)) + .routes(routes!(agent_click_api)) + .routes(routes!(agent_type_api)) + .routes(routes!(agent_extract_api)) + .routes(routes!(agent_pick_api)) .split_for_parts(); // The two paths that carry an extension payload, kept apart so the raised @@ -1072,11 +1145,9 @@ fn is_automation_request(method: &Method, path: &str) -> bool { // expensive thing this API can be asked to do. // // Deliberately NOT here: the cookie-bot schedule writes (PUT and DELETE on - // /v1/cookie-bot/schedules/{profile_id}). They are configuration — a small - // row in donutbrowser-infra — and lease nothing. Metering them would 429 a - // client enrolling a fleet of profiles at start-up, while the thing that - // actually protects the hardware, the pooled hour budget, is enforced - // server-side on every run whether or not it was scheduled from here. + // /v1/cookie-bot/schedules/{profile_id}). They are configuration and lease + // nothing. Metering them would 429 a client enrolling many profiles at + // start-up, and they are not what spends the account's hours. if matches!( path, "/v1/profiles/batch/run" | "/v1/profiles/batch/stop" | "/v1/cookie-bot/runs" @@ -1089,12 +1160,26 @@ fn is_automation_request(method: &Method, path: &str) -> bool { }; let mut segments = profile_action.split('/'); matches!( - (segments.next(), segments.next(), segments.next()), + ( + segments.next(), + segments.next(), + segments.next(), + segments.next() + ), // `run-remote` is a separate segment from `run`, so it matched nothing here // and every remote launch bypassed the quota it declares a 429 for. ( Some(_), Some("run" | "open-url" | "kill" | "run-remote"), + None, + None + ) | ( + // The agent surface reads and drives the same browser the tools above + // launch, through the same paid gate; a native page read is automation + // exactly as a script one is. + Some(_), + Some("agent"), + Some("perceive" | "resolve-locator" | "click" | "type" | "extract" | "pick"), None ) ) @@ -1459,13 +1544,24 @@ async fn create_profile( request.vpn_id.clone(), wayfern_config, request.group_id.clone(), - false, + request.ephemeral.unwrap_or(false) || request.temporary.unwrap_or(false), None, request.launch_hook.clone(), ) .await { Ok(mut profile) => { + if request.temporary.unwrap_or(false) { + match profile_manager.mark_profile_temporary(&profile.id.to_string()) { + Ok(updated) => profile = updated, + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Profile created but could not be marked temporary: {e}"), + )) + } + } + } // Apply tags if provided if let Some(tags) = &request.tags { if profile_manager @@ -1556,13 +1652,8 @@ async fn update_profile( } if let Some(vpn_id) = request.vpn_id { - let normalized = if vpn_id.is_empty() { - None - } else { - Some(vpn_id) - }; if let Err(e) = profile_manager - .update_profile_vpn(state.app_handle.clone(), &id, normalized) + .update_profile_vpn(state.app_handle.clone(), &id, Some(vpn_id)) .await { return Err(manager_error_response(e)); @@ -2764,7 +2855,7 @@ async fn remove_extension_from_group_api( request_body = RunProfileRequest, responses( (status = 200, description = "Profile launched successfully", body = RunProfileResponse), - (status = 400, description = "Cannot launch cross-OS profile"), + (status = 400, description = "Cannot launch cross-OS profile, or the url is not an http, https or about:blank address"), (status = 401, description = "Unauthorized"), (status = 402, description = "Active paid plan with browser automation required"), (status = 404, description = "Profile not found"), @@ -2792,6 +2883,19 @@ async fn run_profile( let headless = request.headless.unwrap_or(false); let url = request.url; + // Same allowlist the MCP surface enforces, from the same predicate so the two + // cannot drift: `Page.navigate` will load `file:///…` and the content tools + // hand the bytes back. This listener is loopback-only, so it is defence in + // depth rather than the remote hole, but a second copy of the rule would be + // a second copy that rots. + if let Some(candidate) = url.as_deref() { + if !crate::mcp_server::is_navigable_url(candidate) { + return Err(( + StatusCode::BAD_REQUEST, + crate::backend_error("URL_SCHEME_NOT_ALLOWED"), + )); + } + } let profile_manager = ProfileManager::instance(); let profiles = profile_manager @@ -2857,7 +2961,7 @@ async fn run_profile( request_body = RunRemoteRequest, responses( (status = 200, description = "Remote session started", body = RunRemoteResponse), - (status = 400, description = "Profile does not have cloud sync enabled"), + (status = 400, description = "Profile does not have cloud sync enabled, or the url is not an http, https or about:blank address"), (status = 401, description = "Unauthorized"), (status = 402, description = "Active paid plan with browser automation required"), (status = 404, description = "Profile not found"), @@ -2899,6 +3003,17 @@ async fn run_profile_remote( return Err((StatusCode::BAD_REQUEST, reason)); } + // Same allowlist the MCP surface enforces, from the same predicate so the two + // cannot drift. + if let Some(candidate) = request.url.as_deref() { + if !crate::mcp_server::is_navigable_url(candidate) { + return Err(( + StatusCode::BAD_REQUEST, + crate::backend_error("URL_SCHEME_NOT_ALLOWED"), + )); + } + } + // Deliberately NO is_cross_os() guard here. Local /run refuses a foreign // profile because this machine is the wrong OS; running it remotely on a host // of its OWN OS is precisely what this endpoint exists for. @@ -3081,8 +3196,8 @@ async fn stop_remote_session( Path(id): Path, ) -> Result, (StatusCode, String)> { // Without this route, `run-remote` hands back a session id nothing can act - // on: the only thing that ends a session is the fleet's own two-hour cap, so - // every launch bills 7200s no matter how briefly it ran. + // on: a session then runs to its maximum duration, so every launch spends the + // same allowance no matter how briefly it ran. let outcome = crate::remote_session::end_remote_session(&id) .await .map_err(remote_session_error_response)?; @@ -3150,7 +3265,7 @@ fn error_code_of(body: &str) -> String { .unwrap_or_default() } -/// Turn a donutbrowser-infra failure into the status a local client can act on. +/// Turn a cloud API failure into the status a local client can act on. /// /// The upstream status is not echoed blindly. A 401 up there means THIS desktop /// has no cloud session, which has nothing to do with the caller's own bearer @@ -3328,9 +3443,8 @@ async fn pump_cdp(session_id: String, client: WebSocket, upstream: crate::cdp_ta RelayMessage::Binary(bytes) => WsMessage::Binary(bytes), RelayMessage::Ping(bytes) => WsMessage::Ping(bytes), RelayMessage::Pong(bytes) => WsMessage::Pong(bytes), - // A relay close carries the only diagnosis the server gives (1008 is a - // rejected credential, 1013 is "not up yet"), so it is passed through - // rather than swallowed into a bare disconnect. + // A relay close carries the only diagnosis the server gives, so it is + // passed through rather than swallowed into a bare disconnect. RelayMessage::Close(frame) => { let _ = client_tx .send(WsMessage::Close(frame.map(|f| { @@ -3352,7 +3466,7 @@ async fn pump_cdp(session_id: String, client: WebSocket, upstream: crate::cdp_ta }; // Either direction ending means the conversation is over. Waiting for both - // would hold a relay socket open — and one of the session's four allowed + // would hold a relay socket open — and one of the session's limited // attachments with it — after the client had gone. tokio::select! { () = to_relay => {} @@ -3447,7 +3561,7 @@ async fn get_remote_hours( // --- Cookie bot ------------------------------------------------------------- // -// Thin proxies onto donutbrowser-infra, which owns the schedule, the calendar +// Thin proxies onto Donut cloud, which owns the schedule, the calendar // arithmetic, the browsing model and the pooled hour budget. Nothing here // decides when a run happens or what it does. What this file DOES decide is // which profiles may be offered to it at all. @@ -3464,7 +3578,7 @@ async fn get_remote_hours( /// Every cookie-bot WRITE on this server goes through here, so there is no /// surface on which a local-only profile can be pointed at the bot. The server /// re-checks all of it; this exists so the refusal happens at the moment the -/// caller asks rather than silently at 02:00. +/// caller asks rather than silently when the run is due. fn cookie_bot_eligible_profile( profile_id: &str, ) -> Result { @@ -3615,8 +3729,8 @@ async fn set_cookie_bot_schedule( jitter_seconds: request.jitter_seconds, ..Default::default() } - // The server requires these and cannot read them itself — the profile lives - // in the user's sync namespace, not its database. + // The server requires these and cannot read them itself: only this machine + // knows the profile's own facts. .with_profile_state(crate::cookie_bot::profile_state(&profile)); crate::cookie_bot::save_schedule(&profile_id, &input, request.acknowledge_conflict) @@ -3823,9 +3937,8 @@ async fn cancel_cookie_bot_run( )] async fn list_cookie_bot_presets( ) -> Result, (StatusCode, String)> { - // Ids and a rough duration only. What a preset expands to — the site - // ordering, the dwell model, the scroll and click programme — is the - // server's, and stays there. + // Ids and a rough duration only. What a preset expands to is the server's, + // and stays there. crate::cookie_bot::list_presets() .await .map(Json) @@ -3878,7 +3991,7 @@ async fn get_cookie_bot_usage( request_body = OpenUrlRequest, responses( (status = 200, description = "URL opened successfully, locally or on the profile's remote session"), - (status = 400, description = "Cannot open URL with a cross-OS profile that is not running remotely"), + (status = 400, description = "Cannot open URL with a cross-OS profile that is not running remotely, or the url is not an http, https or about:blank address"), (status = 401, description = "Unauthorized"), (status = 402, description = "Active paid plan with browser automation required"), (status = 404, description = "Profile not found"), @@ -3904,6 +4017,14 @@ async fn open_url_in_profile( return Err((StatusCode::PAYMENT_REQUIRED, String::new())); } + // Same allowlist the MCP surface enforces, from the same predicate. + if !crate::mcp_server::is_navigable_url(&request.url) { + return Err(( + StatusCode::BAD_REQUEST, + crate::backend_error("URL_SCHEME_NOT_ALLOWED"), + )); + } + let browser_runner = crate::browser_runner::BrowserRunner::instance(); browser_runner @@ -3921,11 +4042,11 @@ async fn open_url_in_profile( // API Handler - Kill browser process // -// Stops the browser wherever it is. A profile open on the leased fleet is ended -// through the backend, which is what makes this endpoint mean "stop this -// profile" rather than "stop this profile if it happens to be on this machine" — -// the latter reported success, killed nothing, and left the session billing to -// its two-hour cap. +// Stops the browser wherever it is. A profile open on a leased remote host is +// ended through the cloud API, which is what makes this endpoint mean "stop +// this profile" rather than "stop this profile if it happens to be on this +// machine" — the latter reported success, killed nothing, and left the session +// running to its maximum duration. #[utoipa::path( post, path = "/v1/profiles/{id}/kill", @@ -3975,9 +4096,9 @@ async fn kill_profile( .await .map_err(|e| { let message = e.to_string(); - // The backend refuses to retire a session it could not stop on the fleet. - // Reporting that as a 500 invites a retry loop against a browser that is - // still running; 503 says "it is still up, try again". + // A stop can fail with the remote browser still running. Reporting that + // as a 500 invites a retry loop against a browser that is still running; + // 503 says "it is still up, try again". if message.contains("REMOTE_") { (StatusCode::SERVICE_UNAVAILABLE, message) } else { @@ -3999,6 +4120,7 @@ async fn kill_profile( request_body = BatchRunRequest, responses( (status = 200, description = "Batch launch completed; inspect per-profile results", body = BatchRunResponse), + (status = 400, description = "The url is not an http, https or about:blank address"), (status = 401, description = "Unauthorized"), (status = 402, description = "Active paid plan with browser automation required"), (status = 429, description = "Automation request rate limit exceeded"), @@ -4020,6 +4142,17 @@ async fn batch_run_profiles( return Err(StatusCode::PAYMENT_REQUIRED); } + // Same allowlist the MCP surface enforces, from the same predicate. Checked + // once up front rather than per profile: a bad scheme is a bad request, not + // a per-profile failure. + if let Some(candidate) = request.url.as_deref() { + if !crate::mcp_server::is_navigable_url(candidate) { + // This handler answers with a bare status, so the code travels in the + // per-profile results rather than a body. + return Err(StatusCode::BAD_REQUEST); + } + } + let headless = request.headless.unwrap_or(false); let profile_manager = ProfileManager::instance(); let profiles = profile_manager @@ -4086,6 +4219,38 @@ async fn batch_run_profiles( Ok(Json(BatchRunResponse { results })) } +// API Handler - Distribute proxies one to one across profiles. +// +// Configuration, not automation: no plan gate and no rate limit, same as +// assigning a group. Never breaks on one profile's failure — a running profile +// is refused by name while the other forty-nine are moved. +#[utoipa::path( + post, + path = "/v1/profiles/distribute-proxies", + request_body = DistributeProxiesRequest, + responses( + (status = 200, description = "Distribution completed; inspect per-profile results", body = DistributeProxiesResponse), + (status = 400, description = "No pairs were supplied"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "profiles" +)] +async fn distribute_proxies( + State(state): State, + Json(request): Json, +) -> Result, StatusCode> { + if request.pairs.is_empty() { + return Err(StatusCode::BAD_REQUEST); + } + let results = + crate::proxy_distribution::apply_pairs(state.app_handle.clone(), &request.pairs).await; + Ok(Json(DistributeProxiesResponse { results })) +} + // API Handler - Batch stop profiles (paid: browser automation). #[utoipa::path( post, @@ -4421,6 +4586,367 @@ async fn check_browser_downloaded( Ok(Json(is_downloaded)) } +// API Handlers - Agent surface: perception, locators, extraction, the picker +// and humanized input. +// +// REST parity for the MCP tools of the same names, from the same shared +// operations in `mcp_server`, so the two front doors cannot drift. Gated and +// resolved the way every automation endpoint is: 402 without the plan, 404 for +// an unknown profile, 409 when it is not running, 429 from the shared limiter. + +/// The running browser behind `id`, and the engine its version entitles it to. +async fn agent_context_for( + id: &str, +) -> Result { + if !crate::cloud_auth::CLOUD_AUTH + .can_use_browser_automation() + .await + { + return Err((StatusCode::PAYMENT_REQUIRED, String::new())); + } + + let profiles = ProfileManager::instance() + .list_profiles() + .map_err(manager_error_response)?; + let profile = profiles + .into_iter() + .find(|p| p.id.to_string() == id) + .ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?; + if profile.browser != "wayfern" { + return Err(( + StatusCode::BAD_REQUEST, + "the agent endpoints drive Wayfern profiles only".to_string(), + )); + } + + let target = crate::cdp_target::resolve(&profile) + .await + .map_err(resolve_error_response)?; + Ok(crate::mcp_server::AgentContext::new(profile, target)) +} + +/// A profile that could not be resolved to a browser. +/// +/// "Not running" is a 409: the profile exists and the request was well +/// formed, but the browser has to be started first. An automation client can +/// act on that, which it cannot on a 500. +fn resolve_error_response(error: crate::cdp_target::ResolveError) -> (StatusCode, String) { + use crate::cdp_target::ResolveError; + match error { + ResolveError::Unsupported(m) => (StatusCode::BAD_REQUEST, m), + ResolveError::NotRunning(m) => (StatusCode::CONFLICT, m), + ResolveError::Endpoint(m) => (StatusCode::BAD_GATEWAY, m), + } +} + +/// Map a shared agent failure onto a status, with the structured detail in +/// the body. +/// +/// The body is the same `{"code": ..., ...}` envelope the MCP transport puts +/// in its error `data`, plus `message`, so a locator that matched three +/// buttons hands a REST client the same candidate list an agent gets. +fn agent_error_response(error: crate::mcp_server::AgentError) -> (StatusCode, String) { + use crate::cdp_target::CdpError; + use crate::mcp_server::AgentError; + let status = match &error { + AgentError::InvalidArgument(_) + | AgentError::RequiresWayfern152 { .. } + | AgentError::AmbiguousLocator { .. } + | AgentError::NoMatch { .. } + | AgentError::TypingTooLong { .. } + | AgentError::BadRequest(_) => StatusCode::BAD_REQUEST, + AgentError::PaymentRequired(_) => StatusCode::PAYMENT_REQUIRED, + AgentError::RateLimited(_) => StatusCode::TOO_MANY_REQUESTS, + AgentError::AuthorizationUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE, + AgentError::PickerTimedOut { .. } => StatusCode::REQUEST_TIMEOUT, + AgentError::PickerCancelled { .. } => StatusCode::CONFLICT, + AgentError::Cdp(CdpError::Unauthorized(_)) => StatusCode::UNAUTHORIZED, + // Nothing answers on the profile's socket: the browser is gone, or is not + // drivable yet. Both read as "start it and try again". + AgentError::Cdp(CdpError::Unreachable(_) | CdpError::NotDrivable(_)) => StatusCode::CONFLICT, + AgentError::Cdp(CdpError::Transport(_) | CdpError::Protocol(_)) + | AgentError::Browser(_) + | AgentError::Malformed(_) => StatusCode::BAD_GATEWAY, + }; + let mut body = error.detail(); + body["message"] = serde_json::Value::from(error.message()); + (status, body.to_string()) +} + +#[utoipa::path( + post, + path = "/v1/profiles/{id}/agent/perceive", + params( + ("id" = String, Path, description = "Profile ID") + ), + request_body = crate::wayfern_cdp::PerceptionRequest, + responses( + (status = 200, description = "The page as the agent sees it: nodes, frames, text, stats, and a cursor when truncated. Node keys are the browser's own camelCase; `engine` says whether Wayfern 152 or the DOM fallback answered", body = crate::wayfern_cdp::PerceptionPage), + (status = 400, description = "Malformed request, or a cursor on a profile whose browser cannot paginate"), + (status = 401, description = "Unauthorized"), + (status = 402, description = "Active paid plan with browser automation required"), + (status = 404, description = "Profile not found"), + (status = 409, description = "The profile is not running"), + (status = 429, description = "Automation request rate limit exceeded"), + (status = 502, description = "The browser did not answer as documented"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "agent" +)] +async fn agent_perceive_api( + Path(id): Path, + Json(request): Json, +) -> Result, (StatusCode, String)> { + let ctx = agent_context_for(&id).await?; + crate::mcp_server::agent_perceive(&ctx, &request) + .await + .map(Json) + .map_err(agent_error_response) +} + +#[utoipa::path( + post, + path = "/v1/profiles/{id}/agent/resolve-locator", + params( + ("id" = String, Path, description = "Profile ID") + ), + request_body = crate::mcp_server::AgentResolveRequest, + responses( + (status = 200, description = "Exactly one element matched; `match` describes it", body = crate::wayfern_cdp::LocatorResolution), + (status = 400, description = "Malformed locator, no element matched (`code` LOCATOR_NO_MATCH), or several did (`code` LOCATOR_AMBIGUOUS, with `candidates`)"), + (status = 401, description = "Unauthorized"), + (status = 402, description = "Active paid plan with browser automation required"), + (status = 404, description = "Profile not found"), + (status = 409, description = "The profile is not running"), + (status = 429, description = "Automation request rate limit exceeded"), + (status = 502, description = "The browser did not answer as documented"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "agent" +)] +async fn agent_resolve_locator_api( + Path(id): Path, + Json(request): Json, +) -> Result, (StatusCode, String)> { + let ctx = agent_context_for(&id).await?; + crate::mcp_server::agent_resolve_locator(&ctx, &request) + .await + .map(Json) + .map_err(agent_error_response) +} + +#[utoipa::path( + post, + path = "/v1/profiles/{id}/agent/click", + params( + ("id" = String, Path, description = "Profile ID") + ), + request_body = crate::mcp_server::AgentClickRequest, + responses( + (status = 200, description = "The element was clicked; `navigated` says whether a page load followed", body = crate::mcp_server::AgentClick), + (status = 400, description = "Malformed request, no element matched, or several did (see resolve-locator)"), + (status = 401, description = "Unauthorized"), + (status = 402, description = "Active paid plan with browser automation required"), + (status = 404, description = "Profile not found"), + (status = 409, description = "The profile is not running"), + (status = 429, description = "Automation request rate limit exceeded"), + (status = 502, description = "The browser did not answer as documented"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "agent" +)] +async fn agent_click_api( + Path(id): Path, + Json(request): Json, +) -> Result, (StatusCode, String)> { + let ctx = agent_context_for(&id).await?; + crate::mcp_server::agent_click_locator(&ctx, &request) + .await + .map(Json) + .map_err(agent_error_response) +} + +#[utoipa::path( + post, + path = "/v1/profiles/{id}/agent/type", + params( + ("id" = String, Path, description = "Profile ID") + ), + request_body = crate::mcp_server::AgentTypeRequest, + responses( + (status = 200, description = "The text was typed", body = crate::mcp_server::AgentTyping), + (status = 400, description = "Malformed request, no element matched, several did, or the text would take longer than the typing budget (`code` TYPING_TOO_LONG)"), + (status = 401, description = "Unauthorized"), + (status = 402, description = "Active paid plan with browser automation required"), + (status = 404, description = "Profile not found"), + (status = 409, description = "The profile is not running"), + (status = 429, description = "Automation request rate limit exceeded"), + (status = 502, description = "The browser did not answer as documented"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "agent" +)] +async fn agent_type_api( + Path(id): Path, + Json(request): Json, +) -> Result, (StatusCode, String)> { + let ctx = agent_context_for(&id).await?; + crate::mcp_server::agent_type_locator(&ctx, &request, crate::mcp_server::MAX_TYPING_SECONDS) + .await + .map(Json) + .map_err(agent_error_response) +} + +#[utoipa::path( + post, + path = "/v1/profiles/{id}/agent/extract", + params( + ("id" = String, Path, description = "Profile ID") + ), + request_body = crate::wayfern_cdp::ExtractionRequest, + responses( + (status = 200, description = "The rows read, and why reading stopped. A missing container is a result with `stopReason` no-container, not an error", body = crate::wayfern_cdp::Extraction), + (status = 400, description = "Malformed request, or the profile runs a Wayfern older than 152 (`code` WAYFERN_152_REQUIRED)"), + (status = 401, description = "Unauthorized"), + (status = 402, description = "Active paid plan with browser automation required"), + (status = 404, description = "Profile not found"), + (status = 409, description = "The profile is not running"), + (status = 429, description = "Automation request rate limit exceeded"), + (status = 502, description = "The browser did not answer as documented"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "agent" +)] +async fn agent_extract_api( + Path(id): Path, + Json(request): Json, +) -> Result, (StatusCode, String)> { + let ctx = agent_context_for(&id).await?; + crate::mcp_server::agent_extract(&ctx, &request) + .await + .map(Json) + .map_err(agent_error_response) +} + +#[utoipa::path( + post, + path = "/v1/profiles/{id}/agent/pick", + params( + ("id" = String, Path, description = "Profile ID") + ), + request_body = crate::mcp_server::AgentPickRequest, + responses( + (status = 200, description = "The user clicked an element; `locator` is the smallest description that resolves to it", body = crate::wayfern_cdp::PickedElement), + (status = 400, description = "Malformed request, or the profile runs a Wayfern older than 152 (`code` WAYFERN_152_REQUIRED)"), + (status = 401, description = "Unauthorized"), + (status = 402, description = "Active paid plan with browser automation required"), + (status = 404, description = "Profile not found"), + (status = 408, description = "Nothing was picked within timeout_ms; the picker has been disarmed"), + (status = 409, description = "The profile is not running, or the picker was cancelled (Escape, or a navigation)"), + (status = 429, description = "Automation request rate limit exceeded"), + (status = 502, description = "The browser did not answer as documented"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "agent" +)] +async fn agent_pick_api( + Path(id): Path, + Json(request): Json, +) -> Result, (StatusCode, String)> { + let ctx = agent_context_for(&id).await?; + let timeout_ms = request + .timeout_ms + .unwrap_or(crate::mcp_server::DEFAULT_PICK_TIMEOUT_MS) + .clamp(1_000, crate::mcp_server::MAX_PICK_TIMEOUT_MS); + crate::mcp_server::agent_pick_element(&ctx, timeout_ms) + .await + .map(Json) + .map_err(agent_error_response) +} + +#[cfg(test)] +mod url_guard_tests { + //! The REST automation surface takes caller-supplied URLs too. + //! + //! The MCP handlers are covered by `every_url_entry_point_is_guarded` in + //! mcp_server.rs; these four had NO test of any kind, so the guards could be + //! deleted or a fifth endpoint added without anything noticing. + + #[test] + fn every_rest_endpoint_that_takes_a_url_validates_its_scheme() { + let full = include_str!("api_server.rs"); + let source = full + .split_once("\n#[cfg(test)]") + .map(|(code, _)| code) + .unwrap_or(full); + + // Every request struct with a `url` field, and the handler that consumes + // it, must reach the shared predicate. + let structs: Vec<&str> = source + .split("struct ") + .skip(1) + .filter(|chunk| { + let head = &chunk[..chunk.len().min(600)]; + head.contains("url: Option") || head.contains("url: String") + }) + .map(|chunk| chunk.split(['{', ' ', '<']).next().unwrap_or("?")) + .collect(); + assert!( + structs.len() >= 4, + "expected the url-carrying request types; found {structs:?}" + ); + + // Each of those types is destructured by exactly one handler, and every + // one of those handlers must consult the predicate. + let guards = source + .matches("crate::mcp_server::is_navigable_url") + .count(); + assert!( + guards >= structs.len(), + "found {} url-carrying request types but only {guards} scheme checks: \ + an endpoint takes a url and never validates it, which is how \ + `file:///…` plus a content read became a file disclosure", + structs.len() + ); + } + + #[test] + fn the_shared_predicate_is_the_one_the_mcp_surface_uses() { + // Two copies of an allowlist are two copies that drift. If this ever + // becomes a local re-implementation, the surfaces can disagree about what + // `file://` means. + // Cut before the test module, or this assertion trips over its own text. + let full = include_str!("api_server.rs"); + let source = full + .split_once("\n#[cfg(test)]") + .map(|(code, _)| code) + .unwrap_or(full); + assert!( + !source.contains("fn is_navigable_url"), + "api_server must CALL the shared predicate, never define its own" + ); + assert!(source.contains("crate::mcp_server::is_navigable_url")); + } +} + #[cfg(test)] mod tests { use super::*; @@ -4679,6 +5205,13 @@ mod tests { // Starting a bot run leases a host for up to two hours and spends the // account's pooled remote-hour budget. "/v1/cookie-bot/runs", + // The agent surface drives the same browser through the same gate. + "/v1/profiles/profile-id/agent/perceive", + "/v1/profiles/profile-id/agent/resolve-locator", + "/v1/profiles/profile-id/agent/click", + "/v1/profiles/profile-id/agent/type", + "/v1/profiles/profile-id/agent/extract", + "/v1/profiles/profile-id/agent/pick", ] { assert!( is_automation_request(&Method::POST, path), @@ -4700,6 +5233,14 @@ mod tests { for (method, path) in [ (Method::GET, "/v1/profiles/profile-id/run"), + // Handing a fleet its proxies rewrites configuration; it never starts a + // browser, so metering it would spend an automation quota on nothing. + (Method::POST, "/v1/profiles/distribute-proxies"), + // Not routes: the agent segment alone, an unknown action, a nested one. + (Method::POST, "/v1/profiles/profile-id/agent"), + (Method::POST, "/v1/profiles/profile-id/agent/unknown"), + (Method::POST, "/v1/profiles/profile-id/agent/click/twice"), + (Method::GET, "/v1/profiles/profile-id/agent/perceive"), (Method::POST, "/v1/profiles"), (Method::POST, "/v1/profiles/import"), (Method::GET, "/v1/profiles"), @@ -4709,10 +5250,9 @@ mod tests { (Method::DELETE, "/v1/remote-sessions/"), (Method::GET, "/v1/remote-sessions/session-id"), (Method::GET, "/v1/remote-sessions"), - // Enrolling a profile writes one row on the server and leases nothing. - // Metering it would 429 a client setting up a fleet of profiles, while - // the budget that actually protects the hardware is spent per RUN and - // enforced server-side however the run was scheduled. + // Enrolling a profile is configuration and leases nothing. Metering it + // would 429 a client setting up many profiles, and it is not what spends + // the account's hours. (Method::PUT, "/v1/cookie-bot/schedules/profile-id"), (Method::DELETE, "/v1/cookie-bot/schedules/profile-id"), (Method::GET, "/v1/cookie-bot/schedules"), @@ -4896,6 +5436,23 @@ mod tests { let _router: Router = build_v1_router(); } + // `""` is the only way a REST client can detach a proxy or VPN (an omitted + // field means "leave unchanged"), so that meaning has to reach clients + // through the served spec rather than living only in the Rust code. + #[test] + fn openapi_documents_the_empty_string_network_id_clear() { + let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes"); + for schema in ["CreateProfileRequest", "UpdateProfileRequest"] { + for field in ["proxy_id", "vpn_id"] { + let property = spec["components"]["schemas"][schema]["properties"][field].to_string(); + assert!( + property.contains("empty string"), + "{schema}.{field} must document the empty-string clear, got: {property}" + ); + } + } + } + fn schema_required(spec: &serde_json::Value, schema: &str) -> Vec { spec["components"]["schemas"][schema]["required"] .as_array() @@ -4910,6 +5467,62 @@ mod tests { // `#[schema(value_type = Object)]` on an `Option` erases the optionality // and marks the field required in the served spec; these fields must stay // optional so generated clients aren't forced to send them. + #[test] + fn openapi_describes_the_distribution_contract() { + let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes"); + let operation = &spec["paths"]["/v1/profiles/distribute-proxies"]["post"]; + + let body = &operation["requestBody"]["content"]["application/json"]["schema"]["$ref"]; + assert_eq!( + body.as_str(), + Some("#/components/schemas/DistributeProxiesRequest"), + "request body does not resolve: {body:?}" + ); + let response = &operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"]; + assert_eq!( + response.as_str(), + Some("#/components/schemas/DistributeProxiesResponse"), + "response body does not resolve: {response:?}" + ); + for status in ["400", "401", "500"] { + assert!( + operation["responses"].get(status).is_some(), + "distribute-proxies is missing its {status} response" + ); + } + // The schemas the two bodies point at have to exist, or a client is + // generated against nothing. + for schema in [ + "DistributeProxiesRequest", + "DistributeProxiesResponse", + "ProxyPair", + "ProxyAssignmentResult", + ] { + assert!( + spec["components"]["schemas"].get(schema).is_some(), + "missing schema: {schema}" + ); + } + + // A successful assignment carries no error, so `error` must stay nullable; + // marking it required would make every generated client demand a string + // that is not there. + let required = spec["components"]["schemas"]["ProxyAssignmentResult"]["required"] + .as_array() + .cloned() + .unwrap_or_default(); + assert!( + !required.iter().any(|r| r == "error"), + "the per-profile error is nullable and must stay optional" + ); + for always in ["profile_id", "proxy_id", "ok"] { + assert!( + required.iter().any(|r| r == always), + "{always} is always reported and must stay required" + ); + } + } + #[test] fn openapi_optional_fields_are_not_required() { let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes"); @@ -4934,12 +5547,39 @@ mod tests { "group_id must be a nullable string, not a free-form object" ); + // A client that does not ask for a disposable profile must not have to + // say so, and one that reads a profile must always learn whether it is. + for field in ["ephemeral", "temporary"] { + assert!( + !create_profile.iter().any(|f| f == field), + "{field} must be optional on create, required list: {create_profile:?}" + ); + assert!( + api_profile.iter().any(|f| f == field), + "{field} must always be reported on ApiProfile, required list: {api_profile:?}" + ); + } + let update_profile = schema_required(&spec, "UpdateProfileRequest"); assert!( !update_profile.iter().any(|f| f == "group_id"), "group_id must be optional, required list: {update_profile:?}" ); + // Sending `""` is the only way a REST client can detach a proxy or VPN, + // since an omitted field means "leave unchanged". Requiring either would + // force generated clients to send one. + for field in ["proxy_id", "vpn_id"] { + assert!( + !create_profile.iter().any(|f| f == field), + "{field} must be optional on create, required list: {create_profile:?}" + ); + assert!( + !update_profile.iter().any(|f| f == field), + "{field} must be optional on update, required list: {update_profile:?}" + ); + } + let update_proxy = schema_required(&spec, "UpdateProxyRequest"); assert!( !update_proxy.iter().any(|f| f == "proxy_settings"), @@ -5112,6 +5752,158 @@ mod tests { "{field} must be optional on an extension, required list: {extension:?}" ); } + + // The agent surface. Every knob on a perception request has a default; + // a locator is any subset of its parts; the pick body is optional to the + // last field. A wrongly-required field here makes a generated client send + // something the caller never chose. + for request in [ + "PerceptionRequest", + "LocatorDescription", + "AgentPickRequest", + ] { + let fields = schema_required(&spec, request); + assert!( + fields.is_empty(), + "every field of {request} must be optional, required list: {fields:?}" + ); + } + assert_eq!( + schema_required(&spec, "AgentResolveRequest"), + vec!["locator"] + ); + assert_eq!(schema_required(&spec, "AgentClickRequest"), vec!["locator"]); + let type_request = schema_required(&spec, "AgentTypeRequest"); + assert!( + type_request.contains(&"locator".to_string()) && type_request.contains(&"text".to_string()) + ); + for field in ["clear_first", "typos", "wpm"] { + assert!( + !type_request.iter().any(|f| f == field), + "{field} must be optional on a type request, required list: {type_request:?}" + ); + } + let extraction = schema_required(&spec, "ExtractionRequest"); + assert!( + extraction.contains(&"container".to_string()) + && extraction.contains(&"field_map".to_string()) + ); + for field in [ + "next_page", + "max_pages", + "max_rows", + "max_bytes", + "max_nodes", + "time_budget_ms", + ] { + assert!( + !extraction.iter().any(|f| f == field), + "{field} must be optional on an extraction, required list: {extraction:?}" + ); + } + // `value` is withheld for a protected control, `url` exists only for links + // and images, and `backendNodeId` only on the native engine. + let candidate = schema_required(&spec, "LocatorCandidate"); + for field in ["value", "url", "backendNodeId"] { + assert!( + !candidate.iter().any(|f| f == field), + "{field} must be optional on a candidate, required list: {candidate:?}" + ); + } + let node = schema_required(&spec, "PerceptionNode"); + for field in [ + "parentId", + "name", + "text", + "value", + "checked", + "expanded", + "scrollable", + "scrollContainerId", + ] { + assert!( + !node.iter().any(|f| f == field), + "{field} must be optional on a node, required list: {node:?}" + ); + } + let page = schema_required(&spec, "PerceptionPage"); + assert!( + !page.iter().any(|f| f == "cursor"), + "cursor follows only a truncated page: {page:?}" + ); + let typing = schema_required(&spec, "AgentTyping"); + assert!( + !typing.iter().any(|f| f == "corrections"), + "the fallback engine counts no corrections: {typing:?}" + ); + } + + #[test] + fn agent_failures_map_onto_statuses_a_client_can_act_on() { + use crate::cdp_target::CdpError; + use crate::mcp_server::AgentError; + + // A locator that matched three things is the caller's problem, and the + // body carries what matched so they can fix it. + let (status, body) = agent_error_response(AgentError::AmbiguousLocator { + match_count: 3, + candidates: vec![serde_json::json!({ "backendNodeId": 1 })], + message: "Ambiguous locator: 3 nodes match.".to_string(), + }); + assert_eq!(status, StatusCode::BAD_REQUEST); + let body: serde_json::Value = serde_json::from_str(&body).expect("a JSON body"); + assert_eq!(body["code"], "LOCATOR_AMBIGUOUS"); + assert_eq!(body["matchCount"], 3); + assert_eq!(body["candidates"][0]["backendNodeId"], 1); + assert!(body["message"] + .as_str() + .unwrap() + .starts_with("Ambiguous locator")); + + let (status, body) = agent_error_response(AgentError::NoMatch { + message: "No node matches locator (role=button).".to_string(), + }); + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(body.contains("LOCATOR_NO_MATCH")); + + // The browser's own gate is the same 402 and 429 the launch routes answer. + let (status, _) = agent_error_response(AgentError::PaymentRequired("no plan".into())); + assert_eq!(status, StatusCode::PAYMENT_REQUIRED); + let (status, _) = agent_error_response(AgentError::RateLimited("too fast".into())); + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + let (status, body) = agent_error_response(AgentError::RequiresWayfern152 { + version: "151.0.7922.76".into(), + }); + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(body.contains("WAYFERN_152_REQUIRED") && body.contains("151.0.7922.76")); + + // Nobody clicked: a timeout, not a fault. Escape: a conflict with what + // the user did, not a fault either. + let (status, _) = agent_error_response(AgentError::PickerTimedOut { timeout_ms: 5 }); + assert_eq!(status, StatusCode::REQUEST_TIMEOUT); + let (status, _) = agent_error_response(AgentError::PickerCancelled { + reason: "escape".into(), + }); + assert_eq!(status, StatusCode::CONFLICT); + + // A browser that is not there is "start it", not "we broke". + let (status, _) = agent_error_response(AgentError::Cdp(CdpError::Unreachable("x".into()))); + assert_eq!(status, StatusCode::CONFLICT); + let (status, _) = agent_error_response(AgentError::Cdp(CdpError::Transport("x".into()))); + assert_eq!(status, StatusCode::BAD_GATEWAY); + let (status, _) = agent_error_response(AgentError::Cdp(CdpError::Unauthorized("x".into()))); + assert_eq!(status, StatusCode::UNAUTHORIZED); + + // And a profile that exists but is not running is a 409 from the resolver. + use crate::cdp_target::ResolveError; + assert_eq!( + resolve_error_response(ResolveError::NotRunning("not running".into())).0, + StatusCode::CONFLICT + ); + assert_eq!( + resolve_error_response(ResolveError::Unsupported("firefox".into())).0, + StatusCode::BAD_REQUEST + ); } #[test] @@ -5365,9 +6157,8 @@ mod tests { #[test] fn the_kill_route_documents_that_it_can_fail_to_stop_a_remote_browser() { - // The backend refuses to retire a session it could not stop on the fleet, so - // stopping can genuinely fail with the browser still running. A spec that - // only lists 204 tells a client that never happens. + // Stopping can genuinely fail with the remote browser still running. A spec + // that only lists 204 tells a client that never happens. let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes"); let responses = &spec["paths"]["/v1/profiles/{id}/kill"]["post"]["responses"]; assert!( @@ -5405,6 +6196,9 @@ mod tests { "/v1/profiles/import", "/v1/profiles/import/detect", "/v1/proxies/import", + // One proxy per profile across a whole fleet. Registered on the router + // is not registered in the spec, and the spec is the contract. + "/v1/profiles/distribute-proxies", // The whole remote-execution surface was registered on the router but // absent from ApiDoc, so it never appeared in the served spec. This list // is a hand-maintained allowlist, which is exactly why that drift went @@ -5424,6 +6218,14 @@ mod tests { "/v1/cookie-bot/runs/{run_id}", "/v1/cookie-bot/presets", "/v1/cookie-bot/usage", + // The agent surface. Same hazard as every route above: registered on + // the router is not registered in the spec. + "/v1/profiles/{id}/agent/perceive", + "/v1/profiles/{id}/agent/resolve-locator", + "/v1/profiles/{id}/agent/click", + "/v1/profiles/{id}/agent/type", + "/v1/profiles/{id}/agent/extract", + "/v1/profiles/{id}/agent/pick", ] { assert!(paths.contains_key(path), "missing from ApiDoc: {path}"); } @@ -5599,8 +6401,8 @@ mod tests { } // The presets a client may choose from must never carry the behaviour they - // expand to. A site list, a dwell range or a step programme appearing here - // would mean the browsing model had leaked out of the server. + // expand to: anything beyond an id and a rough duration would mean the + // browsing model had leaked out of the server. let preset_properties = spec["components"]["schemas"]["CookieBotPreset"]["properties"] .as_object() .expect("preset properties"); @@ -5625,13 +6427,106 @@ mod tests { "/v1/profiles/{id}/run-remote", "/v1/profiles/batch/run", "/v1/profiles/batch/stop", + "/v1/profiles/{id}/agent/perceive", + "/v1/profiles/{id}/agent/resolve-locator", + "/v1/profiles/{id}/agent/click", + "/v1/profiles/{id}/agent/type", + "/v1/profiles/{id}/agent/extract", + "/v1/profiles/{id}/agent/pick", ] { assert!( paths[path]["post"]["responses"].get("429").is_some(), "automation route is missing its 429 response: {path}" ); + for status in ["402", "404", "409"] { + assert!( + paths[path]["post"]["responses"].get(status).is_some() || !path.contains("/agent/"), + "agent route is missing its {status} response: {path}" + ); + } } + // The agent surface's bodies resolve to the components the shared + // operations serialize, and every one of those is registered: a response + // body that resolves to nothing is worse than a missing path. + for (path, schema) in [ + ("/v1/profiles/{id}/agent/perceive", "PerceptionPage"), + ( + "/v1/profiles/{id}/agent/resolve-locator", + "LocatorResolution", + ), + ("/v1/profiles/{id}/agent/click", "AgentClick"), + ("/v1/profiles/{id}/agent/type", "AgentTyping"), + ("/v1/profiles/{id}/agent/extract", "Extraction"), + ("/v1/profiles/{id}/agent/pick", "PickedElement"), + ] { + let reference = + &paths[path]["post"]["responses"]["200"]["content"]["application/json"]["schema"]["$ref"]; + assert_eq!( + reference.as_str(), + Some(format!("#/components/schemas/{schema}").as_str()), + "post {path} 200 does not reference {schema}: {reference:?}" + ); + let tags = paths[path]["post"]["tags"].as_array().expect("tags"); + assert!( + tags.iter().any(|tag| tag == "agent"), + "{path} is not tagged agent" + ); + } + for schema in [ + "Engine", + "LocatorAttribute", + "LocatorDescription", + "LocatorBounds", + "LocatorCandidate", + "LocatorResolution", + "PerceptionRequest", + "PerceptionNode", + "PerceptionFrame", + "PerceptionStats", + "PerceptionPage", + "ExtractionField", + "ExtractionRequest", + "ExtractionRow", + "Extraction", + "PickedElement", + "AgentResolveRequest", + "AgentClickRequest", + "AgentClick", + "AgentTypeRequest", + "AgentTyping", + "AgentPickRequest", + ] { + let component = &spec["components"]["schemas"][schema]; + assert!( + component["properties"].is_object() || component["enum"].is_array(), + "schema is missing from the served spec: {schema}" + ); + } + // The node shape is the browser's, camelCase included: a client generated + // from the spec must read the same keys the wire carries. + let node = spec["components"]["schemas"]["PerceptionNode"]["properties"] + .as_object() + .expect("node properties"); + for key in ["frameId", "inViewport", "parentId", "scrollContainerId"] { + assert!( + node.contains_key(key), + "PerceptionNode.{key} is missing or not camelCase" + ); + } + let candidate = spec["components"]["schemas"]["LocatorCandidate"]["properties"] + .as_object() + .expect("candidate properties"); + assert!(candidate.contains_key("backendNodeId")); + let resolution = spec["components"]["schemas"]["LocatorResolution"]["properties"] + .as_object() + .expect("resolution properties"); + assert!(resolution.contains_key("match") && resolution.contains_key("matchCount")); + assert_eq!( + spec["components"]["schemas"]["Engine"]["enum"], + serde_json::json!(["wayfern", "fallback"]) + ); + assert!( paths["/v1/cookie-bot/runs"]["post"]["responses"] .get("429") diff --git a/src-tauri/src/app_dirs.rs b/src-tauri/src/app_dirs.rs index f428fab..cfcb399 100644 --- a/src-tauri/src/app_dirs.rs +++ b/src-tauri/src/app_dirs.rs @@ -61,10 +61,121 @@ fn log_dir_for(root: Option, portable: Option<&PathBuf>) -> Option> = OnceLock::new(); + +/// Where the pointer to a user-chosen data directory lives. +/// +/// It must never sit inside `data_dir()` itself: a move deletes the old +/// directory once the copy verifies, which would take the pointer with it and +/// send the next start back to the platform default. Every branch below +/// therefore resolves OUTSIDE the data directory it points at. +/// +/// - With `DONUTBROWSER_DATA_ROOT` set, `/data-root.json`, a sibling of +/// `/data`. An isolated run (the E2E harness) then keeps its own +/// pointer and can never read, or write, the real machine's. +/// - In portable mode, `/data-root.json`, beside `/data`, so +/// the choice travels with the install. +/// - Otherwise the platform preference directory, which is a different root +/// from `data_local_dir` on macOS, Linux and Windows alike. +pub fn data_root_pointer_file() -> PathBuf { + data_root_pointer_file_for( + data_root(), + portable_dir(), + base_dirs().preference_dir().join(app_name()), + ) +} + +/// Split out from `data_root_pointer_file` so the precedence is testable +/// without a `.portable` marker or process-wide environment mutation. +fn data_root_pointer_file_for( + root: Option, + portable: Option<&PathBuf>, + preference_dir: PathBuf, +) -> PathBuf { + if let Some(root) = root { + return root.join(DATA_ROOT_POINTER_FILENAME); + } + if let Some(dir) = portable { + return dir.join(DATA_ROOT_POINTER_FILENAME); + } + preference_dir.join(DATA_ROOT_POINTER_FILENAME) +} + +/// Read a pointer file written by a previous "move data directory". +/// +/// A missing, unreadable, malformed, empty or relative entry resolves to +/// `None`. Falling back to the platform default is always better than +/// resolving every profile, binary and setting to a path that cannot exist. +pub fn read_data_root_pointer(file: &std::path::Path) -> Option { + let content = std::fs::read_to_string(file).ok()?; + let parsed: serde_json::Value = match serde_json::from_str(&content) { + Ok(value) => value, + Err(e) => { + log::warn!( + "Ignoring the data directory pointer at {}: it is not valid JSON ({e})", + file.display() + ); + return None; + } + }; + let path = PathBuf::from(parsed.get("path")?.as_str()?); + if path.as_os_str().is_empty() || !path.is_absolute() { + log::warn!( + "Ignoring the data directory pointer at {}: {} is not an absolute path", + file.display(), + path.display() + ); + return None; + } + Some(path) +} + +/// Record a data directory for the next start. Written atomically, because a +/// truncated pointer read at startup would silently drop the user back onto +/// the platform default with an empty profile list. +pub fn write_data_root_pointer( + file: &std::path::Path, + path: &std::path::Path, +) -> std::io::Result<()> { + if let Some(parent) = file.parent() { + std::fs::create_dir_all(parent)?; + } + let body = serde_json::json!({ "path": path.to_string_lossy() }).to_string(); + let temp = file.with_extension("json.tmp"); + std::fs::write(&temp, body.as_bytes())?; + std::fs::rename(&temp, file) +} + +/// Forget a recorded data directory, returning the app to the default. +pub fn clear_data_root_pointer(file: &std::path::Path) -> std::io::Result<()> { + match std::fs::remove_file(file) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } +} + +/// The data directory a previous move chose, read once per process. +/// +/// Cached deliberately. Every open handle, cached path and loaded manager in a +/// running app points at the directory it started on, so a move must take +/// effect at the NEXT start and never mid-session. +pub fn custom_data_root() -> Option<&'static PathBuf> { + CUSTOM_DATA_ROOT + .get_or_init(|| read_data_root_pointer(&data_root_pointer_file())) + .as_ref() +} + /// True when app state has been moved off the platform default location, by -/// portable mode or by either directory override. +/// portable mode, either directory override, or a data directory the user +/// chose in Settings. fn state_is_relocated() -> bool { std::env::var_os("DONUTBROWSER_DATA_DIR").is_some_and(|v| !v.is_empty()) + || custom_data_root().is_some() || data_root().is_some() || portable_dir().is_some() } @@ -80,8 +191,34 @@ fn state_is_relocated() -> bool { /// host machine. If a future plugin version sanitises the name to a bare file /// component this silently reverts to the default directory, which is why the /// first-run probe in `lib.rs` reads this same function rather than assuming. +/// +/// A relocation that resolves to a relative path is rejected: see +/// `window_state_override_for`. pub fn window_state_path_override() -> Option { - state_is_relocated().then(|| data_dir().join(WINDOW_STATE_FILENAME)) + window_state_override_for(state_is_relocated(), data_dir()) +} + +/// Split out from `window_state_path_override` so the absolute-path rule is +/// testable without mutating process-wide environment variables. +/// +/// A relative override is worse than no override: the plugin would resolve it +/// against `app_config_dir` and write into an intermediate directory it never +/// creates, so every save fails with ENOENT and is swallowed by the plugin's +/// fire-and-forget exit handler. Falling back to the platform default at least +/// persists geometry. +fn window_state_override_for(relocated: bool, data_dir: PathBuf) -> Option { + if !relocated { + return None; + } + let path = data_dir.join(WINDOW_STATE_FILENAME); + if !path.is_absolute() { + log::warn!( + "Ignoring relative window-state override {}: the plugin resolves its filename against app_config_dir, so geometry would never persist. Set DONUTBROWSER_DATA_DIR/DONUTBROWSER_DATA_ROOT to an absolute path.", + path.display() + ); + return None; + } + Some(path) } /// Where the window-state file actually is, override or not. Used for the @@ -115,19 +252,66 @@ pub fn data_dir() -> PathBuf { } } - if let Ok(dir) = std::env::var("DONUTBROWSER_DATA_DIR") { - return PathBuf::from(dir); - } + data_dir_for( + std::env::var_os("DONUTBROWSER_DATA_DIR") + .filter(|v| !v.is_empty()) + .map(PathBuf::from), + custom_data_root(), + data_root(), + portable_dir(), + base_dirs().data_local_dir().join(app_name()), + ) +} - if let Some(root) = data_root() { +/// The data directory resolution order, split out so it can be tested without +/// mutating process-wide environment variables. +/// +/// `DONUTBROWSER_DATA_DIR` stays on top: it names an exact directory and is the +/// bluntest override there is. The directory the user picked in Settings comes +/// next, ahead of `DONUTBROWSER_DATA_ROOT` and portable mode, because both of +/// those are defaults for where state *would* live and an explicit choice +/// outranks a default. It cannot break an isolated run, because the pointer it +/// is read from lives under that same `DONUTBROWSER_DATA_ROOT`. +fn data_dir_for( + env_data_dir: Option, + custom_root: Option<&PathBuf>, + env_data_root: Option, + portable: Option<&PathBuf>, + platform_default: PathBuf, +) -> PathBuf { + if let Some(dir) = env_data_dir { + return dir; + } + if let Some(dir) = custom_root { + return dir.clone(); + } + if let Some(root) = env_data_root { return root.join("data"); } - - if let Some(dir) = portable_dir() { + if let Some(dir) = portable { return dir.join("data"); } + platform_default +} - base_dirs().data_local_dir().join(app_name()) +/// Where the data directory would resolve with no user choice recorded. Shown +/// in Settings so a person can see what they moved away from. +pub fn default_data_dir() -> PathBuf { + data_dir_for( + std::env::var_os("DONUTBROWSER_DATA_DIR") + .filter(|v| !v.is_empty()) + .map(PathBuf::from), + None, + data_root(), + portable_dir(), + base_dirs().data_local_dir().join(app_name()), + ) +} + +/// True when an environment override decides the data directory, so a +/// directory chosen in Settings would be recorded but not used. +pub fn data_dir_forced_by_environment() -> bool { + std::env::var_os("DONUTBROWSER_DATA_DIR").is_some_and(|v| !v.is_empty()) } pub fn cache_dir() -> PathBuf { @@ -389,6 +573,27 @@ mod tests { } } + #[test] + fn window_state_override_rejects_a_relative_data_dir() { + // `DONUTBROWSER_DATA_ROOT=don-state` (or a relative DATA_DIR) would hand the + // plugin a relative filename it resolves against app_config_dir, into a + // directory nothing creates. Falling back to the default keeps geometry. + assert_eq!( + window_state_override_for(true, PathBuf::from("don-state/data")), + None + ); + assert_eq!(window_state_override_for(true, PathBuf::from("")), None); + + // temp_dir is absolute on every platform; a hard-coded "/tmp/..." is not + // absolute on Windows, where these tests also run. + let relocated = std::env::temp_dir().join("donut-relocated"); + assert_eq!( + window_state_override_for(true, relocated.clone()), + Some(relocated.join(WINDOW_STATE_FILENAME)) + ); + assert_eq!(window_state_override_for(false, relocated), None); + } + #[test] fn window_state_follows_a_relocated_data_dir() { let tmp = PathBuf::from("/tmp/donut-relocated"); @@ -413,6 +618,111 @@ mod tests { assert!(portable.join("cache").starts_with(&portable)); } + #[test] + fn data_dir_resolution_order_puts_the_chosen_directory_under_the_exact_override() { + let env_dir = PathBuf::from("/env/exact"); + let chosen = PathBuf::from("/Volumes/Big/DonutBrowser"); + let env_root = PathBuf::from("/env/root"); + let portable = PathBuf::from("/stick"); + let default = PathBuf::from("/home/user/.local/share/DonutBrowser"); + + // DONUTBROWSER_DATA_DIR names an exact directory and outranks everything. + assert_eq!( + data_dir_for( + Some(env_dir.clone()), + Some(&chosen), + Some(env_root.clone()), + Some(&portable), + default.clone(), + ), + env_dir + ); + + // The directory the user picked beats both defaults-for-where-state-lives. + assert_eq!( + data_dir_for( + None, + Some(&chosen), + Some(env_root.clone()), + Some(&portable), + default.clone(), + ), + chosen + ); + + // With nothing chosen the existing order is untouched. + assert_eq!( + data_dir_for( + None, + None, + Some(env_root.clone()), + Some(&portable), + default.clone(), + ), + env_root.join("data") + ); + assert_eq!( + data_dir_for(None, None, None, Some(&portable), default.clone()), + portable.join("data") + ); + assert_eq!( + data_dir_for(None, None, None, None, default.clone()), + default + ); + } + + #[test] + fn the_pointer_never_lives_inside_the_directory_it_points_at() { + let root = PathBuf::from("/tmp/donut-root"); + let portable = PathBuf::from("/tmp/donut-portable"); + let preference = PathBuf::from("/home/user/.config/DonutBrowser"); + + // With DONUTBROWSER_DATA_ROOT the data dir is /data, so a sibling + // file survives deleting it — and an isolated run reads only its own. + let with_root = + data_root_pointer_file_for(Some(root.clone()), Some(&portable), preference.clone()); + assert_eq!(with_root, root.join(DATA_ROOT_POINTER_FILENAME)); + assert!(!with_root.starts_with(root.join("data"))); + + let with_portable = data_root_pointer_file_for(None, Some(&portable), preference.clone()); + assert_eq!(with_portable, portable.join(DATA_ROOT_POINTER_FILENAME)); + assert!(!with_portable.starts_with(portable.join("data"))); + + assert_eq!( + data_root_pointer_file_for(None, None, preference.clone()), + preference.join(DATA_ROOT_POINTER_FILENAME) + ); + } + + #[test] + fn a_written_pointer_reads_back_and_a_broken_one_falls_back() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("nested").join(DATA_ROOT_POINTER_FILENAME); + let target = std::env::temp_dir().join("donut-moved-root"); + + assert_eq!(read_data_root_pointer(&file), None, "missing file"); + + write_data_root_pointer(&file, &target).unwrap(); + assert_eq!(read_data_root_pointer(&file), Some(target.clone())); + + // A relative path would be resolved against whatever the working directory + // happens to be, which is not a place app state can live. + write_data_root_pointer(&file, std::path::Path::new("relative/root")).unwrap(); + assert_eq!(read_data_root_pointer(&file), None, "relative path"); + + std::fs::write(&file, b"not json at all").unwrap(); + assert_eq!(read_data_root_pointer(&file), None, "malformed file"); + + std::fs::write(&file, br#"{"other":"key"}"#).unwrap(); + assert_eq!(read_data_root_pointer(&file), None, "no path entry"); + + write_data_root_pointer(&file, &target).unwrap(); + clear_data_root_pointer(&file).unwrap(); + assert_eq!(read_data_root_pointer(&file), None, "cleared"); + // Clearing an absent pointer is not an error; the caller has nothing to fix. + clear_data_root_pointer(&file).unwrap(); + } + #[test] fn test_subdirectory_helpers() { assert!(profiles_dir().ends_with("profiles")); diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs index baca780..f76885c 100644 --- a/src-tauri/src/auto_updater.rs +++ b/src-tauri/src/auto_updater.rs @@ -309,6 +309,19 @@ impl AutoUpdater { // Check if profile is currently running if profile.process_id.is_some() { + // A pending entry is matched on the profile's current version alone, + // so recording one for an older build would downgrade the profile the + // moment it closes, and would pin that older binary against cleanup. + if !self.is_version_newer(new_version, &profile.version) { + log::debug!( + "Not queuing {} for running profile {}: not newer than {}", + new_version, + profile.name, + profile.version + ); + continue; + } + // Store as pending update so it gets applied when browser closes log::info!( "Profile {} is running, storing pending update {} -> {}", @@ -662,6 +675,7 @@ mod tests { last_sync: None, host_os: None, ephemeral: false, + temporary: false, extension_group_id: None, proxy_bypass_rules: Vec::new(), created_by_id: None, diff --git a/src-tauri/src/bin/proxy_server.rs b/src-tauri/src/bin/proxy_server.rs index c7e75eb..14b701e 100644 --- a/src-tauri/src/bin/proxy_server.rs +++ b/src-tauri/src/bin/proxy_server.rs @@ -102,7 +102,7 @@ async fn main() { .arg( Arg::new("type") .long("type") - .help("Proxy type (http, https, socks4, socks5, ss)"), + .help("Proxy type (http, https, httpstls, socks4, socks5, ss)"), ) .arg( Arg::new("port") diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index b22e1bb..b09d7b2 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -727,6 +727,7 @@ mod tests { last_sync: None, host_os: None, ephemeral: false, + temporary: false, extension_group_id: None, proxy_bypass_rules: Vec::new(), created_by_id: None, diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index 19238ff..3a59029 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -33,6 +33,18 @@ async fn lock_profile_launch(profile_id: &str) -> tokio::sync::OwnedMutexGuard<( lock.lock_owned().await } +fn emit_launch_stage(profile: &BrowserProfile, stage: &str, error: Option<&str>) { + let _ = events::emit( + "profile-launch-stage", + serde_json::json!({ + "id": profile.id.to_string(), + "stage": stage, + "timestamp": SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64, + "error": error, + }), + ); +} + pub struct BrowserRunner { pub profile_manager: &'static ProfileManager, pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry, @@ -208,6 +220,10 @@ impl BrowserRunner { .map_err(|e| format!("Failed to get executable path for {}: {e}", profile.browser).into()) } + /// One argument per thing a launch decides, and they are all independent: + /// grouping them into a struct would only move the same list one level out, + /// and the one caller shape that repeats already has `LaunchOptions`. + #[allow(clippy::too_many_arguments)] async fn launch_browser_internal( &self, app_handle: tauri::AppHandle, @@ -215,6 +231,7 @@ impl BrowserRunner { url: Option, remote_debugging_port: Option, headless: bool, + kind: crate::wayfern_manager::LaunchKind, gate: &crate::launch_gate::FingerprintGate, ) -> Result> { // Handle Wayfern profiles using WayfernManager @@ -228,6 +245,7 @@ impl BrowserRunner { WayfernConfig::default() }); + emit_launch_stage(profile, "network", None); // Always start a local proxy for Wayfern (for traffic monitoring and geoip support) let mut upstream_proxy = self .resolve_launch_proxy(profile) @@ -292,9 +310,16 @@ impl BrowserRunner { vpn_id: String, created: bool, profile_name: String, + /// This launch's own hold on the worker, kept until the guard goes out + /// of scope so a sibling launch cannot stop the worker while this one + /// is still between adoption and publishing its browser PID. + claim: Option, } impl Drop for VpnLaunchGuard { fn drop(&mut self) { + // Released before anything reads the claims, or this launch would + // count itself as a reason to keep the worker it just failed to use. + drop(self.claim.take()); let Some(worker_id) = self.worker_id.take() else { return; }; @@ -333,6 +358,7 @@ impl BrowserRunner { vpn_id: vpn_id.clone(), created: started.created, profile_name: profile.name.clone(), + claim: Some(started.claim), }); if let Some(port) = started.config.local_port { upstream_proxy = Some(ProxySettings { @@ -361,6 +387,12 @@ impl BrowserRunner { // unpack, and the browser process, so a blocked launch has nothing to // undo beyond the two workers whose guards are already armed above. // + // The group's bookmarks are written before the gate rather than inside + // it: the gate returns early for several kinds of profile and answers a + // question ("may this launch proceed"), while this is a preparation step + // every spawn needs, including a profile with no route to check. + crate::group_bookmarks::sync_for_launch(profile); + // Run concurrently with the blocklist compile so the added wall clock is // max(), not sum(). let (blocklist, gate_result) = tokio::join!( @@ -522,21 +554,20 @@ impl BrowserRunner { // launch at all, because nothing tells the user to stop using it. // // Structured rather than prose, because the most common failure is the - // browser refusing a generation once the account's hourly quota is - // spent. That has to reach the user as an explanation; a raw CDP string - // is not one, and the frontend only translates a coded error. + // browser refusing a generation outright. That has to reach the user as + // an explanation; a raw CDP string is not one, and the frontend only + // translates a coded error. let generated = self .wayfern_manager .generate_fingerprint_config(&app_handle, profile, &config_for_generation) .await .map_err(|e| { let detail = e.to_string(); - // BOTH refusal texts, because this path serves BOTH releases. 151 - // says "Fingerprint generation limit reached for this account."; - // the shipped 150 browser says "Too many profiles are being - // created." Matching only the 151 wording leaves a quota-blocked - // 150 user staring at a raw CDP string, which is the exact defect - // this mapping exists to remove. + // BOTH refusal texts, because a profile may be on either browser + // version. Older builds word the generation-limit refusal + // differently, and matching only one wording leaves those users + // staring at a raw CDP string, which is the exact defect this + // mapping exists to remove. if detail.contains("generation limit reached") || detail.contains("Too many profiles") { crate::backend_error_with_detail("WAYFERN_GENERATION_LIMIT_REACHED", detail) } else { @@ -650,6 +681,7 @@ impl BrowserRunner { let profile_path_str = profile_data_path.to_string_lossy().to_string(); // Install extensions if an extension group is assigned + emit_launch_stage(profile, "extensions", None); let mut extension_paths = Vec::new(); if updated_profile.extension_group_id.is_some() { let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); @@ -673,6 +705,7 @@ impl BrowserRunner { // Get proxy URL from config let proxy_url = wayfern_config.proxy.as_deref(); + emit_launch_stage(profile, "starting", None); let wayfern_result = self .wayfern_manager .launch_wayfern( @@ -686,6 +719,7 @@ impl BrowserRunner { &extension_paths, remote_debugging_port, headless, + kind, ) .await .map_err(|e| -> Box { @@ -874,6 +908,7 @@ impl BrowserRunner { url, remote_debugging_port, headless, + crate::wayfern_manager::LaunchKind::Automation, gate, ) .await @@ -966,7 +1001,15 @@ impl BrowserRunner { } else { log::info!("Launching new browser instance - browser not running"); self - .launch_browser_internal(app_handle.clone(), &final_profile, url, None, false, gate) + .launch_browser_internal( + app_handle.clone(), + &final_profile, + url, + None, + false, + crate::wayfern_manager::LaunchKind::Interactive, + gate, + ) .await } } @@ -1003,7 +1046,7 @@ impl BrowserRunner { // "Stop this profile" has to mean the browser that is actually running, and // for a profile on the leased fleet that browser is not on this machine. // Without this, stopping reported success, killed nothing, and left the - // session running to its two-hour cap — billing the user for every minute + // session running to its maximum duration — spending the user's allowance // and holding their profile lock the whole time. if self.stop_remote_session_for(&app_handle, profile).await? { return Ok(()); @@ -1039,10 +1082,9 @@ impl BrowserRunner { crate::remote_session::end_remote_session(&session_id) .await .map_err(|e| -> Box { - // Surfaced rather than swallowed. The backend refuses to retire a - // session it could not stop on the fleet, so a failure here means the - // browser is STILL RUNNING; reporting success would tell the user their - // profile is free when a host is still writing to it. + // Surfaced rather than swallowed. A failure here means the browser is + // STILL RUNNING; reporting success would tell the user their profile is + // free when a remote host is still writing to it. log::warn!("Failed to stop remote session {session_id}: {e}"); e.to_error_json().into() })?; @@ -1051,8 +1093,8 @@ impl BrowserRunner { // the profile into "pending sync" and starts the pull, so the user is not // handed back a profile directory that predates the session they just ran. // - // The session's own profile lock is released by the backend when it retires - // the row; nothing is released from here, because this client never held it. + // The session's own profile lock is released by the server; nothing is + // released from here, because this client never held it. crate::remote_session::note_session_stopped(app_handle, &session_id); Ok(true) } @@ -1409,6 +1451,22 @@ impl BrowserRunner { &profile.id.to_string(), ); + // A temporary profile exists for one automation run, so the run ending + // is what ends it. Destroyed rather than trashed: nothing here is worth + // restoring, and a trash full of automation leftovers is its own bug. + if profile.temporary { + match self + .profile_manager + .delete_profile_permanently(&app_handle, &profile.id.to_string()) + { + Ok(()) => log::info!( + "Deleted temporary profile {} now that its browser has stopped", + profile.name + ), + Err(e) => log::warn!("Could not delete temporary profile {}: {e}", profile.name), + } + } + log::info!( "Wayfern process cleanup completed for profile: {} (ID: {})", profile.name, @@ -1632,6 +1690,26 @@ pub async fn launch_browser_profile_impl( profile: BrowserProfile, url: Option, options: LaunchOptions, +) -> Result { + let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await; + emit_launch_stage(&profile, "queued", None); + let result = launch_browser_profile_tracked(app_handle, profile.clone(), url, options).await; + match &result { + Ok(_) => emit_launch_stage(&profile, "running", None), + Err(error) => emit_launch_stage( + &profile, + "failed", + Some(&crate::wrap_backend_error(error, "Browser launch failed")), + ), + } + result +} + +async fn launch_browser_profile_tracked( + app_handle: tauri::AppHandle, + profile: BrowserProfile, + url: Option, + options: LaunchOptions, ) -> Result { let LaunchOptions { remote_debugging_port, @@ -1644,7 +1722,7 @@ pub async fn launch_browser_profile_impl( profile.name, profile.id ); - let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await; + emit_launch_stage(&profile, "preparing", None); if profile.is_cross_os() { return Err(format!( diff --git a/src-tauri/src/cdp_target.rs b/src-tauri/src/cdp_target.rs index a1799cb..7888ba2 100644 --- a/src-tauri/src/cdp_target.rs +++ b/src-tauri/src/cdp_target.rs @@ -2,21 +2,19 @@ //! //! Until this module existed, every automation tool answered "where is this //! browser?" by reading a LOCAL debugging port out of the LOCAL profile -//! directory. A profile launched on a leased host has no local port and no +//! directory. A profile launched on a remote host has no local port and no //! local process, so a customer who paid for remote execution could start a //! session and then do nothing with it — the one thing the feature exists for. //! //! There is exactly one resolver here, [`resolve`], and one connection type, //! [`CdpConnection`]. Tools ask for a target and get either a page socket on -//! this machine or a relayed socket to the fleet; nothing above this module -//! branches on which. That is deliberate: a parallel set of remote-only tools -//! would drift from the local ones within a release. +//! this machine or a relayed socket to a remote browser; nothing above this +//! module branches on which. That is deliberate: a parallel set of remote-only +//! tools would drift from the local ones within a release. //! -//! The remote arm reaches donutbrowser-infra with the USER's own access token. -//! The desktop holds no fleet credential and knows no fleet hostname — infra -//! verifies the session belongs to the caller and relays onward with its own -//! service credential. That boundary is why this is a relay and not a direct -//! connection. +//! The remote arm reaches the cloud API with the USER's own access token, and +//! never holds any credential or hostname belonging to the machine the browser +//! runs on. That boundary is why this is a relay and not a direct connection. use crate::profile::types::BrowserProfile; use serde_json::Value; @@ -30,16 +28,15 @@ use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; /// How long the WebSocket handshake may take. /// -/// A remote attach crosses desktop → infra → wayfern → agent → the VM, so this -/// is far longer than a loopback connect needs. It matches the relay's own -/// upstream handshake budget: waiting longer than the server does can only -/// report a timeout the server already reported. +/// A remote attach crosses several networks before it reaches the browser, so +/// this is far longer than a loopback connect needs. Waiting longer than the +/// server does can only report a timeout the server already reported. const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); /// How long one CDP command may wait for its reply. /// /// Without a cap, a browser that never answers holds the caller until the -/// socket dies — 90 seconds on the relay, indefinitely on loopback. An +/// socket dies — a bounded wait remotely, indefinitely on loopback. An /// automation client that hangs is worse than one that fails. const COMMAND_TIMEOUT: Duration = Duration::from_secs(60); @@ -51,10 +48,9 @@ const CONNECT_RETRY_BASE: Duration = Duration::from_millis(400); /// Ceiling on a relayed CDP message. /// -/// Matches the relay's client-facing cap, which matches the fleet's upstream -/// frame cap. Lower, and a screenshot the server was willing to carry is -/// dropped on arrival; higher buys nothing, because the frame never crosses the -/// relay in the first place. +/// Matches the frame cap the remote endpoint enforces. Lower, and a screenshot +/// the server was willing to carry is dropped on arrival; higher buys nothing, +/// because the frame never crosses the network in the first place. const REMOTE_MAX_MESSAGE_BYTES: usize = 16 * 1024 * 1024; /// Command ids for the two messages the remote arm sends before any tool does. @@ -72,9 +68,9 @@ pub enum CdpTarget { /// A browser on this machine. The URL is a PAGE-level socket, so commands /// carry no CDP session id. Local { ws_url: String }, - /// A browser on the fleet, reached through the infra relay. The relay bridges - /// a BROWSER-level socket, so the connection attaches to a page and stamps - /// every subsequent message with the resulting session id. + /// A browser running remotely, reached through the cloud API. The remote + /// endpoint exposes a BROWSER-level socket, so the connection attaches to a + /// page and stamps every subsequent message with the resulting session id. Remote { ws_url: String, bearer: String, @@ -83,7 +79,7 @@ pub enum CdpTarget { } impl CdpTarget { - /// True when this browser is on the leased fleet rather than this machine. + /// True when this browser is on a remote host rather than this machine. pub fn is_remote(&self) -> bool { matches!(self, Self::Remote { .. }) } @@ -105,9 +101,9 @@ impl CdpTarget { /// broken one. #[derive(Debug)] pub enum CdpError { - /// Nothing is listening, or the relay could not reach the browser. + /// Nothing is listening, or the browser could not be reached. Unreachable(String), - /// The relay refused the credential. + /// The credential was refused. Unauthorized(String), /// The session exists but is not in a state that can be driven. NotDrivable(String), @@ -133,9 +129,9 @@ impl CdpError { /// Whether a fresh connection attempt could plausibly succeed. /// /// A refused credential and a session that is still provisioning are answers, - /// not failures. Retrying either spends the caller's time and, on the relay, - /// burns one of the four attachments a session is allowed — so the retry can - /// make the next honest attempt fail too. + /// not failures. Retrying either spends the caller's time and counts against + /// the session's attachment budget — so the retry can make the next honest + /// attempt fail too. fn is_retryable(&self) -> bool { matches!(self, Self::Unreachable(_) | Self::Transport(_)) } @@ -185,11 +181,11 @@ impl Patience { /// one crosses two networks. /// /// The local check is deliberately split in two. One cheap probe decides the -/// arm, so a profile running on the fleet is not held behind twenty-five -/// seconds of local retries; only once remote has been ruled out does the local -/// probe spend its full budget waiting for a browser that is still starting. -/// The same split covers a stale `process_id` left by a crash — nothing answers -/// on the recorded port, so the fleet session is found instead of a dead one. +/// arm, so a profile running remotely is not held behind twenty-five seconds of +/// local retries; only once remote has been ruled out does the local probe +/// spend its full budget waiting for a browser that is still starting. The same +/// split covers a stale `process_id` left by a crash — nothing answers on the +/// recorded port, so the remote session is found instead of a dead one. pub async fn resolve(profile: &BrowserProfile) -> Result { if profile.browser != "wayfern" { return Err(ResolveError::Unsupported(format!( @@ -304,12 +300,30 @@ async fn local_page_ws_url(profile: &BrowserProfile, patience: Patience) -> Opti } /// Pick a drivable page from what `/json` lists on a local browser. +/// +/// DRIVABLE, not merely first. This used to take the first `type == "page"` and +/// then reach for its socket, so a first entry without a +/// `webSocketDebuggerUrl`, a page another client is already attached to, which +/// Chromium omits the field for, made the whole call answer None and every +/// browser tool fail, while a perfectly drivable second tab sat right behind it. +/// The user sees "no page target found in browser" on a browser plainly showing +/// pages. +/// +/// `devtools://` is excluded for the same reason [`pick_remote_page_target`] +/// excludes it: attaching there drives the inspector rather than the site, which +/// reports success and moves nothing. The two functions answer the same question +/// off different payload shapes, so they must not disagree about what counts. pub fn pick_local_page_socket(targets: &[Value]) -> Option { targets .iter() - .find(|t| t.get("type").and_then(Value::as_str) == Some("page")) - .and_then(|t| t.get("webSocketDebuggerUrl")) - .and_then(Value::as_str) + .filter(|t| t.get("type").and_then(Value::as_str) == Some("page")) + .filter(|t| { + !t.get("url") + .and_then(Value::as_str) + .unwrap_or_default() + .starts_with("devtools://") + }) + .find_map(|t| t.get("webSocketDebuggerUrl").and_then(Value::as_str)) .map(str::to_string) } @@ -414,10 +428,9 @@ impl CdpConnection { /// Turn a hang-up into the error it means. /// - /// The relay's close codes are its whole vocabulary: 1008 is "that credential - /// is no good", 1013 is "come back when the session is up". Reporting either - /// as a generic transport failure throws away the only actionable thing the - /// server said. + /// The close codes carry the only actionable thing the server says: 1008 + /// means the credential was refused, 1013 means the session is not up yet. + /// Reporting either as a generic transport failure throws that away. pub fn closed_error(&self, context: &str) -> CdpError { match &self.closed { Some(info) if info.reason.is_empty() => { @@ -476,20 +489,20 @@ impl CdpConnection { /// Hang up politely so the peer releases its side immediately. /// - /// On the relay every open socket costs a real stream on the leased host and - /// counts against the session's attachment cap, so dropping the TCP - /// connection and letting it time out is not good enough. + /// A remote socket that is not closed keeps consuming the session's + /// attachment budget, so dropping the TCP connection and letting it time out + /// is not good enough. pub async fn close(mut self) { let _ = self.stream.close(None).await; } /// Move a browser-level socket onto a page. /// - /// The relay bridges `/devtools/browser/`. Every tool here speaks - /// `Page.*`, `Runtime.*` and `Input.*`, which a browser socket answers with - /// `'Page.navigate' wasn't found`. Attaching flat, and stamping the resulting - /// session id onto everything after it, is what makes the tools this app - /// already has work remotely without a single per-tool change. + /// The remote endpoint exposes `/devtools/browser/`. Every tool here + /// speaks `Page.*`, `Runtime.*` and `Input.*`, which a browser socket answers + /// with `'Page.navigate' wasn't found`. Attaching flat, and stamping the + /// resulting session id onto everything after it, is what makes the tools + /// this app already has work remotely without a single per-tool change. async fn attach_to_page(&mut self) -> Result<(), CdpError> { let targets = self .call( @@ -641,8 +654,8 @@ pub async fn run_command_awaiting_load( /// /// This is what "open a URL in that profile" means once the browser is already /// up, wherever it is. A remote session navigates its existing page rather than -/// opening a tab: a tab opened on a leased host that nobody can see or close is -/// not a feature, it is litter on hardware the user is paying for by the hour. +/// opening a tab: a tab opened on a remote host that nobody can see or close is +/// not a feature, it is litter on time the user is paying for by the hour. pub async fn navigate(target: &CdpTarget, url: &str, timeout_secs: u64) -> Result<(), CdpError> { run_command_awaiting_load( target, @@ -730,9 +743,9 @@ pub type RelaySocket = WebSocketStream>; /// target from it and stamp a session id onto messages it did not address. /// /// This is what makes a remote session usable from outside the app at all. The -/// relay only accepts the user's cloud credential, which no API consumer holds -/// and none should — so the socket is opened here, with the credential this -/// process already has, and proxied to the caller. +/// endpoint only accepts the user's cloud credential, which no API consumer +/// holds and none should — so the socket is opened here, with the credential +/// this process already has, and proxied to the caller. pub async fn open_relay_socket(session_id: &str) -> Result { let endpoint = crate::remote_session::cdp_endpoint(session_id) .await @@ -782,7 +795,7 @@ fn endpoint_lookup_error(err: crate::remote_session::RemoteSessionError) -> CdpE } } -/// Frame limits for a relay socket. Matches the relay's own client-facing cap. +/// Frame limits for a relay socket. Matches the cap the remote endpoint sets. pub fn relay_socket_config() -> WebSocketConfig { WebSocketConfig::default() .max_message_size(Some(REMOTE_MAX_MESSAGE_BYTES)) @@ -935,12 +948,61 @@ mod tests { assert!(pick_local_page_socket(&[]).is_none()); } + #[test] + fn a_page_that_cannot_be_driven_does_not_hide_the_one_that_can() { + // Chromium omits `webSocketDebuggerUrl` for a page another client is + // already attached to. Committing to the FIRST page and then reaching for + // its socket answered None for the whole browser, so every browser tool + // failed with "no page target found" while a drivable tab sat behind it. + let attached_first = vec![ + serde_json::json!({ "type": "page", "url": "https://example.com/" }), + serde_json::json!({ + "type": "page", + "url": "https://example.com/two", + "webSocketDebuggerUrl": "ws://127.0.0.1:1/devtools/page/B" + }), + ]; + assert_eq!( + pick_local_page_socket(&attached_first).as_deref(), + Some("ws://127.0.0.1:1/devtools/page/B") + ); + + // And the inspector is not a site. Attaching here drives DevTools itself - + // the failure `pick_remote_page_target` already documents, which reports + // success and moves nothing. The two pickers answer the same question off + // different payloads and must not disagree. + let devtools_first = vec![ + serde_json::json!({ + "type": "page", + "url": "devtools://devtools/bundled/devtools_app.html", + "webSocketDebuggerUrl": "ws://127.0.0.1:1/devtools/page/DEVTOOLS" + }), + serde_json::json!({ + "type": "page", + "url": "https://example.com/", + "webSocketDebuggerUrl": "ws://127.0.0.1:1/devtools/page/REAL" + }), + ]; + assert_eq!( + pick_local_page_socket(&devtools_first).as_deref(), + Some("ws://127.0.0.1:1/devtools/page/REAL") + ); + + // A listing with pages but nothing drivable still answers None rather than + // handing back a non-page socket. + let nothing_drivable = vec![ + serde_json::json!({ "type": "page", "url": "https://example.com/" }), + serde_json::json!({ "type": "worker", "webSocketDebuggerUrl": "ws://x/w" }), + ]; + assert!(pick_local_page_socket(¬hing_drivable).is_none()); + } + #[test] fn a_remote_frame_addresses_the_page_and_a_local_one_does_not() { - // A page-level command sent on the relay's BROWSER socket comes back as + // A page-level command sent on a BROWSER-level socket comes back as // "'Page.navigate' wasn't found". One missing sessionId on one message is // enough to make a single tool fail while every other tool works — a - // partial failure that reads as a flaky VM. + // partial failure that reads as a flaky remote browser. let remote = cdp_frame( Some("SESSION-42"), 7, @@ -959,9 +1021,9 @@ mod tests { #[test] fn a_relay_close_says_what_the_caller_should_do_about_it() { - // These codes are the relay's entire vocabulary. Collapsing them into one - // transport failure is how "your session is still provisioning" and "you - // are signed out" both become "something went wrong". + // These codes carry the whole answer. Collapsing them into one transport + // failure is how "your session is still provisioning" and "you are signed + // out" both become "something went wrong". assert!(matches!( classify_close(1008, "x".into()), CdpError::Unauthorized(_) @@ -1038,9 +1100,9 @@ mod tests { #[test] fn a_session_that_is_over_is_not_reported_as_a_broken_gateway() { - // Observed against the real backend: attaching to a session the user had - // just stopped answered 502, so a CDP client read "this is finished" as - // "the gateway is down" and retried it. + // A session the user has already stopped, or one that is not theirs, must + // read as a 404: there is no browser at this address. Collapsing it into + // "unreachable" makes an automation client retry a finished session. use crate::remote_session::RemoteSessionError; assert!(matches!( endpoint_lookup_error(RemoteSessionError::Other( @@ -1090,7 +1152,7 @@ mod tests { #[test] fn a_hasty_probe_tries_once_and_a_patient_one_waits() { - // The split is what stops a profile running on the fleet from being held + // The split is what stops a profile running remotely from being held // behind twenty-five seconds of local retries before anyone looks remote. assert_eq!(Patience::Immediate.attempts(10), 1); assert_eq!(Patience::WaitForLaunch.attempts(10), 10); @@ -1121,7 +1183,7 @@ mod tests { /// Hang up the way a session that is not yet up does. RefuseAsNotDrivable, /// Answer the navigation, then drop the socket before the load event, - /// the way a relay does when the browser it bridges dies mid-navigation. + /// the way the remote endpoint does when its browser dies mid-navigation. DropAfterNavigateReply, /// Drop the socket without answering the navigation. DropBeforeNavigateReply, @@ -1134,7 +1196,7 @@ mod tests { /// The CDP session id the fake relay hands out for a flat attach. const FAKE_CDP_SESSION: &str = "CDP-SESSION-1"; - /// A stand-in for the infra relay bridged onto a browser-level socket. + /// A stand-in for the remote endpoint bridged onto a browser-level socket. /// /// Answers `Target.getTargets` and `Target.attachToTarget` exactly as a real /// browser endpoint does, then echoes each command back so the test can read @@ -1243,8 +1305,8 @@ mod tests { } // The browser died mid-navigation: drop the socket without a close - // frame, the way a relay does when the VM it bridges goes away. The - // command's reply is already in the client's hands. + // frame, the way the remote endpoint does when the browser behind it + // goes away. The command's reply is already in the client's hands. if behaviour == RelayBehaviour::DropAfterNavigateReply && method == "Page.navigate" { break; } @@ -1275,10 +1337,10 @@ mod tests { #[tokio::test] async fn a_relayed_page_command_is_attached_and_stamped_with_its_session() { - // This is the whole feature. The relay bridges /devtools/browser/, so - // without the flat attach and the sessionId stamp every existing tool - // answers "'Page.navigate' wasn't found" and a paid remote session cannot - // be used for anything. + // This is the whole feature. The remote endpoint exposes + // /devtools/browser/, so without the flat attach and the sessionId + // stamp every existing tool answers "'Page.navigate' wasn't found" and a + // paid remote session cannot be used for anything. let (ws_url, server) = fake_relay(RelayBehaviour::Cooperative).await; let target = CdpTarget::Remote { ws_url, @@ -1368,7 +1430,7 @@ mod tests { #[tokio::test] async fn a_session_that_is_not_up_yet_is_reported_as_such_not_as_a_broken_one() { - // 1013 is the relay saying "come back when it is live". Surfacing it as a + // 1013 means "come back when it is live". Surfacing it as a // transport failure would send an automation client into a retry loop // against a session that is doing exactly what it should. let (ws_url, _server) = fake_relay(RelayBehaviour::RefuseAsNotDrivable).await; diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index 4292577..496f222 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -2,7 +2,6 @@ use aes_gcm::{ aead::{Aead, KeyInit}, Aes256Gcm, Key, Nonce, }; -use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; use chrono::Utc; use lazy_static::lazy_static; use rand::RngExt; @@ -15,18 +14,18 @@ use tokio::sync::Mutex; use crate::browser::ProxySettings; use crate::proxy_manager::PROXY_MANAGER; -use crate::settings_manager::SettingsManager; +use crate::settings_manager::{SettingsManager, StoredMcpRemoteKey}; use crate::sync; pub const CLOUD_API_URL: &str = "https://api.donutbrowser.com"; pub const CLOUD_SYNC_URL: &str = "https://sync.donutbrowser.com"; -/// Default per-hour cap on local automation API / MCP requests. Mirrors the -/// backend's DEFAULT_REQUESTS_PER_HOUR. +/// Default per-hour cap on local automation API / MCP requests, used when the +/// cloud API has not sent one. const DEFAULT_REQUESTS_PER_HOUR: i64 = 100; /// Capability + limit set the account is entitled to, derived from its plan. -/// Mirrors `apps/backend/src/plans/entitlements.ts`. Features are gated on these +/// Mirrors the entitlement set the cloud API sends. Features are gated on these /// flags instead of a single "is paid?" boolean, so a plan like "solo" (cloud /// backup + nightly cookie bot, no automation, no fingerprint editing, no /// hands-on remote session) is just data here. @@ -54,6 +53,23 @@ pub struct Entitlements { /// control must read THIS rather than `remote_browser_hours > 0`. #[serde(rename = "remoteInteractive", default)] pub remote_interactive: bool, + /// Whether the plan may drive THIS desktop from Donut cloud: the remote MCP + /// endpoint and the API in front of it. + /// + /// Read only by the UI. The bridge itself never gates on this: the relay + /// decides who may send work, and a cached entitlement that is a refresh + /// cycle out of date must not be what refuses a customer their own machine. + #[serde(rename = "remoteControl", default)] + pub remote_control: bool, + /// Whether the plan may run the browsing agent: a goal the cloud pursues on + /// one profile, on this desktop or on a leased host. + /// + /// Read only by the UI, and never back-filled from `browser_automation`. A + /// backend too old to send this key is a backend with no `api/agent` routes + /// to be entitled to, so `false` is the true answer rather than a gap to + /// guess at — the same reasoning `remote_control` is held to. + #[serde(rename = "agentAutomation", default)] + pub agent_automation: bool, #[serde(rename = "profileLimit", default)] pub profile_limit: i64, #[serde(rename = "requestsPerHour", default)] @@ -83,17 +99,26 @@ fn derive_entitlements( team_collaboration: false, cookie_bot: false, remote_interactive: false, + remote_control: false, + agent_automation: false, profile_limit: 0, requests_per_hour: 0, remote_browser_hours: 0, }; } // Tuple order: (browser_automation, cross_os_fingerprints, cloud_backup, - // team_collaboration, cookie_bot, remote_interactive). + // team_collaboration, cookie_bot, remote_interactive, remote_control, + // agent_automation). // // pro and any unrecognized paid plan -> pro-level (never team). Solo is the // one row where cookie_bot and browser_automation disagree, which is why // cookie_bot can no longer be derived from browser_automation below. + // + // remote_control is enterprise-only, and is withheld from the unrecognized + // row rather than granted with the rest. Everything else here defaults + // generous so a comped account is never locked out of what it is paying for; + // an internet-facing hook into this machine is the one capability where + // guessing "probably yes" is not the safe direction to guess in. let ( browser_automation, cross_os_fingerprints, @@ -101,10 +126,13 @@ fn derive_entitlements( team_collaboration, cookie_bot, remote_interactive, + remote_control, + agent_automation, ) = match plan { - "solo" => (false, false, true, false, true, false), - "team" | "enterprise" => (true, true, true, true, true, true), - _ => (true, true, true, false, true, true), + "solo" => (false, false, true, false, true, false, false, false), + "enterprise" => (true, true, true, true, true, true, true, true), + "team" => (true, true, true, true, true, true, false, true), + _ => (true, true, true, false, true, true, false, true), }; Entitlements { active, @@ -114,6 +142,8 @@ fn derive_entitlements( team_collaboration, cookie_bot, remote_interactive, + remote_control, + agent_automation, profile_limit, requests_per_hour: if browser_automation { DEFAULT_REQUESTS_PER_HOUR @@ -151,10 +181,15 @@ pub struct CloudUser { pub team_name: Option, #[serde(rename = "teamRole", default)] pub team_role: Option, + /// The plan this account is served under. A team member's `plan` stays + /// `"free"` (the owner pays) while the backend resolves this to the owner's + /// tier. `default` keeps the login response and older backends deserializing; + /// read it through `effective_plan()`. + #[serde(rename = "effectivePlan", default)] + pub effective_plan: Option, // This desktop session's position among the user's active devices, oldest - // first. Ordinal 1 is the primary device — the only one that can run browser - // automation. `default` keeps older login/state payloads (which lack these - // fields) deserializing cleanly. + // first, as the cloud API reports it. Shown in the UI. `default` keeps older + // login/state payloads (which lack these fields) deserializing cleanly. #[serde(rename = "deviceOrdinal", default)] pub device_ordinal: Option, #[serde(rename = "deviceCount", default)] @@ -168,6 +203,13 @@ pub struct CloudUser { } impl CloudUser { + /// The plan the account is actually served under: `effectivePlan` when the + /// backend sent one, else the row's own `plan`. Gates that ask "is this a + /// paid / team account" read this; billing-only surfaces keep `plan`. + pub fn effective_plan(&self) -> &str { + self.effective_plan.as_deref().unwrap_or(&self.plan) + } + /// Authoritative entitlements: the server-sent set when present, else derived /// locally from the plan fields (keeps older cached state / backends working). pub fn entitlements(&self) -> Entitlements { @@ -227,6 +269,51 @@ struct SyncTokenResponse { sync_token: String, } +/// Prefix of a remote MCP credential. Only a key carrying this prefix may ever +/// be stored here; a credential of any other kind is rejected. +pub const MCP_KEY_PREFIX: &str = "dmk_"; + +/// A freshly minted remote MCP credential. The plaintext `key` is shown by the +/// server exactly once, in this response. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpKeyGrant { + pub id: String, + pub token_prefix: String, + pub key: String, +} + +/// What a key endpoint answered once the request itself went through. +/// +/// `api_call_with_retry` reads a 401 out of the ERROR string and refreshes the +/// session once, so only a 401 (and a transport failure) may be an `Err`. Every +/// other refusal travels as a value, so it reaches the code mapping below +/// instead of being mistaken for a dead session. +enum McpKeyAnswer { + Granted(T), + Refused { status: u16, body: String }, +} + +/// The `{"code"}` the UI shows for a refused credential mint. +/// +/// A 409 on `POST /api/mcp/keys` is the per-account cap, whether or not the +/// server bothered to name it; everything else is "not right now", carrying +/// the server's message as the detail so the log says why. +fn mcp_key_refusal(status: u16, body: &str) -> String { + if status == 409 { + return crate::backend_error("MCP_REMOTE_KEY_LIMIT"); + } + let message = serde_json::from_str::(body) + .ok() + .and_then(|v| { + v.get("message") + .and_then(|m| m.as_str()) + .map(std::string::ToString::to_string) + }) + .unwrap_or_else(|| body.to_string()); + crate::backend_error_with_detail("MCP_REMOTE_KEY_UNAVAILABLE", format!("{status}: {message}")) +} + #[derive(Debug, Deserialize)] struct WayfernTokenResponse { token: String, @@ -304,17 +391,9 @@ impl CloudAuthManager { let vault_password = Self::get_vault_password(); let salt_bytes: [u8; 16] = rand::rng().random(); - let salt = - SaltString::encode_b64(&salt_bytes).map_err(|e| format!("Failed to encode salt: {e}"))?; - let argon2 = Argon2::default(); - let password_hash = argon2 - .hash_password(vault_password.as_bytes(), &salt) - .map_err(|e| format!("Argon2 key derivation failed: {e}"))?; - let hash_value = password_hash.hash.unwrap(); - let hash_bytes = hash_value.as_bytes(); - let key_bytes: [u8; 32] = hash_bytes[..32] - .try_into() - .map_err(|_| "Invalid key length".to_string())?; + let salt = crate::sync::encryption::encode_salt(&salt_bytes); + let key_bytes = + crate::sync::encryption::derive_vault_key(vault_password.as_bytes(), &salt_bytes)?; let key = Key::::from(key_bytes); let cipher = Aes256Gcm::new(&key); let nonce_bytes: [u8; 12] = rand::rng().random(); @@ -366,7 +445,7 @@ impl CloudAuthManager { } let salt_bytes = &file_data[offset..offset + salt_len]; let salt_str = std::str::from_utf8(salt_bytes).map_err(|_| "Invalid salt encoding")?; - let salt = SaltString::from_b64(salt_str).map_err(|_| "Invalid salt format")?; + let salt_bytes = crate::sync::encryption::decode_salt(salt_str)?; offset += salt_len; if offset + 12 > file_data.len() { @@ -395,15 +474,8 @@ impl CloudAuthManager { let ciphertext = &file_data[offset..offset + ciphertext_len]; let vault_password = Self::get_vault_password(); - let argon2 = Argon2::default(); - let password_hash = argon2 - .hash_password(vault_password.as_bytes(), &salt) - .map_err(|e| format!("Argon2 key derivation failed: {e}"))?; - let hash_value = password_hash.hash.unwrap(); - let hash_bytes = hash_value.as_bytes(); - let key_bytes: [u8; 32] = hash_bytes[..32] - .try_into() - .map_err(|_| "Invalid key length".to_string())?; + let key_bytes = + crate::sync::encryption::derive_vault_key(vault_password.as_bytes(), &salt_bytes)?; let key = Key::::from(key_bytes); let cipher = Aes256Gcm::new(&key); let plaintext = cipher @@ -560,9 +632,9 @@ impl CloudAuthManager { if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - // The backend returns { message, code, … } for 4xx (e.g. the 3-device - // limit or a temporary security block). Surface the human-readable - // message rather than the raw JSON so the sign-in screen is clear. + // The cloud API returns { message, code, … } for 4xx. Surface the + // human-readable message rather than the raw JSON so the sign-in screen + // is clear. let message = serde_json::from_str::(&body) .ok() .and_then(|v| { @@ -672,10 +744,206 @@ impl CloudAuthManager { pub async fn invalidate_session(&self) { log::warn!("Invalidating session — clearing all auth state"); PROXY_MANAGER.remove_cloud_proxy(); + // Same reason `logout` does it: left running, the bridge reconnects with a + // credential that no longer exists, fails, and backs off into an + // "unauthorized" the account page shows to somebody whose session simply + // expired. This is the AUTOMATIC twin of logout, reached when the + // background refresh loop gives up, and it was the one teardown path that + // did not close the bridge. + crate::mcp_remote::stop(None); + // The stored `dmk_` key belongs to the account that just left this + // machine, and the agent configs would keep presenting it. There is no + // session left to revoke it with, so only the local copy goes; the key + // itself is retired from the account page. + Self::forget_mcp_key_locally("after the session expired"); self.clear_auth().await; let _ = crate::events::emit_empty("cloud-auth-expired"); } + /// Ask the server whether this account may drive a desktop remotely. + /// + /// The AUTHORITATIVE answer, and the only one that is correct for a team + /// member: a team member's locally cached plan does not describe what their + /// seat is entitled to, so the server is asked rather than guessed at. + /// + /// It cannot be inferred from the socket either. A connected bridge only + /// proves the plan is active, and nothing about this capability. + pub async fn fetch_remote_control_entitlement(&self) -> Result { + self + .api_call_with_retry(|access_token| { + let url = format!("{CLOUD_API_URL}/api/mcp/status"); + let client = self.client.clone(); + async move { + let response = client + .get(&url) + .header("Authorization", format!("Bearer {access_token}")) + .send() + .await + .map_err(|e| format!("Failed to read remote-control status: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + return Err(format!("Remote-control status failed ({status})")); + } + + let body: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse remote-control status: {e}"))?; + + Ok( + body + .get("entitled") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + ) + } + }) + .await + } + + /// Mint a remote MCP credential for this account. + /// + /// The server caps how many live keys one account may hold and answers a mint + /// past that cap with a 409; the rotation command handles that by retiring the + /// key it is replacing first. The plaintext in the answer is the only copy + /// there will ever be. + pub async fn create_mcp_key(&self, label: &str) -> Result { + let answer = self + .api_call_with_retry(|access_token| { + let url = format!("{CLOUD_API_URL}/api/mcp/keys"); + let client = self.client.clone(); + let label = label.to_string(); + async move { + let response = client + .post(&url) + .header("Authorization", format!("Bearer {access_token}")) + .json(&serde_json::json!({ "label": label })) + .send() + .await + .map_err(|e| format!("Failed to request an MCP credential: {e}"))?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if status.as_u16() == 401 { + // Worded so `api_call_with_retry` recognises it and refreshes once. + return Err(format!( + "MCP credential request failed (401 Unauthorized): {body}" + )); + } + if !status.is_success() { + return Ok(McpKeyAnswer::Refused { + status: status.as_u16(), + body, + }); + } + serde_json::from_str::(&body) + .map(McpKeyAnswer::Granted) + .map_err(|e| format!("Failed to parse the MCP credential response: {e}")) + } + }) + .await + .map_err(|e| crate::backend_error_with_detail("MCP_REMOTE_KEY_UNAVAILABLE", e))?; + + match answer { + McpKeyAnswer::Granted(grant) if grant.key.starts_with(MCP_KEY_PREFIX) => Ok(grant), + // A credential of any other kind must never be stored as if it were ours, + // however well it would authenticate. + McpKeyAnswer::Granted(_) => Err(crate::backend_error_with_detail( + "MCP_REMOTE_KEY_UNAVAILABLE", + "the server issued a credential of an unexpected shape", + )), + McpKeyAnswer::Refused { status, body } => Err(mcp_key_refusal(status, &body)), + } + } + + /// Revoke a remote MCP credential by id. A key the server no longer knows + /// (404) counts as revoked: that is the state the caller wanted. + pub async fn revoke_mcp_key(&self, key_id: &str) -> Result<(), String> { + let answer = self + .api_call_with_retry(|access_token| { + let url = format!( + "{CLOUD_API_URL}/api/mcp/keys/{}", + urlencoding::encode(key_id) + ); + let client = self.client.clone(); + async move { + let response = client + .delete(&url) + .header("Authorization", format!("Bearer {access_token}")) + .send() + .await + .map_err(|e| format!("Failed to revoke the MCP credential: {e}"))?; + let status = response.status(); + if status.as_u16() == 401 { + let body = response.text().await.unwrap_or_default(); + return Err(format!( + "MCP credential revocation failed (401 Unauthorized): {body}" + )); + } + if status.is_success() || status.as_u16() == 404 { + return Ok(McpKeyAnswer::Granted(())); + } + Ok(McpKeyAnswer::Refused { + status: status.as_u16(), + body: response.text().await.unwrap_or_default(), + }) + } + }) + .await + .map_err(|e| crate::backend_error_with_detail("MCP_REMOTE_KEY_UNAVAILABLE", e))?; + + match answer { + McpKeyAnswer::Granted(()) => Ok(()), + McpKeyAnswer::Refused { status, body } => Err(crate::backend_error_with_detail( + "MCP_REMOTE_KEY_UNAVAILABLE", + format!("{status}: {body}"), + )), + } + } + + /// Retire the stored remote MCP credential on sign-out. + /// + /// Best effort, and the local copy goes regardless. The revoke needs the + /// session that is about to be deleted, so this runs before `clear_auth`; + /// if it fails the key stays live server-side and the account page can + /// revoke it, but a signed-out desktop must not keep a credential that + /// belongs to the account that just left it. + async fn retire_mcp_key_on_logout(&self) { + let settings = SettingsManager::instance(); + // Stringified at once: the settings error is a `Box`, which is + // not `Send`, and the command future this runs in has to be. + let stored = settings.get_mcp_remote_key().map_err(|e| e.to_string()); + match stored { + Ok(Some(StoredMcpRemoteKey { id: Some(id), .. })) => { + if let Err(e) = self.revoke_mcp_key(&id).await { + log::warn!( + "Could not revoke the remote MCP credential on logout; revoke it from the account page: {e}" + ); + } + } + Ok(Some(StoredMcpRemoteKey { id: None, .. })) => { + log::warn!( + "The remote MCP credential has no stored id, so it cannot be revoked from here; revoke it from the account page" + ); + } + Ok(None) => return, + Err(e) => { + log::warn!("Could not read the remote MCP credential on logout: {e}"); + } + } + Self::forget_mcp_key_locally("on logout"); + } + + /// Drop the local copy of the remote MCP credential: the storage half of + /// `retire_mcp_key_on_logout`, shared with the expiry path, which has no + /// session left to revoke with. Logs rather than fails, because the + /// credential is leaving with the session either way. + fn forget_mcp_key_locally(when: &str) { + if let Err(e) = SettingsManager::instance().remove_mcp_remote_key() { + log::warn!("Could not forget the remote MCP credential {when}: {e}"); + } + } + pub async fn fetch_profile(&self) -> Result { let user = self .api_call_with_retry(|access_token| { @@ -765,6 +1033,16 @@ impl CloudAuthManager { // Disconnect profile lock manager crate::team_lock::PROFILE_LOCK.disconnect().await; + // Hang up the remote-control bridge before the credential it authenticated + // with is deleted. Left running it would reconnect, fail, and back off into + // an "unauthorized" the account page shows to somebody who has simply + // signed out, and it would hold the account's bridge slot meanwhile. + crate::mcp_remote::stop(None); + + // Before the session is closed server-side and the tokens are deleted: + // both of those take away the only thing that can revoke it. + self.retire_mcp_key_on_logout().await; + // Try to call the logout API (best-effort) if let Ok(Some(access_token)) = Self::load_access_token() { let refresh_token = Self::load_refresh_token().ok().flatten(); @@ -1293,11 +1571,20 @@ impl CloudAuthManager { // Reconnect profile lock manager if needed if let Some(auth_state) = CLOUD_AUTH.get_user().await { - if auth_state.user.plan != "free" && !crate::team_lock::PROFILE_LOCK.is_connected().await { + if auth_state.user.effective_plan() != "free" + && !crate::team_lock::PROFILE_LOCK.is_connected().await + { crate::team_lock::PROFILE_LOCK.connect().await; } } + // And the remote-control bridge, for the same reason one tick below it + // reconnects the profile lock: a setting that says "on" and a bridge that + // is not running is a disagreement only something periodic can notice. + // `mcp_remote::start` is idempotent, so a healthy bridge costs a load of + // the settings file every ten minutes and nothing else. + ensure_remote_bridge(&app_handle).await; + // Sync cloud proxy credentials CLOUD_AUTH.sync_cloud_proxy().await; @@ -1323,11 +1610,10 @@ impl CloudAuthManager { } } -/// Whether a rejected wayfern-token request was refused by one of the -/// device-family rules (automation is pinned to the primary desktop session) -/// rather than by the plan's capabilities. +/// Whether a rejected wayfern-token request was refused by one of the device +/// rules rather than by the plan's capabilities. /// -/// Matches on the backend's message because that is the only thing that +/// Matches on the server's message because that is the only thing that /// distinguishes them: both arrive as a bare 403. Only these two are a state /// the user can clear themselves, which is what the toast asks them to do. fn is_device_restriction(error: &str) -> bool { @@ -1380,10 +1666,22 @@ pub async fn cloud_exchange_device_code( ) -> Result { let mut state = CLOUD_AUTH.exchange_device_code(&code).await?; + // The login response carries the row's own plan and entitlements only: no + // team membership and no `effectivePlan`. For an invited member that reads + // as a free account, and it stayed that way until the ten-minute loop got + // round to `/api/auth/me`. Resolve the served plan here so the sync token, + // the wayfern token and the profile lock below all see the seat. Best + // effort: a failure leaves the login response in place. + match CLOUD_AUTH.fetch_profile().await { + Ok(user) => state.user = user, + Err(e) => log::warn!("Post-login profile refresh failed: {e}"), + } + let has_subscription = CLOUD_AUTH.has_active_paid_subscription().await; log::info!( - "Post-login: plan={}, has_active_subscription={}", + "Post-login: plan={}, effective_plan={}, has_active_subscription={}", state.user.plan, + state.user.effective_plan(), has_subscription ); @@ -1406,10 +1704,19 @@ pub async fn cloud_exchange_device_code( CLOUD_AUTH.sync_cloud_proxy().await; // Connect profile lock manager for paid users - if state.user.plan != "free" { + if state.user.effective_plan() != "free" { crate::team_lock::PROFILE_LOCK.connect().await; } + // Reopen the remote-control bridge the user had switched on. + // + // Signing out stops the bridge but deliberately does NOT clear the setting: + // it is a preference, not a session. So without this, "sign out, sign back + // in" left remote control switched on in Settings and dead in fact until the + // app was restarted, which is the worst of both: the UI says yes and the + // account page says no desktop is connected. + ensure_remote_bridge(&app_handle).await; + let _ = crate::events::emit_empty("cloud-auth-changed"); let _ = &app_handle; @@ -1417,6 +1724,42 @@ pub async fn cloud_exchange_device_code( Ok(state) } +/// Open the remote-control bridge if the user asked for it and is signed in. +/// +/// Idempotent, and safe to call from anywhere: `mcp_remote::start` returns +/// immediately when the bridge is already up. +/// +/// The signed-in half matters as much as the setting. A bridge opened without a +/// credential cannot authenticate, so it would spend its life in the terminal +/// backoff band reporting "not signed in" on the account page: an error +/// describing nothing the user did. +pub(crate) async fn ensure_remote_bridge(app_handle: &tauri::AppHandle) { + if crate::mcp_remote::is_running() { + return; + } + // The same two gates as `start_mcp_remote_bridge`. This helper runs at + // boot, on sign-in and on the reconnect tick, and it used to check only the + // sign-in half: a desktop whose terms acceptance had been withdrawn (or + // never given, with the flag seeded on disk) opened an internet-facing + // bridge into a browser the user had not agreed to automate. + if !crate::wayfern_terms::WayfernTermsManager::instance().is_terms_accepted() { + return; + } + if !CLOUD_AUTH.is_logged_in().await { + return; + } + let enabled = crate::settings_manager::SettingsManager::instance() + .load_settings() + .map(|settings| settings.mcp_remote_enabled) + .unwrap_or(false); + if enabled { + log::info!( + "[mcp-remote] Remote control is enabled and the account is signed in; opening the bridge" + ); + crate::mcp_remote::start(app_handle.clone()); + } +} + #[tauri::command] pub async fn cloud_get_user() -> Result, String> { Ok(CLOUD_AUTH.get_user().await.map(|mut state| { @@ -1452,6 +1795,13 @@ pub async fn cloud_refresh_profile() -> Result { pub async fn cloud_logout(app_handle: tauri::AppHandle) -> Result<(), String> { CLOUD_AUTH.logout().await?; + // Stop the remote session-events stream. Its credential is now invalid, so + // the SSE connection would fail its next credential re-check anyway — but + // until it did, its reconnect loop keeps retrying every 1..60s against a + // signed-out account. The frontend store stops its own subscription on + // logout; nothing stopped the Rust stream, so a sign-out left it churning. + crate::remote_session::stop_session_events(); + // Always clear the stored sync URL and token on cloud logout. While the // user was signed in, the cloud auth flow populated these with the hosted // sync server's URL + a server-issued token — leaving them in place would @@ -1522,10 +1872,32 @@ struct ProxyUsageResponse { limit_mb: i64, #[serde(rename = "remainingMb")] remaining_mb: i64, - #[serde(rename = "recurringLimitMb", default)] - recurring_limit_mb: i64, - #[serde(rename = "extraLimitMb", default)] - extra_limit_mb: i64, + // Optional rather than defaulted to 0 so an omitted half of the split stays + // distinguishable from a backend that genuinely reports zero for it. + #[serde(rename = "recurringLimitMb")] + recurring_limit_mb: Option, + #[serde(rename = "extraLimitMb")] + extra_limit_mb: Option, +} + +/// Combine the live usage response with the cached account snapshot, each half +/// of the recurring/extra split falling back on its own. +/// +/// Gating both halves on `recurringLimitMb` made an omitted `extraLimitMb` +/// report 0 against a total that included it, and threw away a live +/// `extraLimitMb` whenever the recurring half happened to be 0. +fn merge_proxy_usage( + usage: &ProxyUsageResponse, + cached_recurring: i64, + cached_extra: i64, +) -> CloudProxyUsage { + CloudProxyUsage { + used_mb: usage.used_mb, + limit_mb: usage.limit_mb, + remaining_mb: usage.remaining_mb, + recurring_limit_mb: usage.recurring_limit_mb.unwrap_or(cached_recurring), + extra_limit_mb: usage.extra_limit_mb.unwrap_or(cached_extra), + } } #[tauri::command] @@ -1578,21 +1950,11 @@ pub async fn cloud_get_proxy_usage() -> Result, String> }) .await { - Ok(usage) => Ok(Some(CloudProxyUsage { - used_mb: usage.used_mb, - limit_mb: usage.limit_mb, - remaining_mb: usage.remaining_mb, - recurring_limit_mb: if usage.recurring_limit_mb > 0 { - usage.recurring_limit_mb - } else { - cached_recurring - }, - extra_limit_mb: if usage.recurring_limit_mb > 0 { - usage.extra_limit_mb - } else { - cached_extra - }, - })), + Ok(usage) => Ok(Some(merge_proxy_usage( + &usage, + cached_recurring, + cached_extra, + ))), Err(e) => { log::warn!("Failed to fetch live proxy usage, falling back to cached: {e}"); // Fallback to cached values @@ -1651,6 +2013,32 @@ mod tests { ); } + #[test] + fn the_agent_follows_browser_automation_and_is_never_derived_from_it() { + // Solo funds a nightly bot and nothing that drives a browser by hand, so + // it does not get the agent either. + assert!(!active_solo().agent_automation); + for plan in ["pro", "team", "enterprise", "some-comped-plan"] { + let derived = derive_entitlements(plan, Some("monthly"), "active", 50); + assert!(derived.agent_automation, "{plan} should get the agent"); + } + // An inactive subscription buys nothing, whatever the plan says. + assert!(!derive_entitlements("pro", Some("monthly"), "canceled", 50).agent_automation); + } + + #[test] + fn a_backend_that_never_heard_of_the_agent_reports_no_agent() { + // The whole point of `default` here: an older backend's entitlements object + // must decode, and the missing key must read as "no agent routes exist" + // rather than being back-filled from browser automation. + let older: Entitlements = serde_json::from_str( + r#"{"active":true,"browserAutomation":true,"cloudBackup":true,"profileLimit":50}"#, + ) + .unwrap(); + assert!(older.active && older.browser_automation); + assert!(!older.agent_automation); + } + #[test] fn wayfern_token_is_gated_on_automation_not_on_being_paid() { // The regression this guards: gating the mint on `active` asked for a token @@ -1662,6 +2050,109 @@ mod tests { assert!(pro.active && pro.browser_automation); } + #[test] + fn a_refused_credential_mint_maps_to_the_codes_the_ui_knows() { + let limit: serde_json::Value = serde_json::from_str(&mcp_key_refusal( + 409, + r#"{"message":"Too many MCP keys","code":"MCP_KEY_LIMIT","statusCode":409}"#, + )) + .unwrap(); + assert_eq!(limit["code"], "MCP_REMOTE_KEY_LIMIT"); + + // The cap is the only thing a 409 on that route means, named or not. + let unnamed: serde_json::Value = serde_json::from_str(&mcp_key_refusal(409, "")).unwrap(); + assert_eq!(unnamed["code"], "MCP_REMOTE_KEY_LIMIT"); + + let other: serde_json::Value = serde_json::from_str(&mcp_key_refusal( + 429, + r#"{"message":"Too many requests","statusCode":429}"#, + )) + .unwrap(); + assert_eq!(other["code"], "MCP_REMOTE_KEY_UNAVAILABLE"); + assert_eq!(other["params"]["detail"], "429: Too many requests"); + + // A body that is not JSON still reaches the log verbatim. + let plain: serde_json::Value = + serde_json::from_str(&mcp_key_refusal(502, "bad gateway")).unwrap(); + assert_eq!(plain["params"]["detail"], "502: bad gateway"); + } + + #[test] + fn the_boot_and_sign_in_paths_require_the_terms_like_the_command_does() { + // `ensure_remote_bridge` is reached from boot, sign-in and the reconnect + // tick, none of which pass through `start_mcp_remote_bridge`, so the + // command's terms gate protects only the toggle. The helper must carry + // the same gate itself. + let source = include_str!("cloud_auth.rs"); + let helper = source + .split("pub(crate) async fn ensure_remote_bridge(") + .nth(1) + .expect("ensure_remote_bridge must exist"); + let body = &helper[..helper.find("\n}").unwrap_or(helper.len())]; + let terms = body + .find("is_terms_accepted()") + .expect("ensure_remote_bridge must check the Wayfern terms"); + let start = body + .find("crate::mcp_remote::start(") + .expect("ensure_remote_bridge must be what starts the bridge"); + assert!(terms < start, "the terms gate must sit ahead of the start"); + } + + #[test] + fn logout_retires_the_remote_credential_before_the_session_is_gone() { + // The revoke needs the access token; `clear_auth` deletes it and the + // `/api/auth/logout` call may invalidate it server-side. Both must come + // after. + let source = include_str!("cloud_auth.rs"); + let logout = source + .split("pub async fn logout(&self)") + .nth(1) + .expect("logout must exist"); + let body = &logout[..logout.find("\n }").unwrap_or(logout.len())]; + let retire = body + .find("retire_mcp_key_on_logout()") + .expect("logout must retire the remote MCP credential"); + let api_logout = body + .find("/api/auth/logout") + .expect("logout must still call the logout endpoint"); + let clear = body + .find("clear_auth()") + .expect("logout must still clear the session"); + assert!( + retire < api_logout, + "revoke before the server closes the session" + ); + assert!(retire < clear, "revoke before the tokens are deleted"); + } + + #[test] + fn session_expiry_forgets_the_remote_credential_without_a_network_revoke() { + // The automatic twin of logout: the refresh loop gave up, so the session + // is already dead and there is nothing to revoke with. The local copy + // still has to go, or the agents keep presenting a key that belongs to an + // account this machine is no longer signed in to. + let source = include_str!("cloud_auth.rs"); + let expiry = source + .split("pub async fn invalidate_session(&self)") + .nth(1) + .expect("invalidate_session must exist"); + let body = &expiry[..expiry.find("\n }").unwrap_or(expiry.len())]; + let forget = body + .find("forget_mcp_key_locally(") + .expect("invalidate_session must forget the remote MCP credential"); + let clear = body + .find("clear_auth()") + .expect("invalidate_session must still clear the session"); + assert!( + forget < clear, + "forget the credential before the auth state is torn down" + ); + assert!( + !body.contains("revoke_mcp_key("), + "a dead session has nothing to revoke with; the call could only fail" + ); + } + #[test] fn only_the_device_rules_read_as_a_restriction() { assert!(is_device_restriction( @@ -1679,4 +2170,45 @@ mod tests { "Wayfern token request failed (500 Internal Server Error): " )); } + + fn usage_response(recurring: Option, extra: Option) -> ProxyUsageResponse { + ProxyUsageResponse { + used_mb: 40, + limit_mb: 600, + remaining_mb: 560, + recurring_limit_mb: recurring, + extra_limit_mb: extra, + } + } + + #[test] + fn each_half_of_the_proxy_limit_falls_back_on_its_own() { + let both = merge_proxy_usage(&usage_response(Some(500), Some(100)), 400, 0); + assert_eq!(both.used_mb, 40); + assert_eq!(both.limit_mb, 600); + assert_eq!(both.remaining_mb, 560); + assert_eq!(both.recurring_limit_mb, 500); + assert_eq!(both.extra_limit_mb, 100); + + // A backend that does not report the split at all keeps the cached one. + let neither = merge_proxy_usage(&usage_response(None, None), 500, 100); + assert_eq!(neither.recurring_limit_mb, 500); + assert_eq!(neither.extra_limit_mb, 100); + + // An omitted extra half must not be read off the recurring half, which is + // how a cached 100 MB top-up used to vanish from the split. + let recurring_only = merge_proxy_usage(&usage_response(Some(500), None), 400, 100); + assert_eq!(recurring_only.recurring_limit_mb, 500); + assert_eq!(recurring_only.extra_limit_mb, 100); + + // A fresh extra allowance survives a recurring half of zero. + let extra_only = merge_proxy_usage(&usage_response(Some(0), Some(250)), 500, 100); + assert_eq!(extra_only.recurring_limit_mb, 0); + assert_eq!(extra_only.extra_limit_mb, 250); + + // And a spent top-up reported as a live zero is not resurrected from cache. + let spent_extra = merge_proxy_usage(&usage_response(Some(500), Some(0)), 500, 100); + assert_eq!(spent_extra.recurring_limit_mb, 500); + assert_eq!(spent_extra.extra_limit_mb, 0); + } } diff --git a/src-tauri/src/cloud_errors.rs b/src-tauri/src/cloud_errors.rs index 1561529..9ab3e71 100644 --- a/src-tauri/src/cloud_errors.rs +++ b/src-tauri/src/cloud_errors.rs @@ -1,4 +1,4 @@ -//! Turning a donutbrowser-infra HTTP failure into a stable, translatable code. +//! Turning a cloud API HTTP failure into a stable, translatable code. //! //! Every cloud transport in this crate flattens its failures through //! `api_call_with_retry`, which needs a `String` so it can sniff for a 401. @@ -55,7 +55,7 @@ pub struct FailureCodes { /// The desktop has no cloud session at all. pub const NOT_SIGNED_IN: &str = "CLOUD_NOT_SIGNED_IN"; -/// The request never reached donutbrowser-infra. +/// The request never reached the cloud API. pub const UNREACHABLE: &str = "CLOUD_UNREACHABLE"; /// The backend answered, but with nothing the user can act on. pub const UNAVAILABLE: &str = "CLOUD_REQUEST_FAILED"; @@ -243,8 +243,8 @@ mod tests { #[test] fn the_backends_own_code_wins_over_the_status_default() { - // The status table is a fallback for gateway pages. When infra names the - // failure, that name is the one the user's locale has a string for. + // The status table is a fallback for gateway pages. When the server names + // the failure, that name is the one the user's locale has a string for. let failure = classify(403, r#"{"code":"COOKIE_BOT_NOT_ENTITLED"}"#, CODES); assert_eq!(failure.code, "COOKIE_BOT_NOT_ENTITLED"); assert_eq!(failure.status, 403); @@ -261,8 +261,8 @@ mod tests { #[test] fn capacity_and_rate_limits_are_never_reported_as_a_fault() { - // 503 is "come back in a minute" — the fleet is four Windows hosts wide, - // so a busy fleet is normal and must not look like an outage. + // 503 is "come back in a minute" — remote capacity is finite, so a busy + // period is normal and must not look like an outage. assert_eq!(classify(503, "", CODES).code, NO_CAPACITY); assert_eq!(classify(429, "", CODES).code, RATE_LIMITED); } @@ -298,10 +298,10 @@ mod tests { #[test] fn nested_params_are_read_because_that_is_the_shape_cookie_bot_sends() { - // `body(code, params)` in cookie-bot.errors.ts returns `{code, params}`, - // which Nest serialises verbatim. Reading only the top level dropped every - // interpolated value: the timezone the user typed, the site limit, the - // hours a team had actually spent. + // The cookie-bot routes send every interpolated value nested under + // `params`. Reading only the top level dropped every one of them: the + // timezone the user typed, the site limit, the hours a team had actually + // spent. let failure = classify( 400, r#"{"code":"COOKIE_BOT_INVALID_TIMEZONE","params":{"timezone":"Europe/Nowhere"}}"#, diff --git a/src-tauri/src/cookie_bot.rs b/src-tauri/src/cookie_bot.rs index a7d846a..877d530 100644 --- a/src-tauri/src/cookie_bot.rs +++ b/src-tauri/src/cookie_bot.rs @@ -1,10 +1,8 @@ //! Cookie-bot transport. //! -//! The bot warms a profile's cookies overnight by driving it on a leased -//! remote host. NONE of that lives here: the schedule, the calendar maths, the -//! preset expansion, the site ordering, the dwell and scroll model, the pooled -//! budget and the nightly dispatcher are all held by donutbrowser-infra and -//! the Wayfern manager. +//! The bot warms a profile's cookies overnight by driving it on a remote host. +//! None of that behaviour lives here: the schedule and everything the bot +//! actually does are owned by the cloud API. //! //! This module is the wire only. It sends the user's own scalars — when to //! run, for how long, which of their sites, which server-issued preset id — @@ -19,10 +17,10 @@ use serde::{Deserialize, Serialize}; use std::sync::OnceLock; use std::time::Duration; -/// Operating systems the fleet can lease. Linux is refused by the manager, so -/// refusing it here turns a nightly failure at 02:00 into a refusal at the -/// moment the user picks the profile. -pub const BOT_PLATFORMS: [&str; 2] = ["windows", "macos"]; +/// Operating systems a remote run can be scheduled on. Anything else (a mobile +/// OS, a typo) has no host, so it is refused here rather than as a failed run +/// at 02:00. +pub const BOT_PLATFORMS: [&str; 3] = ["windows", "macos", "linux"]; const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); @@ -139,7 +137,7 @@ pub struct CookieBotSchedule { #[serde(default)] pub slots: Vec, pub timezone: String, - /// Server-issued preset id. Opaque here — what it expands to is infra's. + /// Server-issued preset id. Opaque here — what it expands to is the server's. pub preset: String, /// The template the sites came from, or `None` for the user's own list. /// @@ -164,9 +162,9 @@ pub struct CookieBotSchedule { pub encrypted_sync: bool, #[serde(default)] pub has_proxy: bool, - /// Whether that exit is one a leased fleet host could dial. Defaults to false - /// on an older server that does not send it, which reads as "not reachable" - /// and is the safe direction. + /// Whether that exit is one a remote host could dial. Defaults to false on an + /// older server that does not send it, which reads as "not reachable" and is + /// the safe direction. #[serde(default)] pub proxy_remote_reachable: bool, #[serde(default)] @@ -252,7 +250,7 @@ pub struct CookieBotScheduleInput { // Defaulting is safe in exactly one direction: `bool::default()` is false, so // an unstamped input reads as "no sync, no proxy" and is REFUSED. The failure // this must never have is the opposite one, a defaulted `has_proxy: true` - // warming a profile out of the fleet's own datacenter address. + // warming a profile out of the remote host's own address. #[serde(default)] pub sync_enabled: bool, #[serde(default)] @@ -344,8 +342,8 @@ pub struct CookieBotRun { #[serde(default)] pub max_minutes: u32, /// How many browser sessions this night is split into, and which one is - /// running. A night longer than one session's cap is checkpointed at each - /// boundary, and "chunk 2 of 3" is the only honest way to report that. + /// running. "chunk 2 of 3" is the only honest way to report a night the + /// server split. #[serde(default)] pub chunks_total: u32, #[serde(default)] @@ -403,10 +401,8 @@ pub struct CookieBotPreset { /// A server-owned browsing template: a named answer to "what is this profile /// for", which the user picks INSTEAD of typing a site list. /// -/// Carries no URLs, and must not gain any. The pool a template draws from is -/// server-side for the same reason a preset's browsing model is: a published -/// list is one a retailer can filter, and each profile is given its own sample -/// so the template never becomes a fleet-wide fingerprint. +/// Carries no URLs, and must not gain any: the site pool a template draws from +/// is server-owned, and a published list is one a retailer can filter. #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct CookieBotTemplate { pub id: String, @@ -506,8 +502,8 @@ pub struct RemoteHoursMember { pub bot_hours: f64, } -/// The single pooled remote-hour budget. Bot and interactive hours share it; -/// the breakdown is reporting, never a sub-cap. +/// The remote-hour budget as the server reports it, with the bot/interactive +/// breakdown it sends. #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct RemoteHoursQuota { pub granted_hours: f64, @@ -606,15 +602,15 @@ pub struct CookieBotUsage { /// /// The server is authoritative — it re-checks all of this and owns the parts /// the client cannot see — but a profile that can never qualify should never -/// reach a confirm dialog, an hour of quota or a leased host. Returns the +/// reach a confirm dialog, an hour of quota or a remote host. Returns the /// `{"code":…}` string a Tauri command surfaces directly. pub fn bot_precondition( profile: &BrowserProfile, exit: &crate::remote_exit::ExitReachability, ) -> Result<(), String> { if !profile.is_sync_enabled() { - // The host materialises the profile by pulling it from donut-sync. A - // local-only profile has nothing there, so there is no path to a run. + // A remote run obtains the profile through sync, so a local-only profile + // has nothing there and there is no path to a run. return Err(error("COOKIE_BOT_REQUIRES_CLOUD_SYNC", &[])); } if profile.is_encrypted_sync() { @@ -632,16 +628,16 @@ pub fn bot_precondition( )); } if profile.proxy_id.is_none() && profile.vpn_id.is_none() { - // Without one the run egresses from the fleet's own datacenter address. - // Hours of traffic from a hosting ASN is worse for the profile's identity - // than not warming it at all. + // Without one the run egresses from the remote host's own address instead + // of the user's exit, which is worse for the profile's identity than not + // warming it at all. return Err(error("COOKIE_BOT_REQUIRES_EXIT_NODE", &[])); } - // ...and the exit has to be one the leased host can reach. The profile and its - // proxy record are pulled onto the fleet with no address rewriting, so - // 127.0.0.1 arrives meaning THAT host's loopback — an ordinary mistake (an SSH - // tunnel, a local MITM proxy, a locally-run SOCKS client), and by the time the - // run fails an hour has been leased and billed. + // ...and the exit has to be one a remote host can reach. Addresses are not + // rewritten in transit, so a proxy recorded as 127.0.0.1 arrives meaning THAT + // machine's own loopback — an ordinary mistake (an SSH tunnel, a local MITM + // proxy, a locally-run SOCKS client) that costs the user an hour of quota + // before it fails. // // Taken as an ARGUMENT rather than resolved here, for the same reason // `ProfileState` is required rather than defaulted: resolving it needs the @@ -649,6 +645,18 @@ pub fn bot_precondition( // no test can set up and every caller silently depends on. `exit_reachability` // is the one place that resolution happens; this stays a pure predicate over // facts it is handed. + // A protocol a remote host cannot speak is a PERMANENT refusal, and it has to + // say so in its own words. A VLESS server is publicly routable, so the + // reachability question answers "yes" and the older message ("use a proxy with + // a public address") sends the user to fix an address that was never wrong; an + // enrolment accepted on that answer then fails remotely, once per scheduled + // run, until someone notices. + if let crate::remote_exit::ExitReachability::UnsupportedKind { kind, .. } = exit { + return Err(error( + "COOKIE_BOT_PROXY_KIND_UNSUPPORTED", + &[("kind", kind.as_str())], + )); + } if !exit.is_remote() { return Err(error("COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE", &[])); } @@ -660,8 +668,8 @@ pub fn bot_precondition( /// The server holds the schedule; the PROFILE lives in the user's sync /// namespace, so `sync_enabled`, `has_proxy` and the rest are only knowable /// here. It requires them on every write rather than defaulting them, because -/// a defaulted `has_proxy` is a profile warmed out of the fleet's own -/// datacenter address. +/// a defaulted `has_proxy` is a profile warmed out of the remote host's own +/// address. /// /// Derived in one place so the Tauri, REST and MCP call sites cannot drift into /// three different answers about the same profile. @@ -674,21 +682,20 @@ pub fn profile_state(profile: &BrowserProfile) -> ProfileState { has_proxy: profile.proxy_id.is_some() || profile.vpn_id.is_some(), // ...and, separately, whether anyone OTHER than this machine could use it. // `has_proxy` answers "did the user bring an exit"; this answers "is that - // exit an address a leased host can dial". They disagree for every local - // proxy, which is the case that used to be accepted and then fail on the - // fleet. See `remote_exit`. + // exit an address a remote host can dial". They disagree for every local + // proxy, which is the case that used to be accepted and then fail remotely. + // See `remote_exit`. proxy_remote_reachable: exit_reachability(profile).is_remote(), // Always false: this data model has no mobile/touch profile. `resolved_os` - // yields only windows, macos or linux, and `bot_precondition` already - // refuses everything but the first two. Reported rather than omitted so the - // server keeps one required shape, and it stays authoritative — it sees the - // real fingerprint on the host and can still refuse a run this cannot know - // to reject. + // yields only windows, macos or linux, and all three are supported + // remotely. Reported rather than omitted so the server keeps one + // required shape, and it stays authoritative: it sees the real fingerprint + // on the host and can still refuse a run this cannot know to reject. touch_fingerprint: false, // A VPN is one persistent tunnel, so the night's chunks share an exit. A // stored proxy may rotate per connection, and claiming stickiness we cannot - // guarantee is worse than declining it: the server's fallback is to run the - // night as a single chunk, which is the safe answer either way. + // guarantee is worse than declining it, so the conservative answer is the + // safe one either way. sticky_exit: profile.vpn_id.is_some(), } } @@ -700,7 +707,7 @@ pub struct ProfileState { pub sync_enabled: bool, pub encrypted_sync: bool, pub has_proxy: bool, - /// Whether that exit is an address a leased fleet host can dial. + /// Whether that exit is an address a remote host can dial. pub proxy_remote_reachable: bool, pub touch_fingerprint: bool, pub sticky_exit: bool, @@ -713,7 +720,8 @@ pub struct ProfileState { /// even then would have to re-derive what the browser will actually dial. /// /// A profile carrying BOTH a proxy and a VPN is judged on the proxy: that is -/// what the browser is pointed at, and it is the address the fleet has to reach. +/// what the browser is pointed at, and it is the address a remote host has to +/// reach. pub fn exit_reachability(profile: &BrowserProfile) -> crate::remote_exit::ExitReachability { use crate::remote_exit::{classify_proxy, classify_wireguard_endpoint, ExitReachability}; @@ -916,7 +924,7 @@ pub async fn update_profile_state( /// /// The server refuses a run on the copy the desktop last declared — /// `has_proxy: false` is `proxy_required`, and that check exists because a run -/// without an exit node egresses from the leased host's own datacenter address. +/// without an exit node egresses from the remote host's own address. /// Nothing but a full schedule write refreshed that copy, so detaching a proxy /// from an enrolled profile left `has_proxy: true` on the row and the night ran /// anyway. This closes that gap at the moment the profile changes. @@ -1046,10 +1054,10 @@ pub async fn run_now( /// Stop a run that is still going. /// -/// A 503 here means the fleet could not be reached and the browser is still -/// up, so the run stays `running` rather than being marked cancelled under a -/// live browser — retiring a row while something is still writing the cookie -/// jar is the two-writer case the profile lock exists to prevent. +/// A 503 here means the remote host could not be reached and the browser is +/// still up, so the run stays `running` rather than being marked cancelled +/// under a live browser — retiring a row while something is still writing the +/// cookie jar is the two-writer case the profile lock exists to prevent. pub async fn cancel_run(run_id: &str) -> Result { let envelope: RunEnvelope = request( reqwest::Method::DELETE, @@ -1294,7 +1302,7 @@ fn http() -> &'static reqwest::Client { /// /// Built here rather than left to the HTTP client so a profile id or a keyset /// cursor containing a `&` cannot smuggle a second parameter into the request. -fn with_query(url: &str, query: &[(String, String)]) -> String { +pub(crate) fn with_query(url: &str, query: &[(String, String)]) -> String { if query.is_empty() { return url.to_string(); } @@ -1430,9 +1438,9 @@ mod tests { #[test] fn a_local_only_profile_has_no_path_to_a_run() { - // The host obtains the profile from donut-sync. Without sync there is - // nothing to pull, so the run would warm an empty browser and then push - // that emptiness over the user's real profile. + // A remote run obtains the profile through sync, so a local-only profile + // has nothing there: the run would warm an empty browser and then push that + // emptiness over the user's real profile. let mut profile = eligible_profile(); profile.sync_mode = SyncMode::Disabled; let err = bot_precondition(&profile, &ExitReachability::Remote) @@ -1452,19 +1460,37 @@ mod tests { } #[test] - fn linux_is_refused_at_enrolment_rather_than_at_two_in_the_morning() { + fn an_os_the_fleet_cannot_lease_is_refused_at_enrolment_rather_than_at_two_in_the_morning() { let mut profile = eligible_profile(); - profile.host_os = Some("linux".to_string()); + profile.host_os = Some("android".to_string()); let err = bot_precondition(&profile, &ExitReachability::Remote) - .expect_err("linux has no host to lease"); + .expect_err("android has no host to lease"); let parsed: serde_json::Value = serde_json::from_str(&err).expect("valid envelope"); assert_eq!(parsed["code"], "COOKIE_BOT_UNSUPPORTED_PLATFORM"); assert_eq!( - parsed["params"]["platform"], "linux", + parsed["params"]["platform"], "android", "the message must name the platform that cannot run" ); } + #[test] + fn a_linux_profile_passes_the_platform_check() { + // Linux is a supported remote platform, so a linux profile is judged on + // the same preconditions as the other two rather than refused for its OS. + let mut profile = eligible_profile(); + profile.host_os = Some("linux".to_string()); + assert!(bot_precondition(&profile, &ExitReachability::Remote).is_ok()); + + // ...and it reaches the NEXT precondition when it fails one: the refusal a + // linux profile with no exit gets is the exit-node code, not the platform + // code. + profile.proxy_id = None; + profile.vpn_id = None; + let err = bot_precondition(&profile, &ExitReachability::None) + .expect_err("datacenter egress must be refused for linux as for any OS"); + assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_EXIT_NODE"); + } + #[test] fn a_profile_with_no_recorded_os_cannot_be_scheduled_onto_a_host() { let mut profile = eligible_profile(); @@ -1522,10 +1548,32 @@ mod tests { assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE"); } + #[test] + fn a_proxy_kind_the_fleet_cannot_dial_gets_its_own_refusal() { + // The repeated-nightly-failure case. This must NOT collapse into + // REQUIRES_REMOTE_EXIT_NODE: that sentence tells the user their proxy's + // address is unreachable, and a VLESS server's address is perfectly + // reachable — the fix is a different protocol, not a different address. + let err = bot_precondition( + &eligible_profile(), + &ExitReachability::UnsupportedKind { + kind: "VLESS".to_string(), + source: "proxy", + }, + ) + .expect_err("no fleet host runs the xray sidecar VLESS needs"); + + assert_eq!(code_of(&err), "COOKIE_BOT_PROXY_KIND_UNSUPPORTED"); + // The protocol travels in `params` so the sentence can name it rather than + // saying "this proxy type" and leaving the user to guess which one. + let parsed: serde_json::Value = serde_json::from_str(&err).expect("an error envelope"); + assert_eq!(parsed["params"]["kind"], "VLESS"); + } + #[test] fn an_exit_we_could_not_read_is_refused_too() { // Fails closed. Refusing a working setup costs one support question; - // accepting a broken one burns a leased hour and damages an identity. + // accepting a broken one burns an hour of quota and damages an identity. let err = bot_precondition( &eligible_profile(), &ExitReachability::Unknown { @@ -1538,8 +1586,7 @@ mod tests { assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE"); } - /// A verbatim `CookieBotScheduleView`, field for field, as `toScheduleView` - /// in donutbrowser-infra's `cookie-bot.service.ts` builds it. + /// A verbatim schedule payload, field for field, as the cloud API sends it. const SERVER_SCHEDULE_VIEW: &str = r#"{ "profile_id":"p1","profile_name":"Yu","platform":"macos","enabled":true, "run_at_minute":120,"days_mask":127, @@ -1555,10 +1602,9 @@ mod tests { #[test] fn the_schedule_payload_matches_what_the_backend_sends() { - // Pinned against the Schedule shape in donutbrowser-infra's - // cookie-bot controller. A field name that drifts makes every read fail - // at the decode step, which surfaces as "something went wrong" with no - // hint that the contract moved. + // Pinned against the schedule shape the cloud API serves. A field name + // that drifts makes every read fail at the decode step, which surfaces as + // "something went wrong" with no hint that the contract moved. let schedule: CookieBotSchedule = serde_json::from_str(SERVER_SCHEDULE_VIEW) .expect("the backend's schedule payload must deserialize"); @@ -1737,7 +1783,7 @@ mod tests { #[test] fn the_run_payload_matches_what_the_backend_sends() { - // Verbatim `CookieBotRunView`, as `toRunViews` builds it. `max_minutes`, + // Verbatim run payload, exactly as the cloud API serves it. `max_minutes`, // `chunks_total`, `chunk_index`, `dispatch_after` and `team_id` were all // already on the wire and all silently discarded, so a multi-chunk night // could not be reported as one. @@ -1968,9 +2014,9 @@ mod tests { #[test] fn the_preset_list_carries_ids_not_behaviour() { - // If this type ever gained a site list, a dwell range or a step - // programme, the browsing model would have leaked into the open-source - // client. Ids and a rough duration are all that may cross. + // If this type ever gained the parameters that describe what a preset + // actually does, the server-owned browsing model would have leaked into the + // open-source client. Ids and a rough duration are all that may cross. let presets: CookieBotPresetList = serde_json::from_str( r#"{"presets":[{"id":"balanced","typical_minutes":35,"recommended":true}], "default_preset":"balanced"}"#, @@ -1988,9 +2034,9 @@ mod tests { #[test] fn a_template_crosses_the_wire_as_a_count_and_never_as_urls() { - // The pool is server-owned for the same reason a preset's browsing model - // is. If this type ever gained a `sites` field the curation would be - // published, and a published list is one a retailer can filter. + // The site pool is server-owned. If this type ever gained a `sites` field + // the curation would be published, and a published list is one a retailer + // can filter. let presets: CookieBotPresetList = serde_json::from_str( r#"{"presets":[],"default_preset":"balanced", "templates":[{"id":"low-intent-purchaser","site_count":32, diff --git a/src-tauri/src/data_root.rs b/src-tauri/src/data_root.rs new file mode 100644 index 0000000..be03d8a --- /dev/null +++ b/src-tauri/src/data_root.rs @@ -0,0 +1,1036 @@ +//! Moving Donut's data directory to another volume. +//! +//! Everything the app keeps lives under `app_dirs::data_dir()`: profiles, +//! downloaded browser binaries, settings, proxies, VPNs and extensions. A fleet +//! outgrows a small system disk long before it outgrows the machine, so the +//! directory has to be movable without hand-editing anything. +//! +//! The move is **copy, verify, then delete**, in that order and never any +//! other. A `rename` across volumes is not atomic and can leave half a profile +//! at each end; a delete before the copy is proven can lose the only copy of a +//! logged-in profile. The pointer that decides which directory the next start +//! uses is written only after verification passes, so a process killed at any +//! point still starts on a directory that is whole. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; + +use serde::{Deserialize, Serialize}; +use tauri::Emitter; + +/// Emitted while a move runs so the page can show real progress. +pub const MOVE_PROGRESS_EVENT: &str = "data-root-move-progress"; + +/// Sample files compared byte for byte after the copy, on top of the file count +/// and total size. Enough to catch a truncating or silently-failing filesystem +/// without re-reading tens of gigabytes. +const VERIFY_SAMPLE_SIZE: usize = 12; + +/// How much of a sampled file is compared when it is too big to read whole. +/// The head and the tail together catch both a truncated write and a copy that +/// never started. +const SAMPLE_EDGE_BYTES: u64 = 1024 * 1024; +const SAMPLE_WHOLE_FILE_LIMIT: u64 = 4 * 1024 * 1024; + +/// One move at a time. Two concurrent moves would interleave two copies into +/// one destination and then race to delete the same source. +static MOVE_RUNNING: AtomicBool = AtomicBool::new(false); + +/// Set once a move succeeds. The running process keeps using the old directory +/// (every path was resolved at startup), so the page has to say so out loud. +static RESTART_REQUIRED: AtomicBool = AtomicBool::new(false); + +fn code(code: &str) -> String { + serde_json::json!({ "code": code }).to_string() +} + +fn code_with(code: &str, params: serde_json::Value) -> String { + serde_json::json!({ "code": code, "params": params }).to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DataRootInfo { + /// The directory this process is actually using. + pub active_path: String, + /// The directory recorded for the next start, when one was chosen. + pub configured_path: Option, + /// Where the directory would resolve with nothing chosen. + pub default_path: String, + /// Bytes under `active_path`. + pub size_bytes: u64, + /// Regular files under `active_path`. + pub file_count: u64, + /// True when `DONUTBROWSER_DATA_DIR` decides the directory, so a choice made + /// here would be recorded and then ignored. + pub overridden_by_environment: bool, + /// True once a move has completed in this process. + pub restart_required: bool, + /// The folder name a destination gets, so the page can show the full path it + /// is about to move to before the user commits. + pub app_directory_name: String, + /// The recorded directory is not there right now, which is what an + /// unplugged external drive looks like. + /// + /// The app deliberately keeps pointing at it rather than quietly starting + /// empty somewhere else: plugging the drive back in has to restore + /// everything, and a silent fallback is how a person concludes their + /// profiles are gone. + /// + /// Never true straight after a move. The old directory is *supposed* to be + /// gone then, and reporting that as a fault would tell somebody their move + /// had broken something the moment it succeeded. + pub active_path_missing: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MoveProgress { + /// `scanning`, `copying`, `verifying`, `cleaning` or `done`. + pub phase: String, + pub copied_files: u64, + pub total_files: u64, + pub copied_bytes: u64, + pub total_bytes: u64, + pub destination: String, +} + +/// What a walk of the source found. `bytes` counts regular files only: +/// directories and symlinks have a size that means nothing here and would make +/// the free-space estimate and the verification disagree across platforms. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(crate) struct TreeScan { + pub files: u64, + pub bytes: u64, + pub directories: u64, + pub symlinks: u64, +} + +/// The facts a refusal is decided from, gathered before anything is copied. +pub(crate) struct MovePreconditions<'a> { + pub source: &'a Path, + pub destination: &'a Path, + pub browser_running: bool, + pub sync_in_progress: bool, + pub required_bytes: u64, + pub available_bytes: u64, +} + +/// Every refusal that can be decided without touching the disk. +/// +/// Ordered by how much the user can do about it: a destination that is the +/// current directory, or sits inside it, is a mistake in the request itself; +/// a running browser or a live sync is a "not now"; space is last because it +/// is the only one that needs the source measured first. +pub(crate) fn check_move_preconditions(p: &MovePreconditions) -> Result<(), String> { + if paths_equal(p.source, p.destination) { + return Err(code("DATA_ROOT_SAME_AS_CURRENT")); + } + if is_inside(p.destination, p.source) { + // Copying a directory into itself never terminates, and deleting the + // source afterwards would delete the copy with it. + return Err(code("DATA_ROOT_DESTINATION_INSIDE_SOURCE")); + } + if p.browser_running { + return Err(code("DATA_ROOT_BROWSER_RUNNING")); + } + if p.sync_in_progress { + return Err(code("DATA_ROOT_SYNC_IN_PROGRESS")); + } + if p.available_bytes < p.required_bytes { + return Err(code_with( + "DATA_ROOT_INSUFFICIENT_SPACE", + serde_json::json!({ + "required": human_bytes(p.required_bytes), + "available": human_bytes(p.available_bytes), + }), + )); + } + Ok(()) +} + +/// Bytes as a person reads them, for the one refusal that has to quote a size. +/// +/// The unit symbols are the same in every language Donut ships, so the sentence +/// around them is translated and the figure is not. It is written here rather +/// than in the frontend because `backend-errors.ts` is loaded by a bare +/// `node --test` run and cannot import anything of ours. +pub(crate) fn human_bytes(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + if bytes < KB { + return format!("{bytes} B"); + } + if bytes < MB { + return format!("{:.1} KB", bytes as f64 / KB as f64); + } + if bytes < GB { + return format!("{:.1} MB", bytes as f64 / MB as f64); + } + format!("{:.2} GB", bytes as f64 / GB as f64) +} + +/// Compare two paths without needing either to exist. `canonicalize` is used +/// when it works (it resolves `..`, symlinks and case on macOS), and the +/// lexical form is the fallback for a destination that has not been created. +fn paths_equal(a: &Path, b: &Path) -> bool { + match (a.canonicalize(), b.canonicalize()) { + (Ok(a), Ok(b)) => a == b, + _ => normalized(a) == normalized(b), + } +} + +/// True when `inner` is `outer` itself or sits below it. +pub(crate) fn is_inside(inner: &Path, outer: &Path) -> bool { + let (inner, outer) = match (inner.canonicalize(), outer.canonicalize()) { + (Ok(i), Ok(o)) => (i, o), + _ => (normalized(inner), normalized(outer)), + }; + inner.starts_with(&outer) +} + +/// Lexical `..`/`.` removal, so `/a/b/../b/c` and `/a/b/c` compare equal even +/// when neither exists yet. +fn normalized(path: &Path) -> PathBuf { + use std::path::Component; + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + other => out.push(other.as_os_str()), + } + } + out +} + +/// Prove the destination can be written to before a single byte is copied. +/// Creating the directory is part of the probe: a parent that refuses `mkdir` +/// is exactly as unusable as one that refuses a write. +pub(crate) fn probe_writable(destination: &Path) -> Result<(), String> { + if let Err(e) = std::fs::create_dir_all(destination) { + log::warn!( + "Cannot use {} as a data directory: {e}", + destination.display() + ); + return Err(code("DATA_ROOT_DESTINATION_NOT_WRITABLE")); + } + let probe = destination.join(".donut-write-probe"); + match std::fs::write(&probe, b"donut") { + Ok(()) => { + let _ = std::fs::remove_file(&probe); + Ok(()) + } + Err(e) => { + log::warn!("Cannot write inside {}: {e}", destination.display()); + Err(code("DATA_ROOT_DESTINATION_NOT_WRITABLE")) + } + } +} + +/// Refuse a destination that already holds files. Merging into somebody's +/// folder makes the count-and-size verification meaningless and makes the +/// delete that follows impossible to reason about. +pub(crate) fn ensure_empty(destination: &Path) -> Result<(), String> { + let Ok(entries) = std::fs::read_dir(destination) else { + return Ok(()); + }; + for entry in entries.flatten() { + if entry.file_name() == ".donut-write-probe" { + continue; + } + return Err(code("DATA_ROOT_DESTINATION_NOT_EMPTY")); + } + Ok(()) +} + +/// Walk a tree, counting regular files, their bytes, directories and symlinks. +/// +/// Symlinks are counted but never followed: a link out of the data directory +/// would pull unrelated data into the copy, and a link back into it would loop. +pub(crate) fn scan_tree(root: &Path) -> std::io::Result { + let mut scan = TreeScan::default(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + let meta = std::fs::symlink_metadata(&path)?; + if meta.file_type().is_symlink() { + scan.symlinks += 1; + } else if meta.is_dir() { + scan.directories += 1; + stack.push(path); + } else { + scan.files += 1; + scan.bytes += meta.len(); + } + } + } + Ok(scan) +} + +/// Free bytes on the volume holding `path`, found by the longest mount point +/// that is a prefix of it. `None` when no mount point matches, which is not a +/// reason to refuse a move: an unknown figure is not a small one. +pub(crate) fn available_space(path: &Path) -> Option { + let disks = sysinfo::Disks::new_with_refreshed_list(); + let target = normalized(path); + let mut best: Option<(usize, u64)> = None; + for disk in disks.list() { + let mount = disk.mount_point(); + if !target.starts_with(mount) { + continue; + } + let depth = mount.components().count(); + if best.is_none_or(|(previous, _)| depth > previous) { + best = Some((depth, disk.available_space())); + } + } + best.map(|(_, free)| free) +} + +/// Copy `source` into `destination`, reporting progress and collecting the +/// sample the verification re-reads. +fn copy_tree( + source: &Path, + destination: &Path, + total: &TreeScan, + report: &mut dyn FnMut(&str, u64, u64), +) -> std::io::Result> { + let stride = (total.files / VERIFY_SAMPLE_SIZE as u64).max(1); + let mut samples: Vec = Vec::new(); + let mut copied_files = 0u64; + let mut copied_bytes = 0u64; + let mut stack = vec![PathBuf::new()]; + + while let Some(relative) = stack.pop() { + let from = source.join(&relative); + let to = destination.join(&relative); + std::fs::create_dir_all(&to)?; + for entry in std::fs::read_dir(&from)? { + let entry = entry?; + let name = entry.file_name(); + let child = relative.join(&name); + let path = entry.path(); + let meta = std::fs::symlink_metadata(&path)?; + if meta.file_type().is_symlink() { + copy_symlink(&path, &destination.join(&child))?; + } else if meta.is_dir() { + stack.push(child); + } else { + std::fs::copy(&path, destination.join(&child))?; + copied_files += 1; + copied_bytes += meta.len(); + if copied_files.is_multiple_of(stride) && samples.len() < VERIFY_SAMPLE_SIZE { + samples.push(child); + } + if copied_files.is_multiple_of(200) { + report("copying", copied_files, copied_bytes); + } + } + } + } + report("copying", copied_files, copied_bytes); + Ok(samples) +} + +/// Recreate a symlink at the destination rather than following it. +#[cfg(unix)] +fn copy_symlink(from: &Path, to: &Path) -> std::io::Result<()> { + let target = std::fs::read_link(from)?; + if to.exists() { + let _ = std::fs::remove_file(to); + } + std::os::unix::fs::symlink(target, to) +} + +#[cfg(windows)] +fn copy_symlink(from: &Path, to: &Path) -> std::io::Result<()> { + // Creating a symlink on Windows needs a privilege a normal user does not + // have, so the link's contents are copied instead. The entry still exists at + // the same path, which is what the verification checks. + let metadata = std::fs::metadata(from)?; + if metadata.is_dir() { + std::fs::create_dir_all(to) + } else { + std::fs::copy(from, to).map(|_| ()) + } +} + +/// Compare a copied file with its source. Whole files up to +/// `SAMPLE_WHOLE_FILE_LIMIT`; head and tail beyond that, so a 4 GB browser +/// archive is still checked at both ends without being read twice over. +fn same_contents(a: &Path, b: &Path) -> std::io::Result { + use std::io::{Read, Seek, SeekFrom}; + let mut left = std::fs::File::open(a)?; + let mut right = std::fs::File::open(b)?; + let left_len = left.metadata()?.len(); + let right_len = right.metadata()?.len(); + if left_len != right_len { + return Ok(false); + } + if left_len <= SAMPLE_WHOLE_FILE_LIMIT { + let mut left_buf = Vec::new(); + let mut right_buf = Vec::new(); + left.read_to_end(&mut left_buf)?; + right.read_to_end(&mut right_buf)?; + return Ok(left_buf == right_buf); + } + let mut left_buf = vec![0u8; SAMPLE_EDGE_BYTES as usize]; + let mut right_buf = vec![0u8; SAMPLE_EDGE_BYTES as usize]; + for offset in [0, left_len - SAMPLE_EDGE_BYTES] { + left.seek(SeekFrom::Start(offset))?; + right.seek(SeekFrom::Start(offset))?; + left.read_exact(&mut left_buf)?; + right.read_exact(&mut right_buf)?; + if left_buf != right_buf { + return Ok(false); + } + } + Ok(true) +} + +/// Prove the copy is complete before anything is deleted. +/// +/// The counts and the total size catch a lost or truncated file; re-reading the +/// sample catches a filesystem that reported a write it never made. +pub(crate) fn verify_copy( + source: &Path, + destination: &Path, + expected: &TreeScan, + samples: &[PathBuf], +) -> Result<(), String> { + let copied = scan_tree(destination).map_err(|e| { + log::error!("Could not read the copy at {}: {e}", destination.display()); + code_with( + "DATA_ROOT_VERIFY_FAILED", + serde_json::json!({ "detail": e.to_string() }), + ) + })?; + + if copied.files != expected.files || copied.bytes != expected.bytes { + log::error!( + "The copy at {} does not match {}: {} files / {} bytes against {} files / {} bytes", + destination.display(), + source.display(), + copied.files, + copied.bytes, + expected.files, + expected.bytes + ); + return Err(code_with( + "DATA_ROOT_VERIFY_FAILED", + serde_json::json!({ + "expectedFiles": expected.files.to_string(), + "copiedFiles": copied.files.to_string(), + "expectedBytes": expected.bytes.to_string(), + "copiedBytes": copied.bytes.to_string(), + }), + )); + } + + for relative in samples { + let from = source.join(relative); + let to = destination.join(relative); + match same_contents(&from, &to) { + Ok(true) => {} + Ok(false) => { + log::error!("{} did not copy faithfully", relative.display()); + return Err(code_with( + "DATA_ROOT_VERIFY_FAILED", + serde_json::json!({ "detail": relative.to_string_lossy() }), + )); + } + Err(e) => { + log::error!("Could not re-read {}: {e}", relative.display()); + return Err(code_with( + "DATA_ROOT_VERIFY_FAILED", + serde_json::json!({ "detail": e.to_string() }), + )); + } + } + } + Ok(()) +} + +/// True when any profile still has a live process. +fn any_browser_running() -> bool { + let Ok(profiles) = crate::profile::ProfileManager::instance().list_profiles() else { + // Unreadable profiles means an unknown answer, and an unknown answer must + // not clear the way for a move that deletes them. + log::warn!("Could not list profiles before a data directory move; assuming one is running"); + return true; + }; + profiles.into_iter().any(|profile| { + profile + .process_id + .is_some_and(|pid| pid != 0 && crate::proxy_storage::is_process_running(pid)) + }) +} + +async fn sync_in_progress() -> bool { + match crate::sync::get_global_scheduler() { + Some(scheduler) => scheduler.is_sync_in_progress().await, + None => false, + } +} + +fn info_now() -> DataRootInfo { + let active = crate::app_dirs::data_dir(); + let scan = scan_tree(&active).unwrap_or_default(); + let restart_required = RESTART_REQUIRED.load(Ordering::SeqCst); + DataRootInfo { + active_path_missing: !restart_required && !active.is_dir(), + active_path: active.to_string_lossy().to_string(), + configured_path: crate::app_dirs::read_data_root_pointer( + &crate::app_dirs::data_root_pointer_file(), + ) + .map(|path| path.to_string_lossy().to_string()), + default_path: crate::app_dirs::default_data_dir() + .to_string_lossy() + .to_string(), + size_bytes: scan.bytes, + file_count: scan.files, + overridden_by_environment: crate::app_dirs::data_dir_forced_by_environment(), + restart_required, + app_directory_name: crate::app_dirs::app_name().to_string(), + } +} + +/// Release the one-move-at-a-time flag however the move ends. +struct MoveGuard; + +impl Drop for MoveGuard { + fn drop(&mut self) { + MOVE_RUNNING.store(false, Ordering::SeqCst); + } +} + +async fn move_to( + app_handle: tauri::AppHandle, + destination: PathBuf, +) -> Result { + if MOVE_RUNNING.swap(true, Ordering::SeqCst) { + return Err(code("DATA_ROOT_MOVE_IN_PROGRESS")); + } + let _guard = MoveGuard; + + if !destination.is_absolute() || destination.as_os_str().is_empty() { + return Err(code("DATA_ROOT_DESTINATION_NOT_WRITABLE")); + } + + // The one fact that has to be read from the async side; everything after it + // is filesystem work. + let sync_running = sync_in_progress().await; + + // Copying a fleet is minutes of blocking IO. Left on a runtime worker it + // would stall every other task in the app — proxy workers, the sync + // scheduler, the event loop that carries the progress this very move emits. + match tokio::task::spawn_blocking(move || perform_move(app_handle, destination, sync_running)) + .await + { + Ok(result) => result, + Err(e) => { + log::error!("The data directory move task did not finish: {e}"); + Err(code_with( + "DATA_ROOT_COPY_FAILED", + serde_json::json!({ "detail": e.to_string() }), + )) + } + } +} + +/// The move itself, start to finish, on a blocking thread. +fn perform_move( + app_handle: tauri::AppHandle, + destination: PathBuf, + sync_running: bool, +) -> Result { + let source = crate::app_dirs::data_dir(); + let emit = |phase: &str, copied_files: u64, copied_bytes: u64, total: &TreeScan| { + let _ = app_handle.emit( + MOVE_PROGRESS_EVENT, + MoveProgress { + phase: phase.to_string(), + copied_files, + total_files: total.files, + copied_bytes, + total_bytes: total.bytes, + destination: destination.to_string_lossy().to_string(), + }, + ); + }; + + emit("scanning", 0, 0, &TreeScan::default()); + + let total = scan_tree(&source).map_err(|e| { + log::error!("Could not measure {}: {e}", source.display()); + code_with( + "DATA_ROOT_COPY_FAILED", + serde_json::json!({ "detail": e.to_string() }), + ) + })?; + + // The refusals that need no disk write come first, so a destination that was + // never going to be used is not created as a side effect of asking. + check_move_preconditions(&MovePreconditions { + source: &source, + destination: &destination, + browser_running: any_browser_running(), + sync_in_progress: sync_running, + required_bytes: total.bytes, + // An unreadable volume is not a full one: skip the check rather than + // refuse a move that would have worked. + available_bytes: available_space(&destination).unwrap_or(u64::MAX), + })?; + + probe_writable(&destination)?; + ensure_empty(&destination)?; + + let mut report = |phase: &str, files: u64, bytes: u64| emit(phase, files, bytes, &total); + + let samples = copy_tree(&source, &destination, &total, &mut report).map_err(|e| { + log::error!( + "Copying {} to {} failed: {e}", + source.display(), + destination.display() + ); + code_with( + "DATA_ROOT_COPY_FAILED", + serde_json::json!({ "detail": e.to_string() }), + ) + })?; + + emit("verifying", total.files, total.bytes, &total); + verify_copy(&source, &destination, &total, &samples)?; + + // Only now, with the copy proven whole, does the next start change where it + // looks. A process killed before this line still starts on the old directory. + crate::app_dirs::write_data_root_pointer( + &crate::app_dirs::data_root_pointer_file(), + &destination, + ) + .map_err(|e| { + log::error!("Could not record the new data directory: {e}"); + code_with( + "DATA_ROOT_COPY_FAILED", + serde_json::json!({ "detail": e.to_string() }), + ) + })?; + RESTART_REQUIRED.store(true, Ordering::SeqCst); + + emit("cleaning", total.files, total.bytes, &total); + if let Err(e) = std::fs::remove_dir_all(&source) { + // The move already succeeded: the pointer is written and the copy is + // verified. A source that will not delete is leftover disk, not a failure. + log::warn!( + "Moved the data directory to {} but could not remove {}: {e}", + destination.display(), + source.display() + ); + } + + emit("done", total.files, total.bytes, &total); + log::info!( + "Data directory moved to {} ({} files, {} bytes); it takes effect on the next start", + destination.display(), + total.files, + total.bytes + ); + Ok(info_now()) +} + +// --- Tauri commands --- + +#[tauri::command] +pub async fn get_data_root_info() -> Result { + // Walking a fleet's worth of profiles is not work for the UI thread. A join + // failure means the pool is gone, not that the answer is unknowable, so the + // same read runs here rather than inventing a second, emptier answer. + match tokio::task::spawn_blocking(info_now).await { + Ok(info) => Ok(info), + Err(e) => { + log::error!("Reading the data directory off-thread failed: {e}"); + Ok(info_now()) + } + } +} + +#[tauri::command] +pub async fn move_data_root( + app_handle: tauri::AppHandle, + destination: String, +) -> Result { + move_to(app_handle, PathBuf::from(destination)).await +} + +/// Forget a recorded directory so the next start uses the platform default +/// again. +/// +/// It moves nothing. The page offers it only when the recorded directory is +/// not there — an external drive that is gone for good — because that is the +/// one case where pointing at it is worse than starting fresh. +#[tauri::command] +pub async fn clear_data_root_choice() -> Result { + crate::app_dirs::clear_data_root_pointer(&crate::app_dirs::data_root_pointer_file()).map_err( + |e| { + log::error!("Could not clear the data directory choice: {e}"); + code_with( + "DATA_ROOT_COPY_FAILED", + serde_json::json!({ "detail": e.to_string() }), + ) + }, + )?; + RESTART_REQUIRED.store(true, Ordering::SeqCst); + Ok(info_now()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parsed_code(err: &str) -> String { + serde_json::from_str::(err) + .expect("errors are JSON") + .get("code") + .and_then(|c| c.as_str()) + .expect("errors carry a code") + .to_string() + } + + fn baseline<'a>(source: &'a Path, destination: &'a Path) -> MovePreconditions<'a> { + MovePreconditions { + source, + destination, + browser_running: false, + sync_in_progress: false, + required_bytes: 100, + available_bytes: 1000, + } + } + + fn write(path: &Path, bytes: &[u8]) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + } + + #[test] + fn a_clean_request_is_allowed() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let destination = temp.path().join("destination"); + std::fs::create_dir_all(&source).unwrap(); + assert!(check_move_preconditions(&baseline(&source, &destination)).is_ok()); + } + + #[test] + fn a_running_browser_stops_the_move() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let destination = temp.path().join("destination"); + std::fs::create_dir_all(&source).unwrap(); + let mut p = baseline(&source, &destination); + p.browser_running = true; + assert_eq!( + parsed_code(&check_move_preconditions(&p).unwrap_err()), + "DATA_ROOT_BROWSER_RUNNING" + ); + } + + #[test] + fn a_live_sync_stops_the_move() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let destination = temp.path().join("destination"); + std::fs::create_dir_all(&source).unwrap(); + let mut p = baseline(&source, &destination); + p.sync_in_progress = true; + assert_eq!( + parsed_code(&check_move_preconditions(&p).unwrap_err()), + "DATA_ROOT_SYNC_IN_PROGRESS" + ); + } + + #[test] + fn a_destination_inside_the_source_is_refused() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir_all(source.join("profiles")).unwrap(); + + for inside in [ + source.join("profiles"), + source.join("deep").join("nested"), + source.join("profiles").join("..").join("binaries"), + ] { + assert_eq!( + parsed_code(&check_move_preconditions(&baseline(&source, &inside)).unwrap_err()), + "DATA_ROOT_DESTINATION_INSIDE_SOURCE", + "{} is inside {}", + inside.display(), + source.display() + ); + } + + // The source itself is its own refusal: nothing to copy, and the delete + // that follows would take the only copy with it. + assert_eq!( + parsed_code(&check_move_preconditions(&baseline(&source, &source)).unwrap_err()), + "DATA_ROOT_SAME_AS_CURRENT" + ); + + // A sibling that merely shares a prefix is not inside it. + let sibling = temp.path().join("source-elsewhere"); + assert!(check_move_preconditions(&baseline(&source, &sibling)).is_ok()); + } + + #[test] + fn a_destination_with_less_space_than_the_source_needs_is_refused() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let destination = temp.path().join("destination"); + std::fs::create_dir_all(&source).unwrap(); + + let mut p = baseline(&source, &destination); + p.required_bytes = 8 * 1024 * 1024 * 1024; + p.available_bytes = 1024 * 1024 * 1024; + let err = check_move_preconditions(&p).unwrap_err(); + assert_eq!(parsed_code(&err), "DATA_ROOT_INSUFFICIENT_SPACE"); + let value: serde_json::Value = serde_json::from_str(&err).unwrap(); + // Written out, because the sentence is shown to a person. + assert_eq!(value["params"]["required"], "8.00 GB"); + assert_eq!(value["params"]["available"], "1.00 GB"); + + // Exactly enough is enough; one byte short is not. + p.available_bytes = p.required_bytes; + assert!(check_move_preconditions(&p).is_ok()); + p.available_bytes = p.required_bytes - 1; + assert_eq!( + parsed_code(&check_move_preconditions(&p).unwrap_err()), + "DATA_ROOT_INSUFFICIENT_SPACE" + ); + } + + #[cfg(unix)] + #[test] + fn an_unwritable_destination_is_refused() { + use std::os::unix::fs::PermissionsExt; + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("read-only"); + std::fs::create_dir_all(&parent).unwrap(); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o500)).unwrap(); + + let destination = parent.join("DonutBrowser"); + let outcome = probe_writable(&destination); + // Root ignores the mode bits, so the probe legitimately succeeds there and + // there is nothing for this test to assert. + if outcome.is_ok() { + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); + return; + } + assert_eq!( + parsed_code(&outcome.unwrap_err()), + "DATA_ROOT_DESTINATION_NOT_WRITABLE" + ); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); + + // A writable destination passes, and the probe leaves nothing behind. + let fine = temp.path().join("fine"); + assert!(probe_writable(&fine).is_ok()); + assert_eq!(std::fs::read_dir(&fine).unwrap().count(), 0); + } + + #[test] + fn a_destination_that_already_holds_files_is_refused() { + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("destination"); + std::fs::create_dir_all(&destination).unwrap(); + assert!(ensure_empty(&destination).is_ok()); + write(&destination.join("someone-elses.txt"), b"hello"); + assert_eq!( + parsed_code(&ensure_empty(&destination).unwrap_err()), + "DATA_ROOT_DESTINATION_NOT_EMPTY" + ); + } + + #[test] + fn scanning_counts_files_bytes_and_directories_without_following_links() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("root"); + write(&root.join("a.txt"), b"12345"); + write(&root.join("nested").join("b.bin"), &[7u8; 40]); + std::fs::create_dir_all(root.join("empty")).unwrap(); + + let scan = scan_tree(&root).unwrap(); + assert_eq!(scan.files, 2); + assert_eq!(scan.bytes, 45); + assert_eq!(scan.directories, 2); + assert_eq!(scan.symlinks, 0); + + #[cfg(unix)] + { + // A link out of the tree must not drag its target's bytes in. + let outside = temp.path().join("outside.bin"); + std::fs::write(&outside, [1u8; 4096]).unwrap(); + std::os::unix::fs::symlink(&outside, root.join("link")).unwrap(); + let with_link = scan_tree(&root).unwrap(); + assert_eq!(with_link.files, 2); + assert_eq!(with_link.bytes, 45); + assert_eq!(with_link.symlinks, 1); + } + } + + #[test] + fn verification_accepts_a_faithful_copy() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let destination = temp.path().join("destination"); + write(&source.join("settings").join("app.json"), b"{\"a\":1}"); + write( + &source.join("profiles").join("one").join("Cookies"), + &[3u8; 900], + ); + write(&source.join("binaries").join("browser"), &[9u8; 2048]); + + let total = scan_tree(&source).unwrap(); + let mut noop = |_: &str, _: u64, _: u64| {}; + let samples = copy_tree(&source, &destination, &total, &mut noop).unwrap(); + assert!(!samples.is_empty(), "a sample must be collected to verify"); + assert!(verify_copy(&source, &destination, &total, &samples).is_ok()); + } + + #[test] + fn verification_rejects_a_copy_that_lost_a_file() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let destination = temp.path().join("destination"); + write(&source.join("keep.txt"), b"kept"); + write( + &source.join("profiles").join("one").join("Login Data"), + &[5u8; 512], + ); + + let total = scan_tree(&source).unwrap(); + let mut noop = |_: &str, _: u64, _: u64| {}; + let samples = copy_tree(&source, &destination, &total, &mut noop).unwrap(); + + // Exactly the failure a delete-before-verify would turn into data loss. + std::fs::remove_file(destination.join("profiles").join("one").join("Login Data")).unwrap(); + assert_eq!( + parsed_code(&verify_copy(&source, &destination, &total, &samples).unwrap_err()), + "DATA_ROOT_VERIFY_FAILED" + ); + } + + #[test] + fn verification_rejects_a_copy_that_lost_bytes() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let destination = temp.path().join("destination"); + write( + &source.join("profiles").join("one").join("History"), + &[4u8; 4096], + ); + + let total = scan_tree(&source).unwrap(); + let mut noop = |_: &str, _: u64, _: u64| {}; + let samples = copy_tree(&source, &destination, &total, &mut noop).unwrap(); + + std::fs::write( + destination.join("profiles").join("one").join("History"), + [4u8; 2048], + ) + .unwrap(); + assert_eq!( + parsed_code(&verify_copy(&source, &destination, &total, &samples).unwrap_err()), + "DATA_ROOT_VERIFY_FAILED" + ); + } + + #[test] + fn verification_rejects_a_file_whose_contents_changed() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let destination = temp.path().join("destination"); + write(&source.join("only.bin"), &[1u8; 64]); + + let total = scan_tree(&source).unwrap(); + let mut noop = |_: &str, _: u64, _: u64| {}; + let samples = copy_tree(&source, &destination, &total, &mut noop).unwrap(); + assert_eq!(samples.len(), 1); + + // Same length, different bytes: only re-reading the sample catches this. + std::fs::write(destination.join("only.bin"), [2u8; 64]).unwrap(); + assert_eq!( + parsed_code(&verify_copy(&source, &destination, &total, &samples).unwrap_err()), + "DATA_ROOT_VERIFY_FAILED" + ); + } + + #[test] + fn a_copy_carries_every_nested_file_and_empty_directory() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let destination = temp.path().join("destination"); + for i in 0..40 { + write( + &source + .join("profiles") + .join(format!("p{i}")) + .join("Cookies"), + &[i as u8; 64], + ); + } + std::fs::create_dir_all(source.join("extensions")).unwrap(); + + let total = scan_tree(&source).unwrap(); + let mut noop = |_: &str, _: u64, _: u64| {}; + let samples = copy_tree(&source, &destination, &total, &mut noop).unwrap(); + + assert_eq!(samples.len(), VERIFY_SAMPLE_SIZE); + assert_eq!(scan_tree(&destination).unwrap(), total); + assert!(destination.join("extensions").is_dir()); + assert!(verify_copy(&source, &destination, &total, &samples).is_ok()); + } + + #[test] + fn progress_reaches_the_full_count() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let destination = temp.path().join("destination"); + for i in 0..5 { + write(&source.join(format!("f{i}")), &[0u8; 10]); + } + let total = scan_tree(&source).unwrap(); + let mut seen: Vec<(String, u64, u64)> = Vec::new(); + let mut record = |phase: &str, files: u64, bytes: u64| { + seen.push((phase.to_string(), files, bytes)); + }; + copy_tree(&source, &destination, &total, &mut record).unwrap(); + let last = seen.last().expect("progress is reported at least once"); + assert_eq!(last.0, "copying"); + assert_eq!(last.1, total.files); + assert_eq!(last.2, total.bytes); + } + + #[test] + fn sizes_are_written_the_way_a_person_reads_them() { + assert_eq!(human_bytes(0), "0 B"); + assert_eq!(human_bytes(999), "999 B"); + assert_eq!(human_bytes(1024), "1.0 KB"); + assert_eq!(human_bytes(1536), "1.5 KB"); + assert_eq!(human_bytes(5 * 1024 * 1024), "5.0 MB"); + assert_eq!(human_bytes(3 * 1024 * 1024 * 1024), "3.00 GB"); + } + + #[test] + fn available_space_is_read_for_a_real_directory() { + let temp = tempfile::tempdir().unwrap(); + // The figure itself depends on the machine; that it resolves at all is + // what the refusal relies on. + if let Some(free) = available_space(temp.path()) { + assert!(free > 0); + } + } +} diff --git a/src-tauri/src/downloaded_browsers_registry.rs b/src-tauri/src/downloaded_browsers_registry.rs index 3655fb7..5b546e7 100644 --- a/src-tauri/src/downloaded_browsers_registry.rs +++ b/src-tauri/src/downloaded_browsers_registry.rs @@ -375,14 +375,25 @@ impl DownloadedBrowsersRegistry { } /// Get all browsers and versions referenced by active profiles + /// Every (browser, version) something still needs. + /// + /// A TRASHED profile counts. Its browser directory is exactly what a restore + /// puts back into use, and removing the binary underneath it would turn an + /// undo into a gigabyte download, quietly, days after the delete. pub fn get_active_browser_versions( &self, profiles: &[crate::profile::BrowserProfile], ) -> Vec<(String, String)> { - profiles + let mut versions: Vec<(String, String)> = profiles .iter() .map(|profile| (profile.browser.clone(), profile.version.clone())) - .collect() + .collect(); + versions.extend( + crate::profile::trash::list_entries(&crate::profile::trash::trash_dir()) + .into_iter() + .map(|(profile, _)| (profile.browser, profile.version)), + ); + versions } /// Verify that all registered browsers actually exist on disk and clean up stale entries @@ -693,6 +704,73 @@ impl DownloadedBrowsersRegistry { Ok(cleaned_up) } + /// Update every stale profile of one browser to `latest_version`, then drop + /// the version binaries that leaves unused. + /// + /// The update and cleanup passes deliberately sit outside the classification + /// loop. Running them inside it replayed every already-processed profile on + /// each iteration, so N profiles cost N(N+1)/2 metadata rewrites and just as + /// many `profile-updated` events. Taking both actions as callbacks also keeps + /// the pass exercisable without a `tauri::AppHandle`. + fn consolidate_profiles_for_browser( + browser_name: &str, + browser_profiles: &[&BrowserProfile], + latest_version: &str, + update_profile: &mut dyn FnMut(&BrowserProfile) -> Result<(), String>, + remove_version: &mut dyn FnMut(&str) -> Result<(), String>, + ) -> Vec { + let mut consolidated = Vec::new(); + let mut profiles_to_update = Vec::new(); + let mut older_versions_to_remove = std::collections::HashSet::::new(); + + for profile in browser_profiles { + if profile.version != latest_version { + // Only update if profile is not currently running + if profile.process_id.is_none() { + profiles_to_update.push(*profile); + older_versions_to_remove.insert(profile.version.clone()); + } else { + log::info!( + "Skipping version update for running profile: {} ({})", + profile.name, + profile.version + ); + } + } + } + + // Update profiles to latest version + for profile in &profiles_to_update { + match update_profile(profile) { + Ok(()) => { + consolidated.push(format!( + "Updated profile '{}' from {} to {}", + profile.name, profile.version, latest_version + )); + } + Err(e) => { + log::error!("Failed to update profile '{}': {}", profile.name, e); + } + } + } + + // Remove older version binaries that are no longer needed + for old_version in &older_versions_to_remove { + log::info!("Consolidating: removing old version {browser_name} {old_version}"); + match remove_version(old_version.as_str()) { + Ok(()) => { + consolidated.push(format!("Removed old version: {browser_name} {old_version}")); + log::info!("Successfully removed old version: {browser_name} {old_version}"); + } + Err(e) => { + log::error!("Failed to cleanup old version {browser_name} {old_version}: {e}"); + } + } + } + + consolidated + } + /// Consolidate browser versions - keep only the latest version per browser pub fn consolidate_browser_versions( &self, @@ -755,58 +833,24 @@ impl DownloadedBrowsersRegistry { let latest_version = &available_versions[0]; log::info!("Latest available version for {browser_name}: {latest_version}"); - // Check which profiles need to be updated to the latest version - let mut profiles_to_update = Vec::new(); - let mut older_versions_to_remove = std::collections::HashSet::::new(); - - for profile in browser_profiles { - if profile.version != *latest_version { - // Only update if profile is not currently running - if profile.process_id.is_none() { - profiles_to_update.push(profile); - older_versions_to_remove.insert(profile.version.clone()); - } else { - log::info!( - "Skipping version update for running profile: {} ({})", - profile.name, - profile.version - ); - } - } - - // Update profiles to latest version - for profile in &profiles_to_update { - match self.profile_manager.update_profile_version( - app_handle, - &profile.id.to_string(), - latest_version, - ) { - Ok(_) => { - consolidated.push(format!( - "Updated profile '{}' from {} to {}", - profile.name, profile.version, latest_version - )); - } - Err(e) => { - log::error!("Failed to update profile '{}': {}", profile.name, e); - } - } - } - - // Remove older version binaries that are no longer needed - for old_version in &older_versions_to_remove { - log::info!("Consolidating: removing old version {browser_name} {old_version}"); - match self.cleanup_failed_download(browser_name, old_version) { - Ok(_) => { - consolidated.push(format!("Removed old version: {browser_name} {old_version}")); - log::info!("Successfully removed old version: {browser_name} {old_version}"); - } - Err(e) => { - log::error!("Failed to cleanup old version {browser_name} {old_version}: {e}"); - } - } - } - } + let mut consolidated_for_browser = Self::consolidate_profiles_for_browser( + browser_name, + browser_profiles, + latest_version, + &mut |profile: &BrowserProfile| -> Result<(), String> { + self + .profile_manager + .update_profile_version(app_handle, &profile.id.to_string(), latest_version) + .map(|_| ()) + .map_err(|e| e.to_string()) + }, + &mut |old_version: &str| -> Result<(), String> { + self + .cleanup_failed_download(browser_name, old_version) + .map_err(|e| e.to_string()) + }, + ); + consolidated.append(&mut consolidated_for_browser); } // Save registry after consolidation @@ -1061,6 +1105,47 @@ lazy_static::lazy_static! { mod tests { use super::*; + #[test] + fn a_trashed_profile_still_counts_as_a_reason_to_keep_its_browser() { + let root = tempfile::tempdir().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(root.path().to_path_buf()); + let registry = DownloadedBrowsersRegistry::new(); + assert!(registry.get_active_browser_versions(&[]).is_empty()); + + // What `trash_profile` leaves behind: one directory per profile holding + // the profile it archived. + let profile = crate::profile::BrowserProfile { + id: uuid::Uuid::new_v4(), + browser: "wayfern".to_string(), + version: "152.0.7977.64".to_string(), + ..Default::default() + }; + let entry = crate::profile::trash::trash_dir().join(profile.id.to_string()); + std::fs::create_dir_all(&entry).unwrap(); + std::fs::write( + entry.join("profile.json"), + serde_json::to_vec(&profile).unwrap(), + ) + .unwrap(); + std::fs::write( + entry.join("manifest.json"), + serde_json::json!({ + "deleted_at": 1, + "expires_at": 2, + "size_bytes": 0, + "original_name": "Trashed", + }) + .to_string(), + ) + .unwrap(); + + assert_eq!( + registry.get_active_browser_versions(&[]), + vec![("wayfern".to_string(), "152.0.7977.64".to_string())], + "removing the binary under a trashed profile turns an undo into a download" + ); + } + #[test] fn test_registry_creation() { // Create a mock profile manager for testing @@ -1395,6 +1480,51 @@ mod tests { "Browser should not be considered downloaded when files don't exist on disk" ); } + + #[test] + fn test_consolidate_profiles_for_browser_acts_once_per_profile() { + let profile = |name: &str, version: &str, process_id: Option| BrowserProfile { + id: uuid::Uuid::new_v4(), + name: name.to_string(), + browser: "testbrowser".to_string(), + version: version.to_string(), + process_id, + ..Default::default() + }; + + let stale_a = profile("stale-a", "139.0", None); + let stale_b = profile("stale-b", "139.0", None); + let older = profile("older", "138.0", None); + let running = profile("running", "139.0", Some(4242)); + let current = profile("current", "140.0", None); + let profiles = [&stale_a, &stale_b, &older, &running, ¤t]; + + let mut updated: Vec = Vec::new(); + let mut removed: Vec = Vec::new(); + + let consolidated = DownloadedBrowsersRegistry::consolidate_profiles_for_browser( + "testbrowser", + &profiles, + "140.0", + &mut |p: &BrowserProfile| -> Result<(), String> { + updated.push(p.name.clone()); + Ok(()) + }, + &mut |version: &str| -> Result<(), String> { + removed.push(version.to_string()); + Ok(()) + }, + ); + + // Every stale, stopped profile is updated exactly once - the loop used to + // re-update each of them once per remaining profile. + assert_eq!(updated, vec!["stale-a", "stale-b", "older"]); + + removed.sort(); + assert_eq!(removed, vec!["138.0", "139.0"]); + + assert_eq!(consolidated.len(), updated.len() + removed.len()); + } } #[tauri::command] diff --git a/src-tauri/src/downloader.rs b/src-tauri/src/downloader.rs index da15191..574519c 100644 --- a/src-tauri/src/downloader.rs +++ b/src-tauri/src/downloader.rs @@ -945,8 +945,37 @@ impl Downloader { // Auto-update non-running profiles to the latest installed version and cleanup unused binaries { let app_handle_for_update = app_handle.clone(); + let browser_for_update = browser_str.clone(); + let version_for_update = version.clone(); tauri::async_runtime::spawn(async move { let auto_updater = crate::auto_updater::AutoUpdater::instance(); + + // A profile that is open right now cannot be switched to the new binary + // yet, so it only gets a pending update. That entry has to exist before + // cleanup runs: cleanup keeps a version only while it is in use or + // pending, and would otherwise delete what was just downloaded. + match auto_updater + .auto_update_profile_versions( + &app_handle_for_update, + &browser_for_update, + &version_for_update, + ) + .await + { + Ok(updated) => { + if !updated.is_empty() { + log::info!( + "Applied {browser_for_update} {version_for_update} to profiles: {updated:?}" + ); + } + } + Err(e) => { + log::error!( + "Failed to apply {browser_for_update} {version_for_update} to profiles: {e}" + ); + } + } + match auto_updater.update_profiles_to_latest_installed(&app_handle_for_update) { Ok(updated) => { if !updated.is_empty() { diff --git a/src-tauri/src/ephemeral_dirs.rs b/src-tauri/src/ephemeral_dirs.rs index 131bcf5..ab71771 100644 --- a/src-tauri/src/ephemeral_dirs.rs +++ b/src-tauri/src/ephemeral_dirs.rs @@ -432,6 +432,7 @@ mod tests { last_sync: None, host_os: None, ephemeral, + temporary: false, extension_group_id: None, proxy_bypass_rules: Vec::new(), created_by_id: None, diff --git a/src-tauri/src/extension_fetch.rs b/src-tauri/src/extension_fetch.rs new file mode 100644 index 0000000..101364d --- /dev/null +++ b/src-tauri/src/extension_fetch.rs @@ -0,0 +1,600 @@ +//! Import a Chromium extension from a link instead of a file. +//! +//! Three inputs are accepted: a Chrome Web Store detail URL, the bare +//! 32-character extension id from one, and a direct `.crx`/`.zip` URL. All +//! three resolve to a single archive download, whose payload is normalised to +//! the plain ZIP that `extension_manager` already stores, so nothing +//! downstream (assignment, groups, per-profile staging, sync) has to know an +//! extension arrived over the network. + +use serde::{Deserialize, Serialize}; +use url::Url; + +/// Matches the body limit the REST extension routes accept for an upload +/// (`api_server::DefaultBodyLimit::max(64 MiB)`). A link import and a file +/// upload land in the same store, so they get the same ceiling. +pub const MAX_EXTENSION_BYTES: u64 = 64 * 1024 * 1024; + +const CRX_MAGIC: &[u8; 4] = b"Cr24"; +const ZIP_MAGIC: &[u8; 4] = b"PK\x03\x04"; +const MAX_REDIRECTS: usize = 5; + +/// The last-resort `prodversion` for the Web Store endpoint, used only when no +/// Wayfern build is downloaded and no version cache exists yet — a fresh +/// install that has never fetched a browser. Every other path reads the real +/// installed version, so this is a floor, not the normal answer. +const FALLBACK_PRODUCT_VERSION: &str = "120.0.0.0"; + +fn err(code: &str) -> String { + crate::backend_error(code) +} + +/// What a link resolves to before anything is fetched. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExtensionSource { + /// A Chrome Web Store product id, downloaded through the update service. + WebStore(String), + /// An archive served directly. + Direct(Url), +} + +/// A downloaded, validated extension archive, staged in the frontend exactly +/// like a picked file so the user confirms a real name and version before it +/// is stored. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FetchedExtension { + pub file_name: String, + pub file_data: Vec, + /// The manifest's own name, with any `__MSG_key__` placeholder resolved. + pub name: Option, + pub version: Option, + pub description: Option, + /// The URL the bytes actually came from, so the staged form can name it. + pub source_url: String, + /// True when the id was resolved through the Chrome Web Store update + /// service rather than downloaded from a link the user typed in full. + pub from_web_store: bool, +} + +/// A Chrome extension id is 32 characters drawn from `a`-`p`: the store +/// re-encodes the first 128 bits of the packing key's SHA-256 with that +/// alphabet, so anything outside it is not an id however long it is. +pub fn parse_extension_id(candidate: &str) -> Option { + let trimmed = candidate.trim(); + if trimmed.len() != 32 { + return None; + } + let lowered = trimmed.to_ascii_lowercase(); + lowered + .bytes() + .all(|b| b.is_ascii_lowercase() && b <= b'p') + .then_some(lowered) +} + +fn web_store_id_from_path(url: &Url) -> Option { + let segments: Vec<&str> = url.path_segments()?.filter(|s| !s.is_empty()).collect(); + // `/detail//` on the current store, `/webstore/detail//` + // on the legacy host, and both allow the slug to be omitted. Rather than + // encoding every shape, take the first segment that is a real id. + segments + .iter() + .find_map(|segment| parse_extension_id(segment)) +} + +fn is_web_store_host(host: &str) -> bool { + matches!( + host, + "chromewebstore.google.com" | "chrome.google.com" | "www.chrome.google.com" + ) +} + +fn path_is_archive(url: &Url) -> bool { + let path = url.path().to_ascii_lowercase(); + path.ends_with(".crx") || path.ends_with(".zip") +} + +/// Loopback plain HTTP is accepted only in the `e2e` build, where the suite +/// serves its own CRX fixture from a local server. A shipped build has no such +/// path, so every real import crosses TLS. +fn scheme_is_allowed(url: &Url) -> bool { + if url.scheme() == "https" { + return true; + } + cfg!(feature = "e2e") && url.scheme() == "http" && host_is_loopback(url) +} + +fn host_is_loopback(url: &Url) -> bool { + match url.host() { + Some(url::Host::Ipv4(ip)) => ip.is_loopback(), + Some(url::Host::Ipv6(ip)) => ip.is_loopback(), + Some(url::Host::Domain(name)) => name.eq_ignore_ascii_case("localhost"), + None => false, + } +} + +/// Classify what the user typed. Anything that is not one of the three +/// accepted shapes is refused here, before a single byte is requested. +pub fn parse_extension_source(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(err("EXTENSION_URL_INVALID")); + } + + if let Some(id) = parse_extension_id(trimmed) { + return Ok(ExtensionSource::WebStore(id)); + } + + let url = Url::parse(trimmed).map_err(|_| err("EXTENSION_URL_INVALID"))?; + if !scheme_is_allowed(&url) { + return Err(err("EXTENSION_URL_INVALID")); + } + + if let Some(host) = url.host_str() { + if is_web_store_host(host) { + return web_store_id_from_path(&url) + .map(ExtensionSource::WebStore) + .ok_or_else(|| err("EXTENSION_URL_INVALID")); + } + } + + if path_is_archive(&url) { + return Ok(ExtensionSource::Direct(url)); + } + + Err(err("EXTENSION_URL_INVALID")) +} + +/// The `nacl_arch` the Web Store update service expects for this machine. It +/// picks between architecture-specific builds of the same extension, so a +/// wrong value hands back a package the browser cannot load. +pub fn nacl_arch() -> &'static str { + match std::env::consts::ARCH { + "x86_64" => "x86-64", + "x86" => "x86-32", + "aarch64" => "arm64", + "arm" => "arm", + _ => "x86-64", + } +} + +/// Newest Chromium version this machine actually has, because the Web Store +/// serves a package built for the requesting browser and a version it does not +/// recognise is answered with an error rather than a CRX. +pub fn chromium_product_version() -> String { + let downloaded = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance() + .get_downloaded_versions("wayfern"); + if let Some(version) = newest_version(&downloaded) { + return version; + } + + let cached = crate::browser_version_manager::BrowserVersionManager::instance() + .get_cached_browser_versions("wayfern") + .unwrap_or_default(); + newest_version(&cached).unwrap_or_else(|| FALLBACK_PRODUCT_VERSION.to_string()) +} + +/// Highest dotted-numeric version in `versions`. Neither the registry nor the +/// version cache promises an order, and a lexical max reads `9.x` as newer +/// than `151.x`. +fn newest_version(versions: &[String]) -> Option { + versions + .iter() + .filter(|version| !version.trim().is_empty()) + .max_by_key(|version| version_key(version)) + .cloned() +} + +fn version_key(version: &str) -> [u64; 4] { + let mut parts = [0u64; 4]; + for (slot, piece) in parts.iter_mut().zip(version.split('.')) { + *slot = piece.trim().parse().unwrap_or(0); + } + parts +} + +/// The Chrome Web Store update service, the endpoint Chromium itself uses to +/// fetch a package on demand. It needs the product id, the ABI, and a Chromium +/// version, and answers with a redirect to the CRX. +pub fn web_store_download_url(id: &str, product_version: &str, nacl_arch: &str) -> String { + format!( + "https://clients2.google.com/service/update2/crx\ +?response=redirect&acceptformat=crx3&prodversion={product}&nacl_arch={arch}\ +&x=id%3D{id}%26installsource%3Dondemand%26uc", + product = urlencoding::encode(product_version), + arch = urlencoding::encode(nacl_arch), + id = id, + ) +} + +/// Unwrap a CRX3 container to the ZIP it carries. +/// +/// A `.crx` is not a ZIP with a different name: it is `Cr24`, a little-endian +/// format version, a little-endian header length, that many bytes of protobuf +/// signature header, and only then the ZIP. Storing the whole file as if it +/// were an archive leaves every reader to guess where the ZIP starts. +pub fn crx3_zip_payload(data: &[u8]) -> Result<&[u8], String> { + if data.len() < 16 || &data[0..4] != CRX_MAGIC { + return Err(err("EXTENSION_NOT_AN_EXTENSION")); + } + let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]); + if version != 3 { + // CRX2 has a different header (two length fields, no protobuf) and has not + // been accepted by Chromium for years. Refusing is more useful than + // guessing at an offset. + return Err(err("EXTENSION_NOT_AN_EXTENSION")); + } + let header_len = u32::from_le_bytes([data[8], data[9], data[10], data[11]]) as usize; + let start = 12usize + .checked_add(header_len) + .ok_or_else(|| err("EXTENSION_NOT_AN_EXTENSION"))?; + let payload = data + .get(start..) + .ok_or_else(|| err("EXTENSION_NOT_AN_EXTENSION"))?; + if payload.len() < 4 || &payload[0..4] != ZIP_MAGIC { + return Err(err("EXTENSION_NOT_AN_EXTENSION")); + } + Ok(payload) +} + +/// Normalise downloaded bytes to the plain ZIP the store keeps. A CRX3 is +/// unwrapped; a ZIP passes through; anything else is refused. +pub fn archive_payload(data: &[u8]) -> Result<&[u8], String> { + if data.len() >= 4 && &data[0..4] == ZIP_MAGIC { + return Ok(data); + } + crx3_zip_payload(data) +} + +fn redirect_policy() -> reqwest::redirect::Policy { + reqwest::redirect::Policy::custom(|attempt| { + if !scheme_is_allowed(attempt.url()) { + // A store redirect that leaves TLS would download the package in the + // clear, and the package is executable code. Stopping here surfaces the + // final response instead of following it. + return attempt.stop(); + } + if attempt.previous().len() > MAX_REDIRECTS { + return attempt.stop(); + } + attempt.follow() + }) +} + +async fn download_archive(url: &str) -> Result, String> { + use futures_util::StreamExt; + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .connect_timeout(std::time::Duration::from_secs(15)) + .redirect(redirect_policy()) + .build() + .map_err(|_| err("EXTENSION_DOWNLOAD_FAILED"))?; + + let response = client + .get(url) + .header("User-Agent", "Mozilla/5.0 (compatible; donutbrowser)") + .send() + .await + .map_err(|e| { + log::warn!("Extension download request failed: {e}"); + err("EXTENSION_DOWNLOAD_FAILED") + })?; + + if !response.status().is_success() { + log::warn!("Extension download answered HTTP {}", response.status()); + return Err(err("EXTENSION_DOWNLOAD_FAILED")); + } + // A redirect the policy stopped surfaces here as a 3xx, which + // `is_success` already rejects; the final URL is checked again so a + // same-status hop can never slip through. + if !scheme_is_allowed(response.url()) { + return Err(err("EXTENSION_DOWNLOAD_FAILED")); + } + if response + .content_length() + .is_some_and(|len| len > MAX_EXTENSION_BYTES) + { + return Err(err("EXTENSION_TOO_LARGE")); + } + + let mut buffer: Vec = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| { + log::warn!("Extension download stream failed: {e}"); + err("EXTENSION_DOWNLOAD_FAILED") + })?; + // A server is free to lie about, or omit, Content-Length, so the ceiling + // is enforced against what actually arrives. + if buffer.len() as u64 + chunk.len() as u64 > MAX_EXTENSION_BYTES { + return Err(err("EXTENSION_TOO_LARGE")); + } + buffer.extend_from_slice(&chunk); + } + + if buffer.is_empty() { + return Err(err("EXTENSION_DOWNLOAD_FAILED")); + } + Ok(buffer) +} + +/// What the archive is stored as. The payload written to the store is always +/// the plain ZIP, so a `.crx` link keeps its name but not its extension — +/// calling an unwrapped payload `.crx` would tell every later reader to skip a +/// CRX header that is no longer there. +fn direct_file_name(url: &Url) -> String { + let raw = url + .path_segments() + .and_then(|mut segments| segments.rfind(|s| !s.is_empty())) + .unwrap_or_default(); + let stem = raw + .strip_suffix(".crx") + .or_else(|| raw.strip_suffix(".CRX")) + .or_else(|| raw.strip_suffix(".zip")) + .or_else(|| raw.strip_suffix(".ZIP")) + .unwrap_or(raw) + .trim(); + if stem.is_empty() { + return "extension.zip".to_string(); + } + format!("{stem}.zip") +} + +/// Fetch and validate an extension from a link, returning the plain ZIP plus +/// the identity read out of its own manifest. +pub async fn fetch_extension(input: &str) -> Result { + let source = parse_extension_source(input)?; + let (download_url, file_name, from_web_store) = match &source { + ExtensionSource::WebStore(id) => ( + web_store_download_url(id, &chromium_product_version(), nacl_arch()), + format!("{id}.zip"), + true, + ), + ExtensionSource::Direct(url) => (url.to_string(), direct_file_name(url), false), + }; + + let raw = download_archive(&download_url).await?; + let payload = archive_payload(&raw)?; + // A ZIP that carries no manifest is not an extension, whatever it was + // served as. Refusing here keeps a 404 page or an installer out of the + // store instead of leaving a broken row the user has to work out. + let manifest = crate::extension_manager::read_manifest_from_archive(payload, "zip") + .ok_or_else(|| err("EXTENSION_NOT_AN_EXTENSION"))?; + let (name, version, description, _author, _homepage) = + crate::extension_manager::manifest_metadata( + &manifest, + &crate::extension_manager::ManifestSource::Archive { + data: payload, + file_type: "zip", + }, + ); + + Ok(FetchedExtension { + file_name, + file_data: payload.to_vec(), + name, + version, + description, + source_url: if from_web_store { + // The update-service URL is machine-specific noise; the store page is + // what a user recognises and can open. + match &source { + ExtensionSource::WebStore(id) => { + format!("https://chromewebstore.google.com/detail/{id}") + } + ExtensionSource::Direct(url) => url.to_string(), + } + } else { + download_url + }, + from_web_store, + }) +} + +#[tauri::command] +pub async fn fetch_extension_from_url(url: String) -> Result { + fetch_extension(&url).await +} + +#[cfg(test)] +mod tests { + use super::*; + + fn crx3(header: &[u8], zip: &[u8]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(CRX_MAGIC); + out.extend_from_slice(&3u32.to_le_bytes()); + out.extend_from_slice(&(header.len() as u32).to_le_bytes()); + out.extend_from_slice(header); + out.extend_from_slice(zip); + out + } + + fn zip_bytes() -> Vec { + let mut zip = ZIP_MAGIC.to_vec(); + zip.extend_from_slice(b"the rest of an archive"); + zip + } + + #[test] + fn a_crx3_container_yields_exactly_the_zip_it_carries() { + let zip = zip_bytes(); + let crx = crx3(&[7u8; 40], &zip); + assert_eq!(crx3_zip_payload(&crx).unwrap(), zip.as_slice()); + assert_eq!(archive_payload(&crx).unwrap(), zip.as_slice()); + } + + #[test] + fn a_file_that_is_not_a_crx_is_refused_rather_than_scanned_for_a_zip() { + let mut wrong_magic = crx3(&[0u8; 8], &zip_bytes()); + wrong_magic[0] = b'X'; + assert_eq!( + crx3_zip_payload(&wrong_magic).unwrap_err(), + err("EXTENSION_NOT_AN_EXTENSION") + ); + assert_eq!( + archive_payload(b"404").unwrap_err(), + err("EXTENSION_NOT_AN_EXTENSION") + ); + } + + #[test] + fn a_truncated_crx_never_reads_past_its_own_bytes() { + let full = crx3(&[1u8; 32], &zip_bytes()); + for cut in [8usize, 12, 20, 40] { + assert!(crx3_zip_payload(&full[..cut.min(full.len())]).is_err()); + } + // A header length that runs past the file must not panic or return the + // tail of some other structure. + let mut lying = crx3(&[1u8; 32], &zip_bytes()); + lying[8..12].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(crx3_zip_payload(&lying).is_err()); + } + + #[test] + fn a_plain_zip_is_accepted_and_a_crx2_is_not() { + let zip = zip_bytes(); + assert_eq!(archive_payload(&zip).unwrap(), zip.as_slice()); + + let mut crx2 = crx3(&[0u8; 16], &zip); + crx2[4..8].copy_from_slice(&2u32.to_le_bytes()); + assert_eq!( + archive_payload(&crx2).unwrap_err(), + err("EXTENSION_NOT_AN_EXTENSION") + ); + } + + #[test] + fn every_accepted_link_shape_resolves_to_one_source() { + let id = "abcdefghijklmnopabcdefghijklmnop"; + for input in [ + id, + &format!(" {} ", id.to_ascii_uppercase()), + &format!("https://chromewebstore.google.com/detail/some-slug/{id}"), + &format!("https://chromewebstore.google.com/detail/some-slug/{id}?hl=en"), + &format!("https://chromewebstore.google.com/detail/{id}"), + &format!("https://chrome.google.com/webstore/detail/some-slug/{id}"), + &format!("https://chrome.google.com/webstore/detail/some-slug/{id}/related"), + ] { + assert_eq!( + parse_extension_source(input).unwrap(), + ExtensionSource::WebStore(id.to_string()), + "{input}" + ); + } + + let direct = "https://files.example.com/pack/ublock.crx"; + assert_eq!( + parse_extension_source(direct).unwrap(), + ExtensionSource::Direct(Url::parse(direct).unwrap()) + ); + assert!(matches!( + parse_extension_source("https://files.example.com/pack/ublock.zip?v=2").unwrap(), + ExtensionSource::Direct(_) + )); + } + + #[test] + fn a_link_that_is_not_an_extension_is_refused_before_anything_is_fetched() { + for input in [ + "", + " ", + // 31 and 33 characters, and an id using letters past `p`. + "abcdefghijklmnopabcdefghijklmno", + "abcdefghijklmnopabcdefghijklmnopq", + "abcdefghijklmnopabcdefghijklmnoz", + "not a url at all", + "ftp://files.example.com/ublock.crx", + "file:///etc/passwd", + // The right host, but no product id anywhere in the path. + "https://chromewebstore.google.com/category/extensions", + // An https URL that is not an archive. + "https://files.example.com/downloads", + "https://files.example.com/installer.exe", + ] { + assert_eq!( + parse_extension_source(input).unwrap_err(), + err("EXTENSION_URL_INVALID"), + "{input}" + ); + } + } + + #[test] + fn plain_http_is_refused_outside_the_test_build_and_never_off_loopback() { + let loopback = Url::parse("http://127.0.0.1:8321/fixture.crx").unwrap(); + assert_eq!(scheme_is_allowed(&loopback), cfg!(feature = "e2e")); + assert!(!scheme_is_allowed( + &Url::parse("http://files.example.com/ublock.crx").unwrap() + )); + assert!(scheme_is_allowed( + &Url::parse("https://files.example.com/ublock.crx").unwrap() + )); + assert_eq!( + parse_extension_source("http://files.example.com/ublock.crx").unwrap_err(), + err("EXTENSION_URL_INVALID") + ); + } + + /// The redirect policy is what makes the scheme guard hold for the whole + /// chain, not only the first request: the Web Store answers with a redirect, + /// so the URL the bytes actually come from is never the one that was typed. + #[test] + fn a_redirect_is_judged_by_the_same_rule_as_the_first_request() { + let policy_allows = |url: &str| scheme_is_allowed(&Url::parse(url).unwrap()); + assert!(policy_allows( + "https://clients2.googleusercontent.com/crx/blobs/abc/EXT.crx" + )); + // The classic downgrade: an https request answered with a plain-HTTP + // Location. The package is executable code, so the chain stops there. + assert!(!policy_allows("http://mirror.example.com/EXT.crx")); + assert!(!policy_allows("ftp://mirror.example.com/EXT.crx")); + } + + #[test] + fn the_size_ceiling_matches_the_upload_route_and_bounds_the_buffer() { + assert_eq!(MAX_EXTENSION_BYTES, 64 * 1024 * 1024); + // The streaming guard is a comparison on running totals; prove the + // arithmetic it relies on rejects the first chunk that crosses the line. + let already = MAX_EXTENSION_BYTES - 10; + assert!(already + 11 > MAX_EXTENSION_BYTES); + assert!(already + 10 <= MAX_EXTENSION_BYTES); + } + + #[test] + fn the_web_store_url_carries_the_id_the_abi_and_the_installed_version() { + let url = web_store_download_url("abcdefghijklmnopabcdefghijklmnop", "151.0.7922.76", "arm64"); + assert!(url.starts_with("https://clients2.google.com/service/update2/crx?")); + assert!(url.contains("prodversion=151.0.7922.76")); + assert!(url.contains("nacl_arch=arm64")); + assert!(url.contains("id%3Dabcdefghijklmnopabcdefghijklmnop")); + assert!(url.contains("acceptformat=crx3")); + } + + #[test] + fn the_newest_installed_version_wins_over_a_lexically_larger_one() { + let versions = vec![ + "9.0.1.0".to_string(), + "151.0.7922.76".to_string(), + "147.0.7727.138".to_string(), + ]; + assert_eq!(newest_version(&versions).unwrap(), "151.0.7922.76"); + assert_eq!(newest_version(&[]), None); + } + + #[test] + fn a_direct_download_names_the_stored_file_a_zip() { + assert_eq!( + direct_file_name(&Url::parse("https://files.example.com/pack/ublock.crx").unwrap()), + "ublock.zip" + ); + assert_eq!( + direct_file_name(&Url::parse("https://files.example.com/pack/ublock.zip").unwrap()), + "ublock.zip" + ); + assert_eq!( + direct_file_name(&Url::parse("https://files.example.com/.crx").unwrap()), + "extension.zip" + ); + } +} diff --git a/src-tauri/src/extension_manager.rs b/src-tauri/src/extension_manager.rs index d02f150..46df64a 100644 --- a/src-tauri/src/extension_manager.rs +++ b/src-tauri/src/extension_manager.rs @@ -24,6 +24,9 @@ fn default_source_kind() -> String { pub struct Extension { pub id: String, pub name: String, + /// The archive's identity, kept separately from the user's editable name. + #[serde(default)] + pub manifest_name: Option, pub file_name: String, pub file_type: String, pub browser_compatibility: Vec, @@ -258,7 +261,7 @@ fn extract_manifest_metadata(file_data: &[u8], file_type: &str) -> ManifestMetad } } -fn manifest_metadata( +pub(crate) fn manifest_metadata( manifest: &serde_json::Value, source: &ManifestSource<'_>, ) -> ManifestMetadata { @@ -630,7 +633,8 @@ impl ExtensionManager { let ext = Extension { id: uuid::Uuid::new_v4().to_string(), - name: Self::resolve_name(name, manifest_name)?, + name: Self::resolve_name(name, manifest_name.clone())?, + manifest_name, file_name: file_name.clone(), file_type, browser_compatibility, @@ -677,7 +681,8 @@ impl ExtensionManager { let ext = Extension { id: uuid::Uuid::new_v4().to_string(), - name: Self::resolve_name(name, manifest_name)?, + name: Self::resolve_name(name, manifest_name.clone())?, + manifest_name, file_name: absolute .file_name() .map(|n| n.to_string_lossy().to_string()) @@ -1001,6 +1006,7 @@ impl ExtensionManager { if let Some(h) = homepage_url { ext.homepage_url = Some(h); } + ext.manifest_name = manifest_name.clone(); if let Some(mn) = manifest_name { if !explicit_name_provided && !mn.trim().is_empty() { ext.name = mn; @@ -1678,7 +1684,8 @@ impl ExtensionManager { let (manifest_name, version, description, author, homepage_url) = metadata; let mut updated = ext.clone(); - let mut changed = false; + let mut changed = updated.manifest_name != manifest_name; + updated.manifest_name = manifest_name.clone(); // The name is user-editable, so it is only touched when what is stored is // an unresolved placeholder. @@ -2307,6 +2314,7 @@ mod tests { .unwrap(); assert_eq!(ext.name, "uBlock Origin Lite"); + assert_eq!(ext.manifest_name.as_deref(), Some("uBlock Origin Lite")); assert_eq!( ext.description.as_deref(), Some("An efficient content blocker.") @@ -2314,6 +2322,31 @@ mod tests { assert_eq!(ext.version.as_deref(), Some("1.2.3")); } + #[test] + fn manifest_identity_backfill_preserves_an_explicit_name_and_edit_time() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(tmp.path().to_path_buf()); + let mgr = ExtensionManager::new(); + let mut ext = mgr + .add_extension( + "fallback".to_string(), + "ublock.zip".to_string(), + localized_extension_zip(), + ) + .unwrap(); + ext.name = "My blocker".to_string(); + ext.manifest_name = None; + mgr.update_extension_internal(&ext).unwrap(); + mgr.ensure_icons_extracted(); + let restored = mgr.get_extension(&ext.id).unwrap(); + assert_eq!(restored.name, "My blocker"); + assert_eq!( + restored.manifest_name.as_deref(), + Some("uBlock Origin Lite") + ); + assert_eq!(restored.updated_at, ext.updated_at); + } + #[test] fn a_stored_placeholder_is_repaired_rather_than_shown_to_the_user() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/fingerprint_consistency.rs b/src-tauri/src/fingerprint_consistency.rs index 68cb76e..85ddd85 100644 --- a/src-tauri/src/fingerprint_consistency.rs +++ b/src-tauri/src/fingerprint_consistency.rs @@ -6,6 +6,12 @@ //! language. A mismatch (e.g. a US fingerprint behind a German exit IP) is a //! strong anti-bot tell even though the real device never leaks. //! +//! Every comparison has three outcomes, never two: the dimensions agree, they +//! disagree, or nothing was compared because the fingerprint declares no value +//! to compare against. That third state is reported, never folded into the +//! first, "we checked and it matches" and "we checked nothing" are different +//! claims, and only one of them has been earned. +//! //! This module only measures. Deciding what a mismatch *means* for a launch — //! block, warn, or ignore — belongs to `launch_gate`, which calls //! `probe_and_check_consistency` before the browser is spawned. Launches never @@ -89,12 +95,31 @@ lazy_static::lazy_static! { static ref EXIT_CACHE: Mutex> = Mutex::new(HashMap::new()); } +/// The dimensions an exit is compared on, in report order. +pub const CHECKED_DIMENSIONS: [&str; 2] = ["timezone", "language"]; + +/// The outcome of comparing a measured exit against a fingerprint. +/// +/// Three states, deliberately distinct, because collapsing the third into the +/// first is how a launch came to report a match it never made: +/// +/// * **agree**, `checked`, `consistent`, nothing in `unverified`; +/// * **disagree**, `checked`, not `consistent`, the offenders in `mismatches`; +/// * **not compared**, the dimension is named in `unverified`, and if nothing +/// at all could be compared then `checked` is false. +/// +/// `consistent` alone never means "verified": it is also true when there was +/// nothing to compare. Read it together with `checked` and `unverified`, or +/// call [`ConsistencyResult::is_mismatch`] / [`ConsistencyResult::is_verified`]. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct ConsistencyResult { - /// True when everything we could check lines up (or there was nothing to - /// check — no proxy assigned). + /// True when no dimension that was actually compared disagreed. Also true + /// when nothing was compared at all, so this is a claim about what was + /// measured, never a claim that anything was. pub consistent: bool, - /// True when we actually reached an exit node and compared something. + /// True when we reached an exit node **and** compared at least one dimension + /// against it. False both when no exit was measured and when one was measured + /// but the fingerprint declared nothing to compare it against. pub checked: bool, pub exit_ip: Option, pub exit_country_code: Option, @@ -103,6 +128,20 @@ pub struct ConsistencyResult { pub fingerprint_language: Option, /// One of "timezone", "language" — the dimensions that disagree. pub mismatches: Vec, + /// One of "timezone", "language", dimensions the exit supplied a value for + /// but that were never compared, because the fingerprint declares no value of + /// its own (or, for language, because the exit country has no CLDR data). + /// + /// Not a mismatch: a launch is never blocked on one, because a fingerprint + /// whose geolocation probe failed legitimately carries no location at all. + /// `wayfern_manager::apply_geolocation` writes nothing rather than inventing + /// `America/New_York`, and `wayfern_manager::launch_fingerprint_payload` + /// forwards that absence to the browser rather than filling it back in, so + /// "not compared" describes what the launch actually presents. But not a pass + /// either, these dimensions are unverified and must never be reported to the + /// user as agreeing. + #[serde(default)] + pub unverified: Vec, } impl ConsistencyResult { @@ -116,8 +155,48 @@ impl ConsistencyResult { fingerprint_timezone: None, fingerprint_language: None, mismatches: Vec::new(), + unverified: Vec::new(), } } + + /// A positively measured disagreement, the only state that may stop a + /// launch. + pub fn is_mismatch(&self) -> bool { + self.checked && !self.consistent + } + + /// Every dimension the exit offered was compared and agreed. The only state + /// that has earned the word "consistent" in front of a user. + pub fn is_verified(&self) -> bool { + self.checked && self.consistent && self.unverified.is_empty() + } +} + +/// Dimensions this profile can never be verified on, whatever exit it turns out +/// to use, because its stored fingerprint declares no value to compare. +/// +/// Pure and local: no exit measurement, no I/O, so the pre-launch report can +/// state it before a single worker starts. A lower bound on what a real probe +/// will report as unverified, the exit's own country can also leave the +/// language uncomparable, and that is not knowable from here. +pub fn unverifiable_dimensions(profile: &BrowserProfile) -> Vec { + let (fp_tz, fp_lang) = fingerprint_locale(profile); + let mut out = Vec::new(); + if fp_tz.is_none() { + out.push("timezone".to_string()); + } + if fp_lang.is_none() { + out.push("language".to_string()); + } + out +} + +/// True when measuring the exit can still verify at least one dimension of this +/// profile's fingerprint. False means a probe would compare nothing, so telling +/// the user "Donut will check it while starting" would be a promise it cannot +/// keep. +pub fn can_verify_anything(profile: &BrowserProfile) -> bool { + unverifiable_dimensions(profile).len() < CHECKED_DIMENSIONS.len() } /// Whether this upstream can carry a probe request at all. @@ -126,7 +205,7 @@ impl ConsistencyResult { /// rather than guessed at. fn probe_url(settings: &crate::browser::ProxySettings) -> Option { match settings.proxy_type.to_lowercase().as_str() { - "http" | "https" | "socks4" | "socks5" => Some( + "http" | "https" | "httpstls" | "socks4" | "socks5" => Some( crate::proxy_manager::ProxyManager::build_probe_proxy_url(settings), ), _ => None, @@ -141,32 +220,35 @@ fn probe_url(settings: &crate::browser::ProxySettings) -> Option { /// any table naming one "expected" language per country flags fingerprints /// Donut itself produced — roughly 10% of US profiles legitimately get `es-US` /// and ~23% of Canadian ones get `fr-CA`. `None` means the country has no CLDR -/// data and the check is skipped. +/// data, so the language cannot be judged either way, the caller reports that +/// dimension as unverified rather than counting it as a match. fn language_matches_country(cc: &str, language: &str) -> Option { crate::geolocation::locale_selector()?.region_speaks(cc, language) } /// Extract (timezone, language) from a profile's stored location, or from its /// legacy fingerprint payload when it still stores one. +/// +/// Read through `WayfernManager::fingerprint_object`, the same accessor the +/// launcher uses to build the device it hands the browser, so both stored +/// shapes, the bare object and the legacy `{ "fingerprint": {...} }` wrapper +/// old profiles carry, are read identically on both sides. Reading only the +/// top level here made a wrapped fingerprint report "declares no timezone", +/// which sent the check down the not-compared path on exactly the profiles old +/// enough to have the wrapper, while the launch presented the timezone nested +/// one level down. Sharing the accessor is what keeps the two from drifting +/// apart again. fn fingerprint_locale(profile: &BrowserProfile) -> (Option, Option) { - let Some(config) = &profile.wayfern_config else { + let Some(fp) = profile + .wayfern_config + .as_ref() + .and_then(|config| config.location.as_deref().or(config.fingerprint.as_deref())) + .and_then(crate::wayfern_manager::WayfernManager::fingerprint_object) + else { return (None, None); }; - let Some(fp_str) = config.location.as_ref().or(config.fingerprint.as_ref()) else { - return (None, None); - }; - let Ok(fp) = serde_json::from_str::(fp_str) else { - return (None, None); - }; - let timezone = fp - .get("timezone") - .and_then(|v| v.as_str()) - .map(str::to_string); - let language = fp - .get("language") - .and_then(|v| v.as_str()) - .map(str::to_string); - (timezone, language) + let read = |key: &str| fp.get(key).and_then(|v| v.as_str()).map(str::to_string); + (read("timezone"), read("language")) } /// A mutex whose poison is not fatal. @@ -187,28 +269,58 @@ pub fn compare_exit_to_fingerprint( ) -> ConsistencyResult { let (fp_tz, fp_lang) = fingerprint_locale(profile); let mut mismatches = Vec::new(); + let mut unverified = Vec::new(); + let mut compared = 0usize; - if let (Some(exit), Some(fp)) = (&exit_timezone, &fp_tz) { - if !exit.eq_ignore_ascii_case(fp) { - mismatches.push("timezone".to_string()); + // Three outcomes per dimension, never two. An exit whose timezone the + // fingerprint does not declare is NOT agreement: nothing was compared, and + // folding that into "consistent" is a green light this check has not earned. + // "Not compared" is also a claim about the launch, not just about this + // function: it is only honest because the launcher hands the browser no + // timezone either (`wayfern_manager::launch_fingerprint_payload`). If it ever + // starts supplying one again, that value is what this must compare against - + // reporting "nothing was compared" while a location ships is the one outcome + // neither side may produce. + if let Some(exit) = &exit_timezone { + match &fp_tz { + Some(fp) => { + compared += 1; + if !exit.eq_ignore_ascii_case(fp) { + mismatches.push("timezone".to_string()); + } + } + None => unverified.push("timezone".to_string()), } } - if let (Some(cc), Some(lang)) = (&exit_country_code, &fp_lang) { - if language_matches_country(cc, lang) == Some(false) { - mismatches.push("language".to_string()); + // Language has one extra way to be uncomparable: a country CLDR has no data + // for answers `None`, which is no more a match than a missing fingerprint + // language is. + if let Some(cc) = &exit_country_code { + match fp_lang + .as_ref() + .and_then(|lang| language_matches_country(cc, lang)) + { + Some(plausible) => { + compared += 1; + if !plausible { + mismatches.push("language".to_string()); + } + } + None => unverified.push("language".to_string()), } } ConsistencyResult { consistent: mismatches.is_empty(), - checked: true, + checked: compared > 0, exit_ip, exit_country_code, exit_timezone, fingerprint_timezone: fp_tz, fingerprint_language: fp_lang, mismatches, + unverified, } } @@ -223,6 +335,14 @@ fn cached_exit(key: &ExitCacheKey) -> Option { .cloned() } +/// The exit IP the launch gate last measured for this profile's route, while +/// it is still fresh. Cache-only: the gate probes on an interactive launch and +/// an automation launch never probes, so a miss here is "unknown", not "direct". +pub fn cached_exit_ip(profile: &BrowserProfile) -> Option { + let key = exit_cache_key(profile)?; + cached_exit(&key).and_then(|cached| cached.ip) +} + /// Cache-only check. Never performs I/O, so it is safe to call before a launch /// and for every profile in a bulk run. Returns an unchecked result on a miss. pub fn check_profile_consistency_cached(profile: &BrowserProfile) -> ConsistencyResult { @@ -593,8 +713,252 @@ mod tests { Some("DE".into()), None, ); - assert!(result.consistent); + assert!(!result.is_mismatch()); assert!(result.mismatches.is_empty()); + // ...but skipping every dimension is not a pass, and must not be dressed + // as one. + assert!(!result.is_verified()); + assert!(!result.checked); + assert_eq!(result.unverified, vec!["timezone", "language"]); + } + + fn profile_with_raw_fingerprint(fingerprint: serde_json::Value) -> BrowserProfile { + let mut profile = BrowserProfile { + id: uuid::Uuid::new_v4(), + name: "p".into(), + browser: "wayfern".into(), + ..Default::default() + }; + profile.wayfern_config = Some(crate::wayfern_manager::WayfernConfig { + fingerprint: Some(fingerprint.to_string()), + ..Default::default() + }); + profile + } + + #[test] + fn a_fingerprint_with_no_timezone_is_unverified_never_a_match() { + // The regression. Generation no longer invents `America/New_York` when the + // geolocation probe fails, so a fingerprint can legitimately carry no + // timezone. The comparison then has nothing to compare, and reporting that + // as agreement is a green light the check never earned, on the one + // dimension that carries the real signal. + let profile = profile_with_raw_fingerprint(serde_json::json!({ "language": "de-DE" })); + let result = compare_exit_to_fingerprint( + &profile, + Some("Europe/Berlin".into()), + Some("DE".into()), + Some("1.2.3.4".into()), + ); + + assert!( + result.unverified.contains(&"timezone".to_string()), + "an undeclared timezone must be reported as unverified, got {result:?}" + ); + assert!( + !result.is_verified(), + "nothing compared the timezone, so this must not read as consistent" + ); + // The language WAS compared and agreed, so the exit counts as checked... + assert!(result.checked); + assert!(result.mismatches.is_empty()); + // ...but a dimension nobody compared is never a reason to stop a launch. + assert!(!result.is_mismatch()); + } + + #[test] + fn a_fingerprint_with_no_locale_at_all_is_not_checked() { + // Both dimensions undeclared: the exit was reached, and still nothing was + // compared. `checked` has to say so, because every consumer reads it as + // "there is a measurement here worth acting on". + let profile = profile_with_raw_fingerprint(serde_json::json!({ "platform": "Win32" })); + let result = compare_exit_to_fingerprint( + &profile, + Some("Europe/Berlin".into()), + Some("DE".into()), + Some("1.2.3.4".into()), + ); + assert!(!result.checked); + assert!(!result.is_verified()); + assert!(!result.is_mismatch()); + assert_eq!(result.unverified, vec!["timezone", "language"]); + } + + #[test] + fn a_legacy_wrapped_fingerprint_is_read_the_way_the_launcher_reads_it() { + // The launcher accepts `{"fingerprint": {...}}` as well as the bare object, + // so reading only the top level here answered "this profile declares no + // timezone" for a profile whose launch presents one. The check then skipped + // the dimension carrying the real signal, on exactly the profiles old + // enough to still have the wrapper. + let profile = profile_with_raw_fingerprint(serde_json::json!({ + "fingerprint": { "timezone": "America/New_York", "language": "en-US" } + })); + + assert_eq!( + unverifiable_dimensions(&profile), + Vec::::new(), + "a wrapped fingerprint declares both dimensions" + ); + assert!(can_verify_anything(&profile)); + + let result = compare_exit_to_fingerprint( + &profile, + Some("Europe/Berlin".into()), + Some("DE".into()), + Some("1.2.3.4".into()), + ); + assert_eq!( + result.fingerprint_timezone.as_deref(), + Some("America/New_York"), + "the nested timezone must be the one compared, got {result:?}" + ); + assert_eq!(result.fingerprint_language.as_deref(), Some("en-US")); + assert!(result.checked); + assert!( + result.mismatches.contains(&"timezone".to_string()), + "a US timezone behind a German exit must flag, got {result:?}" + ); + assert!(result.is_mismatch()); + assert!( + result.unverified.is_empty(), + "both dimensions were declared, so nothing is unverified: {result:?}" + ); + } + + #[test] + fn a_wrapped_fingerprint_with_no_timezone_is_still_unverified() { + // The other half: unwrapping must not turn "declares nothing" into a pass. + let profile = profile_with_raw_fingerprint(serde_json::json!({ + "fingerprint": { "language": "de-DE" } + })); + assert_eq!(unverifiable_dimensions(&profile), vec!["timezone"]); + + let result = compare_exit_to_fingerprint( + &profile, + Some("Europe/Berlin".into()), + Some("DE".into()), + None, + ); + assert_eq!(result.unverified, vec!["timezone"]); + assert!(!result.is_verified()); + assert!(!result.is_mismatch()); + } + + #[test] + fn a_language_the_country_has_no_cldr_data_for_is_unverified() { + // The other way a comparison can silently not happen. `ZZ` has no CLDR + // entry, so the language was never judged; the timezone still was. + let profile = profile_with_fingerprint("Europe/Berlin", "de-DE"); + let result = compare_exit_to_fingerprint( + &profile, + Some("Europe/Berlin".into()), + Some("ZZ".into()), + None, + ); + assert!(result.checked, "the timezone was compared"); + assert_eq!(result.unverified, vec!["language"]); + assert!(!result.is_verified()); + assert!(!result.is_mismatch()); + } + + #[test] + fn only_a_fully_compared_agreement_reads_as_verified() { + let profile = profile_with_fingerprint("Europe/Berlin", "de-DE"); + let result = compare_exit_to_fingerprint( + &profile, + Some("Europe/Berlin".into()), + Some("DE".into()), + Some("1.2.3.4".into()), + ); + assert!(result.is_verified()); + assert!(result.unverified.is_empty()); + assert!(!result.is_mismatch()); + } + + #[test] + fn a_measured_mismatch_is_still_the_only_blocking_state() { + let profile = profile_with_fingerprint("America/New_York", "en-US"); + let result = compare_exit_to_fingerprint( + &profile, + Some("Europe/Berlin".into()), + Some("DE".into()), + Some("1.2.3.4".into()), + ); + assert!(result.is_mismatch()); + assert!(!result.is_verified()); + // A verdict that blocks must not be diluted into "unverified". + assert!(result.unverified.is_empty()); + } + + #[test] + fn skip_reads_as_neither_verified_nor_mismatched() { + let result = ConsistencyResult::skip(); + assert!(!result.is_verified()); + assert!(!result.is_mismatch()); + assert!(result.unverified.is_empty()); + } + + #[test] + fn unverifiable_dimensions_are_answered_without_measuring_anything() { + // Pure and local, so the pre-launch report can say "this cannot be checked" + // before a single worker starts. + assert_eq!( + unverifiable_dimensions(&profile_with_fingerprint("Europe/Berlin", "de-DE")), + Vec::::new() + ); + assert_eq!( + unverifiable_dimensions(&profile_with_raw_fingerprint( + serde_json::json!({ "language": "de-DE" }) + )), + vec!["timezone"] + ); + assert_eq!( + unverifiable_dimensions(&profile_with_raw_fingerprint( + serde_json::json!({ "platform": "Win32" }) + )), + CHECKED_DIMENSIONS.to_vec() + ); + } + + #[test] + fn a_probe_that_could_compare_nothing_is_not_pending_work() { + assert!(can_verify_anything(&profile_with_fingerprint( + "Europe/Berlin", + "de-DE" + ))); + // One dimension left is still worth probing for. + assert!(can_verify_anything(&profile_with_raw_fingerprint( + serde_json::json!({ "language": "de-DE" }) + ))); + // Nothing left: promising the user the launch will check it would be a + // promise the gate cannot keep. + assert!(!can_verify_anything(&profile_with_raw_fingerprint( + serde_json::json!({ "platform": "Win32" }) + ))); + } + + #[test] + fn an_unverified_dimension_survives_serialization_to_the_ui() { + let profile = profile_with_raw_fingerprint(serde_json::json!({ "language": "de-DE" })); + let result = compare_exit_to_fingerprint( + &profile, + Some("Europe/Berlin".into()), + Some("DE".into()), + None, + ); + let encoded = serde_json::to_value(&result).expect("serializable"); + assert_eq!(encoded["unverified"], serde_json::json!(["timezone"])); + // Older payloads without the field must still decode, defaulting to "we + // were told nothing", not to a silent pass. + let legacy: ConsistencyResult = serde_json::from_str( + r#"{"consistent":true,"checked":false,"exit_ip":null,"exit_country_code":null, + "exit_timezone":null,"fingerprint_timezone":null,"fingerprint_language":null, + "mismatches":[]}"#, + ) + .expect("legacy payloads stay decodable"); + assert!(legacy.unverified.is_empty()); + assert!(!legacy.is_verified()); } #[test] diff --git a/src-tauri/src/geoip_downloader.rs b/src-tauri/src/geoip_downloader.rs index 48771c1..5ad15c1 100644 --- a/src-tauri/src/geoip_downloader.rs +++ b/src-tauri/src/geoip_downloader.rs @@ -53,6 +53,15 @@ impl GeoIPDownloader { Ok(Self::get_cache_dir().join("GeoLite2-City.mmdb")) } + /// Where the autonomous-system database lives. It is the only MaxMind file + /// that carries an organisation for an address, which is what a proxy check + /// reports as the exit's ISP; the city database has no such field. Same + /// release, same publisher, fetched by the same code — it is simply a second + /// asset off the download the city database already comes from. + pub fn get_asn_mmdb_file_path() -> Result> { + Ok(Self::get_cache_dir().join("GeoLite2-ASN.mmdb")) + } + pub fn is_geoip_database_available() -> bool { if let Ok(mmdb_path) = Self::get_mmdb_file_path() { mmdb_path.exists() @@ -99,12 +108,15 @@ impl GeoIPDownloader { } fn find_city_mmdb_asset(&self, release: &GithubRelease) -> Option { - for asset in &release.assets { - if asset.name.ends_with("-City.mmdb") { - return Some(asset.browser_download_url.clone()); - } - } - None + Self::find_mmdb_asset(release, "-City.mmdb") + } + + fn find_mmdb_asset(release: &GithubRelease, suffix: &str) -> Option { + release + .assets + .iter() + .find(|asset| asset.name.ends_with(suffix)) + .map(|asset| asset.browser_download_url.clone()) } pub async fn download_geoip_database( @@ -148,14 +160,26 @@ impl GeoIPDownloader { #[cfg(not(feature = "e2e"))] let fixture_url: Option = None; - let download_url = if let Some(url) = fixture_url { - url + #[cfg(feature = "e2e")] + let asn_fixture_url = std::env::var("DONUT_E2E_GEOIP_ASN_DOWNLOAD_URL") + .ok() + .filter(|url| !url.is_empty()); + #[cfg(not(feature = "e2e"))] + let asn_fixture_url: Option = None; + + // The ASN asset comes off the same release as the city one, so the release + // is kept rather than looked up twice. + let (download_url, asn_url) = if let Some(url) = fixture_url { + (url, asn_fixture_url) } else { let releases = self.fetch_geoip_releases().await?; let latest_release = releases.first().ok_or("No GeoIP database releases found")?; - self - .find_city_mmdb_asset(latest_release) - .ok_or("No compatible GeoIP database asset found")? + ( + self + .find_city_mmdb_asset(latest_release) + .ok_or("No compatible GeoIP database asset found")?, + Self::find_mmdb_asset(latest_release, "-ASN.mmdb"), + ) }; // Create cache directory @@ -250,6 +274,15 @@ impl GeoIPDownloader { .as_secs(); let _ = fs::write(×tamp_path, now.to_string()).await; + // The autonomous-system database, best effort. It only feeds the exit + // organisation a proxy check reports, so a failure here must never fail + // the download that fingerprint geolocation actually depends on. + if let Some(url) = asn_url { + if let Err(e) = self.download_asn_database(&url).await { + log::warn!("Failed to download the GeoIP ASN database: {e}"); + } + } + // Emit completion let _ = events::emit( "geoip-download-progress", @@ -267,6 +300,34 @@ impl GeoIPDownloader { Ok(()) } + /// Fetch the ASN database to a temp file and rename it into place, so a + /// half-written file is never left where a lookup would read it. + async fn download_asn_database( + &self, + url: &str, + ) -> Result<(), Box> { + let path = Self::get_asn_mmdb_file_path()?; + let temp_path = path.with_extension("mmdb.downloading"); + let _ = fs::remove_file(&temp_path).await; + + let response = self.client.get(url).send().await?; + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status()).into()); + } + + let mut file = fs::File::create(&temp_path).await?; + let mut stream = response.bytes_stream(); + use futures_util::StreamExt; + while let Some(chunk) = stream.next().await { + file.write_all(&chunk?).await?; + } + file.flush().await?; + drop(file); + + fs::rename(&temp_path, &path).await?; + Ok(()) + } + async fn fetch_geoip_releases( &self, ) -> Result, Box> { diff --git a/src-tauri/src/geolocation.rs b/src-tauri/src/geolocation.rs index 14bd2bb..22f58eb 100644 --- a/src-tauri/src/geolocation.rs +++ b/src-tauri/src/geolocation.rs @@ -261,6 +261,84 @@ fn normalize_locale(locale: &str) -> Locale { Locale { language, region } } +/// What the bundled MaxMind data says about an exit address, beyond the +/// city and country a check already reports. +/// +/// Everything here is read from the databases already on disk. A proxy check +/// must not hand the exit address to a third-party lookup service: that would +/// tell an outside party which addresses this machine is testing, which is the +/// opposite of what the proxy is for. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ExitInsight { + pub timezone: Option, + /// The ISP, the registered organisation, or the autonomous system's + /// organisation, whichever the installed databases carry. `None` means "not + /// known", never "none". + pub organization: Option, +} + +/// Read timezone and organisation for `ip` out of the local databases. +/// +/// Never fails: a missing database, an unroutable address or a record without +/// the field all resolve to `None`, because "unknown" is the honest answer and +/// a check should still report everything else it learned. +pub fn lookup_exit_insight(ip: &str) -> ExitInsight { + let Ok(ip_addr) = IpAddr::from_str(ip) else { + return ExitInsight::default(); + }; + + let mut insight = ExitInsight::default(); + + if let Ok(path) = GeoIPDownloader::get_mmdb_file_path() { + if let Ok(reader) = Reader::open_readfile(&path) { + if let Ok(lookup) = reader.lookup(ip_addr) { + if let Ok(Some(city)) = lookup.decode::() { + insight.timezone = city.location.time_zone.map(|tz| tz.to_string()); + } + } + // The City database carries no organisation, but the same reader decodes + // one when the file in place is an ISP or Enterprise database instead. + if let Ok(lookup) = reader.lookup(ip_addr) { + if let Ok(Some(isp)) = lookup.decode::() { + insight.organization = first_non_empty([ + isp.isp, + isp.organization, + isp.autonomous_system_organization, + ]); + } + } + } + } + + if insight.organization.is_none() { + insight.organization = lookup_asn_organization(ip_addr); + } + + insight +} + +/// The autonomous system's organisation, from the ASN database that ships +/// alongside the city one. Absent on an install that has only ever fetched the +/// city database, which is why the caller treats `None` as "unknown". +fn lookup_asn_organization(ip_addr: IpAddr) -> Option { + let path = GeoIPDownloader::get_asn_mmdb_file_path().ok()?; + if !path.exists() { + return None; + } + let reader = Reader::open_readfile(&path).ok()?; + let asn: geoip2::Asn = reader.lookup(ip_addr).ok()?.decode().ok()??; + first_non_empty([asn.autonomous_system_organization]) +} + +fn first_non_empty(candidates: [Option<&str>; N]) -> Option { + candidates + .into_iter() + .flatten() + .map(str::trim) + .find(|value| !value.is_empty()) + .map(|value| value.to_string()) +} + pub fn get_geolocation(ip: &str) -> Result { let mmdb_path = GeoIPDownloader::get_mmdb_file_path().map_err(|_| GeolocationError::DatabaseNotFound)?; diff --git a/src-tauri/src/group_bookmarks.rs b/src-tauri/src/group_bookmarks.rs new file mode 100644 index 0000000..2e9f6f8 --- /dev/null +++ b/src-tauri/src/group_bookmarks.rs @@ -0,0 +1,1175 @@ +//! Bookmarks a profile group shares with every profile in it. +//! +//! A group owns an ordered list of `title` + `url` (+ optional folder). Before +//! a launch the list is written into the profile's Chromium `Bookmarks` file +//! inside ONE folder Donut owns. Everything else in that file — the bookmark +//! bar, the other-bookmarks tree, whatever the person browsing saved — is +//! parsed, left alone, and written back byte for byte. +//! +//! Chromium is unforgiving about this file: a structure it cannot decode is +//! dropped and the person loses every bookmark they had. So the writer never +//! regenerates the document. It parses the real JSON, replaces the children of +//! the one folder it owns, and re-serializes. A file that does not parse is +//! refused outright rather than overwritten, because a half-read file plus a +//! confident rewrite is exactly how bookmarks disappear. +//! +//! The write is idempotent by construction: the managed folder is found by its +//! marker, its previous position, ids, GUIDs and `date_added` stamps are +//! carried forward for entries that are still in the group, and a launch that +//! changes nothing does not touch the file at all. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use crate::profile::types::BrowserProfile; + +/// The folder Donut owns inside the bookmark bar. Users see this name. +pub const MANAGED_FOLDER_NAME: &str = "Donut Group Bookmarks"; + +/// Marker written into the folder's `meta_info`, so the folder is still +/// recognised after someone renames it. Chromium round-trips `meta_info` +/// verbatim and never includes it in the file checksum. +const MANAGED_MARKER_KEY: &str = "donut_managed_group_bookmarks"; +const MANAGED_MARKER_VALUE: &str = "1"; + +/// The profile subdirectory Chromium reads when no `--profile-directory` is +/// passed. Donut never passes one. +const INITIAL_PROFILE_DIR: &str = "Default"; + +/// One bookmark shared by every profile in a group. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GroupBookmark { + /// What the bookmark is called in the bar. + pub title: String, + /// An `http` or `https` address. Every other scheme is refused. + pub url: String, + /// Optional sub-folder inside the managed folder. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub folder: Option, +} + +/// Trim and check a bookmark list before it is stored on a group. +/// +/// Same allowlist the browser-navigation surface uses, minus `about:blank`: +/// a bookmark to a blank tab is not a bookmark, and `file:`, `data:`, +/// `javascript:` and friends must never be written into a profile that an +/// automation client can then be told to open. +pub fn validate(bookmarks: Vec) -> Result, String> { + let mut cleaned = Vec::with_capacity(bookmarks.len()); + for bookmark in bookmarks { + let title = bookmark.title.trim().to_string(); + if title.is_empty() { + return Err(json!({ "code": "NAME_CANNOT_BE_EMPTY" }).to_string()); + } + let url = bookmark.url.trim().to_string(); + if !crate::mcp_server::is_navigable_url(&url) || url.eq_ignore_ascii_case("about:blank") { + return Err(json!({ "code": "URL_SCHEME_NOT_ALLOWED" }).to_string()); + } + let folder = bookmark + .folder + .map(|f| f.trim().to_string()) + .filter(|f| !f.is_empty()); + cleaned.push(GroupBookmark { title, url, folder }); + } + Ok(cleaned) +} + +/// Chromium timestamps are microseconds since 1601-01-01 UTC, as a decimal +/// string. 11644473600 seconds separate that epoch from the Unix one. +const WINDOWS_EPOCH_OFFSET_MICROS: u64 = 11_644_473_600_000_000; + +fn chromium_now() -> String { + let unix_micros = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_micros() as u64) + .unwrap_or(0); + (unix_micros + WINDOWS_EPOCH_OFFSET_MICROS).to_string() +} + +/// The three permanent roots, in the order Chromium decodes them. The checksum +/// walks them in exactly this order, so it must not change. +const ROOT_KEYS: [&str; 3] = ["bookmark_bar", "other", "synced"]; + +fn permanent_root(id: &str, name: &str, now: &str) -> Value { + json!({ + "children": [], + "date_added": now, + "date_modified": now, + "guid": uuid::Uuid::new_v4().to_string(), + "id": id, + "name": name, + "type": "folder", + }) +} + +/// A minimal document Chromium accepts, used only when the profile has never +/// had a `Bookmarks` file. +fn empty_document() -> Value { + let now = chromium_now(); + json!({ + "checksum": "", + "roots": { + "bookmark_bar": permanent_root("1", "Bookmarks bar", &now), + "other": permanent_root("2", "Other bookmarks", &now), + "synced": permanent_root("3", "Mobile bookmarks", &now), + }, + "version": 1, + }) +} + +fn is_managed_folder(node: &Value) -> bool { + if node.get("type").and_then(Value::as_str) != Some("folder") { + return false; + } + let marked = node + .get("meta_info") + .and_then(Value::as_object) + .and_then(|m| m.get(MANAGED_MARKER_KEY)) + .and_then(Value::as_str) + == Some(MANAGED_MARKER_VALUE); + marked || node.get("name").and_then(Value::as_str) == Some(MANAGED_FOLDER_NAME) +} + +/// Highest numeric `id` anywhere in the document, so new nodes never collide +/// with an existing one. A duplicate id makes Chromium renumber the whole tree +/// on load, which is harmless but rewrites a file nobody asked it to rewrite. +fn max_id_in_node(node: &Value, current: &mut u64) { + if let Some(id) = node + .get("id") + .and_then(Value::as_str) + .and_then(|s| s.parse::().ok()) + { + *current = (*current).max(id); + } + if let Some(children) = node.get("children").and_then(Value::as_array) { + for child in children { + max_id_in_node(child, current); + } + } +} + +fn max_id(document: &Value, current: &mut u64) { + let Some(roots) = document.get("roots") else { + return; + }; + for key in ROOT_KEYS { + if let Some(root) = roots.get(key) { + max_id_in_node(root, current); + } + } +} + +/// Identity of a bookmark inside the managed folder, used to carry an existing +/// node's id, GUID and creation stamp across a rewrite. +type BookmarkKey = (String, String, String); + +fn bookmark_key(folder: Option<&str>, title: &str, url: &str) -> BookmarkKey { + ( + folder.unwrap_or_default().to_string(), + title.to_string(), + url.to_string(), + ) +} + +#[derive(Default)] +struct CarriedOver { + urls: HashMap, + folders: HashMap, + root: Option, +} + +/// Index the folder Donut owns so a rewrite can keep every stamp it can. +fn carry_over(existing: Option<&Value>) -> CarriedOver { + let mut carried = CarriedOver { + root: existing.cloned(), + ..Default::default() + }; + let Some(children) = existing + .and_then(|n| n.get("children")) + .and_then(Value::as_array) + else { + return carried; + }; + for child in children { + let name = child + .get("name") + .and_then(Value::as_str) + .unwrap_or_default(); + match child.get("type").and_then(Value::as_str) { + Some("folder") => { + carried.folders.insert(name.to_string(), child.clone()); + if let Some(nested) = child.get("children").and_then(Value::as_array) { + for leaf in nested { + if leaf.get("type").and_then(Value::as_str) != Some("url") { + continue; + } + let key = bookmark_key( + Some(name), + leaf.get("name").and_then(Value::as_str).unwrap_or_default(), + leaf.get("url").and_then(Value::as_str).unwrap_or_default(), + ); + carried.urls.insert(key, leaf.clone()); + } + } + } + Some("url") => { + let key = bookmark_key( + None, + child + .get("name") + .and_then(Value::as_str) + .unwrap_or_default(), + child.get("url").and_then(Value::as_str).unwrap_or_default(), + ); + carried.urls.insert(key, child.clone()); + } + _ => {} + } + } + carried +} + +fn stamp_of(previous: Option<&Value>, now: &str) -> String { + previous + .and_then(|n| n.get("date_added")) + .and_then(Value::as_str) + .unwrap_or(now) + .to_string() +} + +fn guid_of(previous: Option<&Value>) -> String { + previous + .and_then(|n| n.get("guid")) + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()) +} + +/// Hands a node the id it already had, so an unchanged group rebuilds to the +/// exact document that is already on disk and no relaunch rewrites the file. +/// A node with no history — or one whose id a duplicate entry already claimed — +/// gets a fresh number above everything in the document. +struct IdSource { + next: u64, + taken: std::collections::HashSet, +} + +impl IdSource { + fn take(&mut self, previous: Option<&Value>) -> String { + if let Some(id) = previous.and_then(|n| n.get("id")).and_then(Value::as_str) { + if self.taken.insert(id.to_string()) { + return id.to_string(); + } + } + loop { + self.next += 1; + let candidate = self.next.to_string(); + if self.taken.insert(candidate.clone()) { + return candidate; + } + } + } +} + +fn url_node( + bookmark: &GroupBookmark, + previous: Option<&Value>, + ids: &mut IdSource, + now: &str, +) -> Value { + json!({ + "date_added": stamp_of(previous, now), + "guid": guid_of(previous), + "id": ids.take(previous), + "name": bookmark.title, + "type": "url", + "url": bookmark.url, + }) +} + +/// Build the managed folder from the group's list, keeping the declared order. +/// A sub-folder appears at the position of its first member. +fn build_managed_folder( + bookmarks: &[GroupBookmark], + carried: &CarriedOver, + ids: &mut IdSource, + now: &str, +) -> Value { + let mut children: Vec = Vec::new(); + let mut folder_slots: HashMap = HashMap::new(); + + for bookmark in bookmarks { + let previous = carried.urls.get(&bookmark_key( + bookmark.folder.as_deref(), + &bookmark.title, + &bookmark.url, + )); + let leaf = url_node(bookmark, previous, ids, now); + + let Some(folder_name) = bookmark.folder.as_deref() else { + children.push(leaf); + continue; + }; + + if let Some(&slot) = folder_slots.get(folder_name) { + if let Some(list) = children[slot] + .get_mut("children") + .and_then(Value::as_array_mut) + { + list.push(leaf); + } + continue; + } + + let previous_folder = carried.folders.get(folder_name); + let folder = json!({ + "children": [leaf], + "date_added": stamp_of(previous_folder, now), + "date_modified": stamp_of(previous_folder, now), + "guid": guid_of(previous_folder), + "id": ids.take(previous_folder), + "name": folder_name, + "type": "folder", + }); + folder_slots.insert(folder_name.to_string(), children.len()); + children.push(folder); + } + + json!({ + "children": children, + "date_added": stamp_of(carried.root.as_ref(), now), + "date_modified": now, + "guid": guid_of(carried.root.as_ref()), + "id": ids.take(carried.root.as_ref()), + "meta_info": { MANAGED_MARKER_KEY: MANAGED_MARKER_VALUE }, + "name": MANAGED_FOLDER_NAME, + "type": "folder", + }) +} + +/// Two folder nodes describe the same bookmarks, ignoring the `date_modified` +/// stamp that moves on every write. +fn same_content(left: &Value, right: &Value) -> bool { + let strip = |node: &Value| { + let mut copy = node.clone(); + if let Some(object) = copy.as_object_mut() { + object.remove("date_modified"); + } + copy + }; + strip(left) == strip(right) +} + +fn roots_mut(document: &mut Value) -> Result<&mut Map, String> { + if !document.is_object() { + return Err("Bookmarks file is not a JSON object".to_string()); + } + let now = chromium_now(); + let object = document + .as_object_mut() + .ok_or_else(|| "Bookmarks file is not a JSON object".to_string())?; + object.entry("version").or_insert_with(|| json!(1)); + let roots = object.entry("roots").or_insert_with(|| json!({})); + let roots = roots + .as_object_mut() + .ok_or_else(|| "Bookmarks roots is not a JSON object".to_string())?; + for (key, id, name) in [ + ("bookmark_bar", "1", "Bookmarks bar"), + ("other", "2", "Other bookmarks"), + ("synced", "3", "Mobile bookmarks"), + ] { + roots + .entry(key) + .or_insert_with(|| permanent_root(id, name, &now)); + } + Ok(roots) +} + +/// Replace the managed folder's contents inside `user_data_dir`. +/// +/// Returns whether the file was written. `Ok(false)` means the folder already +/// said exactly this, which is the normal answer for a relaunch. +pub fn apply_managed_folder( + user_data_dir: &Path, + bookmarks: &[GroupBookmark], +) -> Result { + let profile_dir = user_data_dir.join(INITIAL_PROFILE_DIR); + let file = profile_dir.join("Bookmarks"); + + let mut document = match std::fs::read_to_string(&file) { + Ok(raw) if raw.trim().is_empty() => empty_document(), + Ok(raw) => serde_json::from_str::(&raw).map_err(|e| { + // Never overwrite what could not be read: the file may still hold every + // bookmark this person has, and Chromium keeps its own `Bookmarks.bak`. + format!("Refusing to rewrite an unreadable Bookmarks file at {file:?}: {e}") + })?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + if bookmarks.is_empty() { + return Ok(false); + } + empty_document() + } + Err(e) => return Err(format!("Could not read {file:?}: {e}")), + }; + + let mut ids = IdSource { + next: 0, + taken: std::collections::HashSet::new(), + }; + max_id(&document, &mut ids.next); + + // The managed folder is lifted out of the bar first, so the rest of the file + // is never rebuilt: everything that comes back is the caller's own bytes. + let (previous, previous_slot) = { + let roots = roots_mut(&mut document)?; + let bar = roots + .get_mut("bookmark_bar") + .and_then(Value::as_object_mut) + .ok_or_else(|| "Bookmarks bar root is not a JSON object".to_string())?; + let children = bar + .entry("children") + .or_insert_with(|| json!([])) + .as_array_mut() + .ok_or_else(|| "Bookmarks bar children is not an array".to_string())?; + let slot = children.iter().position(is_managed_folder); + let existing = slot.map(|at| children[at].clone()); + children.retain(|child| !is_managed_folder(child)); + (existing, slot) + }; + + if bookmarks.is_empty() { + if previous.is_none() { + return Ok(false); + } + let checksum = compute_checksum(&document); + return write_document(&profile_dir, &file, &mut document, checksum); + } + + let carried = carry_over(previous.as_ref()); + let folder = build_managed_folder(bookmarks, &carried, &mut ids, &chromium_now()); + + // The relaunch case: nothing about the group changed, so nothing is written + // and the file's mtime (and its sync manifest entry) stays put. + let unchanged = previous + .as_ref() + .is_some_and(|existing| same_content(existing, &folder)); + if unchanged { + return Ok(false); + } + + { + let roots = roots_mut(&mut document)?; + let children = roots + .get_mut("bookmark_bar") + .and_then(|bar| bar.get_mut("children")) + .and_then(Value::as_array_mut) + .ok_or_else(|| "Bookmarks bar children is not an array".to_string())?; + match previous_slot { + Some(slot) if slot <= children.len() => children.insert(slot, folder), + _ => children.push(folder), + } + } + + let checksum = compute_checksum(&document); + write_document(&profile_dir, &file, &mut document, checksum) +} + +fn write_document( + profile_dir: &Path, + file: &Path, + document: &mut Value, + checksum: String, +) -> Result { + if let Some(object) = document.as_object_mut() { + object.insert("checksum".to_string(), Value::String(checksum)); + } + std::fs::create_dir_all(profile_dir) + .map_err(|e| format!("Could not create {profile_dir:?}: {e}"))?; + let serialized = + serde_json::to_string(document).map_err(|e| format!("Could not serialize bookmarks: {e}"))?; + // Rename over the real file so a crash mid-write cannot leave Chromium a + // truncated document to discard. + let temporary = file.with_extension("donut-tmp"); + std::fs::write(&temporary, serialized.as_bytes()) + .map_err(|e| format!("Could not write {temporary:?}: {e}"))?; + std::fs::rename(&temporary, file).map_err(|e| { + let _ = std::fs::remove_file(&temporary); + format!("Could not replace {file:?}: {e}") + })?; + Ok(true) +} + +/// Chromium's `BookmarkCodec` checksum: MD5 over a pre-order walk of the three +/// permanent roots, feeding each node's id, its title as UTF-16 code units, +/// its type, and, for a bookmark, its URL exactly as written. +/// +/// A mismatch is not fatal — Chromium renumbers and re-saves — but a correct +/// one means the browser opens the file we wrote without rewriting it. +fn compute_checksum(document: &Value) -> String { + let mut md5 = Md5::new(); + if let Some(roots) = document.get("roots") { + for key in ROOT_KEYS { + if let Some(root) = roots.get(key) { + checksum_node(root, &mut md5); + } + } + } + md5.finish_hex() +} + +fn checksum_node(node: &Value, md5: &mut Md5) { + let id = node.get("id").and_then(Value::as_str).unwrap_or_default(); + let title = node.get("name").and_then(Value::as_str).unwrap_or_default(); + md5.update(id.as_bytes()); + for unit in title.encode_utf16() { + md5.update(&unit.to_le_bytes()); + } + if node.get("type").and_then(Value::as_str) == Some("url") { + md5.update(b"url"); + md5.update( + node + .get("url") + .and_then(Value::as_str) + .unwrap_or_default() + .as_bytes(), + ); + return; + } + md5.update(b"folder"); + if let Some(children) = node.get("children").and_then(Value::as_array) { + for child in children { + checksum_node(child, md5); + } + } +} + +/// Resolve the group bookmarks a profile should carry, or `None` when it is in +/// no group. +fn bookmarks_for(profile: &BrowserProfile) -> Option> { + let group_id = profile.group_id.as_deref()?; + let manager = crate::group_manager::GROUP_MANAGER.lock().ok()?; + let groups = manager.get_all_groups().ok()?; + groups + .into_iter() + .find(|g| g.id == group_id) + .map(|g| g.bookmarks) +} + +/// Whether this profile's `Bookmarks` file may be rewritten right now. +/// +/// A running browser holds the file and rewrites it from memory on exit, so a +/// write underneath it is thrown away at best. An ephemeral or temporary +/// profile is destroyed when its run ends, so shared bookmarks have nothing to +/// persist into. A password-protected profile keeps its plaintext only in a +/// RAM-backed copy; the on-disk directory is ciphertext and a JSON document +/// dropped into it would be unreadable to the profile and corrupt to the tool +/// that decrypts it. +fn is_writable(profile: &BrowserProfile) -> bool { + !profile.ephemeral + && !profile.temporary + && !profile.password_protected + && !crate::profile::trash::is_running_locally(profile) +} + +fn profile_user_data_dir(profile: &BrowserProfile) -> PathBuf { + let profiles_dir = crate::profile::ProfileManager::instance().get_profiles_dir(); + profile.get_profile_data_path(&profiles_dir) +} + +/// Bring one profile's managed folder up to date with its group. +/// +/// `Ok(false)` means nothing needed writing: the profile is in no group, its +/// folder already says exactly this, or it is a kind of profile shared +/// bookmarks do not apply to. +pub fn sync_profile(profile: &BrowserProfile) -> Result { + if !is_writable(profile) { + return Ok(false); + } + let Some(bookmarks) = bookmarks_for(profile) else { + return Ok(false); + }; + apply_managed_folder(&profile_user_data_dir(profile), &bookmarks) +} + +/// The pre-spawn write. Called once per real browser launch, before the +/// browser process exists and while the profile is provably not running. +pub fn sync_for_launch(profile: &BrowserProfile) { + match sync_profile(profile) { + Ok(true) => log::info!("Wrote group bookmarks into profile {}", profile.name), + Ok(false) => {} + // Never blocks a launch. The browser opening without today's shared + // bookmarks is a smaller failure than the browser not opening. + Err(e) => log::warn!( + "Could not write group bookmarks for profile {}: {e}", + profile.name + ), + } +} + +/// Tauri command: write a profile's group bookmarks now, without launching. +/// +/// The launch does this by itself; this is for pushing an edit out to profiles +/// that are sitting stopped. Returns whether the file changed. +#[tauri::command] +pub async fn apply_group_bookmarks_to_profile(profile_id: String) -> Result { + let profiles = crate::profile::ProfileManager::instance() + .list_profiles() + .map_err(|e| e.to_string())?; + let profile = profiles + .into_iter() + .find(|profile| profile.id.to_string() == profile_id) + .ok_or_else(|| json!({ "code": "PROFILE_NOT_FOUND" }).to_string())?; + if crate::profile::trash::is_running_locally(&profile) { + return Err(json!({ "code": "PROFILE_RUNNING" }).to_string()); + } + sync_profile(&profile) +} + +/// Tauri command: read a group's bookmark list. +#[tauri::command] +pub async fn get_group_bookmarks(group_id: String) -> Result, String> { + let manager = crate::group_manager::GROUP_MANAGER + .lock() + .map_err(|_| json!({ "code": "INTERNAL_ERROR" }).to_string())?; + let groups = manager.get_all_groups().map_err(|e| e.to_string())?; + groups + .into_iter() + .find(|g| g.id == group_id) + .map(|g| g.bookmarks) + .ok_or_else(|| json!({ "code": "GROUP_NOT_FOUND" }).to_string()) +} + +/// Tauri command: replace a group's bookmark list. +#[tauri::command] +pub async fn set_group_bookmarks( + app_handle: tauri::AppHandle, + group_id: String, + bookmarks: Vec, +) -> Result, String> { + let cleaned = validate(bookmarks)?; + let manager = crate::group_manager::GROUP_MANAGER + .lock() + .map_err(|_| json!({ "code": "INTERNAL_ERROR" }).to_string())?; + manager + .set_group_bookmarks(&app_handle, &group_id, cleaned.clone()) + .map_err(|e| e.to_string())?; + Ok(cleaned) +} + +// --- MD5, because Chromium's bookmark checksum is defined in terms of it --- + +const MD5_SHIFTS: [u32; 64] = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, + 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, + 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, +]; + +const MD5_SINE: [u32; 64] = [ + 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501, + 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821, + 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8, + 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, + 0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, + 0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, + 0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1, + 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391, +]; + +struct Md5 { + state: [u32; 4], + buffer: [u8; 64], + buffered: usize, + length: u64, +} + +impl Md5 { + fn new() -> Self { + Self { + state: [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476], + buffer: [0; 64], + buffered: 0, + length: 0, + } + } + + fn update(&mut self, mut data: &[u8]) { + self.length = self.length.wrapping_add(data.len() as u64); + while !data.is_empty() { + let take = (64 - self.buffered).min(data.len()); + self.buffer[self.buffered..self.buffered + take].copy_from_slice(&data[..take]); + self.buffered += take; + data = &data[take..]; + if self.buffered == 64 { + let block = self.buffer; + self.compress(&block); + self.buffered = 0; + } + } + } + + fn compress(&mut self, block: &[u8; 64]) { + let mut words = [0u32; 16]; + for (index, word) in words.iter_mut().enumerate() { + let start = index * 4; + *word = u32::from_le_bytes([ + block[start], + block[start + 1], + block[start + 2], + block[start + 3], + ]); + } + + let [mut a, mut b, mut c, mut d] = self.state; + for i in 0..64 { + let (mixed, index) = match i / 16 { + 0 => ((b & c) | (!b & d), i), + 1 => ((d & b) | (!d & c), (5 * i + 1) % 16), + 2 => (b ^ c ^ d, (3 * i + 5) % 16), + _ => (c ^ (b | !d), (7 * i) % 16), + }; + let rotated = mixed + .wrapping_add(a) + .wrapping_add(MD5_SINE[i]) + .wrapping_add(words[index]) + .rotate_left(MD5_SHIFTS[i]); + a = d; + d = c; + c = b; + b = b.wrapping_add(rotated); + } + + self.state[0] = self.state[0].wrapping_add(a); + self.state[1] = self.state[1].wrapping_add(b); + self.state[2] = self.state[2].wrapping_add(c); + self.state[3] = self.state[3].wrapping_add(d); + } + + fn finish_hex(mut self) -> String { + let bits = self.length.wrapping_mul(8); + self.update(&[0x80]); + // `update` counted the padding, so measure the tail from the buffer. + while self.buffered != 56 { + self.update(&[0]); + } + let block = { + let mut block = self.buffer; + block[56..].copy_from_slice(&bits.to_le_bytes()); + block + }; + self.compress(&block); + + let mut hex = String::with_capacity(32); + for word in self.state { + for byte in word.to_le_bytes() { + use std::fmt::Write; + let _ = write!(hex, "{byte:02x}"); + } + } + hex + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn md5_hex(input: &[u8]) -> String { + let mut md5 = Md5::new(); + md5.update(input); + md5.finish_hex() + } + + #[test] + fn md5_matches_the_published_vectors() { + // Chromium's checksum is only useful if this is really MD5. + assert_eq!(md5_hex(b""), "d41d8cd98f00b204e9800998ecf8427e"); + assert_eq!(md5_hex(b"abc"), "900150983cd24fb0d6963f7d28e17f72"); + assert_eq!( + md5_hex(b"message digest"), + "f96b697d7cb7938d525a2f31aaf161d0" + ); + assert_eq!( + md5_hex(b"abcdefghijklmnopqrstuvwxyz"), + "c3fcd3d76192e4007dfb496cca67e13b" + ); + // Longer than one block, and longer than the 56-byte padding boundary. + assert_eq!( + md5_hex(b"12345678901234567890123456789012345678901234567890123456789012345678901234567890"), + "57edf4a22be3c955ac49da2e2107b67a" + ); + } + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "donut-group-bookmarks-{name}-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(dir.join(INITIAL_PROFILE_DIR)).unwrap(); + dir + } + + fn bookmark(title: &str, url: &str) -> GroupBookmark { + GroupBookmark { + title: title.to_string(), + url: url.to_string(), + folder: None, + } + } + + fn read(dir: &Path) -> Value { + let raw = std::fs::read_to_string(dir.join(INITIAL_PROFILE_DIR).join("Bookmarks")).unwrap(); + serde_json::from_str(&raw).unwrap() + } + + fn bar_children(document: &Value) -> &Vec { + document["roots"]["bookmark_bar"]["children"] + .as_array() + .unwrap() + } + + fn managed(document: &Value) -> Vec<&Value> { + bar_children(document) + .iter() + .filter(|child| is_managed_folder(child)) + .collect() + } + + #[test] + fn writes_a_valid_file_when_the_profile_has_none() { + let dir = temp_dir("fresh"); + assert!(apply_managed_folder(&dir, &[bookmark("Docs", "https://docs.example")]).unwrap()); + + let document = read(&dir); + assert_eq!(document["version"], 1); + for key in ROOT_KEYS { + assert!(document["roots"][key].is_object(), "missing root {key}"); + } + let folders = managed(&document); + assert_eq!(folders.len(), 1); + assert_eq!(folders[0]["name"], MANAGED_FOLDER_NAME); + let entries = folders[0]["children"].as_array().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["type"], "url"); + assert_eq!(entries[0]["url"], "https://docs.example"); + assert_eq!(entries[0]["name"], "Docs"); + // Chromium parses these as decimal strings, not numbers. + assert!(entries[0]["date_added"] + .as_str() + .unwrap() + .parse::() + .is_ok()); + assert!(entries[0]["id"].as_str().unwrap().parse::().is_ok()); + std::fs::remove_dir_all(dir).ok(); + } + + #[test] + fn no_file_and_no_bookmarks_writes_nothing() { + let dir = temp_dir("nothing"); + assert!(!apply_managed_folder(&dir, &[]).unwrap()); + assert!(!dir.join(INITIAL_PROFILE_DIR).join("Bookmarks").exists()); + std::fs::remove_dir_all(dir).ok(); + } + + fn seed_user_file(dir: &Path) { + let file = json!({ + "checksum": "deadbeef", + "roots": { + "bookmark_bar": { + "children": [{ + "date_added": "13300000000000000", + "guid": "11111111-1111-4111-8111-111111111111", + "id": "7", + "name": "My Bank", + "type": "url", + "url": "https://bank.example/" + }], + "date_added": "13300000000000000", + "date_modified": "13300000000000000", + "guid": "22222222-2222-4222-8222-222222222222", + "id": "1", + "name": "Bookmarks bar", + "type": "folder" + }, + "other": { + "children": [{ + "date_added": "13300000000000000", + "guid": "33333333-3333-4333-8333-333333333333", + "id": "8", + "name": "Recipes", + "type": "url", + "url": "https://recipes.example/" + }], + "date_added": "13300000000000000", + "date_modified": "13300000000000000", + "guid": "44444444-4444-4444-8444-444444444444", + "id": "2", + "name": "Other bookmarks", + "type": "folder" + }, + "synced": { + "children": [], + "date_added": "13300000000000000", + "date_modified": "13300000000000000", + "guid": "55555555-5555-4555-8555-555555555555", + "id": "3", + "name": "Mobile bookmarks", + "type": "folder" + } + }, + "sync_metadata": "AAAA", + "version": 1 + }); + std::fs::write( + dir.join(INITIAL_PROFILE_DIR).join("Bookmarks"), + serde_json::to_string_pretty(&file).unwrap(), + ) + .unwrap(); + } + + #[test] + fn a_users_own_bookmarks_survive_the_write() { + let dir = temp_dir("preserve"); + seed_user_file(&dir); + assert!(apply_managed_folder(&dir, &[bookmark("Wiki", "https://wiki.example")]).unwrap()); + + let document = read(&dir); + let bar = bar_children(&document); + assert_eq!(bar.len(), 2); + assert_eq!(bar[0]["name"], "My Bank"); + assert_eq!(bar[0]["id"], "7"); + assert_eq!(bar[0]["guid"], "11111111-1111-4111-8111-111111111111"); + assert_eq!( + document["roots"]["other"]["children"][0]["name"], "Recipes", + "the other-bookmarks tree must be untouched" + ); + // Anything Chromium wrote that Donut does not understand has to come back. + assert_eq!(document["sync_metadata"], "AAAA"); + assert_ne!(document["checksum"], "deadbeef"); + std::fs::remove_dir_all(dir).ok(); + } + + #[test] + fn rewriting_the_same_list_changes_nothing() { + let dir = temp_dir("idempotent"); + seed_user_file(&dir); + let list = vec![ + bookmark("Wiki", "https://wiki.example"), + GroupBookmark { + title: "Ticket queue".to_string(), + url: "https://tickets.example".to_string(), + folder: Some("Internal".to_string()), + }, + ]; + + assert!(apply_managed_folder(&dir, &list).unwrap()); + let first = std::fs::read_to_string(dir.join(INITIAL_PROFILE_DIR).join("Bookmarks")).unwrap(); + + // Second launch: same group, so the file must not be touched at all. + assert!(!apply_managed_folder(&dir, &list).unwrap()); + let second = std::fs::read_to_string(dir.join(INITIAL_PROFILE_DIR).join("Bookmarks")).unwrap(); + assert_eq!(first, second); + + let document = read(&dir); + assert_eq!(managed(&document).len(), 1, "the folder must not duplicate"); + std::fs::remove_dir_all(dir).ok(); + } + + #[test] + fn removing_a_bookmark_from_the_group_removes_it_from_the_folder() { + let dir = temp_dir("removal"); + seed_user_file(&dir); + let full = vec![ + bookmark("Wiki", "https://wiki.example"), + bookmark("Status", "https://status.example"), + ]; + assert!(apply_managed_folder(&dir, &full).unwrap()); + assert_eq!( + managed(&read(&dir))[0]["children"] + .as_array() + .unwrap() + .len(), + 2 + ); + + assert!(apply_managed_folder(&dir, &full[..1]).unwrap()); + let document = read(&dir); + let entries = managed(&document)[0]["children"].as_array().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["name"], "Wiki"); + // The stamp of a surviving entry is carried over, not reset. + assert!(entries[0]["date_added"] + .as_str() + .unwrap() + .parse::() + .is_ok()); + + // Emptying the group takes the whole folder away and leaves the user's own. + assert!(apply_managed_folder(&dir, &[]).unwrap()); + let document = read(&dir); + assert!(managed(&document).is_empty()); + assert_eq!(bar_children(&document).len(), 1); + assert_eq!(bar_children(&document)[0]["name"], "My Bank"); + std::fs::remove_dir_all(dir).ok(); + } + + #[test] + fn the_folder_keeps_its_place_in_the_bar() { + let dir = temp_dir("position"); + seed_user_file(&dir); + assert!(apply_managed_folder(&dir, &[bookmark("Wiki", "https://wiki.example")]).unwrap()); + + // Someone drags the managed folder to the front of the bar. + let mut document = read(&dir); + let children = document["roots"]["bookmark_bar"]["children"] + .as_array_mut() + .unwrap(); + let folder = children.pop().unwrap(); + children.insert(0, folder); + std::fs::write( + dir.join(INITIAL_PROFILE_DIR).join("Bookmarks"), + serde_json::to_string(&document).unwrap(), + ) + .unwrap(); + + assert!(apply_managed_folder( + &dir, + &[ + bookmark("Wiki", "https://wiki.example"), + bookmark("Status", "https://status.example") + ] + ) + .unwrap()); + let document = read(&dir); + assert!(is_managed_folder(&bar_children(&document)[0])); + assert_eq!(bar_children(&document)[1]["name"], "My Bank"); + std::fs::remove_dir_all(dir).ok(); + } + + #[test] + fn the_written_checksum_is_the_one_chromium_computes() { + let dir = temp_dir("checksum"); + seed_user_file(&dir); + apply_managed_folder(&dir, &[bookmark("Wiki", "https://wiki.example")]).unwrap(); + + let document = read(&dir); + let stored = document["checksum"].as_str().unwrap().to_string(); + assert_eq!(stored.len(), 32); + // Recomputed from the file as read back: the value on disk describes the + // tree on disk, which is the whole point of the field. + assert_eq!(stored, compute_checksum(&document)); + + // The digest is over the id/title/type/url stream, so touching a title + // must move it. + let mut tampered = document.clone(); + tampered["roots"]["bookmark_bar"]["children"][0]["name"] = json!("Renamed"); + assert_ne!(stored, compute_checksum(&tampered)); + std::fs::remove_dir_all(dir).ok(); + } + + #[test] + fn an_unreadable_file_is_refused_rather_than_overwritten() { + let dir = temp_dir("corrupt"); + let file = dir.join(INITIAL_PROFILE_DIR).join("Bookmarks"); + std::fs::write(&file, "{ this is not json").unwrap(); + let error = apply_managed_folder(&dir, &[bookmark("Wiki", "https://wiki.example")]) + .expect_err("a file that cannot be parsed must not be rewritten"); + assert!(error.contains("Refusing to rewrite"), "{error}"); + assert_eq!( + std::fs::read_to_string(&file).unwrap(), + "{ this is not json" + ); + std::fs::remove_dir_all(dir).ok(); + } + + #[test] + fn nested_folders_group_their_members_in_order() { + let dir = temp_dir("folders"); + let list = vec![ + GroupBookmark { + title: "Console".to_string(), + url: "https://console.example".to_string(), + folder: Some("Ops".to_string()), + }, + bookmark("Home", "https://home.example"), + GroupBookmark { + title: "Runbook".to_string(), + url: "https://runbook.example".to_string(), + folder: Some("Ops".to_string()), + }, + ]; + apply_managed_folder(&dir, &list).unwrap(); + + let document = read(&dir); + let entries = managed(&document)[0]["children"].as_array().unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0]["type"], "folder"); + assert_eq!(entries[0]["name"], "Ops"); + let ops = entries[0]["children"].as_array().unwrap(); + assert_eq!(ops.len(), 2); + assert_eq!(ops[0]["name"], "Console"); + assert_eq!(ops[1]["name"], "Runbook"); + assert_eq!(entries[1]["name"], "Home"); + + // Every id in the document is unique, or Chromium renumbers on load. + let mut ids = Vec::new(); + fn collect(node: &Value, into: &mut Vec) { + if let Some(id) = node.get("id").and_then(Value::as_str) { + into.push(id.to_string()); + } + if let Some(children) = node.get("children").and_then(Value::as_array) { + for child in children { + collect(child, into); + } + } + } + for key in ROOT_KEYS { + collect(&document["roots"][key], &mut ids); + } + let unique: std::collections::HashSet<_> = ids.iter().collect(); + assert_eq!(unique.len(), ids.len(), "duplicate bookmark ids: {ids:?}"); + std::fs::remove_dir_all(dir).ok(); + } + + #[test] + fn only_http_and_https_urls_are_accepted() { + assert!(validate(vec![bookmark("Ok", "https://example.com/x")]).is_ok()); + assert!(validate(vec![bookmark("Ok", "http://example.com")]).is_ok()); + + for refused in [ + "file:///Users/someone/.ssh/id_rsa", + "data:text/html,", + "javascript:alert(1)", + "chrome://settings", + "about:blank", + "ftp://files.example", + "", + ] { + let error = + validate(vec![bookmark("Bad", refused)]).expect_err(&format!("{refused} must be refused")); + assert!( + error.contains("URL_SCHEME_NOT_ALLOWED"), + "{refused}: {error}" + ); + } + + let error = validate(vec![bookmark(" ", "https://example.com")]) + .expect_err("an empty title must be refused"); + assert!(error.contains("NAME_CANNOT_BE_EMPTY"), "{error}"); + } + + #[test] + fn validation_trims_and_drops_an_empty_folder_name() { + let cleaned = validate(vec![GroupBookmark { + title: " Docs ".to_string(), + url: " https://docs.example ".to_string(), + folder: Some(" ".to_string()), + }]) + .unwrap(); + assert_eq!(cleaned[0].title, "Docs"); + assert_eq!(cleaned[0].url, "https://docs.example"); + assert_eq!(cleaned[0].folder, None); + } +} diff --git a/src-tauri/src/group_manager.rs b/src-tauri/src/group_manager.rs index 2699dea..21c889b 100644 --- a/src-tauri/src/group_manager.rs +++ b/src-tauri/src/group_manager.rs @@ -4,11 +4,16 @@ use std::fs; use std::sync::Mutex; use crate::events; +use crate::group_bookmarks::GroupBookmark; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProfileGroup { pub id: String, pub name: String, + /// Bookmarks every profile in this group carries. Written into the profile's + /// Chromium `Bookmarks` file before each launch; see `group_bookmarks`. + #[serde(default)] + pub bookmarks: Vec, #[serde(default)] pub sync_enabled: bool, #[serde(default)] @@ -25,6 +30,8 @@ pub struct GroupWithCount { pub name: String, pub count: usize, #[serde(default)] + pub bookmark_count: usize, + #[serde(default)] pub sync_enabled: bool, #[serde(default)] pub last_sync: Option, @@ -104,6 +111,7 @@ impl GroupManager { let group = ProfileGroup { id: uuid::Uuid::new_v4().to_string(), name, + bookmarks: Vec::new(), sync_enabled, last_sync: None, updated_at: Some(crate::proxy_manager::now_secs()), @@ -195,6 +203,7 @@ impl GroupManager { if let Some(existing) = groups_data.groups.iter_mut().find(|g| g.id == group.id) { existing.name = group.name.clone(); + existing.bookmarks = group.bookmarks.clone(); existing.sync_enabled = group.sync_enabled; existing.last_sync = group.last_sync; existing.updated_at = group.updated_at; @@ -212,6 +221,7 @@ impl GroupManager { if let Some(existing) = groups_data.groups.iter_mut().find(|g| g.id == group.id) { existing.name = group.name.clone(); + existing.bookmarks = group.bookmarks.clone(); existing.sync_enabled = group.sync_enabled; existing.last_sync = group.last_sync; existing.updated_at = group.updated_at; @@ -294,6 +304,47 @@ impl GroupManager { Ok(()) } + /// Replace a group's shared bookmark list. + /// + /// Bumps `updated_at` because this is a real user edit, which is what sync's + /// last-write-wins reconcile reads; `last_sync` is bookkeeping and must not + /// decide direction. + pub fn set_group_bookmarks( + &self, + _app_handle: &tauri::AppHandle, + id: &str, + bookmarks: Vec, + ) -> Result> { + let mut groups_data = self.load_groups_data()?; + + let group = groups_data + .groups + .iter_mut() + .find(|g| g.id == id) + .ok_or_else(|| serde_json::json!({ "code": "GROUP_NOT_FOUND" }).to_string())?; + + group.bookmarks = bookmarks; + group.updated_at = Some(crate::proxy_manager::now_secs()); + let updated_group = group.clone(); + + self.save_groups_data(&groups_data)?; + + if let Err(e) = events::emit_empty("groups-changed") { + log::error!("Failed to emit groups-changed event: {e}"); + } + + if updated_group.sync_enabled { + if let Some(scheduler) = crate::sync::get_global_scheduler() { + let id = updated_group.id.clone(); + tauri::async_runtime::spawn(async move { + scheduler.queue_group_sync(id).await; + }); + } + } + + Ok(updated_group) + } + pub fn get_groups_with_profile_counts( &self, profiles: &[crate::profile::BrowserProfile], @@ -318,6 +369,7 @@ impl GroupManager { id: group.id, name: group.name, count, + bookmark_count: group.bookmarks.len(), sync_enabled: group.sync_enabled, last_sync: group.last_sync, }); diff --git a/src-tauri/src/launch_gate.rs b/src-tauri/src/launch_gate.rs index 014f8df..0e9530a 100644 --- a/src-tauri/src/launch_gate.rs +++ b/src-tauri/src/launch_gate.rs @@ -1,4 +1,10 @@ -//! The pre-spawn launch gate. +//! The pre-spawn gate. +//! +//! Runs on every real browser spawn, after the upstream has been normalized +//! and before any worker, decrypted copy or browser process exists. It answers +//! one question — may this launch proceed — and changes nothing else; the +//! launch path does its own preparation (the group's bookmarks, the blocklist) +//! around it. //! //! Two findings can stop a launch being what the user expects: //! @@ -126,11 +132,46 @@ fn mismatch_error(result: &ConsistencyResult, token: &str) -> String { "fingerprintTimezone": result.fingerprint_timezone.clone().unwrap_or_default(), "fingerprintLanguage": result.fingerprint_language.clone().unwrap_or_default(), "mismatches": result.mismatches.join(","), + "unverified": result.unverified.join(","), } }) .to_string() } +/// Say which of the three states a non-blocking check landed in. +/// +/// Only a measured disagreement stops a launch. The other two both continue - +/// and telling them apart is the whole point, because "the exit and the +/// fingerprint agree" and "nothing was compared" are different claims and the +/// second used to be reported as the first. +/// +/// Reaching an exit and finding nothing to compare is deliberately not a block: +/// a fingerprint whose geolocation probe failed carries no location at all, +/// since generation stopped inventing one, and refusing to start those profiles +/// would break profiles that are legitimately in that state. It is not a pass +/// either, so the launch says what it could not check. +fn report_consistency(profile: &BrowserProfile, result: &ConsistencyResult) { + if result.is_verified() { + log::debug!( + "Fingerprint gate: {} agrees with its exit on every dimension", + profile.name + ); + return; + } + if result.unverified.is_empty() { + return; + } + log::warn!( + "Fingerprint gate: {} reached its exit but could not verify {}; \ + the fingerprint declares no value to compare against", + profile.name, + result.unverified.join(", ") + ); + if let Err(e) = crate::events::emit("fingerprint-consistency-unverified", result) { + log::warn!("Failed to emit fingerprint consistency notice: {e}"); + } +} + fn gate_disabled() -> bool { crate::settings_manager::SettingsManager::instance() .load_settings() @@ -180,7 +221,8 @@ async fn enforce_direct_exit( return Ok(()); } }; - if !result.checked || result.consistent { + if !result.is_mismatch() { + report_consistency(profile, &result); return Ok(()); } @@ -188,8 +230,7 @@ async fn enforce_direct_exit( Err(mismatch_error(&result, &token)) } -/// The enforcing gate. Called from the launch pipeline once the upstream is -/// normalized and before anything expensive or user-visible happens. +/// The pre-spawn stage: prepare the profile, then gate the launch. /// /// Fails **open** on every degradation — probe failure, timeout, missing geo /// database, private exit IP. The gate blocks only on a positively measured @@ -260,7 +301,10 @@ pub async fn enforce_fingerprint_gate( } }; - if !result.checked || result.consistent { + if !result.is_mismatch() { + // A mismatch carries its own report, and the dialog it opens already lists + // whatever went uncompared alongside it. + report_consistency(profile, &result); return Ok(()); } @@ -294,9 +338,15 @@ pub struct PreLaunchChecks { pub scan_state: String, /// Cache-only; `checked` is false when the exit has not been measured yet. pub consistency: ConsistencyResult, - /// True when the enforcing gate will still probe during the launch, so the - /// UI can say the check is not finished rather than implying it passed. + /// True when the enforcing gate will still probe during the launch AND that + /// probe can actually compare something, so the UI can say the check is not + /// finished rather than implying it passed. pub exit_probe_pending: bool, + /// Dimensions no probe can ever verify for this profile, because its + /// fingerprint declares no value to compare. Answered locally, with no + /// measurement. Informational and never a block, but the launch must not + /// read as verified on a dimension nothing will compare. + pub exit_unverified: Vec, /// An extension holding the `proxy` permission is present, so any exit /// measurement describes a route the browser may not take. Informational /// only — it never relaxes the block. @@ -357,12 +407,22 @@ pub async fn get_profile_pre_launch_checks(profile_id: String) -> Result Some(mint_consent(&profile, &k.identity)), _ => None, }; + // Only meaningful when an exit check is going to happen at all: an + // acknowledged profile, a disabled gate, or a profile with no route never + // measures an exit, so there is nothing it failed to verify. + let gate_will_measure = !disabled && !already_acked && key.is_some(); + let exit_unverified = if gate_will_measure { + fingerprint_consistency::unverifiable_dimensions(&profile) + } else { + Vec::new() + }; + Ok(PreLaunchChecks { vpn_extensions, scan_state: scan.scan_state, @@ -371,7 +431,14 @@ pub async fn get_profile_pre_launch_checks(profile_id: String) -> Result WindowExt for WebviewWindow { } } +/// True when the app runs under a headless automation driver: the `e2e` +/// feature is compiled in, the tauri-wd WebDriver launched this process +/// (`TAURI_AUTOMATION`), and the session asked for the `headless` capability +/// (`TAURI_WEBDRIVER_HEADLESS`). In that mode the app must not create, show or +/// focus a visible window, so an e2e run never steals focus from whatever the +/// user is working in. Gated on the feature so a production binary ignores the +/// variables even when a shell exports them: only the e2e harness links the +/// plugin that keeps a concealed window rendering. +fn headless_automation() -> bool { + fn truthy(key: &str) -> bool { + std::env::var(key) + .map(|value| { + let value = value.trim(); + value == "1" || value.eq_ignore_ascii_case("true") + }) + .unwrap_or(false) + } + cfg!(feature = "e2e") && truthy("TAURI_AUTOMATION") && truthy("TAURI_WEBDRIVER_HEADLESS") +} + // Called internally for deep-link / startup URL handling — not invoked from the // frontend, so it is intentionally not a `#[tauri::command]`. async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> { @@ -291,10 +331,13 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin if let Some(window) = app.get_webview_window("main") { log::debug!("Main window exists"); - // Try to show and focus the window first - let _ = window.show(); - let _ = window.set_focus(); - let _ = window.unminimize(); + // Try to show and focus the window first. Skip under a headless automation + // driver so an e2e run never steals the user's focus. + if !headless_automation() { + let _ = window.show(); + let _ = window.set_focus(); + let _ = window.unminimize(); + } events::emit("show-profile-selector", url.clone()) .map_err(|e| format!("Failed to emit URL open event: {e}"))?; @@ -390,6 +433,13 @@ fn get_cached_proxy_check(proxy_id: String) -> Option Vec { + crate::proxy_manager::PROXY_MANAGER.get_proxy_check_history(&proxy_id) +} + #[tauri::command] fn export_proxies(format: String) -> Result { match format.as_str() { @@ -576,8 +626,14 @@ fn has_acknowledged_trial_expiration(app_handle: tauri::AppHandle) -> Result Result { - mcp_server::McpServer::instance().start(app_handle).await +async fn start_mcp_server(_app_handle: tauri::AppHandle) -> Result { + // Local MCP is removed in favour of remote MCP. Enabling it from the app is + // an "attempt to use it": raise the dialog and refuse. The frontend catches + // MCP_LOCAL_REMOVED and shows the removal panel. The loopback tombstone that + // answers stray external clients is bound at startup for legacy installs, not + // here. + mcp_server::McpServer::note_local_mcp_attempt(); + Err(backend_error("MCP_LOCAL_REMOVED")) } #[tauri::command] @@ -585,6 +641,111 @@ async fn stop_mcp_server() -> Result<(), String> { mcp_server::McpServer::instance().stop().await } +#[derive(serde::Deserialize)] +#[serde(rename_all = "lowercase")] +enum IntegrationTarget { + Api, + Remote, +} + +#[derive(serde::Serialize)] +struct IntegrationDiagnostic { + configured: bool, + reachable: Option, + authorized: Option, + http_status: Option, + checked_at: u64, +} + +/// A read-only authenticated probe. Never returns credentials or response bodies. +#[tauri::command] +async fn check_integration_connection( + app_handle: tauri::AppHandle, + target: IntegrationTarget, +) -> Result { + let manager = settings_manager::SettingsManager::instance(); + let mut diagnostic = IntegrationDiagnostic { + configured: false, + reachable: None, + authorized: None, + http_status: None, + checked_at: crate::proxy_manager::now_secs(), + }; + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .redirect(reqwest::redirect::Policy::none()); + let request = match target { + IntegrationTarget::Api => { + let token = manager + .get_api_token(&app_handle) + .await + .map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))?; + diagnostic.configured = token.is_some(); + let Some(port) = get_api_server_status().await? else { + diagnostic.reachable = Some(false); + return Ok(diagnostic); + }; + client + .no_proxy() + .build() + .map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))? + .get(format!("http://127.0.0.1:{port}/v1/profiles")) + .bearer_auth(token.unwrap_or_default()) + } + IntegrationTarget::Remote => { + let Some(token) = manager + .get_mcp_remote_key() + .map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))? + else { + return Ok(diagnostic); + }; + diagnostic.configured = true; + // The same opening request every configured client sends, against the + // endpoint those clients are configured with, so the receipt reflects + // the real route. Only the status is read; the body is dropped. + client + .build() + .map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))? + .post(mcp_integrations::remote_mcp_url()) + .bearer_auth(token.key) + .header( + reqwest::header::ACCEPT, + "application/json, text/event-stream", + ) + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": { + "name": "donut-browser", + "version": env!("CARGO_PKG_VERSION"), + }, + }, + })) + } + }; + match request.send().await { + Ok(response) => { + let status = response.status(); + diagnostic.reachable = Some(true); + diagnostic.http_status = Some(status.as_u16()); + diagnostic.authorized = if status.is_success() { + Some(true) + } else if matches!(status.as_u16(), 401 | 403) { + Some(false) + } else { + None + }; + } + Err(_) => diagnostic.reachable = Some(false), + } + diagnostic.checked_at = crate::proxy_manager::now_secs(); + Ok(diagnostic) +} + #[tauri::command] fn get_mcp_server_status() -> bool { mcp_server::McpServer::instance().is_running() @@ -617,61 +778,399 @@ async fn get_mcp_config(app_handle: tauri::AppHandle) -> Result Result { + if !wayfern_terms::WayfernTermsManager::instance().is_terms_accepted() { + return Err(backend_error("WAYFERN_TERMS_REQUIRED")); + } + if !cloud_auth::CLOUD_AUTH.is_logged_in().await { + return Err(backend_error("MCP_REMOTE_REQUIRES_SIGN_IN")); + } + + mcp_remote::start(app_handle); + + let settings_manager = settings_manager::SettingsManager::instance(); + let mut settings = settings_manager + .load_settings() + .map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))?; + settings.mcp_remote_enabled = true; + settings_manager + .save_settings(&settings) + .map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))?; + + Ok(mcp_remote::status()) +} + +#[tauri::command] +async fn stop_mcp_remote_bridge( + app_handle: tauri::AppHandle, +) -> Result { + mcp_remote::stop(Some(&app_handle)); + + let settings_manager = settings_manager::SettingsManager::instance(); + let mut settings = settings_manager + .load_settings() + .map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))?; + settings.mcp_remote_enabled = false; + settings_manager + .save_settings(&settings) + .map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))?; + + Ok(mcp_remote::status()) +} + +#[tauri::command] +fn get_mcp_remote_status() -> mcp_remote::McpRemoteStatus { + mcp_remote::status() +} + +/// Whether this account may drive a desktop remotely, per the SERVER. +/// +/// Kept separate from `get_mcp_remote_status`, which is local and instant: this +/// one is a network call, and the UI must not block the status line on it. The +/// local entitlements cache cannot answer this for a team member, and the +/// socket cannot answer it at all, see `fetch_remote_control_entitlement`. +#[tauri::command] +async fn get_remote_control_entitlement() -> Result { + cloud_auth::CLOUD_AUTH + .fetch_remote_control_entitlement() + .await + .map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e)) +} + +// --------------------------------------------------------------------------- +// Remote MCP credential +// +// The durable `dmk_` key an agent presents to https://api.donutbrowser.com/api/mcp. +// Minted by the account, stored encrypted on this machine, shown to the UI as +// a prefix only, and written into agent configs by the installer. +// --------------------------------------------------------------------------- + +/// What the Integrations page shows about the remote MCP credential. +#[derive(serde::Serialize)] +struct McpRemoteCredential { + present: bool, + token_prefix: Option, +} + +/// The answer to a rotation: the prefix of the key that now lives in every +/// remote agent config, and the ids of the clients it could not be written +/// into. The key is stored either way, so those are a retry for the user, not +/// a failure of the rotation. +#[derive(serde::Serialize)] +struct McpRemoteCredentialRotation { + token_prefix: String, + failed_clients: Vec, +} + +/// How many characters of a key identify it on screen. +/// +/// The account page shows `dmk_` plus the next eight characters; deriving the +/// same twelve locally means the desktop can name the stored key without +/// keeping a second copy of anything the server said. +const MCP_KEY_DISPLAY_CHARS: usize = 12; + +fn mcp_key_display_prefix(key: &str) -> String { + key.chars().take(MCP_KEY_DISPLAY_CHARS).collect() +} + +/// The label a rotation mints under, so the account page can tell this +/// machine's key from another's. Kept short so the server accepts it. +fn mcp_remote_key_label() -> String { + const MAX_LABEL_CHARS: usize = 80; + let host = sysinfo::System::host_name() + .map(|h| h.trim().to_string()) + .filter(|h| !h.is_empty()) + .unwrap_or_else(|| "this computer".to_string()); + format!("Donut Browser on {host}") + .chars() + .take(MAX_LABEL_CHARS) + .collect() +} + +fn is_mcp_key_limit(error: &str) -> bool { + serde_json::from_str::(error) + .ok() + .and_then(|v| v.get("code").and_then(|c| c.as_str()).map(str::to_string)) + .is_some_and(|code| code == "MCP_REMOTE_KEY_LIMIT") +} + +#[tauri::command] +async fn get_mcp_remote_credential() -> Result { + let stored = settings_manager::SettingsManager::instance() + .get_mcp_remote_key() + .map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))?; + Ok(McpRemoteCredential { + present: stored.is_some(), + token_prefix: stored.map(|s| mcp_key_display_prefix(&s.key)), + }) +} + +/// Mint a new remote MCP credential, retire the one it replaces, and rewrite +/// every remote agent config to carry it. +/// +/// Minted BEFORE the old key is revoked, so a mint that fails leaves the +/// agents working on the old key. The one exception is the account key cap: +/// when the server refuses the mint and one of the live keys is ours, ours is +/// retired first and the mint tried once more. +#[tauri::command] +async fn rotate_mcp_remote_credential( + app_handle: tauri::AppHandle, +) -> Result { + if !cloud_auth::CLOUD_AUTH.is_logged_in().await { + return Err(backend_error("MCP_REMOTE_REQUIRES_SIGN_IN")); + } + let settings_manager = settings_manager::SettingsManager::instance(); + let previous = settings_manager + .get_mcp_remote_key() + .map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))? + .and_then(|stored| stored.id); + + let label = mcp_remote_key_label(); + let grant = match cloud_auth::CLOUD_AUTH.create_mcp_key(&label).await { + Ok(grant) => grant, + Err(e) if is_mcp_key_limit(&e) => match previous.as_deref() { + Some(id) => { + log::info!("[mcp-remote] At the credential cap; retiring {id} before minting again"); + cloud_auth::CLOUD_AUTH.revoke_mcp_key(id).await?; + cloud_auth::CLOUD_AUTH.create_mcp_key(&label).await? + } + None => return Err(e), + }, + Err(e) => return Err(e), + }; + + settings_manager + .store_mcp_remote_key(&grant.key, &grant.id) + .map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))?; + + if let Some(old) = previous.filter(|id| id != &grant.id) { + // Best effort: the new key is already stored and installed below, and a + // key that outlives its replacement is visible on the account page. + if let Err(e) = cloud_auth::CLOUD_AUTH.revoke_mcp_key(&old).await { + log::warn!("[mcp-remote] Could not revoke the replaced credential {old}: {e}"); + } + } + + log::info!( + "[mcp-remote] Rotated the remote MCP credential to {}", + mcp_key_display_prefix(&grant.key) + ); + // Every client whose entry points at the remote endpoint is rewritten with + // the new key, or the old one keeps failing in them with a 401 the user has + // no way to trace back here. The key is stored and its predecessor revoked + // by now, so a client that could not be rewritten is named rather than + // turned into an error: an Err here reads as "mint again" to the UI, and a + // second mint would retire the key just installed everywhere else. + let failed_clients = + reinstall_mcp_agents(&app_handle, mcp_integrations::McpEndpoint::Remote).await; + + Ok(McpRemoteCredentialRotation { + token_prefix: mcp_key_display_prefix(&grant.key), + failed_clients, + }) +} + +/// Revoke the stored remote MCP credential and forget it. +/// +/// Signed in, a revoke that fails keeps the key: an agent config still +/// carries it, and "forgotten here, live on the server" is the one state the +/// user cannot see. Signed out there is nothing to revoke with, so the local +/// copy goes and the account page is where the key is retired. +#[tauri::command] +async fn forget_mcp_remote_credential() -> Result<(), String> { + let settings_manager = settings_manager::SettingsManager::instance(); + let Some(stored) = settings_manager + .get_mcp_remote_key() + .map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))? + else { + return Ok(()); + }; + + match stored.id { + Some(id) if cloud_auth::CLOUD_AUTH.is_logged_in().await => { + cloud_auth::CLOUD_AUTH.revoke_mcp_key(&id).await?; + } + Some(id) => log::warn!( + "[mcp-remote] Forgetting credential {id} while signed out; revoke it from the account page" + ), + None => log::warn!( + "[mcp-remote] Forgetting a credential with no stored id; revoke it from the account page" + ), + } + + settings_manager + .remove_mcp_remote_key() + .map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e)) +} + +const CLAUDE_DESKTOP_EXT_ID: &str = "local.mcpb.donut-browser.donut-browser"; +/// The bridge script declares its target on this line; detection reads it back. +const BRIDGE_URL_MARKER: &str = "const MCP_URL = "; + +/// The stdio-to-HTTP bridge Claude Desktop runs as a local extension. A +/// template with two placeholders rather than a `format!` string so the +/// JavaScript braces stay readable. +const CLAUDE_DESKTOP_BRIDGE_JS: &str = r##"#!/usr/bin/env node +// Bridges Claude Desktop's stdio transport to Donut Browser's streamable HTTP endpoint. +const http = require("http"); +const https = require("https"); +const readline = require("readline"); +const MCP_URL = __MCP_URL__; +const AUTHORIZATION = __AUTHORIZATION__; +let sid = null; + +function send(method, body) { + return new Promise((resolve, reject) => { + const u = new URL(MCP_URL); + const headers = { Accept: "application/json, text/event-stream" }; + if (body != null) headers["Content-Type"] = "application/json"; + if (AUTHORIZATION) headers.Authorization = AUTHORIZATION; + if (sid) headers["mcp-session-id"] = sid; + const options = { + hostname: u.hostname, + port: u.port || undefined, + path: u.pathname + u.search, + method, + headers, + }; + const request = (u.protocol === "https:" ? https : http).request(options, (res) => { + const s = res.headers["mcp-session-id"]; + if (s) sid = s; + let b = ""; + res.setEncoding("utf8"); + res.on("data", (c) => (b += c)); + res.on("end", () => + resolve({ status: res.statusCode || 0, type: String(res.headers["content-type"] || ""), body: b }) + ); + }); + request.on("error", reject); + if (body != null) request.write(body); + request.end(); + }); +} + +// A streamable HTTP server may answer with an SSE stream; every data line is one JSON-RPC message. +function messages(res) { + if (res.type.includes("text/event-stream")) { + return res.body + .split(/\r?\n/) + .filter((l) => l.startsWith("data:")) + .map((l) => l.slice(5).trim()) + .filter(Boolean); + } + const body = res.body.trim(); + return body ? [body] : []; +} + +function isJsonObject(text) { + try { + const parsed = JSON.parse(text); + return parsed !== null && typeof parsed === "object"; + } catch (_) { + return false; + } +} + +function rpcError(id, message) { + return JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message } }) + "\n"; +} + +// The endpoint answers refusals with a JSON body carrying a code; surface that code rather than the raw body. +function describe(res) { + try { + const parsed = JSON.parse(res.body); + const reason = parsed && (parsed.code || parsed.message); + if (reason) return "HTTP " + res.status + ": " + reason; + } catch (_) {} + const body = res.body.trim(); + return "HTTP " + res.status + (body ? ": " + body.slice(0, 200) : ""); +} + +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +rl.on("line", (line) => { + if (!line.trim()) return; + let id = null; + try { + const parsed = JSON.parse(line); + id = parsed.id === undefined ? null : parsed.id; + } catch (_) { + return; + } + const isRequest = id !== null; + send("POST", line) + .then((res) => { + if (res.status < 200 || res.status >= 300) { + if (isRequest) process.stdout.write(rpcError(id, describe(res))); + return; + } + for (const message of messages(res)) { + if (isJsonObject(message)) process.stdout.write(message + "\n"); + else if (isRequest) process.stdout.write(rpcError(id, "Non-JSON response from the MCP endpoint")); + } + }) + .catch((e) => { + if (isRequest) process.stdout.write(rpcError(id, "HTTP error: " + e.message)); + }); +}); +rl.on("close", () => { + // Tell the server the session is over so it does not linger until its idle timeout. + const done = () => process.exit(0); + if (!sid) { + done(); + return; + } + send("DELETE", null).then(done, done); + setTimeout(done, 2000).unref(); +}); +"##; + fn claude_desktop_extension_dir() -> Option { - #[cfg(target_os = "macos")] - { - dirs::home_dir().map(|h| { - h.join("Library") - .join("Application Support") - .join("Claude") - .join("Claude Extensions") - .join("local.mcpb.donut-browser.donut-browser") - }) - } - #[cfg(target_os = "windows")] - { - std::env::var("APPDATA").ok().map(|appdata| { - std::path::PathBuf::from(appdata) - .join("Claude") - .join("Claude Extensions") - .join("local.mcpb.donut-browser.donut-browser") - }) - } - #[cfg(target_os = "linux")] - { - dirs::config_dir().map(|c| { - c.join("Claude") - .join("Claude Extensions") - .join("local.mcpb.donut-browser.donut-browser") - }) - } + mcp_integrations::claude_desktop_dir() + .map(|dir| dir.join("Claude Extensions").join(CLAUDE_DESKTOP_EXT_ID)) } fn is_mcp_in_claude_desktop_internal() -> bool { - let Some(dir) = claude_desktop_extension_dir() else { - return false; - }; - dir.join("manifest.json").exists() + claude_desktop_extension_dir().is_some_and(|dir| dir.join("manifest.json").exists()) } -async fn add_mcp_to_claude_desktop_internal(app_handle: &tauri::AppHandle) -> Result<(), String> { - let mcp_server = mcp_server::McpServer::instance(); - let port = mcp_server.get_port().ok_or("MCP server is not running")?; +/// Which endpoint the installed bridge talks to, read back from the URL +/// literal baked into its script. +fn claude_desktop_endpoint() -> Option { + let script = std::fs::read_to_string( + claude_desktop_extension_dir()? + .join("server") + .join("index.js"), + ) + .ok()?; + let start = script.find(BRIDGE_URL_MARKER)? + BRIDGE_URL_MARKER.len(); + let literal = script[start..].lines().next()?.trim_end_matches(';'); + let url: String = serde_json::from_str(literal).ok()?; + mcp_integrations::endpoint_of_url(&url) +} - let settings_manager = settings_manager::SettingsManager::instance(); - let token = settings_manager - .get_mcp_token(app_handle) - .await - .map_err(|e| format!("Failed to get MCP token: {e}"))? - .ok_or("MCP token not found")?; +fn claude_desktop_status() -> mcp_integrations::AgentStatus { + mcp_integrations::AgentStatus { + connected: is_mcp_in_claude_desktop_internal(), + endpoint: claude_desktop_endpoint(), + } +} +fn add_mcp_to_claude_desktop_internal(target: &mcp_integrations::McpTarget) -> Result<(), String> { let ext_dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?; let server_dir = ext_dir.join("server"); std::fs::create_dir_all(&server_dir) .map_err(|e| format!("Failed to create extension directory: {e}"))?; - let mcp_url = format!("http://127.0.0.1:{port}/mcp/{token}"); - let manifest = serde_json::json!({ "manifest_version": "0.3", "name": "donut-browser", @@ -698,53 +1197,26 @@ async fn add_mcp_to_claude_desktop_internal(app_handle: &tauri::AppHandle) -> Re ) .map_err(|e| format!("Failed to write manifest: {e}"))?; - let bridge_js = format!( - r#"#!/usr/bin/env node -const http = require("http"); -const readline = require("readline"); -const MCP_URL = "{mcp_url}"; -let sid = null; -function post(line) {{ - return new Promise((resolve, reject) => {{ - const u = new URL(MCP_URL); - const o = {{ - hostname: u.hostname, port: u.port, path: u.pathname, method: "POST", - headers: {{ "Content-Type": "application/json", Accept: "application/json" }}, - }}; - if (sid) o.headers["mcp-session-id"] = sid; - const r = http.request(o, (res) => {{ - const s = res.headers["mcp-session-id"]; - if (s) sid = s; - let b = ""; - res.on("data", (c) => (b += c)); - res.on("end", () => resolve(b)); - }}); - r.on("error", reject); - r.write(line); - r.end(); - }}); -}} -const rl = readline.createInterface({{ input: process.stdin, crlfDelay: Infinity }}); -rl.on("line", (line) => {{ - if (!line.trim()) return; - let notif = false; - try {{ notif = JSON.parse(line).id == null; }} catch {{}} - post(line).then((b) => {{ - if (!notif && b.trim()) process.stdout.write(b.trim() + "\n"); - }}).catch((e) => {{ - if (!notif) process.stdout.write(JSON.stringify({{ - jsonrpc: "2.0", id: null, error: {{ code: -32000, message: "HTTP error: " + e.message }} - }}) + "\n"); - }}); -}}); -rl.on("close", () => setTimeout(() => process.exit(0), 500)); -"# - ); - std::fs::write(server_dir.join("index.js"), bridge_js) + // A JSON string literal is a valid JavaScript string literal, so the URL and + // the credential are quoted through serde_json rather than by hand. + let url_literal = + serde_json::to_string(&target.url).map_err(|e| format!("Failed to quote the URL: {e}"))?; + let authorization_literal = match &target.bearer { + Some(key) => serde_json::to_string(&format!("Bearer {key}")) + .map_err(|e| format!("Failed to quote the credential: {e}"))?, + None => "null".to_string(), + }; + let bridge_js = CLAUDE_DESKTOP_BRIDGE_JS + .replace("__MCP_URL__", &url_literal) + .replace("__AUTHORIZATION__", &authorization_literal); + let script_path = server_dir.join("index.js"); + // The script carries a credential that works from anywhere, so it is + // owner-only from its first byte: a write followed by a chmod leaves a + // window in which anybody on the machine can read it. + crate::app_dirs::write_owner_only(&script_path, bridge_js.as_bytes()) .map_err(|e| format!("Failed to write bridge script: {e}"))?; - // Update the extensions-installations.json registry so Claude Desktop picks it up - update_claude_extensions_registry("local.mcpb.donut-browser.donut-browser", Some(manifest))?; + update_claude_extensions_registry(CLAUDE_DESKTOP_EXT_ID, Some(manifest))?; Ok(()) } @@ -754,10 +1226,14 @@ fn remove_mcp_from_claude_desktop_internal() -> Result<(), String> { if ext_dir.exists() { std::fs::remove_dir_all(&ext_dir).map_err(|e| format!("Failed to remove extension: {e}"))?; } - update_claude_extensions_registry("local.mcpb.donut-browser.donut-browser", None)?; + update_claude_extensions_registry(CLAUDE_DESKTOP_EXT_ID, None)?; Ok(()) } +/// Add or drop Donut's entry in `extensions-installations.json`, the registry +/// Claude Desktop reads its local extensions from. Every other extension on +/// the machine is listed in the same file, so a registry that cannot be +/// parsed is left alone rather than replaced with an empty one. fn update_claude_extensions_registry( ext_id: &str, manifest: Option, @@ -772,34 +1248,45 @@ fn update_claude_extensions_registry( let mut registry: serde_json::Value = if registry_path.exists() { let content = std::fs::read_to_string(®istry_path) .map_err(|e| format!("Failed to read registry: {e}"))?; - serde_json::from_str(&content).unwrap_or(serde_json::json!({"extensions": {}})) + if content.trim().is_empty() { + serde_json::json!({"extensions": {}}) + } else { + serde_json::from_str(&content).map_err(|e| { + format!( + "Claude Desktop's extension registry could not be parsed, so it was left untouched: {e}" + ) + })? + } } else { serde_json::json!({"extensions": {}}) }; - if registry.get("extensions").is_none() { - registry["extensions"] = serde_json::json!({}); - } + let entries = registry + .as_object_mut() + .ok_or("Claude Desktop's extension registry is not a JSON object")? + .entry("extensions") + .or_insert_with(|| serde_json::json!({})); + let entries = entries + .as_object_mut() + .ok_or("Claude Desktop's extension registry has a non-object \"extensions\" field")?; match manifest { Some(m) => { - registry["extensions"][ext_id] = serde_json::json!({ - "id": ext_id, - "version": m.get("version").and_then(|v| v.as_str()).unwrap_or("0.0.0"), - "hash": "", - "installedAt": chrono::Utc::now().to_rfc3339(), - "manifest": m, - "signatureInfo": { "status": "unsigned" }, - "source": "local" - }); + entries.insert( + ext_id.to_string(), + serde_json::json!({ + "id": ext_id, + "version": m.get("version").and_then(|v| v.as_str()).unwrap_or("0.0.0"), + "hash": "", + "installedAt": chrono::Utc::now().to_rfc3339(), + "manifest": m, + "signatureInfo": { "status": "unsigned" }, + "source": "local" + }), + ); } None => { - if let Some(exts) = registry - .get_mut("extensions") - .and_then(|e| e.as_object_mut()) - { - exts.remove(ext_id); - } + entries.remove(ext_id); } } @@ -811,41 +1298,182 @@ fn update_claude_extensions_registry( Ok(()) } -async fn current_mcp_url(app_handle: &tauri::AppHandle) -> Result { - let mcp_server = mcp_server::McpServer::instance(); - let port = mcp_server - .get_port() - .ok_or_else(|| backend_error("MCP_SERVER_NOT_RUNNING"))?; - let settings_manager = settings_manager::SettingsManager::instance(); - let token = settings_manager - .get_mcp_token(app_handle) - .await - .map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))? - .ok_or_else(|| backend_error("MCP_CONFIGURATION_UNAVAILABLE"))?; - Ok(format!("http://127.0.0.1:{port}/mcp/{token}")) +/// The one place an endpoint becomes what gets written into a client. The +/// remote endpoint needs the stored credential; minting one is the credential +/// commands' job, so a missing key is reported rather than created here. +async fn mcp_target_for( + app_handle: &tauri::AppHandle, + endpoint: mcp_integrations::McpEndpoint, +) -> Result { + match endpoint { + mcp_integrations::McpEndpoint::Local => { + // Local MCP is removed: never write a local endpoint into a client again. + // Callers that reach here (an install/switch to local, or a stale + // reinstall) get the removal error and the dialog. + let _ = app_handle; + mcp_server::McpServer::note_local_mcp_attempt(); + Err(backend_error("MCP_LOCAL_REMOVED")) + } + mcp_integrations::McpEndpoint::Remote => { + let stored = settings_manager::SettingsManager::instance() + .get_mcp_remote_key() + .map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))?; + let key = stored + .map(|stored| stored.key) + .filter(|key| !key.is_empty()) + .ok_or_else(|| backend_error("MCP_REMOTE_KEY_MISSING"))?; + Ok(mcp_integrations::McpTarget::remote(key)) + } + } +} + +fn install_mcp_agent(agent_id: &str, target: &mcp_integrations::McpTarget) -> Result<(), String> { + if agent_id == "claude-desktop" { + add_mcp_to_claude_desktop_internal(target) + } else { + mcp_integrations::install_generic(agent_id, target) + } } #[tauri::command] async fn list_mcp_agents() -> Result, String> { - let claude_desktop_connected = is_mcp_in_claude_desktop_internal(); Ok(mcp_integrations::list_agents_with_status(&[( "claude-desktop", - claude_desktop_connected, + claude_desktop_status(), )])) } #[tauri::command] -async fn add_mcp_to_agent(app_handle: tauri::AppHandle, agent_id: String) -> Result<(), String> { +async fn add_mcp_to_agent( + app_handle: tauri::AppHandle, + agent_id: String, + target: String, +) -> Result<(), String> { if !mcp_integrations::agent_exists(&agent_id) { return Err(backend_error("MCP_AGENT_UNKNOWN")); } - let result = if agent_id == "claude-desktop" { - add_mcp_to_claude_desktop_internal(&app_handle).await + // Only the app's own UI sends this string, so a value it does not know is a + // bug on our side rather than user input to explain. + let endpoint = mcp_integrations::McpEndpoint::parse(&target).ok_or_else(|| { + backend_error_with_detail("INTERNAL_ERROR", format!("unknown MCP target: {target}")) + })?; + let target = mcp_target_for(&app_handle, endpoint).await?; + install_mcp_agent(&agent_id, &target) + .map_err(|e| backend_error_with_detail("MCP_AGENT_INSTALL_FAILED", e)) +} + +/// Re-run the install for every client whose entry points at `endpoint`, so a +/// rotated credential or a moved local port and token do not leave them +/// talking to a dead target. Every client is attempted; the ids of the ones +/// that could not be rewritten come back, each already logged with its reason. +/// Ensure a remote MCP credential is stored, minting one when there is none. +/// +/// Used to auto-migrate a paid user off the removed local endpoint. Mirrors the +/// mint-then-store half of `rotate_mcp_remote_credential`; it does not rotate an +/// existing key, because a working key is exactly what migration wants to keep. +async fn ensure_remote_mcp_key() -> Result<(), String> { + let settings_manager = settings_manager::SettingsManager::instance(); + let has_key = settings_manager + .get_mcp_remote_key() + .map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))? + .is_some_and(|stored| !stored.key.is_empty()); + if has_key { + return Ok(()); + } + let grant = cloud_auth::CLOUD_AUTH + .create_mcp_key(&mcp_remote_key_label()) + .await?; + settings_manager + .store_mcp_remote_key(&grant.key, &grant.id) + .map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e)) +} + +/// Migrate away from the removed local MCP endpoint, once per launch. +/// +/// A paid, signed-in user has their clients that still point at local rewritten +/// to remote MCP (minting a remote key if they have none), so their agents keep +/// working from anywhere with no action. Anyone else who still has local +/// installed, or the legacy `mcp_enabled` flag on, gets the deprecation event +/// the desktop turns into a gentle "local MCP is going away" notice — there is +/// no paid remote endpoint to move them to. +pub async fn migrate_local_mcp_clients(app_handle: tauri::AppHandle) { + let mut local_agents = mcp_integrations::agents_on_endpoint(mcp_integrations::McpEndpoint::Local); + if claude_desktop_status().endpoint == Some(mcp_integrations::McpEndpoint::Local) { + local_agents.push("claude-desktop".to_string()); + } + let mcp_was_enabled = settings_manager::SettingsManager::instance() + .load_settings() + .map(|settings| settings.mcp_enabled) + .unwrap_or(false); + if local_agents.is_empty() && !mcp_was_enabled { + return; + } + + let paid = cloud_auth::CLOUD_AUTH.is_logged_in().await + && cloud_auth::CLOUD_AUTH.has_active_paid_subscription().await; + + if !local_agents.is_empty() && paid { + if let Err(e) = ensure_remote_mcp_key().await { + log::warn!("[mcp] Could not provision a remote MCP key to migrate local clients: {e}"); + let _ = crate::events::emit_empty(mcp_server::LOCAL_MCP_DEPRECATED_EVENT); + return; + } + let target = match mcp_target_for(&app_handle, mcp_integrations::McpEndpoint::Remote).await { + Ok(target) => target, + Err(e) => { + log::warn!("[mcp] Could not resolve the remote MCP target for migration: {e}"); + let _ = crate::events::emit_empty(mcp_server::LOCAL_MCP_DEPRECATED_EVENT); + return; + } + }; + let mut migrated = 0usize; + let mut failed: Vec = Vec::new(); + for agent in &local_agents { + match install_mcp_agent(agent, &target) { + Ok(()) => migrated += 1, + Err(e) => { + log::warn!("[mcp] Could not migrate {agent} to remote MCP: {e}"); + failed.push(agent.clone()); + } + } + } + log::info!( + "[mcp] Migrated {migrated} local MCP client(s) to remote MCP ({} could not be rewritten)", + failed.len() + ); + let _ = crate::events::emit( + "mcp-local-migrated", + serde_json::json!({ "migrated": migrated, "failed": failed }), + ); } else { - let url = current_mcp_url(&app_handle).await?; - mcp_integrations::install_generic(&agent_id, &url) + // Free or signed-out: nothing paid to migrate them to, so tell them plainly. + let _ = crate::events::emit_empty(mcp_server::LOCAL_MCP_DEPRECATED_EVENT); + } +} + +pub async fn reinstall_mcp_agents( + app_handle: &tauri::AppHandle, + endpoint: mcp_integrations::McpEndpoint, +) -> Vec { + let mut agents = mcp_integrations::agents_on_endpoint(endpoint); + if claude_desktop_status().endpoint == Some(endpoint) { + agents.push("claude-desktop".to_string()); + } + let target = match mcp_target_for(app_handle, endpoint).await { + Ok(target) => target, + Err(e) => { + log::warn!("Could not resolve the MCP target, so no client was refreshed: {e}"); + return agents; + } }; - result.map_err(|e| backend_error_with_detail("MCP_AGENT_INSTALL_FAILED", e)) + let mut failed = Vec::new(); + for agent_id in agents { + if let Err(e) = install_mcp_agent(&agent_id, &target) { + log::warn!("Could not refresh the MCP entry for {agent_id}: {e}"); + failed.push(agent_id); + } + } + failed } #[tauri::command] @@ -1136,6 +1764,7 @@ pub async fn check_vpn_validity_core( .await .unwrap_or_default(); + let insight = crate::geolocation::lookup_exit_insight(&ip); result = Some(crate::proxy_manager::ProxyCheckResult { ip, city, @@ -1143,6 +1772,12 @@ pub async fn check_vpn_validity_core( country_code, timestamp: now, is_valid: true, + isp: insight.organization, + timezone: insight.timezone, + // A tunnel is not a SOCKS5 endpoint the UDP probe can question, and + // claiming either answer without asking would be a guess. + udp: crate::proxy_udp::UdpSupport::Unknown, + latency_ms: None, }); break; } @@ -1168,6 +1803,10 @@ pub async fn check_vpn_validity_core( country_code: None, timestamp: now, is_valid: false, + isp: None, + timezone: None, + udp: crate::proxy_udp::UdpSupport::Unknown, + latency_ms: None, }); Ok(result) @@ -1248,10 +1887,8 @@ async fn disconnect_vpn(vpn_id: String) -> Result<(), String> { #[tauri::command] async fn get_vpn_status(vpn_id: String) -> Result { - use crate::proxy_storage::is_process_running; - if let Some(worker) = vpn_worker_storage::find_vpn_worker_by_vpn_id(&vpn_id) { - let connected = worker.pid.map(is_process_running).unwrap_or(false); + let connected = vpn_worker_runner::vpn_worker_alive(&worker); Ok(vpn::VpnStatus { connected, vpn_id, @@ -1274,13 +1911,11 @@ async fn get_vpn_status(vpn_id: String) -> Result { #[tauri::command] async fn list_active_vpn_connections() -> Result, String> { - use crate::proxy_storage::is_process_running; - let workers = vpn_worker_storage::list_vpn_worker_configs(); Ok( workers .into_iter() - .filter(|w| w.pid.map(is_process_running).unwrap_or(false)) + .filter(vpn_worker_runner::vpn_worker_alive) .map(|w| vpn::VpnStatus { connected: true, vpn_id: w.vpn_id, @@ -1335,6 +1970,7 @@ async fn generate_sample_fingerprint( last_sync: None, host_os: None, ephemeral: false, + temporary: false, extension_group_id: None, proxy_bypass_rules: Vec::new(), created_by_id: None, @@ -1368,10 +2004,9 @@ async fn generate_sample_fingerprint( // --- Remote sessions -------------------------------------------------------- // -// Everything below is transport only. The session state machine, the fleet, the -// schedule, the browsing behaviour and the budget all live behind -// donutbrowser-infra; these commands carry the user's own scalars there and -// render back what the server says. +// Everything below is transport only. The session state machine, the schedule, +// the browsing behaviour and the budget all live in Donut cloud; these commands +// carry the user's own scalars there and render back what the server says. /// Turn a remote-session failure into the code the frontend translates. /// @@ -1407,8 +2042,8 @@ async fn get_remote_session( /// Stop a remote session and settle what it cost. /// -/// Without this the only thing that ends a session is the fleet's two-hour cap, -/// so a handful of short launches bills an allowance meant for a hundred. +/// Without this a session runs to its maximum duration however briefly it was +/// used, so a handful of short launches exhausts an allowance meant for many. #[tauri::command] async fn stop_remote_session( app_handle: tauri::AppHandle, @@ -1517,8 +2152,8 @@ async fn save_cookie_bot_schedule( schedule: cookie_bot::CookieBotScheduleInput, acknowledge_conflict: bool, ) -> Result { - // Refused here rather than at 02:00: a profile that can never be warmed - // should never reach a schedule row, an hour of quota or a leased host. + // Refused here rather than when the run is due: a profile that can never be + // warmed should never reach an enrolment, an hour of quota or a leased host. let profile = cookie_bot_profile(&profile_id)?; cookie_bot::bot_precondition(&profile, &cookie_bot::exit_reachability(&profile))?; // The frontend sends the user's choices; the profile facts the server refuses @@ -1931,6 +2566,28 @@ pub fn run_with_builder( } // Create the main window programmatically + // Under a headless automation driver, create the window hidden and + // unfocused so the app never activates or steals focus on macOS. Once + // the webview is ready the tauri-wd plugin keeps it off the user's + // screen: on macOS it orders the window in transparent, click-through + // and never key, so WebKit keeps rendering (a hidden window suspends + // requestAnimationFrame and stalls animation-gated tests); elsewhere it + // hides it. Building it hidden here avoids the one-frame flash and the + // activation that flash causes. + let headless = headless_automation(); + if headless { + log::info!( + "Headless automation: the main window is created hidden and never focused" + ); + } + // Set through `App`, not the handle, so it lands in tao's own launch + // state and survives `applicationDidFinishLaunching`; a policy set only + // through the handle before the event loop starts is written back to + // Regular at launch, and a Dock tile appears. + #[cfg(target_os = "macos")] + if headless { + app.set_activation_policy(tauri::ActivationPolicy::Accessory); + } #[allow(unused_variables)] let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default()) .title("Donut Browser") @@ -1939,8 +2596,8 @@ pub fn run_with_builder( .resizable(true) .fullscreen(false) .center() - .focused(true) - .visible(true); + .focused(!headless) + .visible(!headless); #[cfg(feature = "e2e")] let win_builder = match e2e_automation_profile_dir() { @@ -2144,20 +2801,62 @@ pub fn run_with_builder( // "MCP server isn't enabled" without this line. { let mcp_handle = app.handle().clone(); + let bridge_handle = app.handle().clone(); + let engine_handle = app.handle().clone(); + + // The tool engine gets its app handle unconditionally, because remote + // control is a transport of its own: a user who drives this browser + // from the website should not have to open a loopback port to do it. + tauri::async_runtime::spawn(async move { + mcp_server::McpServer::instance() + .attach_app_handle(engine_handle) + .await; + }); + + // Local MCP is removed. Move anyone still on it forward: paid users are + // migrated to remote MCP, everyone else is told it is going away. + let migrate_handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + migrate_local_mcp_clients(migrate_handle).await; + }); + let settings_mgr = settings_manager::SettingsManager::instance(); match settings_mgr.load_settings() { Ok(settings) => { if settings.mcp_enabled { - log::info!("MCP server is enabled in settings, attempting auto-start"); + // Local MCP is removed, but a legacy install may still have the + // flag on and external clients still pointing at the old port. + // Bind the loopback TOMBSTONE so those clients get a clear removal + // message and the dialog, instead of a silent connection refusal. + log::info!("Local MCP was enabled on a previous version; binding the loopback tombstone for legacy clients"); tauri::async_runtime::spawn(async move { match mcp_server::McpServer::instance().start(mcp_handle).await { - Ok(port) => log::info!("MCP server auto-started on port {port}"), - Err(e) => log::warn!("Failed to auto-start MCP server: {e}"), + Ok(port) => log::info!("Local MCP tombstone listening on port {port}"), + Err(e) => log::warn!("Could not bind the local MCP tombstone: {e}"), } }); } else { log::info!( - "MCP server is DISABLED in settings (mcp_enabled=false). Browser automation tools will not be available until it's enabled in Settings → Integrations." + "Local MCP is removed and was not enabled; not binding the tombstone. Remote MCP is available from Settings → Integrations." + ); + } + + if settings.mcp_remote_enabled { + // One helper, shared with the sign-in path and the ten-minute + // reconnect tick, so "when may the bridge open" has exactly one + // answer. It re-reads the setting itself; the branch here only + // decides whether to say anything in the log. + tauri::async_runtime::spawn(async move { + cloud_auth::ensure_remote_bridge(&bridge_handle).await; + if !mcp_remote::is_running() { + log::info!( + "MCP remote control is enabled in settings but nobody is signed in; the bridge opens on sign-in" + ); + } + }); + } else { + log::info!( + "MCP remote control is DISABLED in settings (mcp_remote_enabled=false). This browser cannot be driven from donutbrowser.com until it's enabled in Settings → Integrations." ); } } @@ -2269,16 +2968,14 @@ pub fn run_with_builder( continue; } - if let Some(pid) = worker.pid { - if is_process_running(pid) { - log::info!( - "Startup: killing orphaned VPN worker {} (PID {})", - worker.id, - pid - ); - let _ = crate::vpn_worker_runner::stop_vpn_worker(&worker.id).await; - continue; - } + if crate::vpn_worker_runner::vpn_worker_alive(&worker) { + log::info!( + "Startup: killing orphaned VPN worker {} (PID {:?})", + worker.id, + worker.pid + ); + let _ = crate::vpn_worker_runner::stop_vpn_worker(&worker.id).await; + continue; } delete_vpn_worker_config(&worker.id); } @@ -2334,6 +3031,22 @@ pub fn run_with_builder( } }); + // Expired trash entries are swept at startup and every six hours. Local + // only, so it runs in every mode, e2e included. + profile::trash::start_expiry_sweeper(); + + // A temporary profile is destroyed when its browser stops. One that + // outlived a crash has nothing left to stop it, so startup does. + { + let handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + let swept = profile::ProfileManager::instance().sweep_temporary_profiles(&handle); + if swept > 0 { + log::info!("Swept {swept} temporary profile(s) left by an earlier run"); + } + }); + } + if !e2e_automation_enabled() { // Start periodic cleanup task for unused binaries. tauri::async_runtime::spawn(async move { @@ -2728,6 +3441,10 @@ pub fn run_with_builder( download_browser, cancel_download, delete_profile, + list_trashed_profiles, + restore_trashed_profile, + purge_trashed_profile, + empty_trash, clone_profile, check_browser_exists, create_browser_profile_new, @@ -2763,6 +3480,9 @@ pub fn run_with_builder( get_window_resize_warning_dismissed, get_onboarding_completed, complete_onboarding, + data_root::get_data_root_info, + data_root::move_data_root, + data_root::clear_data_root_choice, clear_all_version_cache_and_refetch, is_default_browser, open_url_with_profile, @@ -2791,6 +3511,7 @@ pub fn run_with_builder( delete_stored_proxy, check_proxy_validity, get_cached_proxy_check, + get_proxy_check_history, export_proxies, import_proxies_json, parse_txt_proxies, @@ -2803,11 +3524,17 @@ pub fn run_with_builder( update_profile_group, delete_profile_group, assign_profiles_to_group, + get_group_bookmarks, + set_group_bookmarks, + apply_group_bookmarks_to_profile, delete_selected_profiles, + plan_proxy_distribution, + distribute_proxies_to_profiles, list_extensions, get_extension_icon, add_extension, add_unpacked_extension, + fetch_extension_from_url, update_extension, update_extension_from_path, delete_extension, @@ -2824,6 +3551,7 @@ pub fn run_with_builder( start_api_server, stop_api_server, get_api_server_status, + check_integration_connection, get_all_traffic_snapshots, get_profile_traffic_snapshot, clear_all_traffic_stats, @@ -2831,6 +3559,13 @@ pub fn run_with_builder( get_traffic_stats_for_period, fingerprint_consistency::match_profile_fingerprint_to_exit, launch_gate::get_profile_pre_launch_checks, + wayfern_persona::get_profile_persona, + recorder::start_recipe_recording, + recorder::stop_recipe_recording, + recorder::get_recipe_recording, + profile::portable::export_profile, + profile::portable::preview_profile_archive, + profile::portable::import_profile_archive, launch_gate::ack_launch_gate, window_decorations::get_window_decoration_layout, validate_vless_uri, @@ -2874,6 +3609,13 @@ pub fn run_with_builder( list_mcp_agents, add_mcp_to_agent, remove_mcp_from_agent, + start_mcp_remote_bridge, + stop_mcp_remote_bridge, + get_mcp_remote_status, + get_remote_control_entitlement, + get_mcp_remote_credential, + rotate_mcp_remote_credential, + forget_mcp_remote_credential, // VPN commands import_vpn_config, list_vpn_configs, @@ -2905,6 +3647,9 @@ pub fn run_with_builder( synchronizer::stop_sync_session, synchronizer::remove_sync_follower, synchronizer::get_sync_sessions, + synchronizer::set_sync_session_paused, + synchronizer::set_sync_follower_held, + synchronizer::arrange_sync_windows, // DNS blocklist commands dns_blocklist::get_dns_blocklist_cache_status, dns_blocklist::refresh_dns_blocklists, @@ -2940,6 +3685,20 @@ pub fn run_with_builder( cookie_bot::create_cookie_bot_user_template, cookie_bot::update_cookie_bot_user_template, cookie_bot::delete_cookie_bot_user_template, + // Agent commands. Defined in `agent.rs` for the same reason: every local + // precondition they have (the profile is on this machine, the goal says + // something) lives beside the transport that sends them. + agent::start_agent_run, + agent::get_agent_runs, + agent::get_agent_run, + agent::cancel_agent_run, + agent::get_agent_recipes, + agent::create_agent_recipe, + agent::update_agent_recipe, + agent::delete_agent_recipe, + agent::start_agent_run_events, + agent::stop_agent_run_events, + agent::get_agent_run_events_status, // Profile password commands set_profile_password, change_profile_password, @@ -2956,14 +3715,23 @@ pub fn run_with_builder( // never waits out a reconnect backoff that is about to be pointless. if let tauri::RunEvent::Exit = _event { remote_session::stop_session_events(); + // The agent step stream is the same shape of subscriber and would hold + // a shutdown for the length of its reconnect backoff. + agent::stop_run_events(); + // Same reasoning for the remote-control bridge, plus one of its own: + // one account holds one bridge at a time, so an instance that exits + // without hanging up delays the account's next machine. + mcp_remote::stop(None); } #[cfg(target_os = "macos")] if let tauri::RunEvent::Reopen { .. } = _event { - if let Some(window) = _app_handle.get_webview_window("main") { - let _ = window.show(); - let _ = window.set_focus(); - let _ = window.unminimize(); + if !headless_automation() { + if let Some(window) = _app_handle.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + let _ = window.unminimize(); + } } } }); @@ -3024,6 +3792,41 @@ mod tests { ); } + #[test] + fn the_display_prefix_matches_what_the_server_shows() { + // `dmk_` plus eight characters, matching what the account page shows, so + // the account page and the desktop name one key the same way. + assert_eq!( + super::mcp_key_display_prefix("dmk_abcdefghijklmnopqrstuvwxyz"), + "dmk_abcdefgh" + ); + // Never longer than the key: a truncated file must not panic the screen. + assert_eq!(super::mcp_key_display_prefix("dmk_ab"), "dmk_ab"); + } + + #[test] + fn the_key_label_names_this_machine_and_fits_the_servers_cap() { + let label = super::mcp_remote_key_label(); + assert!(label.starts_with("Donut Browser on "), "{label}"); + assert!(label.chars().count() <= 80, "{label}"); + assert!(label.len() > "Donut Browser on ".len(), "{label}"); + } + + #[test] + fn only_the_cap_refusal_triggers_a_retire_and_retry() { + assert!(super::is_mcp_key_limit(&super::backend_error( + "MCP_REMOTE_KEY_LIMIT" + ))); + assert!(!super::is_mcp_key_limit(&super::backend_error( + "MCP_REMOTE_KEY_UNAVAILABLE" + ))); + assert!(!super::is_mcp_key_limit(&super::backend_error_with_detail( + "MCP_REMOTE_KEY_UNAVAILABLE", + "409: something else" + ))); + assert!(!super::is_mcp_key_limit("Not logged in")); + } + #[test] fn backend_error_helpers_preserve_codes_and_structure_diagnostics() { let coded = super::backend_error("PROFILE_NOT_FOUND"); @@ -3094,6 +3897,10 @@ mod tests { "cloud_get_wayfern_token", "cloud_refresh_wayfern_token", "lock_profile", + // The credential's revoke-and-clear, exercised by the integrations E2E + // suite; the Integrations page mints and rotates but has no Forget + // action yet. + "forget_mcp_remote_credential", ]; // Extract command names from the generate_handler! macro in this file diff --git a/src-tauri/src/log_redaction.rs b/src-tauri/src/log_redaction.rs index 50abb90..8a0b5f4 100644 --- a/src-tauri/src/log_redaction.rs +++ b/src-tauri/src/log_redaction.rs @@ -8,8 +8,15 @@ static PRIVATE_KEY_RE: LazyLock = LazyLock::new(|| { Regex::new(r"(?is)-----BEGIN [^-\r\n]*PRIVATE KEY-----.*?-----END [^-\r\n]*PRIVATE KEY-----") .expect("valid private-key regex") }); -static BEARER_RE: LazyLock = - LazyLock::new(|| Regex::new(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+").expect("valid bearer regex")); +/// Every HTTP auth scheme that carries its credential as a single token after +/// the scheme name, not just `Bearer`. SECRET_RE cannot reach these: its value +/// class stops at the space between the scheme and the credential, so a +/// `Basic`/`NTLM` blob used to survive into an exported log verbatim. Digest's +/// quoted-parameter form (`response="..."`) is out of scope. +static AUTH_SCHEME_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)\b(Bearer|Basic|Token|Digest|Negotiate|NTLM)\s+[A-Za-z0-9._~+/=-]+") + .expect("valid auth-scheme regex") +}); static SECRET_RE: LazyLock = LazyLock::new(|| { Regex::new( r"(?i)\b(api[_-]?key|authorization|password|passwd|private[_-]?key|proxy[_-]?(password|username)|refresh[_-]?token|secret|token|username)\b\s*[:=]\s*[^\s,;]+", @@ -41,7 +48,9 @@ pub fn url_label(value: &str) -> String { pub fn text(value: &str) -> String { let redacted = PRIVATE_KEY_RE.replace_all(value, ""); let redacted = URL_RE.replace_all(&redacted, ""); - let redacted = BEARER_RE.replace_all(&redacted, "Bearer "); + // Must stay ahead of SECRET_RE, which would otherwise consume + // `Authorization: Basic` and leave the credential with no scheme to match. + let redacted = AUTH_SCHEME_RE.replace_all(&redacted, "${1} "); let redacted = SECRET_RE.replace_all(&redacted, ""); let redacted = EMAIL_RE.replace_all(&redacted, ""); let redacted = UNIX_HOME_RE.replace_all(&redacted, "/"); @@ -85,6 +94,28 @@ mod tests { } } + #[test] + fn redacts_non_bearer_authorization_credentials() { + let headers = [ + ("Authorization: Basic ", "dXNlcjpwYXNzd29yZA=="), + ("Proxy-Authorization: Basic ", "cHJveHk6c2VjcmV0"), + ("Authorization: Token ", "gh_example_credential"), + ("authorization: bearer ", "lower-case-credential"), + ("WWW-Authenticate: NTLM ", "TlRMTVNTUAAB"), + ]; + for (header, credential) in headers { + let output = text(&format!("{header}{credential}")); + assert!( + !output.contains(credential), + "log output leaked {credential}" + ); + } + + // The scheme survives wherever the header name is not itself redacted, so a + // log still says which kind of authentication was in play. + assert!(text("WWW-Authenticate: NTLM TlRMTVNTUAAB").contains("NTLM")); + } + #[test] fn url_labels_retain_only_the_scheme() { assert_eq!( diff --git a/src-tauri/src/mcp_integrations.rs b/src-tauri/src/mcp_integrations.rs index 2242572..c55e17f 100644 --- a/src-tauri/src/mcp_integrations.rs +++ b/src-tauri/src/mcp_integrations.rs @@ -1,17 +1,92 @@ -// MCP client integrations — installs/removes the donut-browser MCP server in -// 14 popular AI assistant clients. Ports the add-mcp registry to Rust. -// -// Claude Desktop is managed via Claude's local extensions bundle -// (manifest.json + node bridge), since the desktop app supports only stdio -// servers via its plain JSON config but exposes HTTP through the extension -// framework. See `add_mcp_to_claude_desktop_internal` in lib.rs. All other -// agents (including Claude Code) use the generic config-file installer here. +//! MCP client installer: writes the Donut Browser server entry into the global +//! config of twenty AI clients and reads it back for the Integrations page. +//! +//! Two endpoints exist. The local loopback server carries its token in the +//! URL; the remote endpoint under `CLOUD_API_URL` needs a bearer credential +//! and is only offered to accounts with remote control. `McpTarget` holds +//! either, and `server_entry` decides per client where the credential goes: +//! most clients +//! take a `headers` map, Codex calls it `http_headers`, and fx refuses a +//! literal header and reads the token from `DONUT_MCP_TOKEN` instead. +//! +//! Every write edits the user's file in place. JSON goes through a concrete +//! syntax tree so comments, key order, indentation and trailing commas survive +//! (`~/.claude.json` is a large file with many unrelated keys; VS Code, Zed, +//! Gemini, OpenCode, Kilo and MCPorter files carry comments by design). TOML +//! goes through `toml_edit`, which keeps comments and table order. YAML is +//! rewritten from an order-preserving mapping; Goose tolerates that. A file +//! that does not parse aborts the operation with the file untouched: the old +//! installer treated a parse failure as an empty file and wiped configs that +//! merely had a comment in them. +//! +//! Claude Desktop has no HTTP entry in its config file; lib.rs installs a +//! local extension bundle with a node bridge instead. This module only +//! resolves its directory. +use jsonc_parser::cst::{CstInputValue, CstObject, CstRootNode}; +use jsonc_parser::ParseOptions; use serde::{Deserialize, Serialize}; use std::fs; use std::path::{Path, PathBuf}; +use toml_edit::{DocumentMut, Item, Table}; -const SERVER_NAME: &str = "donut-browser"; +pub const SERVER_NAME: &str = "donut-browser"; +const REMOTE_MCP_PATH: &str = "/api/mcp"; +/// fx rejects a literal `Authorization` header in its config and reads the +/// bearer from an environment variable named in the entry instead. +pub const FX_TOKEN_ENV: &str = "DONUT_MCP_TOKEN"; + +pub fn remote_mcp_url() -> String { + format!("{}{REMOTE_MCP_PATH}", crate::cloud_auth::CLOUD_API_URL) +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum McpEndpoint { + Remote, + Local, +} + +impl McpEndpoint { + pub fn parse(target: &str) -> Option { + match target { + "remote" => Some(Self::Remote), + "local" => Some(Self::Local), + _ => None, + } + } +} + +/// What gets written into a client: the URL and, for the remote endpoint, +/// the bearer credential. The local server authenticates through the token +/// in its URL, so it carries no bearer. +#[derive(Debug, Clone)] +pub struct McpTarget { + pub url: String, + pub bearer: Option, +} + +impl McpTarget { + pub fn remote(key: String) -> Self { + Self { + url: remote_mcp_url(), + bearer: Some(key), + } + } + + // TODO(local-mcp-removal): local MCP is removed; nothing in production builds + // a local target any more (only tests still exercise the shape). Delete this + // together with the loopback tombstone and the `Local` endpoint variant once + // the deprecation period ends. + #[allow(dead_code)] + pub fn local(url: String) -> Self { + Self { url, bearer: None } + } + + fn authorization(&self) -> Option { + self.bearer.as_ref().map(|key| format!("Bearer {key}")) + } +} #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(rename_all = "kebab-case")] @@ -22,7 +97,7 @@ pub enum AgentCategory { EditorExt, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ConfigFormat { Json, Toml, @@ -34,189 +109,291 @@ struct AgentSpec { id: &'static str, display_name: &'static str, category: AgentCategory, - /// Top-level key (supports dot notation) where the server is written. + /// Top-level key under which the client keeps its server map. config_key: &'static str, format: ConfigFormat, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AgentStatus { + pub connected: bool, + pub endpoint: Option, +} + #[derive(Debug, Serialize, Deserialize)] pub struct McpAgentInfo { pub id: String, pub display_name: String, pub category: AgentCategory, pub connected: bool, - /// True when the underlying client appears to be installed on the system - /// (its config directory exists), regardless of whether we have installed - /// the donut-browser server into it. + /// True when the client itself appears to be installed (its config + /// directory exists), whether or not Donut is configured in it. pub detected: bool, + /// Which Donut endpoint the client's entry points at, when connected. + pub endpoint: Option, + /// Set for clients that read the bearer from an environment variable + /// instead of the config file, so the UI can tell the user to export it. + pub token_env: Option, } -fn home() -> Option { - dirs::home_dir() +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Platform { + MacOs, + Linux, + Windows, } -#[cfg(target_os = "macos")] -fn vscode_user_dir() -> Option { - home().map(|h| { - h.join("Library") - .join("Application Support") - .join("Code") - .join("User") - }) -} - -#[cfg(target_os = "windows")] -fn vscode_user_dir() -> Option { - std::env::var("APPDATA") - .ok() - .map(|a| PathBuf::from(a).join("Code").join("User")) -} - -#[cfg(target_os = "linux")] -fn vscode_user_dir() -> Option { - let base = std::env::var("XDG_CONFIG_HOME") - .ok() - .map(PathBuf::from) - .or_else(|| home().map(|h| h.join(".config")))?; - Some(base.join("Code").join("User")) -} - -#[cfg(target_os = "macos")] -fn zed_config_dir() -> Option { - home().map(|h| h.join("Library").join("Application Support").join("Zed")) -} - -#[cfg(target_os = "windows")] -fn zed_config_dir() -> Option { - std::env::var("APPDATA") - .ok() - .map(|a| PathBuf::from(a).join("Zed")) -} - -#[cfg(target_os = "linux")] -fn zed_config_dir() -> Option { - let base = std::env::var("XDG_CONFIG_HOME") - .ok() - .map(PathBuf::from) - .or_else(|| home().map(|h| h.join(".config")))?; - Some(base.join("zed")) -} - -#[cfg(target_os = "windows")] -fn goose_config_path() -> Option { - std::env::var("APPDATA").ok().map(|a| { - PathBuf::from(a) - .join("Block") - .join("goose") - .join("config") - .join("config.yaml") - }) -} - -#[cfg(not(target_os = "windows"))] -fn goose_config_path() -> Option { - let base = std::env::var("XDG_CONFIG_HOME") - .ok() - .map(PathBuf::from) - .or_else(|| home().map(|h| h.join(".config")))?; - Some(base.join("goose").join("config.yaml")) -} - -/// Resolve the global config path for an agent. Returns `None` on unsupported -/// platforms (none currently — every supported agent has a defined path on -/// macOS/Linux/Windows). -fn config_path_for(agent_id: &str) -> Option { - let h = home()?; - match agent_id { - "antigravity" => Some( - h.join(".gemini") - .join("antigravity") - .join("mcp_config.json"), - ), - "cline" => vscode_user_dir().map(|d| { - d.join("globalStorage") - .join("saoudrizwan.claude-dev") - .join("settings") - .join("cline_mcp_settings.json") - }), - "cline-cli" => { - let base = std::env::var("CLINE_DIR") - .ok() - .map(PathBuf::from) - .unwrap_or_else(|| h.join(".cline")); - Some( - base - .join("data") - .join("settings") - .join("cline_mcp_settings.json"), - ) +impl Platform { + pub fn current() -> Self { + if cfg!(target_os = "windows") { + Self::Windows + } else if cfg!(target_os = "macos") { + Self::MacOs + } else { + Self::Linux } - "claude-code" => Some(h.join(".claude.json")), - "claude-desktop" => claude_desktop_config_path(), - "codex" => { - let base = std::env::var("CODEX_HOME") - .ok() - .map(PathBuf::from) - .unwrap_or_else(|| h.join(".codex")); - Some(base.join("config.toml")) - } - "cursor" => Some(h.join(".cursor").join("mcp.json")), - "gemini-cli" => Some(h.join(".gemini").join("settings.json")), - "goose" => goose_config_path(), - "github-copilot-cli" => Some( - std::env::var("XDG_CONFIG_HOME") - .ok() - .map(PathBuf::from) - .unwrap_or_else(|| h.join(".copilot")) - .join("mcp-config.json"), - ), - "mcporter" => { - // add-mcp's resolveMcporterConfigPath: prefer mcporter.json, fall back - // to mcporter.jsonc if it already exists, else default to mcporter.json. - let dir = h.join(".mcporter"); - let json_path = dir.join("mcporter.json"); - let jsonc_path = dir.join("mcporter.jsonc"); - if json_path.exists() { - Some(json_path) - } else if jsonc_path.exists() { - Some(jsonc_path) - } else { - Some(json_path) - } - } - "opencode" => Some(h.join(".config").join("opencode").join("opencode.json")), - "vscode" => vscode_user_dir().map(|d| d.join("mcp.json")), - "zed" => zed_config_dir().map(|d| d.join("settings.json")), - _ => None, } } -#[cfg(target_os = "macos")] -fn claude_desktop_config_path() -> Option { - home().map(|h| { - h.join("Library") - .join("Application Support") - .join("Claude") - .join("claude_desktop_config.json") - }) +/// Everything path resolution reads from the environment, captured once. +/// Tests build one over a temp directory instead of mutating the process +/// environment, which parallel tests would race on. +#[derive(Debug, Clone)] +pub struct AgentEnv { + pub platform: Platform, + pub home: PathBuf, + pub appdata: Option, + pub xdg_config_home: Option, + pub codex_home: Option, + pub grok_home: Option, + pub kimi_code_home: Option, + pub cline_dir: Option, } -#[cfg(target_os = "windows")] -fn claude_desktop_config_path() -> Option { - std::env::var("APPDATA").ok().map(|a| { - PathBuf::from(a) - .join("Claude") - .join("claude_desktop_config.json") - }) -} - -#[cfg(target_os = "linux")] -fn claude_desktop_config_path() -> Option { - let base = std::env::var("XDG_CONFIG_HOME") - .ok() +fn env_path(name: &str) -> Option { + std::env::var_os(name) + .filter(|value| !value.is_empty()) .map(PathBuf::from) - .or_else(|| home().map(|h| h.join(".config")))?; - Some(base.join("Claude").join("claude_desktop_config.json")) +} + +impl AgentEnv { + pub fn from_process() -> Option { + Some(Self { + platform: Platform::current(), + home: dirs::home_dir()?, + appdata: env_path("APPDATA"), + xdg_config_home: env_path("XDG_CONFIG_HOME"), + codex_home: env_path("CODEX_HOME"), + grok_home: env_path("GROK_HOME"), + kimi_code_home: env_path("KIMI_CODE_HOME"), + cline_dir: env_path("CLINE_DIR"), + }) + } + + /// `$XDG_CONFIG_HOME`, else `~/.config`, on every platform: the clients that + /// follow the XDG layout do so on Windows too. + fn config_home(&self) -> PathBuf { + self + .xdg_config_home + .clone() + .unwrap_or_else(|| self.home.join(".config")) + } + + fn appdata(&self) -> PathBuf { + self + .appdata + .clone() + .unwrap_or_else(|| self.home.join("AppData").join("Roaming")) + } + + /// Where desktop apps keep per-user data: Application Support on macOS, + /// roaming AppData on Windows, the XDG config dir on Linux. + fn app_support(&self) -> PathBuf { + match self.platform { + Platform::MacOs => self.home.join("Library").join("Application Support"), + Platform::Windows => self.appdata(), + Platform::Linux => self.config_home(), + } + } + + fn vscode_user_dir(&self) -> PathBuf { + self.app_support().join("Code").join("User") + } + + pub fn claude_desktop_dir(&self) -> PathBuf { + self.app_support().join("Claude") + } + + fn zed_config_dir(&self) -> PathBuf { + match self.platform { + // Zed's Linux directory is lower-case; the other two keep the app name. + Platform::Linux => self.config_home().join("zed"), + _ => self.app_support().join("Zed"), + } + } + + fn goose_config_path(&self) -> PathBuf { + match self.platform { + Platform::Windows => self + .appdata() + .join("Block") + .join("goose") + .join("config") + .join("config.yaml"), + // Goose on macOS reads ~/.config even when XDG_CONFIG_HOME is set. + Platform::MacOs => self.home.join(".config").join("goose").join("config.yaml"), + Platform::Linux => self.config_home().join("goose").join("config.yaml"), + } + } + + fn codex_home(&self) -> PathBuf { + self + .codex_home + .clone() + .unwrap_or_else(|| self.home.join(".codex")) + } + + fn grok_home(&self) -> PathBuf { + self + .grok_home + .clone() + .unwrap_or_else(|| self.home.join(".grok")) + } + + fn kimi_code_home(&self) -> PathBuf { + self + .kimi_code_home + .clone() + .unwrap_or_else(|| self.home.join(".kimi-code")) + } + + fn cline_dir(&self) -> PathBuf { + self + .cline_dir + .clone() + .unwrap_or_else(|| self.home.join(".cline")) + } + + fn kilo_config_dir(&self) -> PathBuf { + self.config_home().join("kilo") + } +} + +/// The first candidate that already exists, else `default`, which the install +/// will create. Clients that accept both `.json` and `.jsonc` must not end up +/// with two competing files. +fn first_existing(candidates: &[PathBuf], default: PathBuf) -> PathBuf { + candidates + .iter() + .find(|candidate| candidate.exists()) + .cloned() + .unwrap_or(default) +} + +fn config_path(env: &AgentEnv, agent_id: &str) -> Option { + let home = &env.home; + let path = match agent_id { + "antigravity" => home.join(".gemini").join("config").join("mcp_config.json"), + "cline" => env + .vscode_user_dir() + .join("globalStorage") + .join("saoudrizwan.claude-dev") + .join("settings") + .join("cline_mcp_settings.json"), + "cline-cli" => env + .cline_dir() + .join("data") + .join("settings") + .join("cline_mcp_settings.json"), + "claude-code" => home.join(".claude.json"), + "claude-desktop" => env.claude_desktop_dir().join("claude_desktop_config.json"), + "codex" => env.codex_home().join("config.toml"), + "cursor" => home.join(".cursor").join("mcp.json"), + "fx" => home.join(".fx").join("mcp.json"), + "gemini-cli" => home.join(".gemini").join("settings.json"), + "goose" => env.goose_config_path(), + // Copilot joins XDG_CONFIG_HOME directly rather than a subdirectory of it. + "github-copilot-cli" => env + .xdg_config_home + .clone() + .unwrap_or_else(|| home.join(".copilot")) + .join("mcp-config.json"), + "grok-build" => env.grok_home().join("config.toml"), + "kilo-code" => { + let dir = env.kilo_config_dir(); + first_existing( + &[dir.join("kilo.jsonc"), dir.join("kilo.json")], + dir.join("kilo.json"), + ) + } + "kimi-code" => env.kimi_code_home().join("mcp.json"), + "kiro-cli" => home.join(".kiro").join("settings").join("mcp.json"), + "mcporter" => { + let dir = home.join(".mcporter"); + first_existing( + &[dir.join("mcporter.json"), dir.join("mcporter.jsonc")], + dir.join("mcporter.json"), + ) + } + "opencode" => { + // OpenCode reads ~/.config on every platform, with no XDG override, and + // its own docs prefer the .jsonc spelling for new files. + let dir = home.join(".config").join("opencode"); + first_existing( + &[dir.join("opencode.jsonc"), dir.join("opencode.json")], + dir.join("opencode.jsonc"), + ) + } + "vscode" => env.vscode_user_dir().join("mcp.json"), + "windsurf" => home + .join(".codeium") + .join("windsurf") + .join("mcp_config.json"), + "zed" => env.zed_config_dir().join("settings.json"), + _ => return None, + }; + Some(path) +} + +/// Whether the client itself looks installed. A UI annotation only: install +/// and uninstall always operate on the resolved config path. +fn detected(env: &AgentEnv, agent_id: &str) -> bool { + let home = &env.home; + let parent_exists = || { + config_path(env, agent_id) + .and_then(|path| path.parent().map(Path::exists)) + .unwrap_or(false) + }; + match agent_id { + "antigravity" => home.join(".gemini").join("config").exists(), + "cline" | "cline-cli" | "github-copilot-cli" => parent_exists(), + "claude-code" => home.join(".claude").exists(), + "claude-desktop" => env.claude_desktop_dir().exists(), + // Codex detection looks at the default directory even when CODEX_HOME + // points elsewhere; the install still honours the override. + "codex" => home.join(".codex").exists(), + "cursor" => home.join(".cursor").exists(), + "fx" => home.join(".fx").exists(), + "gemini-cli" => home.join(".gemini").exists(), + "goose" => env.goose_config_path().exists(), + "grok-build" => env.grok_home().exists(), + "kilo-code" => { + env.kilo_config_dir().exists() + || env + .vscode_user_dir() + .join("globalStorage") + .join("kilocode.kilo-code") + .exists() + } + "kimi-code" => env.kimi_code_home().exists(), + "kiro-cli" => home.join(".kiro").exists(), + "mcporter" => home.join(".mcporter").exists(), + "opencode" => home.join(".config").join("opencode").exists(), + "vscode" => env.vscode_user_dir().exists(), + "windsurf" => home.join(".codeium").join("windsurf").exists(), + "zed" => env.zed_config_dir().exists(), + _ => false, + } } const AGENT_SPECS: &[AgentSpec] = &[ @@ -248,6 +425,13 @@ const AGENT_SPECS: &[AgentSpec] = &[ config_key: "servers", format: ConfigFormat::Json, }, + AgentSpec { + id: "windsurf", + display_name: "Windsurf", + category: AgentCategory::Editor, + config_key: "mcpServers", + format: ConfigFormat::Json, + }, AgentSpec { id: "zed", display_name: "Zed", @@ -276,6 +460,13 @@ const AGENT_SPECS: &[AgentSpec] = &[ config_key: "mcp_servers", format: ConfigFormat::Toml, }, + AgentSpec { + id: "fx", + display_name: "fx", + category: AgentCategory::Cli, + config_key: "mcp", + format: ConfigFormat::Json, + }, AgentSpec { id: "gemini-cli", display_name: "Gemini CLI", @@ -297,6 +488,13 @@ const AGENT_SPECS: &[AgentSpec] = &[ config_key: "extensions", format: ConfigFormat::Yaml, }, + AgentSpec { + id: "grok-build", + display_name: "Grok Build", + category: AgentCategory::Cli, + config_key: "mcp_servers", + format: ConfigFormat::Toml, + }, AgentSpec { id: "antigravity", display_name: "Antigravity", @@ -304,6 +502,27 @@ const AGENT_SPECS: &[AgentSpec] = &[ config_key: "mcpServers", format: ConfigFormat::Json, }, + AgentSpec { + id: "kilo-code", + display_name: "Kilo Code", + category: AgentCategory::Cli, + config_key: "mcp", + format: ConfigFormat::Json, + }, + AgentSpec { + id: "kimi-code", + display_name: "Kimi Code", + category: AgentCategory::Cli, + config_key: "mcpServers", + format: ConfigFormat::Json, + }, + AgentSpec { + id: "kiro-cli", + display_name: "Kiro CLI", + category: AgentCategory::Cli, + config_key: "mcpServers", + format: ConfigFormat::Json, + }, AgentSpec { id: "opencode", display_name: "OpenCode", @@ -321,249 +540,589 @@ const AGENT_SPECS: &[AgentSpec] = &[ ]; fn spec_for(agent_id: &str) -> Option<&'static AgentSpec> { - AGENT_SPECS.iter().find(|s| s.id == agent_id) + AGENT_SPECS.iter().find(|spec| spec.id == agent_id) } -fn detect_agent_directory(agent_id: &str) -> bool { - // Mirrors add-mcp's `detectGlobalInstall` checks — typically the immediate - // parent of the config file. Used only for UI annotation; install/uninstall - // always operates on the resolved config path. - let Some(h) = home() else { - return false; +fn token_env_for(agent_id: &str) -> Option { + (agent_id == "fx").then(|| FX_TOKEN_ENV.to_string()) +} + +/// A server entry with its keys in the order the client's own docs show them. +/// `serde_json::Map` sorts keys, so the shape stays a list until written. +type Entry = Vec<(&'static str, serde_json::Value)>; + +/// The per-client shape of the Donut entry, including where the credential +/// goes. Every install replaces the entry wholesale so stale keys from an +/// earlier shape never linger. +fn server_entry(agent_id: &str, target: &McpTarget) -> Entry { + use serde_json::json; + let url = json!(target.url); + let headers = target + .authorization() + .map(|value| json!({ "Authorization": value })); + let mut entry: Entry = match agent_id { + "antigravity" | "windsurf" => vec![("serverUrl", url)], + "cursor" | "kiro-cli" | "grok-build" => vec![("url", url)], + "cline" | "cline-cli" => vec![ + ("url", url), + ("type", json!("streamableHttp")), + ("disabled", json!(false)), + ], + "kimi-code" => vec![("transport", json!("http")), ("url", url)], + "github-copilot-cli" => vec![ + ("type", json!("http")), + ("url", url), + ("tools", json!(["*"])), + ], + "zed" => vec![ + ("source", json!("custom")), + ("type", json!("http")), + ("url", url), + ], + "opencode" | "kilo-code" => vec![ + ("type", json!("remote")), + ("url", url), + ("enabled", json!(true)), + ], + "goose" => vec![ + ("name", json!(SERVER_NAME)), + ("description", json!("")), + ("type", json!("streamable_http")), + ("uri", url), + ], + "fx" => vec![ + ("type", json!("http")), + ("url", url), + ("enabled", json!(true)), + ], + // claude-code, codex, gemini-cli, mcporter, vscode: the standard + // streamable HTTP shape. + _ => vec![("type", json!("http")), ("url", url)], }; match agent_id { - "antigravity" => h.join(".gemini").exists(), - "cline" => config_path_for("cline") - .and_then(|p| p.parent().map(|d| d.exists())) - .unwrap_or(false), - "cline-cli" => config_path_for("cline-cli") - .and_then(|p| p.parent().map(|d| d.exists())) - .unwrap_or(false), - "claude-code" => h.join(".claude").exists(), - "claude-desktop" => claude_desktop_config_path() - .and_then(|p| p.parent().map(|d| d.exists())) - .unwrap_or(false), - "codex" => h.join(".codex").exists(), - "cursor" => h.join(".cursor").exists(), - "gemini-cli" => h.join(".gemini").exists(), - "github-copilot-cli" => config_path_for("github-copilot-cli") - .and_then(|p| p.parent().map(|d| d.exists())) - .unwrap_or(false), - "goose" => goose_config_path().is_some_and(|p| p.exists()), - "mcporter" => h.join(".mcporter").exists(), - "opencode" => h.join(".config").join("opencode").exists(), - "vscode" => vscode_user_dir().is_some_and(|d| d.exists()), - "zed" => zed_config_dir().is_some_and(|d| d.exists()), - _ => false, - } -} - -/// Transform the donut-browser HTTP server config into the per-agent shape. -/// All agents speak HTTP except Claude Desktop, which uses a node stdio bridge -/// (handled by the extension installer in lib.rs). -fn transform_remote_config(agent_id: &str, url: &str) -> serde_json::Value { - use serde_json::json; - match agent_id { - "zed" => json!({ "source": "custom", "type": "http", "url": url }), - "opencode" => json!({ "type": "remote", "url": url, "enabled": true }), - "antigravity" => json!({ "serverUrl": url }), - "cursor" => json!({ "url": url }), - "cline" | "cline-cli" => json!({ - "url": url, - "type": "streamableHttp", - "disabled": false, - }), - "codex" => json!({ "type": "http", "url": url }), - "github-copilot-cli" => json!({ "type": "http", "url": url, "tools": ["*"] }), - "goose" => json!({ - "name": SERVER_NAME, - "description": "", - "type": "streamable_http", - "uri": url, - "headers": {}, - "enabled": true, - "timeout": 300, - }), - "vscode" => json!({ "type": "http", "url": url }), - // claude-code, claude-desktop, gemini-cli, mcporter — passthrough - _ => json!({ "type": "http", "url": url }), - } -} - -/// Detect whether a server config object looks like our donut-browser HTTP -/// endpoint by URL prefix. Matches across the various per-agent key shapes -/// (`url`, `uri`, `serverUrl`). -fn config_matches_donut(value: &serde_json::Value) -> bool { - for key in ["url", "uri", "serverUrl"] { - if let Some(s) = value.get(key).and_then(|v| v.as_str()) { - if s.contains("/mcp/") - && (s.starts_with("http://127.0.0.1") || s.starts_with("http://localhost")) - { - return true; + "codex" => { + if let Some(headers) = headers { + entry.push(("http_headers", headers)); + } + } + "fx" => { + if target.bearer.is_some() { + entry.push(("bearer_token_env", json!(FX_TOKEN_ENV))); + } + } + // Zed and Goose document the header map as part of the shape, so it is + // written even when there is nothing to put in it. + "zed" | "goose" => entry.push(("headers", headers.unwrap_or_else(|| json!({})))), + _ => { + if let Some(headers) = headers { + entry.push(("headers", headers)); } } } - false + if agent_id == "goose" { + entry.push(("enabled", json!(true))); + entry.push(("timeout", json!(300))); + } + entry } -fn read_value(path: &Path, format: ConfigFormat) -> serde_json::Value { - let Ok(content) = fs::read_to_string(path) else { - return serde_json::Value::Null; +/// Which Donut endpoint a URL points at, if any. The remote URL is matched +/// exactly (a trailing slash tolerated); the loopback form is any plain-http +/// `127.0.0.1` or `localhost` origin whose path starts with `/mcp`. +pub fn endpoint_of_url(url: &str) -> Option { + let url = url.trim(); + if url.trim_end_matches('/') == remote_mcp_url().trim_end_matches('/') { + return Some(McpEndpoint::Remote); + } + let rest = url.strip_prefix("http://")?; + let (authority, path) = match rest.find('/') { + Some(index) => rest.split_at(index), + None => (rest, ""), }; - match format { - ConfigFormat::Json => serde_json::from_str(&content).unwrap_or(serde_json::Value::Null), - ConfigFormat::Toml => toml::from_str::(&content) - .ok() - .and_then(|t| serde_json::to_value(t).ok()) - .unwrap_or(serde_json::Value::Null), - ConfigFormat::Yaml => serde_yaml::from_str::(&content) - .ok() - .and_then(|y| serde_json::to_value(y).ok()) - .unwrap_or(serde_json::Value::Null), + let host = authority.split(':').next().unwrap_or(""); + let loopback = host == "127.0.0.1" || host == "localhost"; + let mcp_path = path == "/mcp" || path.starts_with("/mcp/"); + (loopback && mcp_path).then_some(McpEndpoint::Local) +} + +fn endpoint_of_entry(value: &serde_json::Value) -> Option { + ["url", "uri", "serverUrl"].iter().find_map(|key| { + value + .get(key) + .and_then(|v| v.as_str()) + .and_then(endpoint_of_url) + }) +} + +/// Ours by name, or ours by URL under any name: what detection counts as +/// connected is exactly what removal deletes. +fn is_donut_entry(name: &str, value: &serde_json::Value) -> bool { + name == SERVER_NAME || endpoint_of_entry(value).is_some() +} + +fn json_to_cst(value: &serde_json::Value) -> CstInputValue { + match value { + serde_json::Value::Null => CstInputValue::Null, + serde_json::Value::Bool(b) => CstInputValue::Bool(*b), + serde_json::Value::Number(n) => CstInputValue::Number(n.to_string()), + serde_json::Value::String(s) => CstInputValue::String(s.clone()), + serde_json::Value::Array(items) => { + CstInputValue::Array(items.iter().map(json_to_cst).collect()) + } + serde_json::Value::Object(map) => CstInputValue::Object( + map + .iter() + .map(|(key, value)| (key.clone(), json_to_cst(value))) + .collect(), + ), } } -fn write_value(path: &Path, value: &serde_json::Value, format: ConfigFormat) -> Result<(), String> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| format!("Failed to create config dir: {e}"))?; +fn entry_to_cst(entry: &Entry) -> CstInputValue { + CstInputValue::Object( + entry + .iter() + .map(|(key, value)| (key.to_string(), json_to_cst(value))) + .collect(), + ) +} + +fn json_to_toml(value: &serde_json::Value) -> Result { + Ok(match value { + serde_json::Value::Null => return Err("TOML has no null value".to_string()), + serde_json::Value::Bool(b) => toml_edit::Value::from(*b), + serde_json::Value::Number(n) => match (n.as_i64(), n.as_f64()) { + (Some(i), _) => toml_edit::Value::from(i), + (None, Some(f)) => toml_edit::Value::from(f), + (None, None) => return Err(format!("{n} does not fit a TOML number")), + }, + serde_json::Value::String(s) => toml_edit::Value::from(s.as_str()), + serde_json::Value::Array(items) => toml_edit::Value::Array( + items + .iter() + .map(json_to_toml) + .collect::>()?, + ), + serde_json::Value::Object(map) => toml_edit::Value::InlineTable( + map + .iter() + .map(|(key, value)| json_to_toml(value).map(|v| (key.clone(), v))) + .collect::>()?, + ), + }) +} + +fn entry_to_toml(entry: &Entry) -> Result { + let mut table = Table::new(); + for (key, value) in entry { + table.insert(key, Item::Value(json_to_toml(value)?)); } - let content = match format { - ConfigFormat::Json => { - serde_json::to_string_pretty(value).map_err(|e| format!("Failed to serialize JSON: {e}"))? + Ok(table) +} + +fn toml_value_to_json(value: &toml_edit::Value) -> serde_json::Value { + use serde_json::json; + match value { + toml_edit::Value::String(s) => json!(s.value()), + toml_edit::Value::Integer(i) => json!(*i.value()), + toml_edit::Value::Float(f) => json!(*f.value()), + toml_edit::Value::Boolean(b) => json!(*b.value()), + toml_edit::Value::Datetime(d) => json!(d.value().to_string()), + toml_edit::Value::Array(items) => { + serde_json::Value::Array(items.iter().map(toml_value_to_json).collect()) } - ConfigFormat::Toml => { - let toml_val: toml::Value = serde_json::from_value(value.clone()) - .map_err(|e| format!("Failed to convert to TOML: {e}"))?; - toml::to_string_pretty(&toml_val).map_err(|e| format!("Failed to serialize TOML: {e}"))? + toml_edit::Value::InlineTable(table) => serde_json::Value::Object( + table + .iter() + .map(|(key, value)| (key.to_string(), toml_value_to_json(value))) + .collect(), + ), + } +} + +fn toml_item_to_json(item: &Item) -> serde_json::Value { + match item { + Item::None => serde_json::Value::Null, + Item::Value(value) => toml_value_to_json(value), + Item::Table(table) => serde_json::Value::Object( + table + .iter() + .map(|(key, item)| (key.to_string(), toml_item_to_json(item))) + .collect(), + ), + Item::ArrayOfTables(tables) => serde_json::Value::Array( + tables + .iter() + .map(|table| toml_item_to_json(&Item::Table(table.clone()))) + .collect(), + ), + } +} + +fn entry_to_yaml(entry: &Entry) -> Result { + let mut mapping = serde_yaml::Mapping::new(); + for (key, value) in entry { + let yaml = + serde_yaml::to_value(value).map_err(|e| format!("Failed to convert to YAML: {e}"))?; + mapping.insert(serde_yaml::Value::String(key.to_string()), yaml); + } + Ok(serde_yaml::Value::Mapping(mapping)) +} + +/// A parsed config file that can be edited without disturbing what the user +/// wrote around the Donut entry. +enum Document { + Json(CstRootNode), + Toml(DocumentMut), + Yaml(serde_yaml::Value), +} + +impl Document { + fn parse(content: &str, format: ConfigFormat) -> Result { + Ok(match format { + ConfigFormat::Json => Self::Json( + CstRootNode::parse(content, &ParseOptions::default()).map_err(|e| e.to_string())?, + ), + ConfigFormat::Toml => Self::Toml(content.parse::().map_err(|e| e.to_string())?), + ConfigFormat::Yaml => { + let value = if content.trim().is_empty() { + serde_yaml::Value::Null + } else { + serde_yaml::from_str(content).map_err(|e| e.to_string())? + }; + Self::Yaml(value) + } + }) + } + + fn json_servers(root: &CstRootNode, key: &str) -> Option { + root.object_value()?.object_value(key) + } + + /// Every entry under the server map, as plain JSON for inspection. + fn entries(&self, key: &str) -> Vec<(String, serde_json::Value)> { + match self { + Self::Json(root) => Self::json_servers(root, key) + .map(|servers| { + servers + .properties() + .into_iter() + .filter_map(|prop| { + let name = prop.name()?.decoded_value().ok()?; + let value = prop.to_serde_value()?; + Some((name, value)) + }) + .collect() + }) + .unwrap_or_default(), + Self::Toml(doc) => doc + .get(key) + .and_then(Item::as_table_like) + .map(|servers| { + servers + .iter() + .map(|(name, item)| (name.to_string(), toml_item_to_json(item))) + .collect() + }) + .unwrap_or_default(), + Self::Yaml(root) => root + .get(key) + .and_then(serde_yaml::Value::as_mapping) + .map(|servers| { + servers + .iter() + .filter_map(|(name, value)| { + let name = name.as_str()?.to_string(); + let value = serde_json::to_value(value).ok()?; + Some((name, value)) + }) + .collect() + }) + .unwrap_or_default(), } - ConfigFormat::Yaml => { - let yaml_val: serde_yaml::Value = serde_yaml::from_str( - &serde_json::to_string(value).map_err(|e| format!("Failed to serialize: {e}"))?, - ) - .map_err(|e| format!("Failed to convert to YAML: {e}"))?; - serde_yaml::to_string(&yaml_val).map_err(|e| format!("Failed to serialize YAML: {e}"))? + } + + /// Write the Donut entry, replacing any entry of that name wholesale. A root + /// or server map that exists but is not an object is an error rather than + /// something to overwrite. + fn set_entry(&mut self, key: &str, entry: &Entry) -> Result<(), String> { + match self { + Self::Json(root) => { + let root_object = root + .object_value_or_create() + .ok_or("the file's top level is not a JSON object")?; + let servers = root_object + .object_value_or_create(key) + .ok_or_else(|| format!("\"{key}\" is not a JSON object"))?; + match servers.get(SERVER_NAME) { + Some(prop) => prop.set_value(entry_to_cst(entry)), + None => { + servers.append(SERVER_NAME, entry_to_cst(entry)); + } + } + } + Self::Toml(doc) => { + let servers = doc.entry(key).or_insert_with(|| { + // Implicit: the entry renders as [mcp_servers.donut-browser] with no + // bare [mcp_servers] header above it, the way Codex writes it. + let mut table = Table::new(); + table.set_implicit(true); + Item::Table(table) + }); + let servers = servers + .as_table_like_mut() + .ok_or_else(|| format!("\"{key}\" is not a TOML table"))?; + servers.insert(SERVER_NAME, Item::Table(entry_to_toml(entry)?)); + } + Self::Yaml(root) => { + if root.is_null() { + *root = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + } + let root_map = root + .as_mapping_mut() + .ok_or("the file's top level is not a YAML mapping")?; + let servers = root_map + .entry(serde_yaml::Value::String(key.to_string())) + .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); + if servers.is_null() { + *servers = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + } + servers + .as_mapping_mut() + .ok_or_else(|| format!("\"{key}\" is not a YAML mapping"))? + .insert( + serde_yaml::Value::String(SERVER_NAME.to_string()), + entry_to_yaml(entry)?, + ); + } } + Ok(()) + } + + fn remove_entries(&mut self, key: &str, names: &[String]) { + match self { + Self::Json(root) => { + if let Some(servers) = Self::json_servers(root, key) { + for name in names { + if let Some(prop) = servers.get(name) { + prop.remove(); + } + } + } + } + Self::Toml(doc) => { + if let Some(servers) = doc.get_mut(key).and_then(Item::as_table_like_mut) { + for name in names { + servers.remove(name); + } + } + } + Self::Yaml(root) => { + if let Some(servers) = root + .get_mut(key) + .and_then(serde_yaml::Value::as_mapping_mut) + { + for name in names { + servers.remove(name.as_str()); + } + } + } + } + } + + fn to_text(&self) -> Result { + match self { + Self::Json(root) => Ok(root.to_string()), + Self::Toml(doc) => Ok(doc.to_string()), + Self::Yaml(root) => { + serde_yaml::to_string(root).map_err(|e| format!("Failed to serialize YAML: {e}")) + } + } + } +} + +fn read_document(path: &Path, format: ConfigFormat) -> Result<(Document, bool), String> { + let content = if path.exists() { + fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))? + } else { + String::new() }; - fs::write(path, content).map_err(|e| format!("Failed to write config: {e}"))?; + let document = Document::parse(&content, format).map_err(|e| { + format!( + "{} could not be parsed, so it was left untouched: {e}", + path.display() + ) + })?; + Ok((document, content.is_empty())) +} + +/// Replace the file through a sibling temp file and rename, so a crash mid +/// write never leaves half of `~/.claude.json` behind. An existing file keeps +/// its permission bits. A new one is owner-only when `private` (the entry +/// carries the remote credential) and otherwise gets the process default. +fn write_text(path: &Path, text: &str, private: bool) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("{} has no parent directory", path.display()))?; + fs::create_dir_all(parent).map_err(|e| format!("Failed to create config dir: {e}"))?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| format!("{} has no file name", path.display()))?; + let tmp = parent.join(format!(".{file_name}.donut-tmp")); + let existing = fs::metadata(path).ok(); + // Owner-only from the first byte whenever the content is secret or the + // target's own bits are about to be copied over it: a temp file created + // with the default mode and tightened afterwards is readable by everyone + // in between. + let written = if private || existing.is_some() { + crate::app_dirs::write_owner_only(&tmp, text.as_bytes()) + } else { + fs::write(&tmp, text) + }; + written.map_err(|e| format!("Failed to write config: {e}"))?; + if let Some(metadata) = existing { + if let Err(e) = fs::set_permissions(&tmp, metadata.permissions()) { + let _ = fs::remove_file(&tmp); + return Err(format!("Failed to keep config permissions: {e}")); + } + } + if let Err(e) = fs::rename(&tmp, path) { + let _ = fs::remove_file(&tmp); + return Err(format!("Failed to save config: {e}")); + } Ok(()) } -/// Navigate `config_key` (dot notation), creating object literals at each -/// missing level. Returns a mutable reference to the bottom container so the -/// caller can set/remove server entries. -fn ensure_nested_object<'a>( - root: &'a mut serde_json::Value, - config_key: &str, -) -> &'a mut serde_json::Map { - if !root.is_object() { - *root = serde_json::Value::Object(serde_json::Map::new()); +/// fx expects its config to be private: the directory 700 and the file 600, +/// matching what its own installer does. +#[cfg(unix)] +fn restrict_to_owner(path: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + if let Some(dir) = path.parent() { + fs::set_permissions(dir, fs::Permissions::from_mode(0o700)) + .map_err(|e| format!("Failed to restrict config dir permissions: {e}"))?; } - let mut current = root.as_object_mut().expect("just set to object"); - let parts: Vec<&str> = config_key.split('.').collect(); - for part in &parts { - let entry = current - .entry(part.to_string()) - .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new())); - if !entry.is_object() { - *entry = serde_json::Value::Object(serde_json::Map::new()); - } - current = entry.as_object_mut().expect("just ensured object"); - } - current + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("Failed to restrict config permissions: {e}")) } -fn nested_object<'a>( - root: &'a serde_json::Value, - config_key: &str, -) -> Option<&'a serde_json::Map> { - let mut current = root.as_object()?; - for part in config_key.split('.') { - current = current.get(part)?.as_object()?; - } - Some(current) +#[cfg(not(unix))] +fn restrict_to_owner(_path: &Path) -> Result<(), String> { + Ok(()) } -fn is_generic_agent_connected(agent_id: &str) -> bool { - let Some(spec) = spec_for(agent_id) else { - return false; - }; - let Some(path) = config_path_for(agent_id) else { - return false; +fn install_in(env: &AgentEnv, agent_id: &str, target: &McpTarget) -> Result<(), String> { + let spec = spec_for(agent_id).ok_or_else(|| format!("Unknown agent: {agent_id}"))?; + let path = config_path(env, agent_id) + .ok_or_else(|| format!("Unable to resolve config path for {agent_id}"))?; + let (mut document, was_empty) = read_document(&path, spec.format)?; + document.set_entry(spec.config_key, &server_entry(agent_id, target))?; + let mut text = document.to_text()?; + if was_empty && !text.ends_with('\n') { + text.push('\n'); + } + write_text(&path, &text, target.bearer.is_some())?; + if agent_id == "fx" { + restrict_to_owner(&path)?; + } + Ok(()) +} + +fn uninstall_in(env: &AgentEnv, agent_id: &str) -> Result<(), String> { + let spec = spec_for(agent_id).ok_or_else(|| format!("Unknown agent: {agent_id}"))?; + let Some(path) = config_path(env, agent_id) else { + return Ok(()); }; if !path.exists() { - return false; + return Ok(()); } - let root = read_value(&path, spec.format); - let Some(servers) = nested_object(&root, spec.config_key) else { - return false; - }; - if let Some(entry) = servers.get(SERVER_NAME) { - return config_matches_donut(entry); + let (mut document, _) = read_document(&path, spec.format)?; + let names: Vec = document + .entries(spec.config_key) + .into_iter() + .filter(|(name, value)| is_donut_entry(name, value)) + .map(|(name, _)| name) + .collect(); + if names.is_empty() { + return Ok(()); } - servers.values().any(config_matches_donut) + document.remove_entries(spec.config_key, &names); + write_text(&path, &document.to_text()?, false) } -/// Install or remove the donut-browser entry from a generic agent. Returns -/// `true` if a write happened. Callers handle higher-level dispatch (Claude -/// Desktop extension setup, Claude Code CLI invocation). -pub fn install_generic(agent_id: &str, url: &str) -> Result<(), String> { - let spec = spec_for(agent_id).ok_or_else(|| format!("Unknown agent: {agent_id}"))?; - let path = config_path_for(agent_id) - .ok_or_else(|| format!("Unable to resolve config path for {agent_id}"))?; - - let mut root = if path.exists() { - read_value(&path, spec.format) - } else { - serde_json::Value::Object(serde_json::Map::new()) - }; - if !root.is_object() { - root = serde_json::Value::Object(serde_json::Map::new()); +/// The endpoint a client's config points at. The entry named `donut-browser` +/// decides when it is ours; otherwise any entry with a Donut URL counts, so a +/// renamed entry still reads as connected (and `uninstall_in` removes it). +fn status_in(env: &AgentEnv, agent_id: &str) -> Option { + let spec = spec_for(agent_id)?; + let path = config_path(env, agent_id)?; + if !path.exists() { + return None; } + let (document, _) = read_document(&path, spec.format).ok()?; + let entries = document.entries(spec.config_key); + entries + .iter() + .find(|(name, _)| name == SERVER_NAME) + .and_then(|(_, value)| endpoint_of_entry(value)) + .or_else(|| { + entries + .iter() + .find_map(|(_, value)| endpoint_of_entry(value)) + }) +} - let container = ensure_nested_object(&mut root, spec.config_key); - container.insert( - SERVER_NAME.to_string(), - transform_remote_config(agent_id, url), - ); +fn process_env() -> Result { + AgentEnv::from_process().ok_or_else(|| "Home directory unavailable".to_string()) +} - write_value(&path, &root, spec.format) +pub fn install_generic(agent_id: &str, target: &McpTarget) -> Result<(), String> { + install_in(&process_env()?, agent_id, target) } pub fn uninstall_generic(agent_id: &str) -> Result<(), String> { - let spec = spec_for(agent_id).ok_or_else(|| format!("Unknown agent: {agent_id}"))?; - let Some(path) = config_path_for(agent_id) else { - return Ok(()); - }; - if !path.exists() { - return Ok(()); - } - - let mut root = read_value(&path, spec.format); - if !root.is_object() { - return Ok(()); - } - - let container = ensure_nested_object(&mut root, spec.config_key); - container.remove(SERVER_NAME); - - write_value(&path, &root, spec.format) + uninstall_in(&process_env()?, agent_id) } -pub fn list_agents_with_status(connected_overrides: &[(&str, bool)]) -> Vec { +/// Ids of the file-based clients whose entry points at `endpoint`. Claude +/// Desktop is not included: its bundle is inspected by lib.rs. +pub fn agents_on_endpoint(endpoint: McpEndpoint) -> Vec { + let Some(env) = AgentEnv::from_process() else { + return Vec::new(); + }; + AGENT_SPECS + .iter() + .filter(|spec| spec.id != "claude-desktop") + .filter(|spec| status_in(&env, spec.id) == Some(endpoint)) + .map(|spec| spec.id.to_string()) + .collect() +} + +pub fn list_agents_with_status(overrides: &[(&str, AgentStatus)]) -> Vec { + let env = AgentEnv::from_process(); AGENT_SPECS .iter() .map(|spec| { - let connected = connected_overrides + let status = overrides .iter() .find(|(id, _)| *id == spec.id) - .map(|(_, c)| *c) - .unwrap_or_else(|| is_generic_agent_connected(spec.id)); + .map(|(_, status)| *status) + .unwrap_or_else(|| { + let endpoint = env.as_ref().and_then(|env| status_in(env, spec.id)); + AgentStatus { + connected: endpoint.is_some(), + endpoint, + } + }); McpAgentInfo { id: spec.id.to_string(), display_name: spec.display_name.to_string(), category: spec.category, - connected, - detected: detect_agent_directory(spec.id), + connected: status.connected, + detected: env.as_ref().is_some_and(|env| detected(env, spec.id)), + endpoint: status.endpoint, + token_env: token_env_for(spec.id), } }) .collect() @@ -572,3 +1131,776 @@ pub fn list_agents_with_status(connected_overrides: &[(&str, bool)]) -> Vec bool { spec_for(agent_id).is_some() } + +/// `/Claude`: where Claude Desktop keeps its config, its +/// extension bundles and the extension registry. +pub fn claude_desktop_dir() -> Option { + AgentEnv::from_process().map(|env| env.claude_desktop_dir()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + const KEY: &str = "dmk_test_credential"; + const LOCAL_URL: &str = "http://127.0.0.1:51080/mcp/abc123"; + + fn test_env(root: &Path, platform: Platform) -> AgentEnv { + AgentEnv { + platform, + home: root.join("home"), + appdata: None, + xdg_config_home: None, + codex_home: None, + grok_home: None, + kimi_code_home: None, + cline_dir: None, + } + } + + fn remote() -> McpTarget { + McpTarget::remote(KEY.to_string()) + } + + fn local() -> McpTarget { + McpTarget::local(LOCAL_URL.to_string()) + } + + fn read(path: &Path) -> String { + fs::read_to_string(path).unwrap() + } + + fn entry_of(env: &AgentEnv, agent_id: &str) -> serde_json::Value { + let spec = spec_for(agent_id).unwrap(); + let path = config_path(env, agent_id).unwrap(); + let (document, _) = read_document(&path, spec.format).unwrap(); + document + .entries(spec.config_key) + .into_iter() + .find(|(name, _)| name == SERVER_NAME) + .map(|(_, value)| value) + .unwrap_or_else(|| panic!("{agent_id} has no {SERVER_NAME} entry")) + } + + fn generic_ids() -> impl Iterator { + AGENT_SPECS + .iter() + .map(|spec| spec.id) + .filter(|id| *id != "claude-desktop") + } + + #[test] + fn registry_has_the_twenty_clients() { + let mut ids: Vec<&str> = AGENT_SPECS.iter().map(|spec| spec.id).collect(); + ids.sort_unstable(); + assert_eq!( + ids, + vec![ + "antigravity", + "claude-code", + "claude-desktop", + "cline", + "cline-cli", + "codex", + "cursor", + "fx", + "gemini-cli", + "github-copilot-cli", + "goose", + "grok-build", + "kilo-code", + "kimi-code", + "kiro-cli", + "mcporter", + "opencode", + "vscode", + "windsurf", + "zed", + ] + ); + } + + #[test] + fn paths_per_platform_match_the_registry() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let h = root.join("home"); + let cases: Vec<(Platform, &str, PathBuf)> = vec![ + (Platform::MacOs, "antigravity", h.join(".gemini/config/mcp_config.json")), + (Platform::Linux, "antigravity", h.join(".gemini/config/mcp_config.json")), + (Platform::Windows, "antigravity", h.join(".gemini/config/mcp_config.json")), + ( + Platform::MacOs, + "cline", + h.join("Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json"), + ), + ( + Platform::Linux, + "cline", + h.join(".config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json"), + ), + ( + Platform::Windows, + "cline", + h.join("AppData/Roaming/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json"), + ), + (Platform::MacOs, "cline-cli", h.join(".cline/data/settings/cline_mcp_settings.json")), + (Platform::Windows, "cline-cli", h.join(".cline/data/settings/cline_mcp_settings.json")), + (Platform::MacOs, "claude-code", h.join(".claude.json")), + (Platform::Linux, "claude-code", h.join(".claude.json")), + (Platform::Windows, "claude-code", h.join(".claude.json")), + ( + Platform::MacOs, + "claude-desktop", + h.join("Library/Application Support/Claude/claude_desktop_config.json"), + ), + (Platform::Linux, "claude-desktop", h.join(".config/Claude/claude_desktop_config.json")), + ( + Platform::Windows, + "claude-desktop", + h.join("AppData/Roaming/Claude/claude_desktop_config.json"), + ), + (Platform::MacOs, "codex", h.join(".codex/config.toml")), + (Platform::Windows, "codex", h.join(".codex/config.toml")), + (Platform::MacOs, "cursor", h.join(".cursor/mcp.json")), + (Platform::Windows, "cursor", h.join(".cursor/mcp.json")), + (Platform::MacOs, "fx", h.join(".fx/mcp.json")), + (Platform::Windows, "fx", h.join(".fx/mcp.json")), + (Platform::MacOs, "gemini-cli", h.join(".gemini/settings.json")), + (Platform::Windows, "gemini-cli", h.join(".gemini/settings.json")), + (Platform::MacOs, "goose", h.join(".config/goose/config.yaml")), + (Platform::Linux, "goose", h.join(".config/goose/config.yaml")), + ( + Platform::Windows, + "goose", + h.join("AppData/Roaming/Block/goose/config/config.yaml"), + ), + (Platform::MacOs, "github-copilot-cli", h.join(".copilot/mcp-config.json")), + (Platform::Windows, "github-copilot-cli", h.join(".copilot/mcp-config.json")), + (Platform::MacOs, "grok-build", h.join(".grok/config.toml")), + (Platform::Windows, "grok-build", h.join(".grok/config.toml")), + (Platform::MacOs, "kilo-code", h.join(".config/kilo/kilo.json")), + (Platform::Linux, "kilo-code", h.join(".config/kilo/kilo.json")), + (Platform::Windows, "kilo-code", h.join(".config/kilo/kilo.json")), + (Platform::MacOs, "kimi-code", h.join(".kimi-code/mcp.json")), + (Platform::Windows, "kimi-code", h.join(".kimi-code/mcp.json")), + (Platform::MacOs, "kiro-cli", h.join(".kiro/settings/mcp.json")), + (Platform::Windows, "kiro-cli", h.join(".kiro/settings/mcp.json")), + (Platform::MacOs, "mcporter", h.join(".mcporter/mcporter.json")), + (Platform::Windows, "mcporter", h.join(".mcporter/mcporter.json")), + (Platform::MacOs, "opencode", h.join(".config/opencode/opencode.jsonc")), + (Platform::Linux, "opencode", h.join(".config/opencode/opencode.jsonc")), + (Platform::Windows, "opencode", h.join(".config/opencode/opencode.jsonc")), + (Platform::MacOs, "vscode", h.join("Library/Application Support/Code/User/mcp.json")), + (Platform::Linux, "vscode", h.join(".config/Code/User/mcp.json")), + (Platform::Windows, "vscode", h.join("AppData/Roaming/Code/User/mcp.json")), + (Platform::MacOs, "windsurf", h.join(".codeium/windsurf/mcp_config.json")), + (Platform::Windows, "windsurf", h.join(".codeium/windsurf/mcp_config.json")), + (Platform::MacOs, "zed", h.join("Library/Application Support/Zed/settings.json")), + (Platform::Linux, "zed", h.join(".config/zed/settings.json")), + (Platform::Windows, "zed", h.join("AppData/Roaming/Zed/settings.json")), + ]; + for (platform, agent_id, expected) in cases { + let env = test_env(root, platform); + assert_eq!( + config_path(&env, agent_id).unwrap(), + expected, + "{agent_id} on {platform:?}" + ); + } + } + + #[test] + fn env_overrides_move_the_config_files() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let mut env = test_env(root, Platform::Linux); + env.xdg_config_home = Some(root.join("xdg")); + env.codex_home = Some(root.join("codex-home")); + env.grok_home = Some(root.join("grok-home")); + env.kimi_code_home = Some(root.join("kimi-home")); + env.cline_dir = Some(root.join("cline-dir")); + + let expect = |env: &AgentEnv, agent_id: &str, path: PathBuf| { + assert_eq!(config_path(env, agent_id).unwrap(), path, "{agent_id}"); + }; + expect(&env, "codex", root.join("codex-home/config.toml")); + expect(&env, "grok-build", root.join("grok-home/config.toml")); + expect(&env, "kimi-code", root.join("kimi-home/mcp.json")); + expect( + &env, + "cline-cli", + root.join("cline-dir/data/settings/cline_mcp_settings.json"), + ); + expect(&env, "vscode", root.join("xdg/Code/User/mcp.json")); + expect(&env, "zed", root.join("xdg/zed/settings.json")); + expect(&env, "goose", root.join("xdg/goose/config.yaml")); + expect(&env, "kilo-code", root.join("xdg/kilo/kilo.json")); + expect(&env, "github-copilot-cli", root.join("xdg/mcp-config.json")); + expect( + &env, + "claude-desktop", + root.join("xdg/Claude/claude_desktop_config.json"), + ); + // OpenCode ignores XDG_CONFIG_HOME, and Goose on macOS does too. + expect( + &env, + "opencode", + root.join("home/.config/opencode/opencode.jsonc"), + ); + env.platform = Platform::MacOs; + expect(&env, "goose", root.join("home/.config/goose/config.yaml")); + + let mut windows = test_env(root, Platform::Windows); + windows.appdata = Some(root.join("roaming")); + assert_eq!( + config_path(&windows, "vscode").unwrap(), + root.join("roaming/Code/User/mcp.json") + ); + assert_eq!( + config_path(&windows, "goose").unwrap(), + root.join("roaming/Block/goose/config/config.yaml") + ); + assert_eq!( + config_path(&windows, "zed").unwrap(), + root.join("roaming/Zed/settings.json") + ); + assert_eq!( + config_path(&windows, "claude-desktop").unwrap(), + root.join("roaming/Claude/claude_desktop_config.json") + ); + } + + #[test] + fn jsonc_variants_are_preferred_when_present() { + let dir = tempfile::tempdir().unwrap(); + let env = test_env(dir.path(), Platform::Linux); + let home = &env.home; + + fs::create_dir_all(home.join(".config/opencode")).unwrap(); + fs::write(home.join(".config/opencode/opencode.json"), "{}").unwrap(); + assert_eq!( + config_path(&env, "opencode").unwrap(), + home.join(".config/opencode/opencode.json") + ); + fs::write(home.join(".config/opencode/opencode.jsonc"), "{}").unwrap(); + assert_eq!( + config_path(&env, "opencode").unwrap(), + home.join(".config/opencode/opencode.jsonc") + ); + + fs::create_dir_all(home.join(".mcporter")).unwrap(); + fs::write(home.join(".mcporter/mcporter.jsonc"), "{}").unwrap(); + assert_eq!( + config_path(&env, "mcporter").unwrap(), + home.join(".mcporter/mcporter.jsonc") + ); + fs::write(home.join(".mcporter/mcporter.json"), "{}").unwrap(); + assert_eq!( + config_path(&env, "mcporter").unwrap(), + home.join(".mcporter/mcporter.json") + ); + + fs::create_dir_all(home.join(".config/kilo")).unwrap(); + fs::write(home.join(".config/kilo/kilo.jsonc"), "{}").unwrap(); + assert_eq!( + config_path(&env, "kilo-code").unwrap(), + home.join(".config/kilo/kilo.jsonc") + ); + } + + #[test] + fn detection_follows_the_registry_rules() { + let dir = tempfile::tempdir().unwrap(); + let env = test_env(dir.path(), Platform::MacOs); + let home = &env.home; + for id in AGENT_SPECS.iter().map(|spec| spec.id) { + assert!(!detected(&env, id), "{id} detected in an empty home"); + } + + // Gemini CLI alone must not make Antigravity look installed. + fs::create_dir_all(home.join(".gemini")).unwrap(); + assert!(detected(&env, "gemini-cli")); + assert!(!detected(&env, "antigravity")); + fs::create_dir_all(home.join(".gemini/config")).unwrap(); + assert!(detected(&env, "antigravity")); + + fs::create_dir_all(home.join(".config/kilo")).unwrap(); + assert!(detected(&env, "kilo-code")); + fs::create_dir_all(home.join(".codeium/windsurf")).unwrap(); + assert!(detected(&env, "windsurf")); + fs::create_dir_all(home.join(".kiro")).unwrap(); + assert!(detected(&env, "kiro-cli")); + fs::create_dir_all(home.join(".fx")).unwrap(); + assert!(detected(&env, "fx")); + fs::create_dir_all(home.join(".grok")).unwrap(); + assert!(detected(&env, "grok-build")); + fs::create_dir_all(home.join(".kimi-code")).unwrap(); + assert!(detected(&env, "kimi-code")); + assert!(!detected(&env, "goose")); + fs::create_dir_all(home.join(".config/goose")).unwrap(); + fs::write(home.join(".config/goose/config.yaml"), "").unwrap(); + assert!(detected(&env, "goose")); + fs::create_dir_all(home.join("Library/Application Support/Claude")).unwrap(); + assert!(detected(&env, "claude-desktop")); + } + + #[test] + fn remote_shapes_put_the_credential_where_each_client_reads_it() { + let dir = tempfile::tempdir().unwrap(); + let env = test_env(dir.path(), Platform::Linux); + let url = remote_mcp_url(); + let auth = json!({ "Authorization": format!("Bearer {KEY}") }); + let expected: Vec<(&str, serde_json::Value)> = vec![ + ( + "claude-code", + json!({ "type": "http", "url": url, "headers": auth }), + ), + ( + "gemini-cli", + json!({ "type": "http", "url": url, "headers": auth }), + ), + ( + "mcporter", + json!({ "type": "http", "url": url, "headers": auth }), + ), + ( + "vscode", + json!({ "type": "http", "url": url, "headers": auth }), + ), + ("cursor", json!({ "url": url, "headers": auth })), + ("kiro-cli", json!({ "url": url, "headers": auth })), + ("grok-build", json!({ "url": url, "headers": auth })), + ("antigravity", json!({ "serverUrl": url, "headers": auth })), + ("windsurf", json!({ "serverUrl": url, "headers": auth })), + ( + "kimi-code", + json!({ "transport": "http", "url": url, "headers": auth }), + ), + ( + "cline", + json!({ "url": url, "type": "streamableHttp", "disabled": false, "headers": auth }), + ), + ( + "cline-cli", + json!({ "url": url, "type": "streamableHttp", "disabled": false, "headers": auth }), + ), + ( + "github-copilot-cli", + json!({ "type": "http", "url": url, "tools": ["*"], "headers": auth }), + ), + ( + "codex", + json!({ "type": "http", "url": url, "http_headers": auth }), + ), + ( + "zed", + json!({ "source": "custom", "type": "http", "url": url, "headers": auth }), + ), + ( + "opencode", + json!({ "type": "remote", "url": url, "enabled": true, "headers": auth }), + ), + ( + "kilo-code", + json!({ "type": "remote", "url": url, "enabled": true, "headers": auth }), + ), + ( + "goose", + json!({ + "name": SERVER_NAME, "description": "", "type": "streamable_http", "uri": url, + "headers": auth, "enabled": true, "timeout": 300 + }), + ), + ( + "fx", + json!({ "type": "http", "url": url, "enabled": true, "bearer_token_env": FX_TOKEN_ENV }), + ), + ]; + assert_eq!(expected.len(), generic_ids().count()); + for (agent_id, shape) in expected { + install_in(&env, agent_id, &remote()).unwrap(); + assert_eq!(entry_of(&env, agent_id), shape, "{agent_id}"); + assert_eq!( + status_in(&env, agent_id), + Some(McpEndpoint::Remote), + "{agent_id}" + ); + } + let fx_text = read(&config_path(&env, "fx").unwrap()); + assert!( + !fx_text.contains("Authorization"), + "fx must never carry a literal header" + ); + } + + #[test] + fn jsonc_comments_indentation_and_trailing_commas_survive() { + let dir = tempfile::tempdir().unwrap(); + let env = test_env(dir.path(), Platform::MacOs); + let path = config_path(&env, "zed").unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let original = "// Zed settings\n{\n \"theme\": \"One Dark\",\n /* keep me */\n \"context_servers\": {\n \"other\": { \"command\": \"x\", },\n },\n \"vim_mode\": true,\n}\n"; + fs::write(&path, original).unwrap(); + + install_in(&env, "zed", &remote()).unwrap(); + let text = read(&path); + assert!(text.starts_with("// Zed settings\n")); + assert!(text.contains("/* keep me */")); + assert!(text.contains("\n \"vim_mode\": true,\n")); + assert!(text.contains("\"other\": { \"command\": \"x\", }")); + assert!( + text.contains("\n \"donut-browser\": {\n \"source\": \"custom\","), + "entry must use the file's four-space indent: {text}" + ); + assert!(text.ends_with("}\n")); + assert_eq!(status_in(&env, "zed"), Some(McpEndpoint::Remote)); + + uninstall_in(&env, "zed").unwrap(); + let text = read(&path); + assert!(text.starts_with("// Zed settings\n")); + assert!(text.contains("/* keep me */")); + assert!(!text.contains("donut-browser")); + assert!(text.contains("\"other\": { \"command\": \"x\", }")); + assert_eq!(status_in(&env, "zed"), None); + } + + #[test] + fn large_json_keeps_unrelated_keys_in_their_order() { + let dir = tempfile::tempdir().unwrap(); + let env = test_env(dir.path(), Platform::Linux); + let path = config_path(&env, "claude-code").unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let original = "{\n \"zeta\": 1,\n \"projects\": {\"/a\": {\"allowedTools\": []}},\n \"alpha\": \"b\"\n}\n"; + fs::write(&path, original).unwrap(); + install_in(&env, "claude-code", &remote()).unwrap(); + let text = read(&path); + let zeta = text.find("\"zeta\"").unwrap(); + let projects = text.find("\"projects\"").unwrap(); + let alpha = text.find("\"alpha\"").unwrap(); + let servers = text.find("\"mcpServers\"").unwrap(); + assert!( + zeta < projects && projects < alpha && alpha < servers, + "{text}" + ); + assert!(text.contains("{\"/a\": {\"allowedTools\": []}}")); + } + + #[test] + fn toml_comments_and_table_order_survive() { + let dir = tempfile::tempdir().unwrap(); + let env = test_env(dir.path(), Platform::Linux); + let path = config_path(&env, "codex").unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let original = "# codex config\nmodel = \"o3\"\n\n[projects.\"/tmp/x\"]\ntrust_level = \"trusted\"\n\n[notice]\nseen = true\n\n[mcp_servers.other]\ncommand = \"npx\"\n"; + fs::write(&path, original).unwrap(); + + install_in(&env, "codex", &remote()).unwrap(); + let text = read(&path); + assert!(text.starts_with("# codex config\nmodel = \"o3\"\n")); + let projects = text.find("[projects.\"/tmp/x\"]").unwrap(); + let notice = text.find("[notice]").unwrap(); + let other = text.find("[mcp_servers.other]").unwrap(); + let donut = text.find("[mcp_servers.donut-browser]").unwrap(); + assert!( + projects < notice && notice < other && other < donut, + "{text}" + ); + assert!( + !text.contains("\n[mcp_servers]\n"), + "no bare header: {text}" + ); + assert!(text.contains(&format!( + "http_headers = {{ Authorization = \"Bearer {KEY}\" }}" + ))); + assert_eq!( + entry_of(&env, "codex")["http_headers"]["Authorization"], + json!(format!("Bearer {KEY}")) + ); + + uninstall_in(&env, "codex").unwrap(); + let text = read(&path); + assert!(text.starts_with("# codex config\n")); + assert!(text.contains("[mcp_servers.other]\ncommand = \"npx\"\n")); + assert!(!text.contains("donut-browser")); + } + + #[test] + fn grok_toml_entry_has_no_type_and_a_headers_table() { + let dir = tempfile::tempdir().unwrap(); + let env = test_env(dir.path(), Platform::Linux); + install_in(&env, "grok-build", &remote()).unwrap(); + let text = read(&config_path(&env, "grok-build").unwrap()); + assert!(text.contains("[mcp_servers.donut-browser]\n")); + assert!(text.contains(&format!("url = \"{}\"", remote_mcp_url()))); + assert!(text.contains(&format!("headers = {{ Authorization = \"Bearer {KEY}\" }}"))); + assert!(!text.contains("type =")); + } + + #[test] + fn goose_yaml_keeps_other_extensions_and_their_order() { + let dir = tempfile::tempdir().unwrap(); + let env = test_env(dir.path(), Platform::Linux); + let path = config_path(&env, "goose").unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + "GOOSE_PROVIDER: openai\nextensions:\n zeta:\n enabled: true\n type: builtin\n alpha:\n enabled: false\n type: builtin\n", + ) + .unwrap(); + install_in(&env, "goose", &remote()).unwrap(); + let text = read(&path); + let provider = text.find("GOOSE_PROVIDER: openai").unwrap(); + let zeta = text.find("zeta:").unwrap(); + let alpha = text.find("alpha:").unwrap(); + let donut = text.find("donut-browser:").unwrap(); + assert!(provider < zeta && zeta < alpha && alpha < donut, "{text}"); + let name = text.find("name: donut-browser").unwrap(); + let uri = text.find("uri:").unwrap(); + let timeout = text.find("timeout: 300").unwrap(); + assert!(name < uri && uri < timeout, "{text}"); + assert_eq!(status_in(&env, "goose"), Some(McpEndpoint::Remote)); + + uninstall_in(&env, "goose").unwrap(); + let text = read(&path); + assert!(text.contains("zeta:") && text.contains("alpha:")); + assert!(!text.contains("donut-browser")); + } + + #[test] + fn parse_failures_abort_and_leave_the_file_alone() { + let dir = tempfile::tempdir().unwrap(); + let env = test_env(dir.path(), Platform::Linux); + let cases = [ + ("cursor", "{ \"mcpServers\": { \"a\": }"), + ("codex", "[mcp_servers\nbroken = "), + ("goose", "extensions:\n - [unclosed\n"), + ]; + for (agent_id, broken) in cases { + let path = config_path(&env, agent_id).unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, broken).unwrap(); + let error = install_in(&env, agent_id, &remote()).unwrap_err(); + assert!(error.contains("left untouched"), "{agent_id}: {error}"); + assert_eq!(read(&path), broken, "{agent_id} was rewritten"); + let error = uninstall_in(&env, agent_id).unwrap_err(); + assert!(error.contains("left untouched"), "{agent_id}: {error}"); + assert_eq!(read(&path), broken, "{agent_id} was rewritten on remove"); + assert_eq!(status_in(&env, agent_id), None); + } + } + + #[test] + fn non_object_containers_are_an_error_not_an_overwrite() { + let dir = tempfile::tempdir().unwrap(); + let env = test_env(dir.path(), Platform::Linux); + let path = config_path(&env, "cursor").unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + for content in ["[1, 2]", "{ \"mcpServers\": \"nope\" }"] { + fs::write(&path, content).unwrap(); + assert!(install_in(&env, "cursor", &remote()).is_err(), "{content}"); + assert_eq!(read(&path), content); + } + } + + #[test] + fn empty_and_missing_files_become_a_fresh_object() { + let dir = tempfile::tempdir().unwrap(); + let env = test_env(dir.path(), Platform::Linux); + let path = config_path(&env, "cursor").unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "").unwrap(); + install_in(&env, "cursor", &local()).unwrap(); + let text = read(&path); + assert!( + text.starts_with("{\n \"mcpServers\": {\n \"donut-browser\": {"), + "{text}" + ); + assert!(text.ends_with("}\n")); + + assert!(!config_path(&env, "vscode").unwrap().exists()); + install_in(&env, "vscode", &remote()).unwrap(); + assert_eq!(status_in(&env, "vscode"), Some(McpEndpoint::Remote)); + let text = read(&config_path(&env, "vscode").unwrap()); + assert!(text.ends_with("\n")); + assert!(fs::read_to_string(config_path(&env, "codex").unwrap()).is_err()); + install_in(&env, "codex", &remote()).unwrap(); + assert_eq!(status_in(&env, "codex"), Some(McpEndpoint::Remote)); + install_in(&env, "goose", &remote()).unwrap(); + assert_eq!(status_in(&env, "goose"), Some(McpEndpoint::Remote)); + } + + #[test] + fn reinstall_replaces_the_entry_wholesale() { + let dir = tempfile::tempdir().unwrap(); + let env = test_env(dir.path(), Platform::Linux); + let path = config_path(&env, "cursor").unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + "{\"mcpServers\": {\"donut-browser\": {\"url\": \"http://127.0.0.1:1/mcp/old\", \"disabled\": true, \"env\": {\"X\": \"1\"}}}}", + ) + .unwrap(); + assert_eq!(status_in(&env, "cursor"), Some(McpEndpoint::Local)); + install_in(&env, "cursor", &remote()).unwrap(); + let entry = entry_of(&env, "cursor"); + assert!(entry.get("disabled").is_none()); + assert!(entry.get("env").is_none()); + assert_eq!(entry["url"], json!(remote_mcp_url())); + assert_eq!(status_in(&env, "cursor"), Some(McpEndpoint::Remote)); + + install_in(&env, "cursor", &local()).unwrap(); + let entry = entry_of(&env, "cursor"); + assert!(entry.get("headers").is_none()); + assert_eq!(status_in(&env, "cursor"), Some(McpEndpoint::Local)); + } + + #[test] + fn renamed_entries_are_detected_and_removed_while_foreign_ones_stay() { + let dir = tempfile::tempdir().unwrap(); + let env = test_env(dir.path(), Platform::Linux); + let path = config_path(&env, "cursor").unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + format!( + "{{\"mcpServers\": {{\"github\": {{\"url\": \"https://api.githubcopilot.com/mcp/\"}}, \"donut\": {{\"url\": \"{}\"}}, \"donut-browser\": {{\"url\": \"https://example.com/mcp\"}}}}}}", + remote_mcp_url() + ), + ) + .unwrap(); + assert_eq!(status_in(&env, "cursor"), Some(McpEndpoint::Remote)); + uninstall_in(&env, "cursor").unwrap(); + assert_eq!(status_in(&env, "cursor"), None); + let text = read(&path); + assert!(text.contains("\"github\"")); + assert!(!text.contains("\"donut\"")); + assert!(!text.contains("\"donut-browser\"")); + + // Nothing of ours left: removal is a no-op that does not rewrite the file. + let before = read(&path); + uninstall_in(&env, "cursor").unwrap(); + assert_eq!(read(&path), before); + uninstall_in(&env, "vscode").unwrap(); + assert!(!config_path(&env, "vscode").unwrap().exists()); + } + + #[test] + fn endpoint_of_url_recognises_both_endpoints_only() { + assert_eq!( + endpoint_of_url(&remote_mcp_url()), + Some(McpEndpoint::Remote) + ); + assert_eq!( + endpoint_of_url(&format!("{}/", remote_mcp_url())), + Some(McpEndpoint::Remote) + ); + assert_eq!( + endpoint_of_url("http://127.0.0.1:51080/mcp/tok"), + Some(McpEndpoint::Local) + ); + assert_eq!( + endpoint_of_url("http://localhost:51080/mcp/tok"), + Some(McpEndpoint::Local) + ); + assert_eq!( + endpoint_of_url("http://localhost:51080/mcp"), + Some(McpEndpoint::Local) + ); + assert_eq!(endpoint_of_url("http://127.0.0.1:51080/mcpx"), None); + assert_eq!(endpoint_of_url("http://127.0.0.1:51080/api"), None); + assert_eq!(endpoint_of_url("https://api.githubcopilot.com/mcp/"), None); + assert_eq!(endpoint_of_url("http://evil.example/mcp/tok"), None); + assert_eq!( + endpoint_of_url("https://api.donutbrowser.com/api/mcp-bridge"), + None + ); + assert_eq!(McpEndpoint::parse("remote"), Some(McpEndpoint::Remote)); + assert_eq!(McpEndpoint::parse("local"), Some(McpEndpoint::Local)); + assert_eq!(McpEndpoint::parse("cloud"), None); + } + + #[test] + fn agent_info_reports_the_fx_token_variable() { + assert_eq!(token_env_for("fx").as_deref(), Some(FX_TOKEN_ENV)); + assert_eq!(token_env_for("cursor"), None); + } + + #[cfg(unix)] + #[test] + fn fx_config_is_private_to_the_owner() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let env = test_env(dir.path(), Platform::Linux); + install_in(&env, "fx", &remote()).unwrap(); + let path = config_path(&env, "fx").unwrap(); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(path.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + } + + #[cfg(unix)] + #[test] + fn a_first_time_config_carrying_the_credential_is_private() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let env = test_env(dir.path(), Platform::Linux); + let path = config_path(&env, "cursor").unwrap(); + assert!(!path.exists()); + install_in(&env, "cursor", &remote()).unwrap(); + // No file existed to copy bits from, and the entry carries the remote + // credential: the file is owner-only from its first byte, not after a + // chmod that follows a world-readable write. + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + #[cfg(unix)] + #[test] + fn a_first_time_local_config_keeps_the_default_mode() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let env = test_env(dir.path(), Platform::Linux); + // Whatever this process's umask makes of an ordinary new file: the local + // entry carries no secret, so it is not tightened beyond that. + let probe = dir.path().join("probe"); + fs::write(&probe, "").unwrap(); + let default_mode = fs::metadata(&probe).unwrap().permissions().mode() & 0o777; + install_in(&env, "cursor", &local()).unwrap(); + let path = config_path(&env, "cursor").unwrap(); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + default_mode + ); + } + + #[cfg(unix)] + #[test] + fn rewrites_keep_the_existing_permission_bits() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let env = test_env(dir.path(), Platform::Linux); + let path = config_path(&env, "cursor").unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "{}").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap(); + install_in(&env, "cursor", &remote()).unwrap(); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o640 + ); + assert!(!path.parent().unwrap().join(".mcp.json.donut-tmp").exists()); + } +} diff --git a/src-tauri/src/mcp_remote.rs b/src-tauri/src/mcp_remote.rs new file mode 100644 index 0000000..280b6bd --- /dev/null +++ b/src-tauri/src/mcp_remote.rs @@ -0,0 +1,2341 @@ +//! The remote-control bridge: one outbound socket that lets Donut cloud drive +//! this installation's MCP tools. +//! +//! The local MCP server in [`crate::mcp_server`] answers on loopback, which is +//! only reachable by an agent running on this machine. Remote control inverts +//! the reach without inverting the trust: nothing dials in to the desktop. The +//! app dials OUT to `wss://api.donutbrowser.com/api/mcp-bridge`, proves who it +//! is with the same cloud access token every other cloud call uses, and then +//! answers JSON-RPC that arrives down that socket. +//! +//! That direction is the whole security argument. There is no listening port to +//! find, no inbound firewall hole, no credential parked on a server that could +//! drive a customer's browser if it leaked, the desktop can hang up at any +//! time and the capability disappears with it. +//! +//! ## The wire +//! +//! Text frames of JSON, versioned by [`BRIDGE_PROTOCOL`]. Server to app: +//! +//! ```jsonc +//! {"t":"hello","protocol":"donut-mcp-bridge/1","instanceId":"…"} +//! {"t":"rpc","cid":"…","sessionId":"…|null","payload":{ /* JSON-RPC */ }} +//! {"t":"endSession","sessionId":"…"} +//! ``` +//! +//! App to server, one `result` per `rpc`, correlated by `cid`: +//! +//! ```jsonc +//! {"t":"result","cid":"…","status":"ok","sessionId":"…|null","payload":{…}} +//! {"t":"result","cid":"…","status":"accepted"} // a notification +//! {"t":"result","cid":"…","status":"unknownSession"} +//! {"t":"result","cid":"…","status":"badRequest"} +//! {"t":"result","cid":"…","status":"rateLimited","retryAfter":42} +//! {"t":"result","cid":"…","status":"busy"} +//! {"t":"result","cid":"…","status":"tooLarge"} +//! ``` +//! +//! The statuses are [`McpOutcome`]'s variants PLUS the two this transport +//! produces on its own, `busy` when the in-flight cap is spent, and +//! `tooLarge` when an answer exceeds the frame budget. Each status corresponds +//! to the HTTP status a local MCP client would have seen, so a caller needs no +//! special case for the remote transport. +//! +//! Liveness is protocol-level: the server PINGs, we PONG, and silence past +//! [`IDLE_TIMEOUT`] is treated as a dead socket. No JSON heartbeat is injected +//! into the stream, because a frame an MCP client did not ask for is a frame it +//! has to be taught to ignore. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use serde::Serialize; +use tauri::AppHandle; +use tokio::sync::{mpsc, Semaphore}; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::handshake::client::Request as WsRequest; +use tokio_tungstenite::tungstenite::Message; + +use crate::cloud_auth::{CLOUD_API_URL, CLOUD_AUTH}; +use crate::mcp_server::{McpOutcome, McpServer}; + +/// The wire contract's version. Bumped only for a change a current desktop +/// could not understand, so the relay can refuse a build it cannot talk to +/// instead of failing one frame at a time. +pub const BRIDGE_PROTOCOL: &str = "donut-mcp-bridge/1"; + +/// Path on the cloud API. Absolute rather than derived, and paired with the +/// protocol string above so the two can never be changed apart. +const BRIDGE_PATH: &str = "/api/mcp-bridge"; + +/// Emitted whenever the bridge's connection state changes. +pub const EVENT_MCP_REMOTE_STATUS: &str = "mcp-remote-status"; + +/// Silence that means the socket is gone. +/// +/// Matches the idle budget of the endpoint: several missed protocol pings mean +/// a dead peer rather than a slow one, and an idle WebSocket is closed upstream +/// well before this, which is exactly what the pings prevent. +const IDLE_TIMEOUT: Duration = Duration::from_secs(90); + +/// How many tool calls may be in flight at once. +/// +/// A cap, not a queue: the relay is ours and paces itself, but a bug on either +/// side must not be able to spawn unbounded work inside a customer's app. Past +/// this the app answers `busy`, which the caller can retry, rather than +/// accepting work it will not get to. +const MAX_IN_FLIGHT: usize = 8; + +/// The in-flight budget, owned for the life of the bridge rather than per +/// socket. +/// +/// `pump` used to create its own. But a socket teardown DELIBERATELY abandons +/// in-flight calls rather than aborting them, half of them are launching +/// browsers, so those tasks keep holding permits from the semaphore their old +/// socket owned. A reconnect then handed the new socket a fresh full budget, +/// and across a flapping connection the cap that exists to protect the +/// customer's machine stopped bounding anything. Held here, an abandoned call +/// still occupies its slot until it finishes, which is the whole point. +static BRIDGE_PERMITS: std::sync::LazyLock> = + std::sync::LazyLock::new(|| Arc::new(Semaphore::new(MAX_IN_FLIGHT))); + +/// The maximum a single `result` frame may be. +/// +/// A screenshot is base64 and genuinely large, so this is generous. It exists so +/// a runaway `get_page_content` cannot try to push an unbounded frame through a +/// socket whose far end will drop it anyway. +const MAX_RESULT_BYTES: usize = 8 * 1024 * 1024; + +/// How long a dead socket's writer may take to drain before it is abandoned. +/// +/// Long enough for a queued result to reach a socket that is merely slow, short +/// enough that reconnecting is never held up by a tool call the answer of which +/// nobody can receive any more. +const WRITER_DRAIN_TIMEOUT: Duration = Duration::from_secs(5); + +static BRIDGE_RUNNING: AtomicBool = AtomicBool::new(false); +static BRIDGE_TASK: Mutex>> = Mutex::new(None); +static BRIDGE_CONNECTED: AtomicBool = AtomicBool::new(false); +static LAST_ERROR: Mutex> = Mutex::new(None); + +/// What the Integrations page shows about remote control. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct McpRemoteStatus { + /// Whether the bridge task is meant to be up. Distinct from `connected`: a + /// bridge that is enabled but reconnecting is a different thing to say than + /// one that is switched off. + pub enabled: bool, + pub connected: bool, + pub instance_id: String, + /// Why the last attempt failed, as a `{"code": …}` envelope the UI resolves + /// through `translateBackendError`. Cleared on a good connect. + /// + /// A code rather than a message: the underlying reasons are English, and some + /// are written by the server, so neither can be shown to a customer reading + /// the app in one of the other nine languages. + pub last_error: Option, +} + +/// Why a connection attempt ended, which decides how hard to back off. +#[derive(Debug)] +enum BridgeError { + /// The credential was refused. Retrying fast fixes nothing. + Unauthorized(String), + /// Another instance of this account holds the single bridge slot. + SlotTaken(String), + /// The plan does not include remote control. + NotEntitled(String), + /// Anything transient: DNS, TLS, a restarting backend. + Unreachable(String), +} + +impl std::fmt::Display for BridgeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unauthorized(reason) + | Self::SlotTaken(reason) + | Self::NotEntitled(reason) + | Self::Unreachable(reason) => write!(f, "{reason}"), + } + } +} + +impl BridgeError { + /// The stable code the UI translates this into. + /// + /// The variants carry English prose, some of it the relay's own close + /// reason, and that prose is for the log file, where a support engineer + /// reads it. It must never reach the screen: the Integrations page is + /// localised into ten languages, and rendering a server-authored English + /// sentence under a Japanese UI is exactly what the translation rule exists + /// to stop. So the status carries a code and the words are chosen locally. + fn code(&self) -> &'static str { + match self { + Self::Unauthorized(_) => "MCP_REMOTE_UNAUTHORIZED", + Self::SlotTaken(_) => "MCP_REMOTE_SLOT_TAKEN", + Self::NotEntitled(_) => "MCP_REMOTE_NOT_ENTITLED", + Self::Unreachable(_) => "MCP_REMOTE_UNREACHABLE", + } + } + + /// Whether a fast retry could plausibly succeed. It cannot for any refusal + /// the server will keep repeating, and a client that retries one of those on + /// a one-second timer is a battery bug wearing a reconnect loop. + fn is_terminal_refusal(&self) -> bool { + matches!( + self, + Self::Unauthorized(_) | Self::SlotTaken(_) | Self::NotEntitled(_) + ) + } +} + +/// The instance-id shape the bridge endpoint accepts. +/// +/// Checked HERE rather than relying on the server to complain: a persisted id +/// that fails the shape is refused at the upgrade with "an instance id is +/// required", and the desktop can only guess what that means. Regenerating a +/// malformed one removes the condition instead of improving the error message +/// for it. +fn is_valid_instance_id(value: &str) -> bool { + (8..=64).contains(&value.len()) && value.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') +} + +/// This installation's stable identity on the bridge. +/// +/// Stable across restarts on purpose. The bridge slot is granted per instance +/// id, and a reconnect after a network blip must be able to take its OWN slot +/// back rather than be refused until the stale one is cleaned up. A fresh id +/// per process would make every dropped socket a minutes-long outage. +/// +/// Not a secret and not a credential: it names a machine, it does not +/// authenticate one. The access token does that. +pub fn instance_id() -> String { + static CACHED: std::sync::OnceLock = std::sync::OnceLock::new(); + CACHED + .get_or_init(|| read_or_create_instance_id(&instance_id_path())) + .clone() +} + +/// Where the id lives, a temp path under `cfg(test)`, the real settings +/// directory otherwise. +/// +/// Structural, not per-test discipline. Announcing the connection now happens +/// inside `dispatch`'s hello branch, which reaches `publish_state` -> `status` +/// -> `instance_id`, and that CREATES the file when it is absent. Four tests +/// feed a real `hello` through the real `pump`, so `cargo test` began writing +/// into the developer's own DonutBrowserDev settings directory. Making each of +/// those tests seed a cache would work only until the fifth test forgot; making +/// the PATH itself test-aware cannot be forgotten. +fn instance_id_path() -> std::path::PathBuf { + #[cfg(test)] + { + std::env::temp_dir().join("donut-mcp-instance-id-test") + } + #[cfg(not(test))] + { + crate::app_dirs::settings_dir().join("mcp_instance_id") + } +} + +/// The body of [`instance_id`], taking its path so it can be tested. +/// +/// `instance_id` caches in a `OnceLock`, so a test can only ever observe the +/// first call in the process; keeping the logic here is what lets the +/// regenerate-a-malformed-id behaviour actually be exercised rather than only +/// its helper. +fn read_or_create_instance_id(path: &std::path::Path) -> String { + if let Ok(existing) = std::fs::read_to_string(path) { + let trimmed = existing.trim(); + if is_valid_instance_id(trimmed) { + return trimmed.to_string(); + } + if !trimmed.is_empty() { + log::warn!( + "[mcp-remote] Persisted instance id is not a shape the bridge accepts; regenerating" + ); + } + } + + let fresh = uuid::Uuid::new_v4().to_string(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Err(e) = std::fs::write(path, &fresh) { + // A machine that cannot persist this still works; it just loses the + // reclaim-my-own-slot property until the write succeeds. + log::warn!("[mcp-remote] Could not persist the instance id: {e}"); + } + fresh +} + +pub fn is_running() -> bool { + BRIDGE_RUNNING.load(Ordering::SeqCst) +} + +pub fn is_connected() -> bool { + BRIDGE_CONNECTED.load(Ordering::SeqCst) +} + +pub fn status() -> McpRemoteStatus { + McpRemoteStatus { + enabled: is_running(), + connected: is_connected(), + instance_id: instance_id(), + last_error: LAST_ERROR + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(), + } +} + +/// Record the state and tell the screen, WITHOUT needing an `AppHandle`. +/// +/// `stop(None)` is how both credential teardowns hang up, logout and the +/// automatic `invalidate_session`, and neither has a handle to pass. Emitting +/// only when one was supplied left the Integrations page reading +/// "Connected as " over a bridge that had already been torn down, +/// until the dialog happened to be reopened. +fn publish_state(connected: bool, error: Option) { + // A bridge that is not running cannot be connected, so a late "connected" + // is downgraded rather than believed. `stop` flips BRIDGE_RUNNING first, + // and cancellation only takes effect at an await point, so the read loop can + // still be part-way through a frame when the stop lands. Without this the + // greeting published microseconds later would overwrite the teardown and + // leave the Integrations page reading "Connected as " over a + // socket that was hung up and a credential logout had already deleted - + // exactly the stale-state bug this function was introduced to fix. + let connected = connected && BRIDGE_RUNNING.load(Ordering::SeqCst); + BRIDGE_CONNECTED.store(connected, Ordering::SeqCst); + { + let mut slot = LAST_ERROR + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *slot = error; + } + let _ = crate::events::emit(EVENT_MCP_REMOTE_STATUS, status()); +} + +/// Start the bridge. Idempotent: a second call while it is up is a no-op rather +/// than a second socket racing the first for the same slot. +pub fn start(app: AppHandle) { + // The handle is still taken so callers keep a single obvious entry point, + // and so a future need for it does not churn every call site. + let _ = app; + // The task lock is held across BOTH the flag flip and the handle store. + // With the flag flipped first and the handle stored afterwards, a `stop` + // landing in between saw the flag, found no handle to abort, and returned; + // the spawn then parked its handle in the slot with the flag already false, + // leaving a reconnect loop nobody could stop until the next start replaced + // the handle. Under the lock a stop either runs before this (and the start + // proceeds) or after it (and finds the handle). + let mut slot = BRIDGE_TASK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if BRIDGE_RUNNING.swap(true, Ordering::SeqCst) { + return; + } + *slot = Some(tauri::async_runtime::spawn(async move { + run().await; + })); +} + +/// Stop the bridge. Safe to call when nothing is running. +pub fn stop(app: Option<&AppHandle>) { + // Same lock, same reason as `start`: the flag and the handle change together. + let mut slot = BRIDGE_TASK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !BRIDGE_RUNNING.swap(false, Ordering::SeqCst) { + return; + } + if let Some(handle) = slot.take() { + handle.abort(); + } + drop(slot); + // Unconditionally, not only when there is a handle to notify: a stopped + // bridge has no last error by definition, and the screen has to learn it was + // stopped even when the caller is a teardown path with no handle. + publish_state(false, None); + // The `app` parameter is kept because callers that HAVE a handle read as + // clearer at the call site, but nothing needs it any more: the global + // emitter is a `TauriEmitter` installed during setup, so it reaches the + // frontend identically. Emitting through both sent every status change + // twice. + let _ = app; +} + +/// The reconnect loop. +/// +/// Takes no `AppHandle`: status reaches the screen through the global emitter +/// installed at startup, which is the same `TauriEmitter`. Threading a handle +/// through here as well meant every state change was emitted twice, and left +/// the two teardown paths, which have no handle, unable to emit at all. +async fn run() { + let mut attempt = 0u32; + // Whether the current credential has already had its one refresh. + // + // Reset only when we have been GREETED, never merely on a handshake: the + // upgrade completes before the credential is judged, so a completed handshake + // says nothing about it. Resetting there made the bound inert for the only + // path that can produce a credential refusal, a close frame, so a desktop + // that keeps being refused rotated its refresh token on every cycle instead + // of exactly once. + let mut refreshed = false; + let greeted = Arc::new(AtomicBool::new(false)); + + while BRIDGE_RUNNING.load(Ordering::SeqCst) { + // `dialled_with` is the access token the socket was dialled with, so a + // refusal can be compared against what is on disk NOW. None when the dial + // itself failed before a credential was read. + let (failure, dialled_with) = match connect().await { + Ok((stream, token)) => { + greeted.store(false, Ordering::SeqCst); + log::info!("[mcp-remote] Bridge dialled as instance {}", instance_id()); + + // The connection is announced by `dispatch`, synchronously, the moment + // the `hello` is read, NOT here, and no longer from a task of its own. + // The upgrade completes before the credential is judged, so a completed + // handshake is not a connection, it is a question that has not been + // answered yet; announcing on it made a refused customer (an + // unentitled plan, a taken slot) watch the tab flash connected and then + // fail once a minute, for ever. + // + // Announcing from a spawned task was the previous shape and it was + // wrong twice. The task waited on a `Notify` and `stop` could not reach + // it, `BRIDGE_TASK` holds only this loop's handle, and dropping a + // JoinHandle detaches rather than aborts, so a stop between dialling + // and greeting parked it on a signal nobody could ever fire again, one + // leaked task per stop. And `abort` cannot cancel a task that has + // already passed its last await, so on a greet-then-close socket the + // announcement could land AFTER the teardown below and leave the screen + // reading connected with no error. Publishing on this task removes both: + // there is one publisher, its order is the order of the loop, and a + // cancellation takes the announcement with it. + let opened_at = std::time::Instant::now(); + let outcome = pump(stream, Arc::clone(&greeted)).await; + let lasted = opened_at.elapsed(); + // A `hello` is the first frame that arrives AFTER the credential has + // been accepted, so it is the only evidence here of that, and therefore + // the only thing that should clear either counter. Clearing them on the + // handshake meant a bridge that upgrades and is then refused every time + // never backed off at all: the upgrade completes before the credential + // is judged, so `connect()` succeeds on every cycle no matter how + // hopeless the credential is. + if greeted.load(Ordering::SeqCst) { + // The credential was accepted, so it may spend another refresh. + refreshed = false; + + // The BACKOFF is a separate question, and needs more than a + // greeting. The `hello` arrives as soon as the socket is registered, + // before anything has been proved, so a connection that lived five + // milliseconds reset the counter exactly like one that lived a day, + // and a close that classifies as non-terminal (an idle 1011, say) + // then retried at 1 Hz for ever. Only a connection that actually held + // resets it. + if lasted >= MIN_UPTIME_FOR_BACKOFF_RESET { + attempt = 0; + } + } + match &outcome { + Ok(()) => log::info!("[mcp-remote] Bridge closed by the backend"), + Err(e) => log::warn!("[mcp-remote] Bridge ended: {e}"), + } + // The code for the screen, the prose for the log above. + publish_state( + false, + outcome + .as_ref() + .err() + .map(|e| crate::backend_error(e.code())), + ); + (outcome.err(), Some(token)) + } + Err(e) => { + log::warn!("[mcp-remote] Bridge could not connect: {e}"); + publish_state(false, Some(crate::backend_error(e.code()))); + (Some(e), None) + } + }; + + if let Some(error) = &failure { + // An expired access token is the one "unauthorized" that fixes itself, + // and it MUST be handled here rather than at the dial. + // + // A refused credential never arrives as an HTTP 401: the upgrade + // completes first, and the refusal arrives as a close frame on an + // already-open socket. A refresh-and-retry hung off the dial therefore + // never runs, and the desktop sits in the terminal backoff band + // redialling the same dead token until an unrelated ten-minute loop + // happens to renew it: minutes of "sign out and sign in again" for a + // condition that needed neither. + // + // First, though: is the refused token still the one on disk? The + // ten-minute loop and every `api_call_with_retry` also rotate it, and a + // socket that lived for hours was dialled with a token that has almost + // certainly been replaced since. Refreshing in that case spends the + // one-shot on a token that is already gone, and the rotation itself is + // what was refused. So a stale token is simply redialled with the current + // one, and the refresh is reserved for a token that was refused while it + // was still current. + let rotated = matches!(error, BridgeError::Unauthorized(_)) + && credential_rotated_since( + dialled_with.as_deref(), + crate::remote_session::access_token_for_cdp() + .ok() + .as_deref(), + ); + if rotated { + log::info!( + "[mcp-remote] The refused access token has already been replaced; redialling with the current one" + ); + attempt = 0; + } else if should_refresh_credential(error, refreshed) { + match CLOUD_AUTH.refresh_access_token().await { + Ok(()) => { + log::info!("[mcp-remote] Refreshed the access token; retrying the bridge at once"); + // Spent only NOW. Marking it before the attempt meant a refresh + // that FAILED, a transient network blip at exactly the wrong + // moment, permanently disarmed the retry: the reset needs a + // `hello`, which never arrives for a credential that keeps being + // refused. The bridge then sat on a dead token until the app + // restarted. + refreshed = true; + attempt = 0; + } + Err(e) => { + log::warn!("[mcp-remote] Could not refresh the access token: {e}"); + attempt = attempt.max(TERMINAL_BACKOFF_ATTEMPT); + } + } + } else if error.is_terminal_refusal() { + // Everything the server will keep saying no to, a slot conflict, an + // unentitled plan, a credential a refresh did not fix. Without this a + // second copy of Donut reconnects on a one-second timer for as long as + // both are open. + attempt = attempt.max(TERMINAL_BACKOFF_ATTEMPT); + } + } + + if !BRIDGE_RUNNING.load(Ordering::SeqCst) { + break; + } + let delay = crate::remote_session::jittered(crate::remote_session::reconnect_delay(attempt)); + attempt = attempt.saturating_add(1); + sleep_unless_stopped(delay).await; + } + + BRIDGE_CONNECTED.store(false, Ordering::SeqCst); + log::info!("[mcp-remote] Bridge stopped"); +} + +/// Whether this failure is worth one token refresh and an immediate retry. +/// +/// Only `Unauthorized`, and only once per credential. A slot conflict or an +/// unentitled plan is not something a new token fixes, and retrying either on a +/// fast timer is how a background task becomes a battery bug. +fn should_refresh_credential(error: &BridgeError, already_refreshed: bool) -> bool { + !already_refreshed && matches!(error, BridgeError::Unauthorized(_)) +} + +/// Whether the token on disk is no longer the one that was refused. +/// +/// Only a KNOWN refused token that differs from a KNOWN current one counts. +/// With nothing to compare (the dial failed before reading a credential, or +/// there is no credential now) the answer is no, and the refusal takes the +/// refresh path or the terminal backoff as it always did. +fn credential_rotated_since(refused: Option<&str>, current: Option<&str>) -> bool { + match (refused, current) { + (Some(refused), Some(current)) => refused != current, + _ => false, + } +} + +/// Where the backoff restarts after a refusal the server will keep repeating. +/// Shares the SSE stream's constant so one deployment has one answer to "how +/// long before we ask again". +const TERMINAL_BACKOFF_ATTEMPT: u32 = 6; + +/// How long a connection must hold before it counts as "working" for backoff. +/// +/// Shorter than the idle timeout upstream, so a healthy but quiet bridge still +/// resets, and far longer than the greet-then-close cycle that made the retry +/// loop spin. +const MIN_UPTIME_FOR_BACKOFF_RESET: Duration = Duration::from_secs(60); + +/// Granularity of the cancellable sleep, so switching remote control off is not +/// held up by a minute-long backoff. +const SHUTDOWN_POLL: Duration = Duration::from_millis(250); + +async fn sleep_unless_stopped(total: Duration) { + let mut slept = Duration::ZERO; + while slept < total && BRIDGE_RUNNING.load(Ordering::SeqCst) { + let step = SHUTDOWN_POLL.min(total - slept); + tokio::time::sleep(step).await; + slept += step; + } +} + +type BridgeStream = + tokio_tungstenite::WebSocketStream>; + +fn bridge_url() -> String { + format!( + "{}{BRIDGE_PATH}", + CLOUD_API_URL.replacen("https://", "wss://", 1) + ) +} + +/// Build the upgrade request. +/// +/// The credential is a header, never a query parameter: a URL that grants +/// control of a browser must not reach a proxy access log. The instance id and +/// build details ride alongside so one machine can be told from another and the +/// account page can name the device that holds the slot. +fn bridge_request(bearer: &str) -> Result { + let mut request = bridge_url() + .into_client_request() + .map_err(|e| BridgeError::Unreachable(format!("invalid bridge endpoint: {e}")))?; + + let header = |value: &str| { + value.parse().map_err(|_| { + BridgeError::Unauthorized("a bridge header value is not valid ASCII".to_string()) + }) + }; + + let headers = request.headers_mut(); + headers.insert( + tokio_tungstenite::tungstenite::http::header::AUTHORIZATION, + header(&format!("Bearer {bearer}"))?, + ); + headers.insert("x-donut-instance", header(&instance_id())?); + headers.insert("x-donut-protocol", header(BRIDGE_PROTOCOL)?); + headers.insert("x-donut-client-version", header(env!("CARGO_PKG_VERSION"))?); + headers.insert("x-donut-platform", header(std::env::consts::OS)?); + Ok(request) +} + +/// Dial the bridge endpoint with the stored access token. +/// +/// Deliberately does NOT refresh on refusal. The credential is judged AFTER the +/// handshake, so a bad one never reaches this function as an error. It arrives +/// later as a close frame, and `run` is the only place that can see it. A +/// refresh here would be dead code that reads like a safety net. +/// +/// Returns the token alongside the socket so `run` can later tell whether a +/// refusal was for THIS token or for one that has since been replaced. +async fn connect() -> Result<(BridgeStream, String), BridgeError> { + let token = crate::remote_session::access_token_for_cdp() + .map_err(|e| BridgeError::Unauthorized(e.to_string()))?; + let stream = dial(&token).await?; + Ok((stream, token)) +} + +/// How long the upgrade may take before it counts as unreachable. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); + +async fn dial(bearer: &str) -> Result { + let request = bridge_request(bearer)?; + let connect = tokio_tungstenite::connect_async(request); + match tokio::time::timeout(CONNECT_TIMEOUT, connect).await { + Err(_) => Err(BridgeError::Unreachable(format!( + "the bridge did not answer within {}s", + CONNECT_TIMEOUT.as_secs() + ))), + Ok(Ok((stream, _response))) => Ok(stream), + Ok(Err(tokio_tungstenite::tungstenite::Error::Http(response))) => { + Err(classify_status(response.status().as_u16())) + } + Ok(Err(e)) => Err(BridgeError::Unreachable(e.to_string())), + } +} + +/// Turn a refused upgrade into the reason it means. +/// +/// The three refusals a user can act on are told apart on purpose: sign in +/// again, close the other copy of Donut, or upgrade the plan. Collapsing them +/// into "connection failed" is what makes a feature look broken when it is +/// merely saying no. +fn classify_status(status: u16) -> BridgeError { + match status { + 401 => BridgeError::Unauthorized(format!("the bridge refused the credential (HTTP {status})")), + 402 | 403 => BridgeError::NotEntitled( + "this plan does not include remote control of the desktop app".to_string(), + ), + 409 => BridgeError::SlotTaken( + "another Donut instance on this account already holds the remote-control slot".to_string(), + ), + other => BridgeError::Unreachable(format!("the bridge answered HTTP {other}")), + } +} + +/// Map a close frame onto the same vocabulary as a refused handshake. +/// +/// A 409 cannot answer a socket that has already been upgraded, so a slot taken +/// *after* connect arrives as a 1008 with a reason. Reading only the code would +/// report a plan problem and a slot conflict identically. +fn classify_close(code: u16, reason: &str) -> BridgeError { + let lowered = reason.to_ascii_lowercase(); + match code { + // Matched on "slot", not on the bare word "instance": the malformed-header + // refusal is "an instance id is required", which shares that noun while + // meaning something completely different. Reading it as a conflict told the + // customer to close a second Donut that was not running, and never showed + // the real cause. + 1008 if lowered.contains("slot") => BridgeError::SlotTaken(reason.to_string()), + 1008 if lowered.contains("entitle") || lowered.contains("plan") => { + BridgeError::NotEntitled(reason.to_string()) + } + 1008 => BridgeError::Unauthorized(if reason.is_empty() { + "the bridge revoked this connection".to_string() + } else { + reason.to_string() + }), + // Evicted because another socket took the slot. This arrives as a normal + // close with a replacement reason, because being replaced by yourself after + // a reconnect is not an error, but two installs that share a copied data + // directory carry the SAME instance id, so each looks like the other + // reconnecting and they evict each other. Read as `Unreachable` this + // retried at once and never settled: a permanent one-per-second flip in + // which whichever machine won the last flip served the account's tool + // calls. It is a slot conflict whatever code carries it, and naming it one + // puts it in the terminal backoff band the comment on + // `is_terminal_refusal` already promises. + 1000 if lowered.contains("replaced") || lowered.contains("newer connection") => { + BridgeError::SlotTaken(reason.to_string()) + } + _ if reason.is_empty() => BridgeError::Unreachable(format!("the bridge closed ({code})")), + _ => BridgeError::Unreachable(format!("the bridge closed ({code}: {reason})")), + } +} + +/// Carry frames until the socket dies. +/// +/// Reads and writes are split so a long tool call never blocks the pong that +/// keeps the connection alive: work is spawned, and its answer is pushed onto a +/// channel that a dedicated writer drains. +async fn pump(stream: BridgeStream, greeted: Arc) -> Result<(), BridgeError> { + let (mut sink, mut source) = stream.split(); + let (tx, mut rx) = mpsc::channel::(MAX_IN_FLIGHT * 2 + 8); + let permits = Arc::clone(&BRIDGE_PERMITS); + + let writer = tokio::spawn(async move { + while let Some(message) = rx.recv().await { + if sink.send(message).await.is_err() { + break; + } + } + let _ = sink.close().await; + }); + + let result = loop { + let next = match tokio::time::timeout(IDLE_TIMEOUT, source.next()).await { + Err(_) => { + break Err(BridgeError::Unreachable(format!( + "no frame from the bridge in {}s", + IDLE_TIMEOUT.as_secs() + ))) + } + Ok(None) => break Ok(()), + Ok(Some(Err(e))) => break Err(BridgeError::Unreachable(e.to_string())), + Ok(Some(Ok(message))) => message, + }; + + match next { + Message::Text(text) => { + if let Some(frame) = parse_frame(&text) { + dispatch(frame, &tx, &permits, &greeted); + } + } + Message::Ping(payload) => { + // Answered through OUR writer, rather than relying on tungstenite's + // automatic pong. + // + // The library does currently put its own pong on the wire, so this is + // belt and braces and a test cannot tell the two apart, deleting this + // line leaves `the_desktop_answers_the_relays_keepalive_ping` green, + // because that test asserts the property that actually matters (a pong + // reaches the peer) rather than which code path produced it. It stays + // because the alternative is depending on when a third-party library + // chooses to flush, and the cost of being wrong about that is every + // desktop being dropped upstream at the idle timeout. + if tx.send(Message::Pong(payload)).await.is_err() { + break Ok(()); + } + } + Message::Close(frame) => { + break match frame { + Some(frame) => Err(classify_close(u16::from(frame.code), &frame.reason)), + None => Ok(()), + } + } + // Binary and pong frames carry nothing this protocol defines. + _ => {} + } + }; + + // Dropping this sender does NOT close the channel: `dispatch` clones it into + // every spawned tool call, and the writer only stops when the LAST sender is + // gone. So the join below is bounded and then abandoned. + // + // Without the bound, a single stuck tool call outlives the socket and pins + // this function forever: the idle timeout fires, `result` is already decided, + // and `run` still never reaches its reconnect sleep, so `BRIDGE_CONNECTED` + // stays true, the Integrations page reports a healthy bridge, and remote + // control is dead until the app restarts. That defeats the exact failure the + // idle timeout exists to catch. + // + // The in-flight calls themselves are deliberately left running rather than + // aborted. Their answers have nowhere to go, but half of them are launching + // browsers, and cancelling one mid-launch leaves a profile in a worse state + // than letting it finish into a closed channel. + drop(tx); + drain_writer(writer, WRITER_DRAIN_TIMEOUT).await; + result +} + +/// Wait for the writer to finish, then give up on it. +/// +/// Split out so the bound can be tested at a cadence a test can wait for. The +/// bound is the whole point: without it a caller can wait forever, and the +/// only thing that would notice is the customer. +async fn drain_writer(writer: tokio::task::JoinHandle<()>, budget: Duration) { + let mut writer = writer; + if tokio::time::timeout(budget, &mut writer).await.is_err() { + log::warn!( + "[mcp-remote] A relayed call outlived its socket; abandoning the writer after {}s", + budget.as_secs() + ); + writer.abort(); + } +} + +/// One decoded server frame. +enum BridgeFrame { + Hello { + protocol: String, + }, + Rpc { + cid: String, + session_id: Option, + payload: Vec, + }, + EndSession { + session_id: String, + }, +} + +fn parse_frame(text: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(text).ok()?; + match value.get("t").and_then(serde_json::Value::as_str)? { + "hello" => Some(BridgeFrame::Hello { + protocol: value + .get("protocol") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + }), + "rpc" => { + // Non-empty, not merely present. A relayed call is correlated ONLY by + // `cid`, so an empty one is as unanswerable as a missing one: the reply + // would match nothing at the relay and the caller would wait out its full + // timeout rather than learn anything. + let cid = value + .get("cid") + .and_then(serde_json::Value::as_str) + .filter(|cid| !cid.is_empty())?; + let payload = value.get("payload")?; + Some(BridgeFrame::Rpc { + cid: cid.to_string(), + session_id: value + .get("sessionId") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + payload: serde_json::to_vec(payload).ok()?, + }) + } + "endSession" => Some(BridgeFrame::EndSession { + // Likewise non-empty: forgetting the session named "" is a no-op the + // relay would read as a successful teardown. + session_id: value + .get("sessionId") + .and_then(serde_json::Value::as_str) + .filter(|id| !id.is_empty())? + .to_string(), + }), + other => { + // Forward compatibility: a newer relay may add frames this build has + // never heard of, and dropping one must not take the socket down. + log::debug!("[mcp-remote] Ignoring unknown bridge frame '{other}'"); + None + } + } +} + +fn dispatch( + frame: BridgeFrame, + tx: &mpsc::Sender, + permits: &Arc, + greeted: &AtomicBool, +) { + match frame { + BridgeFrame::Hello { protocol } => { + // The relay sends this only once it has accepted the credential, which + // is what makes it the signal `run` uses to allow another refresh and to + // tell the screen the bridge is actually up. + greeted.store(true, Ordering::SeqCst); + log::info!("[mcp-remote] Bridge accepted; the relay greeted us"); + publish_state(true, None); + if protocol != BRIDGE_PROTOCOL { + log::warn!( + "[mcp-remote] The bridge speaks '{protocol}' and this build speaks '{BRIDGE_PROTOCOL}'" + ); + } + } + BridgeFrame::EndSession { session_id } => { + tokio::spawn(async move { + McpServer::instance().end_session(&session_id).await; + }); + } + BridgeFrame::Rpc { + cid, + session_id, + payload, + } => { + let Ok(permit) = Arc::clone(permits).try_acquire_owned() else { + log::warn!("[mcp-remote] Refused a relayed call: {MAX_IN_FLIGHT} already in flight"); + let _ = tx.try_send(encode(&serde_json::json!({ + "t": "result", + "cid": cid, + "status": "busy", + }))); + return; + }; + + let tx = tx.clone(); + tokio::spawn(async move { + let outcome = McpServer::instance() + .handle_message( + crate::mcp_server::McpOrigin::Bridge, + session_id.as_deref(), + &payload, + ) + .await; + drop(permit); + let _ = tx.send(encode(&result_frame(&cid, outcome))).await; + }); + } + } +} + +fn encode(value: &serde_json::Value) -> Message { + Message::Text(value.to_string().into()) +} + +fn result_frame(cid: &str, outcome: McpOutcome) -> serde_json::Value { + match outcome { + McpOutcome::Body { + body, + new_session_id, + } => { + let encoded = body.to_string(); + if encoded.len() > MAX_RESULT_BYTES { + log::warn!( + "[mcp-remote] Result of {} bytes exceeds the {MAX_RESULT_BYTES}-byte frame budget", + encoded.len() + ); + return serde_json::json!({ + "t": "result", + "cid": cid, + "status": "tooLarge", + }); + } + serde_json::json!({ + "t": "result", + "cid": cid, + "status": "ok", + "sessionId": new_session_id, + "payload": body, + }) + } + McpOutcome::Accepted => serde_json::json!({ + "t": "result", "cid": cid, "status": "accepted", + }), + McpOutcome::UnknownSession => serde_json::json!({ + "t": "result", "cid": cid, "status": "unknownSession", + }), + McpOutcome::BadRequest => serde_json::json!({ + "t": "result", "cid": cid, "status": "badRequest", + }), + McpOutcome::RateLimited { retry_after_secs } => serde_json::json!({ + "t": "result", "cid": cid, "status": "rateLimited", "retryAfter": retry_after_secs, + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bridge_url_is_the_websocket_scheme_of_the_cloud_api() { + assert_eq!(bridge_url(), "wss://api.donutbrowser.com/api/mcp-bridge"); + } + + #[test] + fn credentials_never_reach_the_url() { + let request = bridge_request("secret-token").expect("request"); + assert!(!request.uri().to_string().contains("secret-token")); + assert_eq!( + request + .headers() + .get(tokio_tungstenite::tungstenite::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some("Bearer secret-token") + ); + } + + #[test] + fn handshake_statuses_map_onto_actionable_reasons() { + assert!(matches!(classify_status(401), BridgeError::Unauthorized(_))); + assert!(matches!(classify_status(402), BridgeError::NotEntitled(_))); + assert!(matches!(classify_status(403), BridgeError::NotEntitled(_))); + assert!(matches!(classify_status(409), BridgeError::SlotTaken(_))); + assert!(matches!(classify_status(500), BridgeError::Unreachable(_))); + } + + #[test] + fn a_slot_conflict_after_connect_is_not_read_as_a_credential_problem() { + assert!(matches!( + classify_close(1008, "another instance already holds the slot"), + BridgeError::SlotTaken(_) + )); + assert!(matches!( + classify_close(1008, "remote control is not included in this plan"), + BridgeError::NotEntitled(_) + )); + assert!(matches!( + classify_close(1008, "credential revoked"), + BridgeError::Unauthorized(_) + )); + assert!(matches!( + classify_close(1011, ""), + BridgeError::Unreachable(_) + )); + } + + #[tokio::test] + async fn the_screen_says_connected_only_after_the_relay_greets_us() { + // The upgrade completes BEFORE the credential is judged, so a completed + // handshake is a question, not a connection. Announcing "Connected as" on + // it made a refused customer watch the tab flash connected and fail once a + // minute for ever, which reads as a flapping network rather than as the + // refusal it is. + // Observed through `greeted`, which is per-dial state owned by this test, + // NOT through BRIDGE_CONNECTED. That global is shared with every sibling + // test on cargo's parallel threads, and asserting on it would couple + // this test to whatever they happened to publish. `greeted` is the same + // flag `run` itself reads to decide whether the credential was accepted, + // and the source assertion at the end pins the announcement to it. + let greeted = Arc::new(AtomicBool::new(false)); + + // Refused right after the upgrade: nothing to announce. + let (url, server) = fake_relay(vec![], 0).await; + let stream = dial_fake(&url).await; + let _ = pump(stream, Arc::clone(&greeted)).await; + let _ = server.await; + assert!( + !greeted.load(Ordering::SeqCst), + "a bare handshake must not announce a connection" + ); + + // A frame that is NOT a hello must not announce either: the relay sends + // rpc and endSession over a socket it has already accepted, but reading + // any of them as acceptance would put the "only hello" rule back to + // "anything at all", which is what it replaced. + let (url, server) = fake_relay( + vec![ + serde_json::json!({ + "t": "rpc", + "cid": "cid-x", + "sessionId": serde_json::Value::Null, + "payload": { "jsonrpc": "2.0", "id": 1, "method": "ping" }, + }), + serde_json::json!({ "t": "endSession", "sessionId": "some-session" }), + ], + 1, + ) + .await; + let stream = dial_fake(&url).await; + let _ = pump(stream, Arc::clone(&greeted)).await; + let _ = server.await; + assert!( + !greeted.load(Ordering::SeqCst), + "only the relay's hello may announce a connection" + ); + + // Greeted: now the screen may say connected. + let (url, server) = fake_relay( + vec![serde_json::json!({ "t": "hello", "protocol": BRIDGE_PROTOCOL })], + 0, + ) + .await; + let stream = dial_fake(&url).await; + let _ = pump(stream, Arc::clone(&greeted)).await; + let _ = server.await; + assert!( + greeted.load(Ordering::SeqCst), + "the relay's hello must announce the connection" + ); + + // And the announcement is wired to exactly that branch. Without this, the + // behavioural half above would still pass if `publish_state(true, ..)` were + // moved somewhere reached before the greeting, which is the bug the whole + // test exists to prevent. + let source = include_str!("mcp_remote.rs"); + let production = source + .split_once("\n#[cfg(test)]") + .map_or(source, |(code, _)| code); + let announcements: Vec<&str> = production + .match_indices("publish_state(true") + .map(|(at, _)| { + let start = production[..at].rfind("\nfn ").unwrap_or(0); + production[start..].lines().nth(1).unwrap_or("").trim() + }) + .collect(); + assert_eq!( + announcements.len(), + 1, + "exactly one place may announce a connection; found {announcements:?}" + ); + let hello = production + .split("BridgeFrame::Hello { protocol } => {") + .nth(1) + .expect("the hello branch must exist"); + assert!( + hello[..hello.find("\n }").unwrap_or(hello.len())].contains("publish_state(true, None)"), + "the one announcement must sit in the hello branch" + ); + } + + #[test] + fn a_teardown_with_no_app_handle_still_corrects_the_screen() { + // Both credential teardowns, `logout` and the automatic + // `invalidate_session`, call `stop(None)`, because neither holds an + // AppHandle. Emitting the new state only when one was supplied left the + // Integrations page reading "Connected as " over a bridge + // that had already been hung up. + let source = include_str!("mcp_remote.rs"); + let publish = source + .split("fn publish_state(") + .nth(1) + .expect("publish_state must exist"); + // Bounded by the function's own end, not by a character count: the window + // used to be 700 characters and a later comment pushed the emit past it, + // so the assertion stopped reading the code it was written to guard. + let body = &publish[..publish.find("\n}").unwrap_or(publish.len())]; + assert!( + body.contains("crate::events::emit(EVENT_MCP_REMOTE_STATUS"), + "the state must be published through the handle-free global emitter" + ); + + let stop = source + .split("pub fn stop(app: Option<&AppHandle>) {") + .nth(1) + .expect("stop must exist"); + let stop = &stop[..stop.len().min(700)]; + assert!( + stop.contains("publish_state(false, None);"), + "stop must publish, not only notify a handle it may not have been given" + ); + } + + #[test] + fn a_failed_refresh_does_not_spend_the_one_shot() { + // `refreshed = true` before the attempt meant a refresh that FAILED, a + // blip at the wrong moment, permanently disarmed the retry, because the + // reset needs the relay's `hello` and it will never greet a credential it + // keeps refusing. The bridge then sat on a dead token until restart. + let source = include_str!("mcp_remote.rs"); + let block = source + .split("if should_refresh_credential(error, refreshed) {") + .nth(1) + .expect("the refresh block must exist"); + let block = &block[..block.len().min(900)]; + + let ok_arm = block.find("Ok(()) =>").expect("an Ok arm"); + let spend = block + .find("refreshed = true;") + .expect("the one-shot must be spent somewhere"); + assert!( + spend > ok_arm, + "the one-shot may only be spent AFTER a refresh succeeds, not before the attempt" + ); + let err_arm = block.find("Err(e) =>").expect("an Err arm"); + assert!(spend < err_arm, "the spend belongs inside the success arm"); + } + + #[test] + fn backoff_resets_only_after_a_connection_that_actually_held() { + // The `hello` arrives as soon as the socket is registered, so a connection + // that lived milliseconds greeted exactly like one that lived a day. + // Resetting `attempt` on the greeting alone turned any non-terminal close, + // an idle 1011 for instance, into a 1 Hz retry loop that never backed off. + assert!( + MIN_UPTIME_FOR_BACKOFF_RESET >= Duration::from_secs(30), + "a threshold this short would not outlast a greet-then-close cycle" + ); + assert!( + MIN_UPTIME_FOR_BACKOFF_RESET < IDLE_TIMEOUT, + "must be under the idle reap, or a healthy but quiet bridge never resets" + ); + + let source = include_str!("mcp_remote.rs"); + let block = source + .split("if greeted.load(Ordering::SeqCst) {") + .nth(1) + .expect("the greeting block must exist"); + let block = &block[..block.len().min(900)]; + assert!( + block.contains("if lasted >= MIN_UPTIME_FOR_BACKOFF_RESET {"), + "the backoff reset must be gated on real uptime, not on the greeting alone" + ); + let reset = block.find("attempt = 0;").expect("the reset must exist"); + let gate = block + .find("if lasted >= MIN_UPTIME_FOR_BACKOFF_RESET {") + .expect("the gate must exist"); + assert!(reset > gate, "the reset must sit inside the uptime gate"); + } + + #[tokio::test] + async fn only_a_greeting_re_arms_the_credential_refresh() { + // The upgrade completes BEFORE the credential is judged, so a completed + // handshake is no evidence it was accepted, `hello` is the first frame that + // follows that decision. Resetting the one-refresh bound on the handshake + // made the bound inert for the only path that produces a credential refusal + // (a close frame), so a desktop that keeps being refused rotated its + // refresh token on every cycle instead of exactly once. + let greeted = Arc::new(AtomicBool::new(false)); + + // Refused before any greeting: the flag stays clear, so `run` keeps + // `refreshed` set and does not refresh a second time. + let (url, server) = fake_relay(vec![], 0).await; + let stream = dial_fake(&url).await; + let _ = pump(stream, Arc::clone(&greeted)).await; + let _ = server.await; + assert!( + !greeted.load(Ordering::SeqCst), + "a bare handshake must not count as the credential being accepted" + ); + + // Greeted: the credential was accepted, so a later refusal may refresh. + let (url, server) = fake_relay( + vec![serde_json::json!({ "t": "hello", "protocol": BRIDGE_PROTOCOL })], + 0, + ) + .await; + let stream = dial_fake(&url).await; + let _ = pump(stream, Arc::clone(&greeted)).await; + let _ = server.await; + assert!( + greeted.load(Ordering::SeqCst), + "the relay's hello is what proves the credential was accepted" + ); + } + + #[test] + fn nothing_announces_a_connection_from_a_task_of_its_own() { + // The connection used to be announced by a task parked on a `Notify`, and + // that shape was wrong in two independent ways. + // + // `stop` could not reach it. BRIDGE_TASK holds only the reconnect loop's + // handle, and dropping a JoinHandle DETACHES rather than aborts, so a stop + // between dialling and greeting left the announcer waiting on a signal + // whose only other holder had just been dropped, it could never be woken + // and never finished. One leaked task per stop-during-connect, for the life + // of the process. + // + // And `abort` could not cancel it once it mattered. The task's only await + // was the wait for the greeting, so once that fired it ran straight through + // to publishing; on a greet-then-close socket the announcement could land + // after the teardown, leaving the screen reading connected with no error + // and nothing to correct it until the next dial, up to a minute later in + // the terminal backoff band. + // + // Publishing on the read loop's own task removes both, so the property to + // hold is that no announcer is ever spawned again. + let source = include_str!("mcp_remote.rs"); + let production = source + .split_once("\n#[cfg(test)]") + .map_or(source, |(code, _)| code); + + assert!( + !production.contains("notified().await"), + "the connection must not be announced from a task waiting on a signal: \ + stop cannot reach such a task and abort cannot cancel it once the \ + signal has fired" + ); + assert!( + !production.contains("Notify::new()"), + "the greeting signal is gone; a new one would reintroduce the detached \ + announcer it existed to feed" + ); + + // The residual window is closed by an invariant rather than by ordering: + // cancellation only takes effect at an await point, so the read loop can be + // part-way through a frame when a stop lands. Asserted from the source + // because BRIDGE_RUNNING and BRIDGE_CONNECTED are process-wide and cargo + // runs these tests on parallel threads, a test that drove them would race + // every sibling that publishes state. + let publish = production + .split("fn publish_state(") + .nth(1) + .expect("publish_state must exist"); + let body = &publish[..publish.find("\n}").unwrap_or(publish.len())]; + assert!( + body.contains("connected && BRIDGE_RUNNING.load(Ordering::SeqCst)"), + "a bridge that is not running must never be published as connected" + ); + } + + #[test] + fn the_in_flight_budget_outlives_the_socket_that_spent_it() { + // A socket teardown deliberately abandons in-flight calls rather than + // aborting them, half of them are launching browsers. Those tasks keep + // their permits, so a per-socket semaphore handed each reconnect a fresh + // full budget and the cap stopped bounding anything across a flapping + // connection. The budget therefore belongs to the bridge, not the socket. + let source = include_str!("mcp_remote.rs"); + let pump = source + .split("async fn pump(") + .nth(1) + .expect("pump must exist"); + let body = &pump[..pump.len().min(1200)]; + assert!( + !body.contains("Semaphore::new("), + "pump must not mint its own budget; abandoned calls would keep permits \ + from a semaphore nobody is counting any more" + ); + assert!( + body.contains("Arc::clone(&BRIDGE_PERMITS)"), + "pump must share the bridge-lifetime budget" + ); + + // And nowhere else mints one either. Scanning the whole of the production + // half is the part that generalises: the check above only reads pump's + // first 1200 characters, so a budget re-minted in any other helper on the + // relayed-call path would pass it while re-creating the exact bug. + // + // This replaced a runtime check that counted permits on BRIDGE_PERMITS, + // which was wrong twice over. It was VACUOUS, `first` and `second` were + // two clones of one static, so comparing their counts compared an object + // with itself and held whatever the code did. And it was RACY: a dozen + // tests drive pump, whose relayed calls take global permits on spawned + // tasks that outlive the test that started them, so a sibling holding one + // made `assert_eq!(available, MAX_IN_FLIGHT)` fail intermittently. It went + // green six runs in a row before failing, which is precisely why a count + // over shared mutable state is not evidence of anything here. + let production = source + .split_once("\n#[cfg(test)]") + .map_or(source, |(before, _)| before); + // Each mint is reported with the declaration it belongs to, so a failure + // names the offending site instead of just a count. + let mints: Vec<&str> = production + .match_indices("Semaphore::new(") + .map(|(at, _)| { + let stmt = production[..at].rfind(';').map_or(0, |n| n + 1); + production[stmt..at].trim() + }) + .collect(); + assert_eq!( + mints.len(), + 1, + "the bridge budget must be minted exactly once for the process; \ + minted by: {mints:?}" + ); + assert!( + mints[0].contains("BRIDGE_PERMITS"), + "the one Semaphore must be the BRIDGE_PERMITS static, not {:?}", + mints[0] + ); + } + + #[test] + fn every_reason_the_relay_can_close_with_is_classified_correctly() { + // These are the literal close reasons the bridge endpoint sends. + // Classifying them is not cosmetic: each one produces a different sentence + // on the Integrations page telling the customer what to do about it. + for (reason, expect_slot, expect_plan) in [ + ( + "another Donut instance on this account holds the remote-control slot", + true, + false, + ), + ("remote control is not included in this plan", false, true), + ("this plan no longer includes remote control", false, true), + ] { + let error = classify_close(1008, reason); + assert_eq!( + matches!(error, BridgeError::SlotTaken(_)), + expect_slot, + "slot classification wrong for {reason:?}" + ); + assert_eq!( + matches!(error, BridgeError::NotEntitled(_)), + expect_plan, + "plan classification wrong for {reason:?}" + ); + } + + // The malformed-header refusal shares the word "instance" with the slot + // conflict and means something entirely different. Read as a conflict it + // told the customer to close a second Donut that was not running. + assert!( + !matches!( + classify_close(1008, "an instance id is required"), + BridgeError::SlotTaken(_) + ), + "a malformed instance id is not a second copy of Donut" + ); + + for reason in [ + "unauthorized", + "credential no longer valid", + "credential revoked", + ] { + assert!( + matches!(classify_close(1008, reason), BridgeError::Unauthorized(_)), + "{reason:?} should read as a credential problem" + ); + } + } + + #[test] + fn a_malformed_persisted_instance_id_is_not_sent_to_be_rejected() { + // An instance id outside the accepted shape is refused at the upgrade. + // Sending a value that cannot pass wastes a dial and produces a refusal the + // desktop can only guess at. + assert!(is_valid_instance_id(&uuid::Uuid::new_v4().to_string())); + assert!(is_valid_instance_id("abcd1234")); + assert!(is_valid_instance_id(&"a".repeat(64))); + + assert!(!is_valid_instance_id(""), "empty"); + assert!(!is_valid_instance_id("short7"), "under 8 characters"); + assert!(!is_valid_instance_id(&"a".repeat(65)), "over 64 characters"); + assert!(!is_valid_instance_id("has spaces here"), "space"); + assert!(!is_valid_instance_id("has_underscore1"), "underscore"); + assert!(!is_valid_instance_id("café-instance"), "non-ascii"); + assert!(!is_valid_instance_id("newline\n1234"), "control character"); + } + + #[test] + fn a_malformed_id_on_disk_is_replaced_rather_than_reused() { + // Exercises the real read-and-decide path, not just the shape helper: an + // earlier version of this test asserted only `is_valid_instance_id`, so + // deleting its call site from the reader changed nothing and the test + // still passed. + let dir = std::env::temp_dir().join(format!("donut-iid-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let path = dir.join("mcp_instance_id"); + + // A good id is kept exactly as written. + let good = uuid::Uuid::new_v4().to_string(); + std::fs::write(&path, &good).expect("write"); + assert_eq!(read_or_create_instance_id(&path), good); + + // A malformed one is replaced, persisted, and stable from then on. + std::fs::write(&path, "not a valid id!").expect("write"); + let replaced = read_or_create_instance_id(&path); + assert!(is_valid_instance_id(&replaced), "{replaced:?}"); + assert_eq!( + std::fs::read_to_string(&path).expect("read").trim(), + replaced, + "the replacement must be written back, or every launch regenerates" + ); + assert_eq!(read_or_create_instance_id(&path), replaced); + + // A missing file mints one. + std::fs::remove_file(&path).expect("remove"); + assert!(is_valid_instance_id(&read_or_create_instance_id(&path))); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn being_evicted_from_the_slot_backs_off_instead_of_racing() { + // A superseded socket is retired with 1000 and this exact reason. Two + // installs sharing a copied data directory carry the same instance id, + // so each reads as the other reconnecting and they evict each other; read + // as a plain close this reconnected immediately and flapped forever. + let evicted = classify_close(1000, "replaced by a newer connection"); + assert!(matches!(evicted, BridgeError::SlotTaken(_))); + assert!( + evicted.is_terminal_refusal(), + "an evicted desktop must land in the terminal backoff band, not retry at once" + ); + + // A normal close still is one; only the replacement reason is special. + assert!(matches!( + classify_close(1000, ""), + BridgeError::Unreachable(_) + )); + assert!(matches!( + classify_close(1000, "going away"), + BridgeError::Unreachable(_) + )); + } + + #[test] + fn the_status_carries_a_translatable_code_never_server_authored_prose() { + // The Integrations page ships in ten languages, and these reasons are + // English, some of them written by the relay and echoed in a close frame. + // Rendering one verbatim puts an English sentence under a Japanese UI, so + // the status carries a code and the words are chosen in the locale files. + // Every code here has a `backendErrors.*` key in all ten of them and a + // `case` in src/lib/backend-errors.ts. + let cases = [ + ( + BridgeError::Unauthorized("the bridge refused the credential".into()), + "MCP_REMOTE_UNAUTHORIZED", + ), + ( + BridgeError::SlotTaken("another Donut instance holds the slot".into()), + "MCP_REMOTE_SLOT_TAKEN", + ), + ( + BridgeError::NotEntitled("this plan does not include it".into()), + "MCP_REMOTE_NOT_ENTITLED", + ), + ( + BridgeError::Unreachable("dns failure".into()), + "MCP_REMOTE_UNREACHABLE", + ), + ]; + + let switch = std::fs::read_to_string("../src/lib/backend-errors.ts") + .expect("the frontend error translator must exist"); + for (error, code) in cases { + assert_eq!(error.code(), code); + let envelope = crate::backend_error(error.code()); + // Exactly the shape `translateBackendError` parses. + let parsed: serde_json::Value = + serde_json::from_str(&envelope).expect("the status must be a JSON envelope"); + assert_eq!(parsed["code"], code); + // And the prose is nowhere near it. + assert!(!envelope.contains("bridge refused")); + assert!( + switch.contains(&format!("case \"{code}\":")), + "{code} has no case in translateBackendError, so it would render raw" + ); + } + } + + #[test] + fn an_expired_token_is_refreshed_and_retried_at_once() { + // The one "unauthorized" that fixes itself. An access token eventually + // expires, so every long-running desktop hits this once per token: the + // socket is closed with 1008, and without this the desktop drops into the + // terminal backoff band redialling the SAME dead token, showing "sign out + // and sign in again" for a condition that needed neither. + // + // This lives here rather than at the dial because the credential is judged + // AFTER the handshake, so a refused one is never an HTTP 401 the dial could + // see. + assert!(should_refresh_credential( + &BridgeError::Unauthorized("credential no longer valid".into()), + false + )); + + // Once per credential. A token the server genuinely rejects must not send + // the desktop round a refresh loop. + assert!(!should_refresh_credential( + &BridgeError::Unauthorized("credential revoked".into()), + true + )); + + // Nothing else is fixed by a new token, and retrying these fast is how a + // background task becomes a battery bug. + for error in [ + BridgeError::SlotTaken("another instance".into()), + BridgeError::NotEntitled("not on this plan".into()), + BridgeError::Unreachable("dns".into()), + ] { + assert!( + !should_refresh_credential(&error, false), + "{error:?} is not something a token refresh fixes" + ); + } + } + + #[test] + fn a_refusal_of_a_token_that_was_already_replaced_redials_instead_of_refreshing() { + // The decision itself. + assert!(credential_rotated_since(Some("old"), Some("new"))); + assert!(!credential_rotated_since(Some("same"), Some("same"))); + // Nothing to compare: the dial failed before reading a credential, or the + // account signed out meanwhile. Neither is a rotation. + assert!(!credential_rotated_since(None, Some("new"))); + assert!(!credential_rotated_since(Some("old"), None)); + assert!(!credential_rotated_since(None, None)); + + // And it sits AHEAD of the refresh in `run`, guarded on Unauthorized: a + // slot conflict on a rotated token is still a slot conflict. + let source = include_str!("mcp_remote.rs"); + let production = source + .split_once("\n#[cfg(test)]") + .map_or(source, |(code, _)| code); + let run = production + .split("async fn run() {") + .nth(1) + .expect("run must exist"); + let rotated = run + .find("credential_rotated_since(") + .expect("run must compare the refused token with the one on disk"); + let refresh = run + .find("should_refresh_credential(error, refreshed)") + .expect("run must still refresh"); + assert!( + rotated < refresh, + "the rotation check must come first, or the refresh is spent on a token that is already gone" + ); + let guard = &run[..rotated]; + assert!( + guard + .rfind("matches!(error, BridgeError::Unauthorized(_))") + .is_some_and(|at| at > guard.len().saturating_sub(200)), + "the redial must be limited to an Unauthorized refusal" + ); + // The token is remembered from the dial that produced the socket, and + // forgotten when the dial fails before reading one. + assert!(run.contains("(outcome.err(), Some(token))")); + assert!(run.contains("(Some(e), None)")); + } + + #[test] + fn start_and_stop_change_the_flag_and_the_handle_under_one_lock() { + // Flag first and handle second, with no lock across them, let a `stop` + // between the two see the flag, find no handle, and return, after which + // the start stored a handle for a loop nobody could stop any more. + let source = include_str!("mcp_remote.rs"); + let production = source + .split_once("\n#[cfg(test)]") + .map_or(source, |(code, _)| code); + for (name, header) in [ + ("start", "pub fn start(app: AppHandle) {"), + ("stop", "pub fn stop(app: Option<&AppHandle>) {"), + ] { + let body = production + .split(header) + .nth(1) + .unwrap_or_else(|| panic!("{name} must exist")); + let body = &body[..body.find("\n}").unwrap_or(body.len())]; + let lock = body + .find("BRIDGE_TASK\n .lock()") + .unwrap_or_else(|| panic!("{name} must take the task lock")); + let flip = body + .find("BRIDGE_RUNNING.swap(") + .unwrap_or_else(|| panic!("{name} must flip the running flag")); + assert!( + lock < flip, + "{name} must hold the task lock before it flips the running flag" + ); + assert_eq!( + body.matches("BRIDGE_TASK").count(), + 1, + "{name} must touch the slot once, under the lock it already holds" + ); + } + } + + #[test] + fn the_dial_no_longer_pretends_to_recover_a_credential_it_never_sees() { + // Guards the reason the refresh moved. `connect` must not grow a + // refresh-and-retry again: the upgrade completes before the credential is + // judged, so `dial` cannot observe a rejected one, and a retry there would + // be dead code that reads like a safety net. + let source = std::fs::read_to_string("src/mcp_remote.rs").expect("mcp_remote.rs"); + let start = source + .find("async fn connect()") + .expect("connect must exist"); + let body = &source[start..start + 400]; + assert!( + !body.contains("refresh_access_token"), + "connect() must not refresh; the relay's refusals arrive as close frames, not dial errors" + ); + } + + #[test] + fn every_refusal_the_server_will_repeat_backs_off_slowly() { + assert!(BridgeError::Unauthorized(String::new()).is_terminal_refusal()); + assert!(BridgeError::SlotTaken(String::new()).is_terminal_refusal()); + assert!(BridgeError::NotEntitled(String::new()).is_terminal_refusal()); + assert!(!BridgeError::Unreachable(String::new()).is_terminal_refusal()); + } + + #[test] + fn rpc_frames_decode_with_and_without_a_session() { + let with = parse_frame( + r#"{"t":"rpc","cid":"c1","sessionId":"s1","payload":{"jsonrpc":"2.0","id":1,"method":"ping"}}"#, + ) + .expect("frame"); + match with { + BridgeFrame::Rpc { + cid, + session_id, + payload, + } => { + assert_eq!(cid, "c1"); + assert_eq!(session_id.as_deref(), Some("s1")); + let parsed: serde_json::Value = serde_json::from_slice(&payload).expect("payload"); + assert_eq!(parsed["method"], "ping"); + } + _ => panic!("expected an rpc frame"), + } + + let without = + parse_frame(r#"{"t":"rpc","cid":"c2","payload":{"jsonrpc":"2.0","id":2,"method":"ping"}}"#) + .expect("frame"); + match without { + BridgeFrame::Rpc { session_id, .. } => assert!(session_id.is_none()), + _ => panic!("expected an rpc frame"), + } + } + + #[test] + fn a_frame_missing_the_field_that_makes_it_answerable_is_refused() { + // A relayed call is correlated ONLY by `cid`. A frame without one cannot be + // answered, the reply would carry an empty correlation id, the relay would + // match it to nothing, and the caller would sit until its 90s timeout with + // no idea why. Rejecting it here at least leaves the socket healthy. + assert!(parse_frame(r#"{"t":"rpc","payload":{"jsonrpc":"2.0","id":1}}"#).is_none()); + assert!(parse_frame(r#"{"t":"rpc","cid":"","payload":{"jsonrpc":"2.0"}}"#).is_none()); + + // Same for the one field `endSession` exists to carry. Accepting it empty + // would make the desktop forget the session named "", which is every + // session it does not have, a silent no-op the relay reads as success. + assert!(parse_frame(r#"{"t":"endSession"}"#).is_none()); + // The empty case specifically: `?` already rejects a MISSING field, so + // without this line the filter that rejects an empty one is unguarded. + assert!(parse_frame(r#"{"t":"endSession","sessionId":""}"#).is_none()); + assert!(parse_frame(r#"{"t":"endSession","sessionId":"s1"}"#).is_some()); + } + + #[tokio::test] + async fn a_relay_that_outruns_the_in_flight_cap_is_told_to_retry() { + // The cap is what stops a bug on either side spawning unbounded work inside + // a customer's app. Past it the desktop must ANSWER `busy`, a caller that + // gets no frame at all waits out the call timeout upstream and then sees + // the same refusal it could have had immediately. + let (tx, mut rx) = mpsc::channel::(4); + let permits = Arc::new(Semaphore::new(MAX_IN_FLIGHT)); + let held: Vec<_> = (0..MAX_IN_FLIGHT) + .map(|_| { + Arc::clone(&permits) + .try_acquire_owned() + .expect("a free permit") + }) + .collect(); + + dispatch( + BridgeFrame::Rpc { + cid: "cid-over-cap".to_string(), + session_id: None, + payload: br#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#.to_vec(), + }, + &tx, + &permits, + &AtomicBool::new(false), + ); + + let Message::Text(text) = rx.try_recv().expect("a refusal, not silence") else { + panic!("expected a text frame"); + }; + let frame: serde_json::Value = serde_json::from_str(&text).expect("json"); + assert_eq!(frame["status"], "busy"); + assert_eq!(frame["cid"], "cid-over-cap"); + drop(held); + } + + #[test] + fn an_unknown_frame_is_ignored_rather_than_fatal() { + assert!(parse_frame(r#"{"t":"somethingNewer","x":1}"#).is_none()); + assert!(parse_frame("not json").is_none()); + assert!(parse_frame(r#"{"t":"rpc","cid":"c1"}"#).is_none()); + } + + #[test] + fn result_frames_carry_the_outcome_the_relay_has_to_render() { + let ok = result_frame( + "c1", + McpOutcome::Body { + body: serde_json::json!({"jsonrpc":"2.0","id":1,"result":{}}), + new_session_id: Some("s9".to_string()), + }, + ); + assert_eq!(ok["status"], "ok"); + assert_eq!(ok["sessionId"], "s9"); + assert_eq!(ok["payload"]["id"], 1); + + assert_eq!( + result_frame("c2", McpOutcome::Accepted)["status"], + "accepted" + ); + assert_eq!( + result_frame("c3", McpOutcome::UnknownSession)["status"], + "unknownSession" + ); + assert_eq!( + result_frame("c4", McpOutcome::BadRequest)["status"], + "badRequest" + ); + + let limited = result_frame( + "c5", + McpOutcome::RateLimited { + retry_after_secs: 42, + }, + ); + assert_eq!(limited["status"], "rateLimited"); + assert_eq!(limited["retryAfter"], 42); + } + + #[test] + fn an_oversized_result_is_refused_rather_than_sent() { + let huge = "x".repeat(MAX_RESULT_BYTES + 1); + let frame = result_frame( + "c1", + McpOutcome::Body { + body: serde_json::json!({ "text": huge }), + new_session_id: None, + }, + ); + assert_eq!(frame["status"], "tooLarge"); + } + + #[test] + fn the_instance_id_is_stable_within_a_process() { + // Against a temp path, not `instance_id()`. That reads + // `app_dirs::settings_dir()`, which `cargo test` does not redirect, so the + // old version of this test MINTED AND WROTE an id into the developer's + // real DonutBrowserDev data directory every time the suite ran, a test + // reaching outside its sandbox to touch state the app itself owns. + let dir = std::env::temp_dir().join(format!("donut-iid-stable-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let path = dir.join("mcp_instance_id"); + + let first = read_or_create_instance_id(&path); + assert!(!first.is_empty()); + assert!(is_valid_instance_id(&first)); + // Stability is the property that matters: the slot is granted per instance + // id, so one that changed between reads could never reclaim it. + assert_eq!(read_or_create_instance_id(&path), first); + assert_eq!(read_or_create_instance_id(&path), first); + + let _ = std::fs::remove_dir_all(&dir); + } + + // --------------------------------------------------------------------- + // The transport, end to end. + // + // Everything above tests one function against a literal. These drive the + // REAL `pump` over a REAL WebSocket against a server that speaks the frames + // the bridge endpoint actually sends, and assert the frames it actually + // parses come back. That join is the part neither side's own tests can + // cover: each was written against its own idea of the contract. + // + // The canonical frames below match the wire form observed from the endpoint: + // a null `sessionId` stays on the wire rather than being omitted, so that is + // what is sent here, a parser that only handled the absent case would pass a + // friendlier fixture and fail in production. + // --------------------------------------------------------------------- + + /// A server that speaks the relay's half of `donut-mcp-bridge/1`. + /// + /// Returns the URL to dial and a handle yielding every `result` frame the + /// desktop sent back, in order. + async fn fake_relay( + script: Vec, + expected_results: usize, + ) -> (String, tokio::task::JoinHandle>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("the fake relay must bind"); + let port = listener.local_addr().expect("a bound port").port(); + + let handle = tokio::spawn(async move { + let mut results = Vec::new(); + let Ok((socket, _)) = listener.accept().await else { + return results; + }; + let Ok(mut stream) = tokio_tungstenite::accept_async(socket).await else { + return results; + }; + + for frame in script { + if stream + .send(Message::Text(frame.to_string().into())) + .await + .is_err() + { + return results; + } + } + + while results.len() < expected_results { + match stream.next().await { + Some(Ok(Message::Text(text))) => { + if let Ok(value) = serde_json::from_str::(&text) { + results.push(value); + } + } + Some(Ok(_)) => continue, + _ => break, + } + } + + let _ = stream.close(None).await; + results + }); + + (format!("ws://127.0.0.1:{port}"), handle) + } + + async fn dial_fake(url: &str) -> BridgeStream { + let (stream, _) = tokio_tungstenite::connect_async(url) + .await + .expect("the desktop must reach the fake relay"); + stream + } + + #[tokio::test] + async fn a_relayed_tools_list_reaches_the_engine_and_the_answer_comes_back() { + McpServer::instance().mark_engine_ready_for_tests(); + + let (url, server) = fake_relay( + vec![ + serde_json::json!({ + "t": "hello", + "protocol": BRIDGE_PROTOCOL, + "instanceId": "instance-under-test", + }), + // Exactly what the relay puts on the wire, null sessionId and all. + serde_json::json!({ + "t": "rpc", + "cid": "cid-1", + "sessionId": serde_json::Value::Null, + "payload": { "jsonrpc": "2.0", "id": 7, "method": "tools/list" }, + }), + ], + 1, + ) + .await; + + let stream = dial_fake(&url).await; + let _ = pump(stream, Arc::new(AtomicBool::new(false))).await; + let results = server.await.expect("the fake relay must finish"); + + assert_eq!(results.len(), 1, "one result per rpc"); + let frame = &results[0]; + assert_eq!(frame["t"], "result"); + assert_eq!(frame["cid"], "cid-1"); + assert_eq!(frame["status"], "ok"); + assert!(frame["sessionId"].is_null()); + + // The real tool list, carried whole. This is the assertion that proves the + // bridge is a transport and not a second implementation: the relay adds no + // schema of its own, so whatever this build exposes is what an agent sees. + let payload = &frame["payload"]; + assert_eq!(payload["jsonrpc"], "2.0"); + assert_eq!(payload["id"], 7); + let tools = payload["result"]["tools"] + .as_array() + .expect("tools/list must return an array"); + assert_eq!(tools.len(), McpServer::instance().get_tools().len()); + assert!(tools.iter().any(|tool| tool["name"] == "navigate")); + assert!(tools + .iter() + .all(|tool| tool["inputSchema"].is_object() && tool["name"].is_string())); + } + + #[tokio::test] + async fn initialize_mints_a_session_the_relay_can_echo_back() { + McpServer::instance().mark_engine_ready_for_tests(); + + let (url, server) = fake_relay( + vec![ + serde_json::json!({ "t": "hello", "protocol": BRIDGE_PROTOCOL, "instanceId": "i" }), + serde_json::json!({ + "t": "rpc", + "cid": "cid-init", + "sessionId": serde_json::Value::Null, + "payload": { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { "protocolVersion": "2025-11-25" }, + }, + }), + ], + 1, + ) + .await; + + let stream = dial_fake(&url).await; + let _ = pump(stream, Arc::new(AtomicBool::new(false))).await; + let results = server.await.expect("the fake relay must finish"); + + let frame = &results[0]; + assert_eq!(frame["status"], "ok"); + // The relay turns this into the `mcp-session-id` response header, which is + // the whole reason it travels beside the payload rather than inside it. + let session = frame["sessionId"] + .as_str() + .expect("initialize must mint a session id"); + assert!(!session.is_empty()); + assert_eq!(frame["payload"]["result"]["protocolVersion"], "2025-11-25"); + } + + #[tokio::test] + async fn a_notification_is_accepted_with_no_reply_body() { + McpServer::instance().mark_engine_ready_for_tests(); + + let (url, server) = fake_relay( + vec![serde_json::json!({ + "t": "rpc", + "cid": "cid-note", + "sessionId": "session-that-does-not-exist", + "payload": { "jsonrpc": "2.0", "method": "notifications/initialized" }, + })], + 1, + ) + .await; + + let stream = dial_fake(&url).await; + let _ = pump(stream, Arc::new(AtomicBool::new(false))).await; + let results = server.await.expect("the fake relay must finish"); + + // 202 over HTTP, `accepted` here. JSON-RPC forbids replying to a + // notification, and the session check must not turn one into a 404. + assert_eq!(results[0]["status"], "accepted"); + assert!(results[0].get("payload").is_none()); + } + + #[tokio::test] + async fn an_unknown_session_is_reported_as_such_rather_than_answered() { + McpServer::instance().mark_engine_ready_for_tests(); + + let (url, server) = fake_relay( + vec![serde_json::json!({ + "t": "rpc", + "cid": "cid-ghost", + "sessionId": "00000000-0000-4000-8000-000000000000", + "payload": { "jsonrpc": "2.0", "id": 3, "method": "tools/list" }, + })], + 1, + ) + .await; + + let stream = dial_fake(&url).await; + let _ = pump(stream, Arc::new(AtomicBool::new(false))).await; + let results = server.await.expect("the fake relay must finish"); + + // The relay renders this as 404, exactly as the loopback server does, so an + // agent's session handling needs no special case for the remote transport. + assert_eq!(results[0]["status"], "unknownSession"); + } + + #[tokio::test] + async fn a_malformed_payload_is_refused_without_dropping_the_socket() { + McpServer::instance().mark_engine_ready_for_tests(); + + let (url, server) = fake_relay( + vec![ + serde_json::json!({ + "t": "rpc", + "cid": "cid-bad", + "sessionId": serde_json::Value::Null, + "payload": { "not": "json-rpc" }, + }), + // Sent after the bad one: the socket has to survive it, or one + // malformed call from a buggy client takes the whole bridge down. + serde_json::json!({ + "t": "rpc", + "cid": "cid-good", + "sessionId": serde_json::Value::Null, + "payload": { "jsonrpc": "2.0", "id": 9, "method": "ping" }, + }), + ], + 2, + ) + .await; + + let stream = dial_fake(&url).await; + let _ = pump(stream, Arc::new(AtomicBool::new(false))).await; + let results = server.await.expect("the fake relay must finish"); + + let by_cid = |cid: &str| { + results + .iter() + .find(|frame| frame["cid"] == cid) + .unwrap_or_else(|| panic!("no result for {cid}")) + }; + assert_eq!(by_cid("cid-bad")["status"], "badRequest"); + assert_eq!(by_cid("cid-good")["status"], "ok"); + } + + #[tokio::test] + async fn a_frame_this_build_does_not_know_is_ignored_rather_than_fatal() { + McpServer::instance().mark_engine_ready_for_tests(); + + let (url, server) = fake_relay( + vec![ + // A newer server's frame. Dropping it must not cost the connection, or + // a server-side rollout would take every desktop offline. + serde_json::json!({ "t": "somethingNewer", "data": 1 }), + serde_json::json!({ + "t": "rpc", + "cid": "cid-after", + "sessionId": serde_json::Value::Null, + "payload": { "jsonrpc": "2.0", "id": 1, "method": "ping" }, + }), + ], + 1, + ) + .await; + + let stream = dial_fake(&url).await; + let _ = pump(stream, Arc::new(AtomicBool::new(false))).await; + let results = server.await.expect("the fake relay must finish"); + + assert_eq!(results.len(), 1); + assert_eq!(results[0]["cid"], "cid-after"); + assert_eq!(results[0]["status"], "ok"); + } + + #[tokio::test] + async fn the_desktop_answers_the_relays_keepalive_ping() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let port = listener.local_addr().expect("port").port(); + + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.expect("accept"); + let mut stream = tokio_tungstenite::accept_async(socket) + .await + .expect("handshake"); + stream + .send(Message::Ping(b"keepalive".to_vec().into())) + .await + .expect("ping"); + // An idle WebSocket is closed upstream, so the pong is what keeps the + // bridge alive. A desktop that never answers is dropped and reconnects + // forever. + let mut pong = None; + while let Some(Ok(message)) = stream.next().await { + if let Message::Pong(payload) = message { + pong = Some(payload.to_vec()); + break; + } + } + let _ = stream.close(None).await; + pong + }); + + let stream = dial_fake(&format!("ws://127.0.0.1:{port}")).await; + let _ = pump(stream, Arc::new(AtomicBool::new(false))).await; + assert_eq!( + server.await.expect("the fake relay must finish"), + Some(b"keepalive".to_vec()) + ); + } + + #[tokio::test] + async fn a_writer_held_open_by_a_stuck_call_is_abandoned_rather_than_waited_on() { + // The scenario, reduced to its mechanism: `dispatch` clones the outbound + // sender into every spawned tool call, so `drop(tx)` in `pump` closes + // nothing while one is still running. A plain `writer.await` then waits for + // the slowest tool call, and the socket it would write to is already gone. + // + // Unbounded, that pins `pump` forever: the idle timeout fires, `run` never + // reaches its reconnect sleep, BRIDGE_CONNECTED stays true, and the + // Integrations page reports a healthy bridge over a dead socket until the + // app restarts. Exactly the failure the idle timeout exists to catch. + let (tx, mut rx) = mpsc::channel::(4); + let held = tx.clone(); // stands in for an in-flight tool call + let writer = tokio::spawn(async move { while rx.recv().await.is_some() {} }); + + drop(tx); + let budget = Duration::from_millis(50); + let started = tokio::time::Instant::now(); + // Bounded from the outside too: an unbounded drain does not return a wrong + // answer, it never returns at all, and a bare `.await` here would hang the + // whole test binary instead of reporting the regression. + tokio::time::timeout(budget * 8, drain_writer(writer, budget)) + .await + .expect("a stuck call must not be able to hold the reconnect loop open"); + let waited = started.elapsed(); + assert!(waited >= budget, "the budget must actually be honoured"); + // The stand-in outlives the drain, which is the point: the call is left to + // finish into a closed channel rather than aborted mid-browser-launch. + drop(held); + } + + #[test] + fn stopping_a_bridge_that_is_not_running_returns_before_it_publishes() { + // Called from logout and from app exit, both of which run whether or not + // remote control was ever switched on. + // + // This used to assert `!is_running()`, call `stop(None)` twice, and assert + // it again. Both globals default to false, so every assertion already held + // before a single line of `stop` ran, the test could only have failed if a + // sibling on another of cargo's threads had flipped them, which is the one + // thing it was not testing. The property that actually matters is that the + // guard sits AHEAD of the publish, so a stop on a bridge nobody started + // cannot emit a state change to a screen that never showed one. + let source = include_str!("mcp_remote.rs"); + let production = source + .split_once("\n#[cfg(test)]") + .map_or(source, |(code, _)| code); + let stop_fn = production + .split("pub fn stop(app: Option<&AppHandle>) {") + .nth(1) + .expect("stop must exist"); + let body = &stop_fn[..stop_fn.find("\n}").unwrap_or(stop_fn.len())]; + + let guard = body + .find("if !BRIDGE_RUNNING.swap(false, Ordering::SeqCst) {") + .expect("stop must be guarded by the running flag"); + let publish = body + .find("publish_state(") + .expect("stop must publish the new state"); + assert!( + guard < publish, + "the running guard must return before anything is published, or every \ + logout and every app exit emits a bridge state change for a bridge that \ + was never switched on" + ); + + // And it really is safe to call when nothing is running. + stop(None); + stop(None); + } + + #[test] + fn every_path_that_closes_the_bridge_has_one_that_can_reopen_it() { + // A source assertion, because this is a WHOLE-LIFECYCLE property and no + // single function can hold it. It exists because the obvious version of the + // startup gate got it wrong: adding a signed-in check at boot, with nothing + // on the sign-in path, left "sign out, sign back in" showing remote control + // switched ON in Settings while the bridge was dead until a restart. The UI + // said yes and the account page said no desktop was connected. + // + // The rule this pins: the bridge is stopped on logout and on exit, so it + // must be (re)opened on sign-in and on a periodic tick, not only at boot. + let cloud_auth = std::fs::read_to_string("src/cloud_auth.rs").expect("cloud_auth.rs"); + let lib = std::fs::read_to_string("src/lib.rs").expect("lib.rs"); + + // BOTH teardown paths in cloud_auth, not just one. The original assertion + // used `contains`, which logout alone satisfied, so `invalidate_session`, + // the AUTOMATIC twin reached when the background refresh loop gives up, + // silently left the bridge dialling a relay with a credential that no + // longer existed, for ever, backing off into an "unauthorized" shown to + // somebody whose session had merely expired. + assert_eq!( + cloud_auth.matches("crate::mcp_remote::stop(None);").count(), + 2, + "both logout AND invalidate_session must hang up the bridge before \ + deleting the credential it was using" + ); + for owner in ["pub async fn invalidate_session", "pub async fn logout"] { + let body = cloud_auth + .split(owner) + .nth(1) + .unwrap_or_else(|| panic!("{owner} not found in cloud_auth.rs")); + let body = &body[..body.len().min(1200)]; + assert!( + body.contains("crate::mcp_remote::stop(None);"), + "{owner} clears the credential, so it must close the bridge too" + ); + } + assert!( + lib.contains("mcp_remote::stop(None);"), + "exiting must release the account's single bridge slot rather than let the server reap it" + ); + + // Three reopen paths, and each is load-bearing: boot restores it, sign-in + // covers the logout/login round trip, and the tick catches every other way + // the setting and the socket can drift apart. + assert_eq!( + cloud_auth + .matches("ensure_remote_bridge(&app_handle).await;") + .count(), + 2, + "the sign-in path and the periodic reconnect tick must both reopen the bridge" + ); + assert!( + lib.contains("cloud_auth::ensure_remote_bridge(&bridge_handle).await;"), + "startup must reopen a bridge the user had switched on" + ); + } + + #[test] + fn the_bridge_and_the_loopback_listener_accept_the_same_body_size() { + // The frame budget must match the limit the remote endpoint accepts. + // Changing this number without changing that one makes the remote + // transport silently stricter than the local one. + assert_eq!(McpServer::MAX_MESSAGE_BYTES, 1024 * 1024); + } + + #[tokio::test] + async fn a_writer_with_nothing_left_to_send_is_joined_immediately() { + let (tx, mut rx) = mpsc::channel::(4); + let writer = tokio::spawn(async move { while rx.recv().await.is_some() {} }); + + drop(tx); + let started = tokio::time::Instant::now(); + // The common case: no call in flight, so the channel really does close and + // the join returns at once instead of waiting out the budget. + drain_writer(writer, Duration::from_secs(30)).await; + assert!(started.elapsed() < Duration::from_secs(5)); + } + + #[tokio::test] + async fn a_close_naming_the_slot_conflict_is_reported_as_one() { + use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; + use tokio_tungstenite::tungstenite::protocol::CloseFrame; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let port = listener.local_addr().expect("port").port(); + + tokio::spawn(async move { + let (socket, _) = listener.accept().await.expect("accept"); + let mut stream = tokio_tungstenite::accept_async(socket) + .await + .expect("handshake"); + // The exact wording the bridge endpoint sends when the slot is held. + let _ = stream + .close(Some(CloseFrame { + code: CloseCode::Policy, + reason: "another Donut instance on this account holds the remote-control slot".into(), + })) + .await; + while stream.next().await.is_some() {} + }); + + let stream = dial_fake(&format!("ws://127.0.0.1:{port}")).await; + let outcome = pump(stream, Arc::new(AtomicBool::new(false))).await; + + let error = outcome.expect_err("a policy close is not a clean shutdown"); + assert!( + matches!(error, BridgeError::SlotTaken(_)), + "got {error:?}, which would retry on a one-second timer against a server that keeps saying no" + ); + assert!(error.is_terminal_refusal()); + } +} diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 7ca8da4..bbbbb7a 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -2,19 +2,21 @@ use axum::{ body::Body, extract::State, http::{header, Request, StatusCode}, - middleware::{self, Next}, + middleware::Next, response::{IntoResponse, Response}, - routing::{get, post}, + routing::get, Json, Router, }; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::net::SocketAddr; -use std::sync::atomic::{AtomicBool, AtomicU16, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU64, Ordering}; use std::sync::Arc; +use std::time::Duration; use tauri::AppHandle; use tokio::net::TcpListener; use tokio::sync::Mutex as AsyncMutex; +use utoipa::ToSchema; use uuid::Uuid; use crate::browser::ProxySettings; @@ -24,6 +26,11 @@ use crate::group_manager::GROUP_MANAGER; use crate::profile::{BrowserProfile, ProfileManager}; use crate::proxy_manager::PROXY_MANAGER; use crate::settings_manager::SettingsManager; +use crate::wayfern_cdp::{ + self, vellum, Engine, Extraction, ExtractionRequest, LocatorCandidate, LocatorDescription, + LocatorResolution, PerceptionFrame, PerceptionNode, PerceptionPage, PerceptionRequest, + PerceptionStats, PickedElement, ResolveOptions, ViewportTarget, WayfernError, WayfernSession, +}; use crate::wayfern_terms::WayfernTermsManager; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -37,9 +44,10 @@ pub struct McpTool { /// JavaScript executed in the target page to enumerate visible interactive /// elements. Returns a JSON string `{elements, count, truncated}` where /// `elements` is the newline-joined labeled list. Live references are stashed -/// on `window.__donut_interactive` so subsequent `click_by_index` / +/// on a per-caller `window[...]` slot so subsequent `click_by_index` / /// `type_by_index` calls can resolve `index → Element` without round-tripping -/// a selector. `__MAX_CHARS__` is substituted at call time. +/// a selector. `__MAX_CHARS__`, `__CACHE__`, `__REGISTRY__` and `__MAX_SLOTS__` +/// are substituted at call time. const INTERACTIVE_ELEMENTS_JS: &str = r#"(() => { const SELECTORS = 'a, button, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="tab"], [role="menuitem"], [role="combobox"], [role="option"], [contenteditable=""], [contenteditable="true"], [tabindex]:not([tabindex="-1"])'; const ATTRS = ['type','name','id','role','aria-label','aria-checked','aria-expanded','placeholder','title','value','href','alt']; @@ -72,10 +80,48 @@ const INTERACTIVE_ELEMENTS_JS: &str = r#"(() => { interactive.push(el); lines.push(line); } - window.__donut_interactive = interactive; + window[__CACHE__] = interactive; + // Bound how many snapshots one page carries. Each slot holds live element + // references, so it pins every node in it, including nodes the page has + // since detached, for as long as the tab lives, and NOTHING on the page + // ever hears that a session ended. `end_session` deletes a session's own + // slot, but no first-party client sends one, and an evicted or crashed + // session never will; without this cap a long-lived tab accumulates one + // array per session that ever listed it. Evicting the least recently + // written slot is safe in the way that matters: the evicted caller's next + // click_by_index finds no array and is told to re-list, which is an error, + // not a click on the wrong element. + try { + const registry = Array.isArray(window[__REGISTRY__]) ? window[__REGISTRY__] : []; + const kept = registry.filter((slot) => typeof slot === 'string' && slot !== __CACHE__ && slot in window); + kept.push(__CACHE__); + while (kept.length > __MAX_SLOTS__) { + const evicted = kept.shift(); + try { delete window[evicted]; } catch (e) { window[evicted] = undefined; } + } + window[__REGISTRY__] = kept; + } catch (e) { + // A page that has made the registry unwritable only keeps its own slots + // alive; it must not cost the caller the listing it asked for. + } return JSON.stringify({ elements: lines.join('\n'), count: interactive.length, truncated: truncated }); })()"#; +/// Page global naming, in write order, every interactive-element slot the page +/// currently holds. Read and rewritten by the enumeration script so the oldest +/// slot can be evicted once [`MAX_CACHE_SLOTS_PER_PAGE`] is exceeded. +/// +/// A quoted JS string literal, because it is substituted into `window[...]`. +const INTERACTIVE_SLOT_REGISTRY: &str = "'__donut_interactive_slots'"; + +/// How many interactive-element snapshots one page keeps at once. +/// +/// Far above what any real page needs, a caller has one live snapshot, and +/// the handful of callers driving one page at the same moment are a browser +/// automation edge case already, and low enough that a tab open for a day +/// cannot collect hundreds of arrays of detached DOM nodes. +const MAX_CACHE_SLOTS_PER_PAGE: usize = 8; + #[derive(Debug, Deserialize)] #[allow(dead_code)] pub struct McpRequest { @@ -86,6 +132,30 @@ pub struct McpRequest { } const PROTOCOL_VERSION: &str = "2025-11-25"; + +/// Every MCP protocol revision this engine can speak, newest first. +/// +/// The spec says a server MUST echo the client's requested version when it +/// supports it. Answering with our own newest regardless meant a client on an +/// older SDK compared the reply against ITS supported list, found nothing, and +/// threw "Server's protocol version is not supported", so the whole feature +/// was unreachable from any agent that had not upgraded in lockstep. +const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = + &["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"]; + +/// The version to answer `initialize` with: the client's, when we speak it. +fn negotiate_protocol_version(requested: Option<&str>) -> &'static str { + match requested { + Some(asked) => SUPPORTED_PROTOCOL_VERSIONS + .iter() + .find(|known| **known == asked) + .copied() + // Not a version we know. The spec's fallback is to answer with one we + // do support and let the client decide whether it can proceed. + .unwrap_or(PROTOCOL_VERSION), + None => PROTOCOL_VERSION, + } +} const SERVER_NAME: &str = "donut-browser"; const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -104,6 +174,11 @@ pub struct McpResponse { pub struct McpError { code: i32, message: String, + /// Structured detail for errors an agent has to act on, such as the + /// candidate list behind an ambiguous locator. Absent for the rest, so the + /// wire shape of every existing error is unchanged. + #[serde(skip_serializing_if = "Option::is_none")] + data: Option, } /// Surface a CDP failure to the agent with the reason intact. @@ -116,11 +191,26 @@ fn cdp_error(error: CdpError) -> McpError { McpError { code: -32000, message: error.to_string(), + data: None, } } const DEFAULT_MCP_PORT: u16 = 51080; +/// The event the desktop turns into the "local MCP is being removed" dialog. +/// +/// Emitted by the loopback tombstone (below) when anything still tries to reach +/// the removed local server, and by the enable/install commands when the user +/// asks for local MCP in the app. +pub const LOCAL_MCP_DEPRECATED_EVENT: &str = "mcp-local-deprecated"; + +/// Unix-seconds of the last deprecation event, so a client retry loop hitting +/// the dead port cannot pop the dialog on every attempt. +static LAST_LOCAL_DEPRECATION_EMIT: AtomicU64 = AtomicU64::new(0); + +/// Seconds between deprecation dialogs, however many requests arrive. +const LOCAL_DEPRECATION_THROTTLE_SECS: u64 = 30; + /// How long a keystroke waits for its acknowledgement before moving on. /// /// Generous enough to absorb a relayed round trip, short enough that a browser @@ -130,8 +220,34 @@ const KEYSTROKE_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_sec struct McpSession { initialized: bool, + /// When this session was last USED, which is what the cap evicts by. + /// + /// Not creation time: a long-lived agent's session is by definition the + /// oldest, so evicting by age threw out the one client actually working and + /// kept 512 abandoned ones from browser tabs that were closed hours ago. + last_used: std::time::Instant, + /// Every `(profile_id, slot)` this session has written an interactive-element + /// snapshot to, so `end_session` can delete the page globals it left behind. + /// + /// Server-side state alone was never the whole session: the snapshot lives in + /// somebody's still-open tab, holds live element references, and outlives the + /// session that made it. Forgetting the session without deleting the array + /// leaks one array per session for the life of the page. + cached_pages: HashSet<(String, String)>, } +/// How many MCP sessions one desktop will hold at once. +/// +/// The map had no bound at all: nothing evicts a session except an explicit +/// `end_session`, and no first-party client sends one, the website mints a +/// session per page load in which the customer actually clicks something, and +/// the one-shot 404 retry abandons an id and mints a replacement. That is tens +/// of kilobytes over months rather than a leak with teeth, but "grows for the +/// life of the process with no ceiling" is not a property worth keeping when a +/// bound costs this little. Far above any real client's usage, so hitting it +/// means something is wrong rather than someone is busy. +const MAX_SESSIONS: usize = 512; + struct McpServerInner { app_handle: Option, token: Option, @@ -145,12 +261,1866 @@ struct McpHttpState { token: String, } +/// What answering one JSON-RPC message produced, in transport-neutral terms. +/// +/// The engine has two front doors, the loopback HTTP listener and the cloud +/// bridge in [`crate::mcp_remote`], and neither may own a rule the other +/// needs. Session validation, the notification path and the automation limiter +/// used to live inside the axum handler, which meant a second transport either +/// duplicated them or silently skipped them. They live in +/// [`McpServer::handle_message`] now, and each transport only translates this +/// enum into whatever "no such session" means on its own wire. +pub(crate) enum McpOutcome { + /// A JSON-RPC response to send back. `new_session_id` is set only by + /// `initialize`, and is the id the caller must echo on later messages. + Body { + body: serde_json::Value, + new_session_id: Option, + }, + /// A notification was accepted. JSON-RPC defines no reply to one. + Accepted, + /// The caller named a session this process does not have. + UnknownSession, + /// The payload was not a JSON-RPC message. + BadRequest, + /// The shared automation limiter refused the call. + RateLimited { retry_after_secs: u64 }, +} + pub struct McpServer { inner: Arc>, is_running: AtomicBool, + /// Whether the tool engine can answer at all, which is a different question + /// from whether the loopback listener is bound. + /// + /// Remote control is usable without opening a local port, and turning the + /// local server off must not sever a live bridge, so the engine's readiness + /// tracks the app handle it needs, not the HTTP transport it no longer + /// exclusively serves. + engine_ready: AtomicBool, port: AtomicU16, } +/// Which transport a message arrived on. +/// +/// The two are the same engine and almost the same trust, but not quite: a +/// LOOPBACK caller is already on the machine, while a BRIDGE caller reached it +/// from the internet with an account credential. Tools that take a local +/// filesystem PATH are meaningful only to someone standing on the machine - +/// and `add_extension` reads that path and stores the bytes, which the sync +/// engine then uploads. Left open, that is an arbitrary local-file read with +/// the same shape as the `file://` hole the URL allowlist closed. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum McpOrigin { + /// The 127.0.0.1 listener: the caller is already on this machine. + Loopback, + /// Relayed from the cloud bridge. + Bridge, +} + +/// Who is asking: the transport that carried the message, and the MCP session +/// it belongs to. +/// +/// The two travel together because either one alone identifies a caller wrongly. +/// Origin decides what a caller is ALLOWED to do (a local-path tool is +/// meaningless to a remote caller); the session decides whose page state a +/// caller is looking at, and two callers routinely share one transport. +#[derive(Clone, Copy)] +pub(crate) struct McpCaller<'a> { + origin: McpOrigin, + /// The session id echoed back by the caller, when it opened one at all. + session: Option<&'a str>, +} + +/// The longest a single `type_text` call may plan to spend typing, on the +/// loopback transport. +/// +/// Five minutes is far past any real form field (roughly 2,000 words at the +/// default rate) and far short of the hours an unbounded string can reach. +pub(crate) const MAX_TYPING_SECONDS: f64 = 300.0; + +/// The same bound over the bridge. +/// +/// A call over the bridge is answered with a timeout if it runs too long, so a +/// plan that would type for longer than that is not slow, it is a guaranteed +/// failure that still holds one of the eight process-wide permits for the full +/// five minutes. Sitting under that budget leaves room for the focus step, the +/// round trips and the answer itself. +const MAX_BRIDGE_TYPING_SECONDS: f64 = 80.0; + +/// The typing budget for a caller on the given transport. +fn max_typing_seconds(origin: McpOrigin) -> f64 { + match origin { + McpOrigin::Loopback => MAX_TYPING_SECONDS, + McpOrigin::Bridge => MAX_BRIDGE_TYPING_SECONDS, + } +} + +/// The longest text `type_text` will even PLAN, checked before planning starts. +/// +/// `MarkovTyper::run` is superlinear, so the duration bound alone was not a +/// bound at all: the plan for a megabyte of text costs minutes of CPU to build, +/// and the refusal only arrives afterwards. +const MAX_TYPING_CHARS: usize = 4096; + +/// Build the keystroke plan for `type_text`, or refuse it as too long. +/// +/// Refused up front rather than typed for hours. Human typing sleeps between +/// keystrokes, `session_wpm` is floored at 10 (human_typing.rs), and the text is +/// bounded only by the 1 MiB frame cap, so at the floor a 10,000 character +/// string types for over three hours and a megabyte for a fortnight. Over the +/// bridge such a call holds one of eight PROCESS-WIDE permits the whole time, so +/// eight of them wedge remote control for the account, and the caller cannot +/// tell that apart from an outage. +/// +/// Measured on the generated plan rather than the character count, so the bound +/// means the same thing at any wpm. Refusing beats truncating: a half-typed form +/// field is worse than an error that says what to do. +/// +/// Separated from the sending loop so the decision can be tested for what it +/// DOES, not merely asserted to be present in the source. +fn plan_typing( + text: &str, + wpm: Option, + max_seconds: f64, +) -> Result, McpError> { + // Length FIRST, before a plan is built. `MarkovTyper::run` is superlinear - + // planning 20,000 characters took a single unit test 24 seconds of solid CPU - + // so a duration bound measured on the finished plan is defeated by the work of + // producing it: the caller still burns minutes of CPU, and over the bridge + // still holds one of eight process-wide permits, before being told no. + // + // 4,096 characters is far past any real form field and cheap to plan. The + // duration bound below still applies, because a slow wpm can exceed the time + // limit well under this many characters. + let chars = text.chars().count(); + if chars > MAX_TYPING_CHARS { + return Err(McpError { + code: -32602, + message: serde_json::json!({ + "code": "TYPING_TOO_LONG", + "params": { + "seconds": format!("{chars}"), + "limit": format!("{MAX_TYPING_CHARS}"), + } + }) + .to_string(), + data: None, + }); + } + + let events = crate::human_typing::MarkovTyper::new(text, wpm).run(); + let planned = events.last().map_or(0.0, |event| event.time); + if planned > max_seconds { + return Err(McpError { + code: -32602, + message: serde_json::json!({ + "code": "TYPING_TOO_LONG", + "params": { + "seconds": format!("{planned:.0}"), + "limit": format!("{max_seconds:.0}"), + } + }) + .to_string(), + data: None, + }); + } + Ok(events) +} + +/// The page-global slot one SESSION caches its interactive-element snapshot in. +/// +/// Keyed by transport AND by MCP session. One slot per origin was not enough: +/// the website console and an agent both arrive over the BRIDGE, as do two runs +/// of the same agent, so one caller's `get_interactive_elements` overwrote the +/// array another had just built and that caller's next `click_by_index(3)` +/// resolved against the wrong array, a wrong click on somebody's real browser, +/// reported as a successful one. Sessions are what tell two callers on one +/// transport apart, so the slot is keyed by both. +/// +/// The session is not an `Option` here on purpose. A caller that never ran +/// `initialize` used to fall back to the literal `"anon"`, which is a SHARED +/// slot by another name: two such callers on one transport still overwrote each +/// other's array and still clicked each other's elements. Taking a `&str` means +/// no caller can reach a slot without a session at all - +/// [`require_indexed_session`] is the only way to get one, and it refuses. +/// +/// A quoted JS string literal, because it is substituted into `window[...]`. +fn interactive_cache_slot(origin: McpOrigin, session: &str) -> String { + let transport = match origin { + McpOrigin::Loopback => "local", + McpOrigin::Bridge => "bridge", + }; + format!( + "'__donut_interactive_{transport}_{}'", + cache_key_for_session(session) + ) +} + +/// Reduce a session id to a distinct, JS-safe fragment of a global's name. +/// +/// Hashed rather than truncated. Replacing every character outside +/// `[A-Za-z0-9_]` and cutting at 64 was safe but NOT distinct, whatever the doc +/// claimed: `"abc-def"` and `"abc_def"` mapped to one slot, as did any two ids +/// agreeing on their first 64 characters, and a slot collision is the wrong +/// click this key exists to prevent. A hash of the WHOLE id collides only at +/// the 128-bit birthday bound, and hex is inherently safe to paste inside a JS +/// string literal, so nothing caller-supplied survives to close the quote, +/// inject an expression, or grow the evaluated script. +/// +/// Deterministic: the same id yields the same slot for the life of the session, +/// which is what lets a second `click_by_index` find the array the first +/// `get_interactive_elements` built. +fn cache_key_for_session(session: &str) -> String { + let mut digest = blake3::hash(session.as_bytes()).to_hex().to_string(); + // 128 bits of it. The full 256 would only make the global's name longer. + digest.truncate(32); + digest +} + +/// The session an index-based tool needs, or a refusal explaining why. +/// +/// Refusing is the only answer that cannot click the wrong element. The +/// alternative considered, minting a per-connection identity for a caller that +/// skipped `initialize`, keeps such a caller working, but every scheme for it +/// either shares a slot between two connections (the bug again) or invents an +/// identity the caller cannot name on its NEXT request, so its `click_by_index` +/// resolves against a slot it does not own. An index is meaningless without a +/// session anyway: it is a pointer into an array a PREVIOUS call left on the +/// page, and only the session says whose array that is. An error the agent can +/// act on ("call initialize") beats a click on somebody's real browser reported +/// as a success. +fn require_indexed_session(caller: McpCaller<'_>) -> Result<&str, McpError> { + caller.session.ok_or_else(|| McpError { + code: -32600, + message: "This tool needs an MCP session: call initialize and send the session id it \ + returns on every later request. An element index only means something inside \ + the session whose get_interactive_elements produced it." + .to_string(), + data: None, + }) +} + +/// Tools that take a caller-supplied filesystem path. +/// +/// Refused over the bridge rather than sanitised: a remote caller cannot know +/// this machine's filesystem, so there is no legitimate remote use to preserve +///, which makes refusing strictly better than guessing at safe roots. +const LOCAL_PATH_TOOLS: &[&str] = &[ + "add_extension", + "update_extension", + "detect_browser_profiles", + // Its path arrives nested inside `items[].source_path` rather than as a + // top-level argument, which is exactly how it stayed off this list: the + // regression test used to grep each handler for the literal "path" and a + // deserialized struct field has no such literal. It reads a caller-named + // directory off this disk and copies it into a profile, including the + // source browser's cookies and passwords, so over the bridge it is an + // arbitrary local-file read with an account credential. + "import_browser_profiles", +]; + +/// Tools refused over the bridge because their whole output is stored secrets. +/// +/// The bridge gate is by tool, not by field. `export_proxies` exists to write +/// every proxy's password and VLESS URI out in full, which is exactly what the +/// redaction on `list_proxies` and `get_proxy` withholds from a remote caller, +/// so there is no redacted form of it worth serving. Refused with the same +/// code as the local-path tools: it is a local-only action. +const SECRET_EXPORT_TOOLS: &[&str] = &["export_proxies"]; + +/// Strip the fields of a stored proxy a remote caller must not see. +/// +/// A bridge caller holds an account credential, not this machine. The proxy +/// password, the VLESS URI (which carries the credential inline) and the +/// dynamic list URL (which usually carries an API key) are secrets of the +/// machine, not of the account, and an agent needs none of them to pick a +/// proxy by id. Present values are replaced rather than removed so the shape +/// an agent has learned stays the same, and absent ones stay absent. +fn redact_proxy_secrets(proxy: &mut serde_json::Value) { + const REDACTED: &str = "[redacted]"; + if let Some(settings) = proxy + .get_mut("proxy_settings") + .and_then(serde_json::Value::as_object_mut) + { + for field in ["password", "vless_uri"] { + if settings.get(field).is_some_and(|v| !v.is_null()) { + settings.insert(field.to_string(), serde_json::Value::from(REDACTED)); + } + } + } + if let Some(map) = proxy.as_object_mut() { + if map.get("dynamic_proxy_url").is_some_and(|v| !v.is_null()) { + map.insert( + "dynamic_proxy_url".to_string(), + serde_json::Value::from(REDACTED), + ); + } + } +} + +/// Reject a URL the browser should never be told to open on a customer's behalf. +/// +/// `Page.navigate` will happily load `file:///Users/…/.ssh/id_rsa`, and +/// `get_page_content` will then hand the bytes straight back to the caller. On +/// the loopback transport that is merely a local tool reading local files; over +/// the CLOUD BRIDGE it is an arbitrary local-file read reachable from the +/// internet by anyone holding an account credential, which is not "control +/// your browser", and is exactly the machine this module exists to protect. +/// +/// Allowed: the schemes a browser is actually asked to browse. `about:blank` +/// is permitted because it is the standard way to park a tab. Everything else, +/// including `file:`, `data:`, `blob:`, `chrome:`, `devtools:` and +/// `javascript:`, is refused. +pub(crate) fn is_navigable_url(url: &str) -> bool { + let trimmed = url.trim(); + if trimmed.eq_ignore_ascii_case("about:blank") { + return true; + } + let lowered = trimmed.to_ascii_lowercase(); + lowered.starts_with("http://") || lowered.starts_with("https://") +} + +fn validate_navigable_url(url: &str) -> Result<(), McpError> { + if is_navigable_url(url) { + return Ok(()); + } + Err(McpError { + code: -32602, + message: crate::backend_error("URL_SCHEME_NOT_ALLOWED"), + data: None, + }) +} + +// --- Agent surface: perception, locators, extraction, picker, humanized input -- + +/// The longest `pick_element` waits for a click on the loopback transport. +pub(crate) const MAX_PICK_TIMEOUT_MS: u64 = 300_000; + +/// How long `pick_element` waits when the caller does not say. +pub(crate) const DEFAULT_PICK_TIMEOUT_MS: u64 = 60_000; + +/// The same bound over the bridge. +/// +/// A call over the bridge is answered with a timeout if it runs too long, so a +/// wait that would outlive it is not patience, it is a guaranteed failure that +/// still holds one of the eight process-wide permits. Same margin as typing. +const MAX_BRIDGE_PICK_TIMEOUT_MS: u64 = 80_000; + +/// The picker budget for a caller on the given transport. +fn max_pick_timeout_ms(origin: McpOrigin) -> u64 { + match origin { + McpOrigin::Loopback => MAX_PICK_TIMEOUT_MS, + McpOrigin::Bridge => MAX_BRIDGE_PICK_TIMEOUT_MS, + } +} + +/// The longest `extract_structured` may let the browser page through rows. +/// +/// The browser's own ceiling is two minutes; over the bridge that is longer +/// than a call is allowed to run, so the same margin as typing applies. +const MAX_EXTRACTION_BUDGET_MS: u64 = 120_000; + +/// The extraction budget for a caller on the given transport. +fn max_extraction_budget_ms(origin: McpOrigin) -> u64 { + match origin { + McpOrigin::Loopback => MAX_EXTRACTION_BUDGET_MS, + McpOrigin::Bridge => MAX_BRIDGE_PICK_TIMEOUT_MS, + } +} + +/// How long the browser's own typing rhythm is budgeted per character. +/// +/// `Vellum.inscribe` paces the keys itself, so this is a per-character ceiling +/// rather than an estimate: a text the budget refuses is one the browser would +/// have turned into a hung call. +const VELLUM_SECONDS_PER_CHAR: f64 = 0.25; + +/// How long a click waits for the page it may have navigated to. +const CLICK_LOAD_TIMEOUT: Duration = Duration::from_secs(10); + +/// The `TYPING_TOO_LONG` envelope, shared by both typing engines. +fn typing_too_long(seconds: f64, limit: f64) -> McpError { + McpError { + code: -32602, + message: serde_json::json!({ + "code": "TYPING_TOO_LONG", + "params": { + "seconds": format!("{seconds:.0}"), + "limit": format!("{limit:.0}"), + } + }) + .to_string(), + data: None, + } +} + +/// The time a `Vellum.inscribe` of `text` may take, or the refusal. +/// +/// Decided BEFORE anything touches the page, for the same reason `plan_typing` +/// is: a refusal that arrives after the field was emptied is a lie. +fn vellum_typing_budget(text: &str, max_seconds: f64) -> Result { + let chars = text.chars().count(); + if chars > MAX_TYPING_CHARS { + return Err(typing_too_long(chars as f64, MAX_TYPING_CHARS as f64)); + } + let estimate = chars as f64 * VELLUM_SECONDS_PER_CHAR + 1.0; + if estimate > max_seconds { + return Err(typing_too_long(estimate, max_seconds)); + } + Ok(Duration::from_secs_f64(max_seconds)) +} + +/// Why an agent operation could not be completed. +/// +/// One vocabulary for both front doors. The MCP transport renders it as a +/// JSON-RPC error whose `data` carries the structured part, and the REST +/// transport maps the same variants onto statuses, so a locator that matched +/// three buttons is reported the same way whichever door it came through. +#[derive(Debug)] +pub(crate) enum AgentError { + /// The caller's arguments cannot mean anything on any page. + InvalidArgument(String), + /// The profile runs a Wayfern older than the feature. + RequiresWayfern152 { + version: String, + }, + /// The browser refused: no paid plan behind this browser. + PaymentRequired(String), + /// The browser refused: too many calls too fast. + RateLimited(String), + /// The browser could not confirm this account's plan. + AuthorizationUnavailable(String), + AmbiguousLocator { + match_count: u64, + candidates: Vec, + message: String, + }, + NoMatch { + message: String, + }, + PickerTimedOut { + timeout_ms: u64, + }, + PickerCancelled { + reason: String, + }, + TypingTooLong { + seconds: f64, + limit: f64, + }, + /// The browser refused the request as malformed (`-32602`), or the page + /// threw at it: what was asked for does not exist or cannot be done. + BadRequest(String), + /// The browser failed to do what it was asked (`-32000`). + Browser(String), + /// The transport, or the browser's absence. + Cdp(CdpError), + /// A reply without the documented shape. + Malformed(String), +} + +impl AgentError { + /// A stable code an agent can branch on. + pub(crate) fn code(&self) -> &'static str { + match self { + Self::InvalidArgument(_) => "INVALID_ARGUMENT", + Self::RequiresWayfern152 { .. } => "WAYFERN_152_REQUIRED", + Self::PaymentRequired(_) => "PAYMENT_REQUIRED", + Self::RateLimited(_) => "RATE_LIMITED", + Self::AuthorizationUnavailable(_) => "AUTHORIZATION_UNAVAILABLE", + Self::AmbiguousLocator { .. } => "LOCATOR_AMBIGUOUS", + Self::NoMatch { .. } => "LOCATOR_NO_MATCH", + Self::PickerTimedOut { .. } => "PICKER_TIMEOUT", + Self::PickerCancelled { .. } => "PICKER_CANCELLED", + Self::TypingTooLong { .. } => "TYPING_TOO_LONG", + Self::BadRequest(_) => "BAD_REQUEST", + Self::Browser(_) => "BROWSER_ERROR", + Self::Cdp(_) => "CDP_ERROR", + Self::Malformed(_) => "MALFORMED_REPLY", + } + } + + /// The human-readable part. + pub(crate) fn message(&self) -> String { + match self { + Self::InvalidArgument(m) + | Self::PaymentRequired(m) + | Self::RateLimited(m) + | Self::AuthorizationUnavailable(m) + | Self::BadRequest(m) + | Self::Browser(m) + | Self::Malformed(m) => m.clone(), + Self::RequiresWayfern152 { version } => format!( + "This tool requires Wayfern 152 or newer; the profile runs {version}. Update the profile's browser, or use the selector and index tools." + ), + Self::AmbiguousLocator { message, .. } | Self::NoMatch { message } => message.clone(), + Self::PickerTimedOut { timeout_ms } => { + format!("No element was picked within {timeout_ms} ms; the picker has been disarmed") + } + Self::PickerCancelled { reason } => format!("The element picker was cancelled ({reason})"), + Self::TypingTooLong { seconds, limit } => { + format!("Typing this text would take about {seconds:.0}s, over the {limit:.0}s limit") + } + Self::Cdp(e) => e.to_string(), + } + } + + /// The structured part, when there is one. + pub(crate) fn detail(&self) -> serde_json::Value { + let mut detail = serde_json::json!({ "code": self.code() }); + match self { + Self::AmbiguousLocator { + match_count, + candidates, + .. + } => { + detail["matchCount"] = serde_json::Value::from(*match_count); + detail["candidates"] = serde_json::Value::Array(candidates.clone()); + } + Self::NoMatch { .. } => { + detail["matchCount"] = serde_json::Value::from(0); + detail["candidates"] = serde_json::json!([]); + } + Self::RequiresWayfern152 { version } => { + detail["version"] = serde_json::Value::from(version.as_str()); + } + Self::PickerTimedOut { timeout_ms } => { + detail["timeoutMs"] = serde_json::Value::from(*timeout_ms); + } + Self::PickerCancelled { reason } => { + detail["reason"] = serde_json::Value::from(reason.as_str()); + } + Self::TypingTooLong { seconds, limit } => { + detail["seconds"] = serde_json::Value::from(*seconds); + detail["limit"] = serde_json::Value::from(*limit); + } + _ => {} + } + detail + } + + /// As a JSON-RPC error. Argument problems are `-32602`; the rest is the + /// same `-32000` every other browser tool answers with, with `data` telling + /// the two apart. + fn into_mcp(self) -> McpError { + let code = match self { + Self::InvalidArgument(_) | Self::TypingTooLong { .. } | Self::BadRequest(_) => -32602, + _ => -32000, + }; + let message = match &self { + // The same envelope `plan_typing` answers with, so a client that already + // understands one typing refusal understands both. + Self::TypingTooLong { seconds, limit } => typing_too_long(*seconds, *limit).message, + _ => self.message(), + }; + McpError { + code, + message, + data: Some(self.detail()), + } + } +} + +impl From for AgentError { + fn from(error: CdpError) -> Self { + if let Some(refusal) = wayfern_cdp::classify_refusal(&error) { + let message = wayfern_cdp::protocol_message(&error).unwrap_or_else(|| error.to_string()); + return match refusal { + wayfern_cdp::BrowserRefusal::PaymentRequired => Self::PaymentRequired(message), + wayfern_cdp::BrowserRefusal::RateLimited => Self::RateLimited(message), + wayfern_cdp::BrowserRefusal::AuthorizationUnavailable => { + Self::AuthorizationUnavailable(message) + } + }; + } + match wayfern_cdp::protocol_code(&error) { + Some(-32602) => { + Self::BadRequest(wayfern_cdp::protocol_message(&error).unwrap_or_else(|| error.to_string())) + } + Some(_) => { + Self::Browser(wayfern_cdp::protocol_message(&error).unwrap_or_else(|| error.to_string())) + } + None => Self::Cdp(error), + } + } +} + +impl From for AgentError { + fn from(error: WayfernError) -> Self { + match error { + WayfernError::Cdp(e) => Self::from(e), + WayfernError::AmbiguousLocator { + match_count, + candidates, + message, + } => Self::AmbiguousLocator { + match_count, + candidates, + message, + }, + WayfernError::NoMatch { message } => Self::NoMatch { message }, + WayfernError::PickerTimedOut { timeout_ms } => Self::PickerTimedOut { timeout_ms }, + WayfernError::PickerCancelled { reason } => Self::PickerCancelled { reason }, + WayfernError::Malformed(m) => Self::Malformed(m), + } + } +} + +impl From for AgentError { + /// The typing planner answers in the `TYPING_TOO_LONG` envelope; anything + /// else from that layer is a browser-side failure. + fn from(error: McpError) -> Self { + if let Ok(envelope) = serde_json::from_str::(&error.message) { + if envelope.get("code").and_then(|c| c.as_str()) == Some("TYPING_TOO_LONG") { + let number = |key: &str| { + envelope["params"][key] + .as_str() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0.0) + }; + return Self::TypingTooLong { + seconds: number("seconds"), + limit: number("limit"), + }; + } + } + Self::Browser(error.message) + } +} + +/// A running browser, and the engine its version entitles it to. +pub(crate) struct AgentContext { + pub profile: BrowserProfile, + pub target: CdpTarget, + pub engine: Engine, +} + +impl AgentContext { + /// The engine is read from the profile HERE, at the point of use, never + /// cached: a profile updated to 152 gets the native path on its next call. + pub(crate) fn new(profile: BrowserProfile, target: CdpTarget) -> Self { + let engine = Engine::for_version(&profile.version); + Self { + profile, + target, + engine, + } + } + + fn requires_wayfern_152(&self) -> Result<(), AgentError> { + if self.engine.is_wayfern() { + Ok(()) + } else { + Err(AgentError::RequiresWayfern152 { + version: self.profile.version.clone(), + }) + } + } +} + +/// The body of `resolve_locator`. +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub(crate) struct AgentResolveRequest { + pub locator: LocatorDescription, + /// How many candidates an ambiguity error lists. Default 10, ceiling 100. + #[serde(default, alias = "candidateLimit")] + pub candidate_limit: Option, +} + +/// The body of `click_locator`. +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub(crate) struct AgentClickRequest { + pub locator: LocatorDescription, + /// "left" (default), "middle", "right", "back" or "forward". + #[serde(default)] + pub button: Option, + /// 1 (default), 2 for a double click, 3 for a triple. + #[serde(default, alias = "clickCount")] + pub click_count: Option, +} + +/// What `click_locator` did. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub(crate) struct AgentClick { + pub clicked: bool, + #[serde(rename = "match")] + pub matched: LocatorCandidate, + pub engine: Engine, + /// Whether a page load followed the click. + pub navigated: bool, +} + +/// The body of `type_locator`. +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub(crate) struct AgentTypeRequest { + pub locator: LocatorDescription, + pub text: String, + /// Empty the field first. Default true. + #[serde(default, alias = "clearFirst")] + pub clear_first: Option, + /// Mistype and correct a few characters, as a hand does. Default true. + #[serde(default)] + pub typos: Option, + /// Target words per minute. Honoured by the fallback engine only: Wayfern + /// 152 types at the profile's own rhythm. + #[serde(default)] + pub wpm: Option, +} + +/// What `type_locator` did. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub(crate) struct AgentTyping { + pub typed: bool, + /// Characters delivered. + pub characters: u64, + /// Mistyped characters that were corrected. Absent on the fallback engine, + /// which does not count its own. + #[serde(skip_serializing_if = "Option::is_none")] + pub corrections: Option, + pub duration_ms: f64, + pub engine: Engine, + #[serde(rename = "match")] + pub matched: LocatorCandidate, +} + +/// The body of `pick_element`. +#[derive(Debug, Clone, Default, Deserialize, ToSchema)] +pub(crate) struct AgentPickRequest { + /// How long to wait for the click. Default 60000, ceiling 300000. + #[serde(default, alias = "timeoutMs")] + pub timeout_ms: Option, +} + +/// Where a locator refers to, spelled the way the browser spells it in its +/// own error messages. +fn describe_locator(locator: &LocatorDescription) -> String { + let mut parts = Vec::new(); + if let Some(role) = &locator.role { + parts.push(format!("role={role}")); + } + if let Some(name) = &locator.name { + parts.push(format!("name={name}")); + } + if let Some(name) = &locator.name_contains { + parts.push(format!("nameContains={name}")); + } + if let Some(text) = &locator.text { + parts.push(format!("text={text}")); + } + if let Some(text) = &locator.text_contains { + parts.push(format!("textContains={text}")); + } + for attribute in locator.attributes.iter().flatten() { + parts.push(format!("{}={}", attribute.name, attribute.value)); + } + parts.join(", ") +} + +/// ARIA role names for the Blink AX tokens the browser indexes. +/// +/// The perception snapshot and the locator resolver speak Blink's own role +/// vocabulary (`textField`, `staticText`, `radioButton`), and so does the +/// fallback engine, so the two agree. An agent that has read ARIA writes +/// `textbox`. The browser matches roles case- and separator-insensitively, so +/// only the tokens that differ in substance are translated here, and only +/// where the ARIA role has exactly one Blink counterpart. +const ROLE_SYNONYMS: [(&str, &str); 7] = [ + ("textbox", "textField"), + ("radio", "radioButton"), + ("progressbar", "progressIndicator"), + ("separator", "splitter"), + ("generic", "genericContainer"), + ("text", "staticText"), + ("img", "image"), +]; + +/// The locator with its role spelled the way the browser spells it. +fn canonical_locator(locator: &LocatorDescription) -> LocatorDescription { + let mut canonical = locator.clone(); + if let Some(role) = &locator.role { + let key: String = role + .chars() + .filter(char::is_ascii_alphanumeric) + .collect::() + .to_ascii_lowercase(); + if let Some((_, blink)) = ROLE_SYNONYMS.iter().find(|(aria, _)| *aria == key) { + canonical.role = Some((*blink).to_string()); + } + } + canonical +} + +fn validate_locator(locator: &LocatorDescription) -> Result<(), AgentError> { + if locator.is_empty() { + return Err(AgentError::InvalidArgument( + "locator needs at least one of role, name, nameContains, text, textContains or attributes" + .to_string(), + )); + } + for attribute in locator.attributes.iter().flatten() { + if attribute.name.trim().is_empty() { + return Err(AgentError::InvalidArgument( + "every locator attribute needs a name".to_string(), + )); + } + } + Ok(()) +} + +/// The mouse button names both engines accept. +const MOUSE_BUTTONS: [&str; 5] = ["left", "middle", "right", "back", "forward"]; + +fn validate_click(request: &AgentClickRequest) -> Result<(), AgentError> { + validate_locator(&request.locator)?; + if let Some(button) = request.button.as_deref() { + if !MOUSE_BUTTONS.contains(&button) { + return Err(AgentError::InvalidArgument(format!( + "button must be one of {}", + MOUSE_BUTTONS.join(", ") + ))); + } + } + if let Some(count) = request.click_count { + if !(1..=3).contains(&count) { + return Err(AgentError::InvalidArgument( + "click_count must be 1, 2 or 3".to_string(), + )); + } + } + Ok(()) +} + +fn validate_extraction(request: &ExtractionRequest) -> Result<(), AgentError> { + validate_locator(&request.container)?; + if request.field_map.is_empty() { + return Err(AgentError::InvalidArgument( + "field_map needs at least one field".to_string(), + )); + } + for field in &request.field_map { + if field.key.trim().is_empty() { + return Err(AgentError::InvalidArgument( + "every field needs a key".to_string(), + )); + } + validate_locator(&field.locator)?; + match field.source.as_str() { + "text" | "link" => {} + "attribute" => { + if field + .attribute + .as_deref() + .is_none_or(|a| a.trim().is_empty()) + { + return Err(AgentError::InvalidArgument(format!( + "field '{}' reads an attribute but names none", + field.key + ))); + } + } + other => { + return Err(AgentError::InvalidArgument(format!( + "field '{}' has source '{other}'; it must be text, attribute or link", + field.key + ))) + } + } + } + if let Some(next) = &request.next_page { + validate_locator(next)?; + } + Ok(()) +} + +/// Evaluate `expression` on a fresh connection and hand back the JSON string +/// it returned, or the exception it threw as a [`AgentError::BadRequest`]. +/// +/// Every fallback script returns `JSON.stringify(...)`, so one reader serves +/// them all. +async fn evaluate_json_script( + target: &CdpTarget, + expression: String, +) -> Result { + let result = crate::cdp_target::run_command( + target, + "Runtime.evaluate", + serde_json::json!({ "expression": expression, "returnByValue": true }), + ) + .await?; + parse_json_script_result(&result) +} + +/// The same reader for a script evaluated on an existing session. +fn parse_json_script_result(result: &serde_json::Value) -> Result { + if let Some(exception) = result.get("exceptionDetails") { + let message = exception + .get("exception") + .and_then(|e| e.get("description")) + .or_else(|| exception.get("text")) + .and_then(|v| v.as_str()) + .unwrap_or("the page script failed"); + return Err(AgentError::BadRequest(message.to_string())); + } + let text = result + .get("result") + .and_then(|r| r.get("value")) + .and_then(|v| v.as_str()) + .ok_or_else(|| AgentError::Malformed("the page script returned no value".to_string()))?; + serde_json::from_str(text) + .map_err(|e| AgentError::Malformed(format!("the page script returned invalid JSON: {e}"))) +} + +/// The pre-152 stand-in for the browser's perception and locator domains. +/// +/// One script, two modes. `perceive` walks the visible DOM and describes it +/// in the SAME node shape `Wayfern.capturePagePerception` answers with, so an +/// agent written against 152 reads both. `resolve` applies a locator with the +/// browser's own semantics (role token, exact or substring name and text, every +/// attribute pair) and refuses ambiguity the same way; with `act` it also +/// scrolls to, or focuses and prepares, the one match, because the element +/// reference cannot leave the page and a second script could resolve a +/// different node. `__OPTS__` is substituted with a JSON object at call time. +const AGENT_FALLBACK_JS: &str = r#"(() => { + const opts = __OPTS__; + const now = () => (window.performance && performance.now) ? performance.now() : Date.now(); + const started = now(); + const norm = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); + const roleKey = (r) => norm(r).toLowerCase().replace(/[^a-z0-9]/g, ''); + // Blink's own AX role tokens, which is what Wayfern 152 answers with, so an + // agent reads one vocabulary whichever engine answered. + const INPUT_ROLES = { button: 'button', submit: 'button', reset: 'button', image: 'button', file: 'button', color: 'button', checkbox: 'checkBox', radio: 'radioButton', range: 'slider', number: 'spinButton', search: 'searchBox', hidden: 'none' }; + const TAG_ROLES = { a: 'link', area: 'link', button: 'button', select: 'comboBoxSelect', textarea: 'textField', img: 'image', h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading', nav: 'navigation', main: 'main', header: 'banner', footer: 'contentInfo', form: 'form', table: 'table', tr: 'row', td: 'cell', th: 'columnHeader', ul: 'list', ol: 'list', li: 'listItem', p: 'paragraph', option: 'listBoxOption', label: 'labelText', dialog: 'dialog', section: 'region', article: 'article', aside: 'complementary', summary: 'disclosureTriangle', details: 'details', fieldset: 'group', legend: 'legend', progress: 'progressIndicator', meter: 'meter', hr: 'splitter', blockquote: 'blockquote', code: 'code', em: 'emphasis', strong: 'strong', figure: 'figure', menu: 'menu', output: 'status', time: 'time', dd: 'definition', dt: 'term', dl: 'descriptionList', caption: 'caption', video: 'video', audio: 'audio', iframe: 'iframe' }; + const NAMED_FROM_CONTENT = new Set(['button', 'link', 'heading', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'listboxoption', 'option', 'cell', 'columnheader', 'rowheader', 'gridcell', 'checkbox', 'radiobutton', 'radio', 'switch', 'treeitem', 'tooltip', 'legend', 'caption', 'labeltext', 'label', 'disclosuretriangle']); + const ATTRS = ['id', 'name', 'type', 'role', 'href', 'src', 'placeholder', 'aria-label', 'title', 'alt', 'data-testid', 'data-test', 'data-id', 'for', 'value']; + const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'HEAD', 'META', 'LINK', 'TITLE', 'BR', 'WBR']); + const vw = window.innerWidth, vh = window.innerHeight; + const sx = window.scrollX || 0, sy = window.scrollY || 0; + const fnv = (s) => { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; } return ('00000000' + h.toString(16)).slice(-8); }; + const tagOf = (el) => String(el.tagName || '').toLowerCase(); + const pathOf = (el) => { const parts = []; let n = el; while (n && n.nodeType === 1 && n !== document.documentElement) { let i = 1, s = n; while ((s = s.previousElementSibling)) { if (s.tagName === n.tagName) i++; } parts.push(tagOf(n) + ':' + i); n = n.parentElement; } return parts.reverse().join('>'); }; + const roleOf = (el) => { + const explicit = el.getAttribute('role'); + if (explicit && norm(explicit)) return norm(explicit).split(' ')[0]; + const tag = tagOf(el); + if (tag === 'input') { const t = (el.getAttribute('type') || 'text').toLowerCase(); return INPUT_ROLES[t] || 'textField'; } + if (tag === 'a' && !el.hasAttribute('href')) return 'genericContainer'; + if (el.isContentEditable && !TAG_ROLES[tag]) return 'textField'; + return TAG_ROLES[tag] || 'genericContainer'; + }; + const isProtected = (el) => tagOf(el) === 'input' && ((el.getAttribute('type') || '').toLowerCase() === 'password' || /(^|\s)(cc-|password|one-time-code)/i.test(el.getAttribute('autocomplete') || '')); + const contentText = (el) => norm(el.innerText != null ? el.innerText : el.textContent); + const nameOf = (el, role) => { + const ids = el.getAttribute('aria-labelledby'); + if (ids) { const t = norm(ids.split(/\s+/).map((id) => { const n = document.getElementById(id); return n ? (n.innerText != null ? n.innerText : n.textContent) : ''; }).join(' ')); if (t) return t.slice(0, 300); } + const aria = el.getAttribute('aria-label'); if (aria && norm(aria)) return norm(aria).slice(0, 300); + if (el.labels && el.labels.length) { const t = norm(Array.from(el.labels).map((l) => l.innerText != null ? l.innerText : l.textContent).join(' ')); if (t) return t.slice(0, 300); } + const tag = tagOf(el); + if (tag === 'img' || tag === 'area') { const alt = el.getAttribute('alt'); if (alt != null && norm(alt)) return norm(alt).slice(0, 300); } + if (tag === 'input') { const t = (el.getAttribute('type') || 'text').toLowerCase(); if (t === 'button' || t === 'submit' || t === 'reset') { const v = el.getAttribute('value'); if (v && norm(v)) return norm(v).slice(0, 300); if (t === 'submit') return 'Submit'; if (t === 'reset') return 'Reset'; } } + const ph = el.getAttribute('placeholder'); if (ph && norm(ph)) return norm(ph).slice(0, 300); + const title = el.getAttribute('title'); if (title && norm(title)) return norm(title).slice(0, 300); + if (NAMED_FROM_CONTENT.has(roleKey(role)) || tag === 'summary') return contentText(el).slice(0, 300); + return ''; + }; + const liveValue = (el) => { const tag = tagOf(el); if (tag === 'input' || tag === 'textarea') return String(el.value); if (tag === 'select') { const o = el.options[el.selectedIndex]; return o ? norm(o.text) : ''; } return null; }; + const attrsOf = (el) => { const out = []; for (const a of ATTRS) { if (a === 'value') { if (isProtected(el)) continue; const v = liveValue(el); if (v !== null) { if (norm(v)) out.push({ name: a, value: norm(v).slice(0, 200) }); continue; } } const v = el.getAttribute(a); if (v != null && norm(v)) out.push({ name: a, value: norm(v).slice(0, 200) }); } return out; }; + const attrValue = (el, name) => { if (name === 'value') { if (isProtected(el)) return null; const v = liveValue(el); if (v !== null) return v; } const v = el.getAttribute(name); return v == null ? null : v; }; + const urlOf = (el) => { const tag = tagOf(el); if (tag === 'a' || tag === 'area') return el.href || undefined; if (tag === 'img') return el.currentSrc || el.src || undefined; return undefined; }; + const visibleRect = (el) => { if (SKIP.has(String(el.tagName).toUpperCase())) return null; const style = window.getComputedStyle(el); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return null; const r = el.getBoundingClientRect(); if (!(r.width > 0 && r.height > 0)) return null; return r; }; + const describe = (el, r) => { + const role = roleOf(el); const name = nameOf(el, role); + const out = { role, name, text: contentText(el).slice(0, 2000), signature: 'js-' + fnv(tagOf(el) + '|' + role + '|' + name + '|' + pathOf(el)), attributes: attrsOf(el), bounds: { x: r.left + sx, y: r.top + sy, width: r.width, height: r.height } }; + if (!isProtected(el)) { const v = liveValue(el); if (v !== null) out.value = v; } + const u = urlOf(el); if (u) out.url = u; + return out; + }; + const clampCenter = (r) => { const left = Math.max(r.left, 0), top = Math.max(r.top, 0), right = Math.min(r.right, vw), bottom = Math.min(r.bottom, vh); if (right <= left || bottom <= top) return { x: r.left + r.width / 2, y: r.top + r.height / 2, width: r.width, height: r.height, visible: false }; return { x: left + (right - left) / 2, y: top + (bottom - top) / 2, width: right - left, height: bottom - top, visible: true }; }; + const all = document.querySelectorAll('*'); + + if (opts.mode === 'resolve') { + const L = opts.locator || {}; + const roleWanted = L.role != null ? roleKey(L.role) : null; + const attrs = Array.isArray(L.attributes) ? L.attributes : []; + const matches = []; + for (const el of all) { + const r = visibleRect(el); if (!r) continue; + const role = roleOf(el); + if (roleWanted !== null && roleKey(role) !== roleWanted) continue; + if (L.name != null || L.nameContains != null) { + const name = nameOf(el, role); + if (L.name != null && name !== norm(L.name)) continue; + if (L.nameContains != null && !name.includes(norm(L.nameContains))) continue; + } + if (L.text != null || L.textContains != null) { + const text = contentText(el); + if (L.text != null && text !== norm(L.text)) continue; + if (L.textContains != null && !text.includes(norm(L.textContains))) continue; + } + let ok = true; + for (const a of attrs) { if (!a || typeof a.name !== 'string' || attrValue(el, a.name) !== String(a.value)) { ok = false; break; } } + if (!ok) continue; + matches.push({ el, r }); + } + // A wrapper matches a text locator only because its child does; the + // innermost of nested matches is the element the caller meant. + const kept = matches.filter((m) => !matches.some((o) => o !== m && m.el !== o.el && m.el.contains(o.el))); + const limit = Math.max(1, Math.min(100, Number(opts.candidateLimit) || 10)); + const result = { matchCount: kept.length, candidates: kept.slice(0, limit).map((m) => describe(m.el, m.r)) }; + if (kept.length === 1) { + const el = kept[0].el; + if (opts.act === 'scroll' || opts.act === 'focus') { + try { el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' }); } catch (e) { el.scrollIntoView(); } + } + if (opts.act === 'focus') { + el.focus(); + const editable = !!el.isContentEditable; const tag = tagOf(el); + if (opts.clearFirst) { + if (editable) { el.textContent = ''; } else if (tag === 'input' || tag === 'textarea') { el.value = ''; } + el.dispatchEvent(new Event('input', { bubbles: true })); + } else if (editable) { + const sel = window.getSelection(); if (sel) { sel.selectAllChildren(el); sel.collapseToEnd(); } + } else if (typeof el.setSelectionRange === 'function') { + try { const n = String(el.value || '').length; el.setSelectionRange(n, n); } catch (e) {} + } + } + result.match = describe(el, el.getBoundingClientRect()); + result.center = clampCenter(el.getBoundingClientRect()); + } + return JSON.stringify(result); + } + + const maxNodes = Number(opts.maxNodes) > 0 ? Number(opts.maxNodes) : 100000; + const maxBytes = Number(opts.maxBytes) > 0 ? Number(opts.maxBytes) : 1048576; + const includeText = opts.includeText !== false; + const ids = new Map(); + const scrollables = new Set(); + const nodes = []; + let total = 0; + let truncated = false; + for (const el of all) { + const r = visibleRect(el); if (!r) continue; + total++; + const role = roleOf(el); + if (role === 'none' || role === 'presentation') continue; + const own = norm(Array.from(el.childNodes).filter((n) => n.nodeType === 3).map((n) => n.nodeValue).join(' ')); + if (role === 'genericContainer' && !own) continue; + const inViewport = r.right > 0 && r.bottom > 0 && r.left < vw && r.top < vh; + if (opts.viewportOnly && !inViewport) continue; + if (nodes.length >= maxNodes) { truncated = true; break; } + const id = 'n' + nodes.length; + ids.set(el, id); + const node = { id, frameId: 'f0', role, x: r.left + sx, y: r.top + sy, width: r.width, height: r.height, inViewport, visible: true, focused: document.activeElement === el, disabled: !!el.disabled || el.getAttribute('aria-disabled') === 'true' }; + for (let p = el.parentElement; p; p = p.parentElement) { const pid = ids.get(p); if (pid) { node.parentId = pid; if (scrollables.has(pid)) node.scrollContainerId = pid; break; } } + if (!node.scrollContainerId) { for (let p = el.parentElement; p; p = p.parentElement) { const pid = ids.get(p); if (pid && scrollables.has(pid)) { node.scrollContainerId = pid; break; } } } + const name = nameOf(el, role); if (name) node.name = name; + if (own && includeText) node.text = own.slice(0, 2000); + if (!isProtected(el)) { const v = liveValue(el); if (v !== null && v !== '') node.value = v.slice(0, 500); } + const tag = tagOf(el); + const rk = roleKey(role); + if (rk === 'checkbox' || rk === 'radiobutton' || rk === 'radio' || rk === 'switch' || rk === 'menuitemcheckbox' || rk === 'menuitemradio') { const ac = el.getAttribute('aria-checked'); node.checked = ac != null ? ac : (el.indeterminate ? 'mixed' : (el.checked ? 'true' : 'false')); } + const ae = el.getAttribute('aria-expanded'); + if (ae != null) node.expanded = ae === 'true'; else if (tag === 'summary' && el.parentElement && tagOf(el.parentElement) === 'details') node.expanded = !!el.parentElement.open; + const style = window.getComputedStyle(el); const oy = style.overflowY, ox = style.overflowX; + if (((oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight + 1) || ((ox === 'auto' || ox === 'scroll') && el.scrollWidth > el.clientWidth + 1)) { node.scrollable = true; scrollables.add(id); } + nodes.push(node); + } + let text = includeText ? norm(document.body ? document.body.innerText : '') : ''; + let bytes = JSON.stringify(nodes).length + text.length; + if (bytes > maxBytes) { + truncated = true; + if (text.length > maxBytes / 2) text = text.slice(0, Math.floor(maxBytes / 2)); + while (nodes.length && JSON.stringify(nodes).length + text.length > maxBytes) nodes.splice(Math.floor(nodes.length * 0.9)); + bytes = JSON.stringify(nodes).length + text.length; + } + const frames = [{ frameId: 'f0', url: location.href, crossOrigin: false }]; + let framesFailed = 0; + document.querySelectorAll('iframe, frame').forEach((f, i) => { let cross = true; try { cross = !f.contentDocument; } catch (e) { cross = true; } frames.push({ frameId: 'f' + (i + 1), url: f.src || '', crossOrigin: cross, parentFrameId: 'f0' }); framesFailed++; }); + return JSON.stringify({ nodes, frames, text, truncated, stats: { totalNodes: total, returnedNodes: nodes.length, bytes, elapsedMs: Math.round(now() - started), framesVisited: 1, framesFailed } }); +})()"#; + +/// The fallback script with its options substituted. +fn fallback_script(opts: &serde_json::Value) -> String { + AGENT_FALLBACK_JS.replace("__OPTS__", &opts.to_string()) +} + +/// What the fallback perception script answers with, before it is wrapped. +#[derive(Debug, Deserialize)] +struct FallbackPerception { + nodes: Vec, + frames: Vec, + text: String, + truncated: bool, + stats: PerceptionStats, +} + +/// Where the fallback resolver left the one match, in viewport pixels. +#[derive(Debug, Clone, Copy, Deserialize)] +struct FallbackCenter { + x: f64, + y: f64, + width: f64, + height: f64, + visible: bool, +} + +/// What the fallback resolver answers with. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FallbackResolution { + match_count: u64, + #[serde(default)] + candidates: Vec, + #[serde(rename = "match")] + matched: Option, + center: Option, +} + +/// What to do to the one match, once the fallback resolver has found it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FallbackAct { + /// Describe it and nothing more. + Describe, + /// Scroll it into view, for a click. + Scroll, + /// Scroll, focus and prepare the field, for typing. + Focus { clear_first: bool }, +} + +/// Resolve `locator` in the page with the fallback script. +/// +/// Refuses ambiguity and absence in the same structured form the native +/// resolver does, so a caller sees one contract whichever engine answered. +async fn fallback_resolve( + target: &CdpTarget, + locator: &LocatorDescription, + candidate_limit: Option, + act: FallbackAct, +) -> Result<(LocatorCandidate, Option), AgentError> { + let (act_name, clear_first) = match act { + FallbackAct::Describe => ("none", false), + FallbackAct::Scroll => ("scroll", false), + FallbackAct::Focus { clear_first } => ("focus", clear_first), + }; + let opts = serde_json::json!({ + "mode": "resolve", + "locator": locator, + "candidateLimit": candidate_limit.unwrap_or(10).clamp(1, 100), + "act": act_name, + "clearFirst": clear_first, + }); + let answer = evaluate_json_script(target, fallback_script(&opts)).await?; + let resolution: FallbackResolution = serde_json::from_value(answer) + .map_err(|e| AgentError::Malformed(format!("fallback resolver: {e}")))?; + match resolution.match_count { + 0 => Err(AgentError::NoMatch { + message: format!("No node matches locator ({}).", describe_locator(locator)), + }), + 1 => { + let matched = resolution + .matched + .ok_or_else(|| AgentError::Malformed("fallback resolver answered one match without it".into()))?; + Ok((matched, resolution.center)) + } + count => Err(AgentError::AmbiguousLocator { + match_count: count, + message: format!( + "Ambiguous locator: {count} nodes match. Refine it with a role, a stable attribute, or more exact text. Candidates: {}", + serde_json::Value::Array(resolution.candidates.clone()) + ), + candidates: resolution.candidates, + }), + } +} + +/// A point the fallback resolver left an element at, as somewhere to strike. +fn fallback_target(center: Option) -> Result { + let center = center + .ok_or_else(|| AgentError::Malformed("the fallback resolver answered no position".into()))?; + if !center.visible { + return Err(AgentError::BadRequest( + "the element could not be scrolled into view; it may be hidden or clipped".to_string(), + )); + } + Ok(ViewportTarget { + x: center.x, + y: center.y, + width: center.width, + height: center.height, + }) +} + +/// Keep a strike point inside the layout viewport. +fn clamp_to_viewport(point: ViewportTarget, viewport: (f64, f64)) -> ViewportTarget { + let (width, height) = viewport; + if width <= 0.0 || height <= 0.0 { + return point; + } + ViewportTarget { + x: point.x.clamp(1.0, (width - 1.0).max(1.0)), + y: point.y.clamp(1.0, (height - 1.0).max(1.0)), + ..point + } +} + +/// Glide to `point` and strike it, on an open session with `Page.enable` on, +/// waiting for a load afterwards. +async fn vellum_click_in( + session: &mut WayfernSession, + point: ViewportTarget, + button: Option<&str>, + click_count: Option, +) -> Result<(vellum::Strike, bool), AgentError> { + let viewport = wayfern_cdp::layout_viewport(session).await?; + let point = clamp_to_viewport(point, viewport); + let origin = wayfern_cdp::glide_origin(&point, viewport); + let width = Some(point.width.min(point.height)); + // Owned by the gesture: the boxed future may only borrow what the gesture + // itself holds, never the caller's frame. + let button = button.map(str::to_owned); + let outcome = vellum::with_pointer(session, origin.0, origin.1, |s, p| { + Box::pin(async move { + vellum::glide(s, p, point.x, point.y, width).await?; + vellum::strike_awaiting_load(s, p, button.as_deref(), click_count, CLICK_LOAD_TIMEOUT).await + }) + }) + .await?; + Ok(outcome) +} + +/// Strike `point` with a humanized pointer on a fresh session. +async fn vellum_click( + target: &CdpTarget, + point: ViewportTarget, + button: Option<&str>, + click_count: Option, +) -> Result<(vellum::Strike, bool), AgentError> { + let mut session = WayfernSession::open(target).await?; + let outcome = async { + session.call("Page.enable", serde_json::json!({})).await?; + vellum_click_in(&mut session, point, button, click_count).await + } + .await; + let _ = session.call("Page.disable", serde_json::json!({})).await; + session.close().await; + outcome +} + +/// What to do to a field between the strike that focuses it and the typing. +enum FieldPreparation { + /// Nothing: the field was already prepared by the caller's script. + None, + /// Run this script, which must return `true`. + Script(String), + /// Clear, or move the caret to the end of, the node behind this id. + Node { backend_node_id: i64, clear: bool }, +} + +/// Script run on a resolved node to empty it, or park the caret at its end. +/// +/// A strike puts the caret where the click landed; without this a text typed +/// into a field that already holds one would land in the middle of it. +const PREPARE_FIELD_FN: &str = r#"function(clear) { + const el = this; + const editable = !!el.isContentEditable; + const tag = String(el.tagName || '').toLowerCase(); + if (clear) { + if (editable) { el.textContent = ''; } else if (tag === 'input' || tag === 'textarea') { el.value = ''; } + el.dispatchEvent(new Event('input', { bubbles: true })); + } else if (editable) { + const sel = window.getSelection(); if (sel) { sel.selectAllChildren(el); sel.collapseToEnd(); } + } else if (typeof el.setSelectionRange === 'function') { + try { const n = String(el.value || '').length; el.setSelectionRange(n, n); } catch (e) {} + } + return true; +}"#; + +/// Script run on the focused element to park the caret at its end. +const CARET_TO_END_JS: &str = r#"(() => { + const el = document.activeElement; + if (!el) return true; + if (el.isContentEditable) { const sel = window.getSelection(); if (sel) { sel.selectAllChildren(el); sel.collapseToEnd(); } } + else if (typeof el.setSelectionRange === 'function') { try { const n = String(el.value || '').length; el.setSelectionRange(n, n); } catch (e) {} } + return true; +})()"#; + +async fn prepare_field( + session: &mut WayfernSession, + preparation: &FieldPreparation, +) -> Result<(), AgentError> { + match preparation { + FieldPreparation::None => Ok(()), + FieldPreparation::Script(script) => { + let result = session + .call( + "Runtime.evaluate", + serde_json::json!({ "expression": script, "returnByValue": true }), + ) + .await?; + if let Some(exception) = result.get("exceptionDetails") { + let message = exception + .get("exception") + .and_then(|e| e.get("description")) + .or_else(|| exception.get("text")) + .and_then(|v| v.as_str()) + .unwrap_or("preparing the field failed"); + return Err(AgentError::BadRequest(message.to_string())); + } + Ok(()) + } + FieldPreparation::Node { + backend_node_id, + clear, + } => { + let resolved = session + .call( + "DOM.resolveNode", + serde_json::json!({ "backendNodeId": backend_node_id }), + ) + .await?; + let object_id = resolved + .get("object") + .and_then(|o| o.get("objectId")) + .and_then(|v| v.as_str()) + .ok_or_else(|| AgentError::Malformed("DOM.resolveNode answered no object".into()))?; + session + .call( + "Runtime.callFunctionOn", + serde_json::json!({ + "objectId": object_id, + "functionDeclaration": PREPARE_FIELD_FN, + "arguments": [{ "value": clear }], + "returnByValue": true, + }), + ) + .await?; + Ok(()) + } + } +} + +/// Focus `point` with a strike, prepare the field, and type `text`. +async fn vellum_type_in( + session: &mut WayfernSession, + point: ViewportTarget, + text: &str, + typos: bool, + preparation: FieldPreparation, + timeout: Duration, +) -> Result { + let viewport = wayfern_cdp::layout_viewport(session).await?; + let point = clamp_to_viewport(point, viewport); + let origin = wayfern_cdp::glide_origin(&point, viewport); + let width = Some(point.width.min(point.height)); + // Owned by the gesture, for the reason `vellum_click_in` gives. + let text = text.to_owned(); + let inscription = vellum::with_pointer(session, origin.0, origin.1, |s, p| { + Box::pin(async move { + vellum::glide(s, p, point.x, point.y, width).await?; + vellum::strike(s, p, None, None).await?; + prepare_field(s, &preparation).await?; + Ok::<_, AgentError>(vellum::inscribe(s, p, &text, typos, timeout).await?) + }) + }) + .await?; + Ok(inscription) +} + +/// Type `text` at `point` on a fresh session. +async fn vellum_type( + target: &CdpTarget, + point: ViewportTarget, + text: &str, + typos: bool, + preparation: FieldPreparation, + timeout: Duration, +) -> Result { + let mut session = WayfernSession::open(target).await?; + let outcome = vellum_type_in(&mut session, point, text, typos, preparation, timeout).await; + session.close().await; + outcome +} + +/// Deliver an accepted keystroke plan through `Input.dispatchKeyEvent`. +/// +/// The transport-level half of the pre-152 typing path, shared by the MCP +/// handlers and the REST agent endpoints. +pub(crate) async fn dispatch_keystrokes( + target: &CdpTarget, + events: &[crate::human_typing::TypingEvent], +) -> Result<(), CdpError> { + use crate::human_typing::TypingAction; + + let mut connection = target.connect().await?; + + let mut cmd_id = 1u64; + let mut last_time = 0.0; + + for event in events { + let delay = event.time - last_time; + if delay > 0.0 { + tokio::time::sleep(Duration::from_secs_f64(delay)).await; + } + last_time = event.time; + + let (down, up) = match &event.action { + TypingAction::Char(ch) => { + let ch = ch.to_string(); + ( + serde_json::json!({ + "type": "keyDown", + "text": ch, + "key": ch, + "unmodifiedText": ch, + }), + serde_json::json!({ "type": "keyUp", "key": ch }), + ) + } + TypingAction::Backspace => ( + serde_json::json!({ + "type": "keyDown", + "key": "Backspace", + "code": "Backspace", + "windowsVirtualKeyCode": 8, + "nativeVirtualKeyCode": 8, + }), + serde_json::json!({ + "type": "keyUp", + "key": "Backspace", + "code": "Backspace", + "windowsVirtualKeyCode": 8, + "nativeVirtualKeyCode": 8, + }), + ), + }; + + for params in [down, up] { + connection + .send_command(cmd_id, "Input.dispatchKeyEvent", params) + .await?; + // Drained rather than matched: the point is to keep reading so the + // browser is never writing into a full socket while the next keystroke + // is being timed. Bounded, because a reply that never comes must not + // freeze typing forever — the keystroke itself was already delivered. + let _ = tokio::time::timeout(KEYSTROKE_ACK_TIMEOUT, connection.next_text()).await; + cmd_id += 1; + } + } + + connection.close().await; + Ok(()) +} + +/// Read the page as the agent sees it. +pub(crate) async fn agent_perceive( + ctx: &AgentContext, + request: &PerceptionRequest, +) -> Result { + if let Some(order) = request.text_order.as_deref() { + if order != "reading" && order != "visual" { + return Err(AgentError::InvalidArgument( + "text_order must be \"reading\" or \"visual\"".to_string(), + )); + } + } + if ctx.engine.is_wayfern() { + let mut session = WayfernSession::open(&ctx.target).await?; + let page = wayfern_cdp::capture_page_perception(&mut session, request).await; + session.close().await; + return Ok(page?); + } + + if request.cursor.as_deref().is_some_and(|c| !c.is_empty()) { + return Err(AgentError::InvalidArgument( + "the fallback engine answers in one page and has no cursors to continue".to_string(), + )); + } + let opts = serde_json::json!({ + "mode": "perceive", + "maxNodes": request.max_nodes.unwrap_or(100_000), + "maxBytes": request.byte_cap(), + "includeText": request.include_text.unwrap_or(true), + "viewportOnly": request.viewport_only.unwrap_or(false), + }); + let answer = evaluate_json_script(&ctx.target, fallback_script(&opts)).await?; + let perception: FallbackPerception = serde_json::from_value(answer) + .map_err(|e| AgentError::Malformed(format!("fallback perception: {e}")))?; + let snapshot_id = format!( + "fallback-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) + ); + Ok(PerceptionPage { + snapshot_id, + nodes: perception.nodes, + frames: perception.frames, + text: perception.text, + truncated: perception.truncated, + stats: perception.stats, + cursor: None, + engine: Engine::Fallback, + }) +} + +/// Resolve a locator to exactly one node. +pub(crate) async fn agent_resolve_locator( + ctx: &AgentContext, + request: &AgentResolveRequest, +) -> Result { + validate_locator(&request.locator)?; + let locator = canonical_locator(&request.locator); + if ctx.engine.is_wayfern() { + let mut session = WayfernSession::open(&ctx.target).await?; + let resolved = wayfern_cdp::resolve_locator( + &mut session, + &locator, + ResolveOptions { + candidate_limit: request.candidate_limit, + ..Default::default() + }, + ) + .await; + session.close().await; + return Ok(resolved?); + } + + let (matched, _) = fallback_resolve( + &ctx.target, + &locator, + request.candidate_limit, + FallbackAct::Describe, + ) + .await?; + Ok(LocatorResolution { + backend_node_id: None, + match_count: 1, + matched, + locator, + engine: Engine::Fallback, + }) +} + +/// Resolve a locator and click it. +pub(crate) async fn agent_click_locator( + ctx: &AgentContext, + request: &AgentClickRequest, +) -> Result { + validate_click(request)?; + let locator = canonical_locator(&request.locator); + let button = request.button.as_deref(); + + if ctx.engine.is_wayfern() { + let mut session = WayfernSession::open(&ctx.target).await?; + let outcome = async { + session.call("Page.enable", serde_json::json!({})).await?; + let resolved = + wayfern_cdp::resolve_locator(&mut session, &locator, ResolveOptions::default()).await?; + let backend_node_id = resolved + .backend_node_id + .ok_or_else(|| AgentError::Malformed("resolveLocator answered no backendNodeId".into()))?; + let point = wayfern_cdp::viewport_target(&mut session, backend_node_id).await?; + let (_, navigated) = + vellum_click_in(&mut session, point, button, request.click_count).await?; + Ok::<_, AgentError>((resolved, navigated)) + } + .await; + let _ = session.call("Page.disable", serde_json::json!({})).await; + session.close().await; + let (resolved, navigated) = outcome?; + return Ok(AgentClick { + clicked: true, + matched: resolved.matched, + engine: Engine::Wayfern, + navigated, + }); + } + + let (matched, center) = + fallback_resolve(&ctx.target, &locator, None, FallbackAct::Scroll).await?; + let point = fallback_target(center)?; + let mut session = WayfernSession::open(&ctx.target).await?; + let outcome = async { + session.call("Page.enable", serde_json::json!({})).await?; + session + .call( + "Input.dispatchMouseEvent", + serde_json::json!({ "type": "mouseMoved", "x": point.x, "y": point.y }), + ) + .await?; + let press = serde_json::json!({ + "type": "mousePressed", + "x": point.x, + "y": point.y, + "button": button.unwrap_or("left"), + "clickCount": request.click_count.unwrap_or(1), + }); + session.call("Input.dispatchMouseEvent", press).await?; + let release = serde_json::json!({ + "type": "mouseReleased", + "x": point.x, + "y": point.y, + "button": button.unwrap_or("left"), + "clickCount": request.click_count.unwrap_or(1), + }); + let (_, navigated) = session + .call_then_await_event( + "Input.dispatchMouseEvent", + release, + "Page.loadEventFired", + CLICK_LOAD_TIMEOUT, + ) + .await?; + Ok::<_, AgentError>(navigated) + } + .await; + let _ = session.call("Page.disable", serde_json::json!({})).await; + session.close().await; + Ok(AgentClick { + clicked: true, + matched, + engine: Engine::Fallback, + navigated: outcome?, + }) +} + +/// Resolve a locator and type into it. +/// +/// `max_seconds` is the caller's typing budget; the refusal for a text that +/// would outlive it comes before anything touches the page. +pub(crate) async fn agent_type_locator( + ctx: &AgentContext, + request: &AgentTypeRequest, + max_seconds: f64, +) -> Result { + validate_locator(&request.locator)?; + if request.text.is_empty() { + return Err(AgentError::InvalidArgument( + "text must not be empty".to_string(), + )); + } + let clear_first = request.clear_first.unwrap_or(true); + let typos = request.typos.unwrap_or(true); + let locator = canonical_locator(&request.locator); + + if ctx.engine.is_wayfern() { + let timeout = vellum_typing_budget(&request.text, max_seconds)?; + let mut session = WayfernSession::open(&ctx.target).await?; + let outcome = async { + let resolved = + wayfern_cdp::resolve_locator(&mut session, &locator, ResolveOptions::default()).await?; + let backend_node_id = resolved + .backend_node_id + .ok_or_else(|| AgentError::Malformed("resolveLocator answered no backendNodeId".into()))?; + let point = wayfern_cdp::viewport_target(&mut session, backend_node_id).await?; + let inscription = vellum_type_in( + &mut session, + point, + &request.text, + typos, + FieldPreparation::Node { + backend_node_id, + clear: clear_first, + }, + timeout, + ) + .await?; + Ok::<_, AgentError>((resolved, inscription)) + } + .await; + session.close().await; + let (resolved, inscription) = outcome?; + return Ok(AgentTyping { + typed: true, + characters: inscription.characters, + corrections: Some(inscription.corrections), + duration_ms: inscription.duration_ms, + engine: Engine::Wayfern, + matched: resolved.matched, + }); + } + + // Planned first, before the field is touched, for the reason the selector + // tools plan first: a refusal after the clear is a lie. + let plan = plan_typing(&request.text, request.wpm, max_seconds)?; + let (matched, _) = fallback_resolve( + &ctx.target, + &locator, + None, + FallbackAct::Focus { clear_first }, + ) + .await?; + dispatch_keystrokes(&ctx.target, &plan).await?; + Ok(AgentTyping { + typed: true, + characters: request.text.chars().count() as u64, + corrections: None, + duration_ms: plan.last().map_or(0.0, |event| event.time * 1000.0), + engine: Engine::Fallback, + matched, + }) +} + +/// Read rows off the page. Wayfern 152 only. +pub(crate) async fn agent_extract( + ctx: &AgentContext, + request: &ExtractionRequest, +) -> Result { + validate_extraction(request)?; + ctx.requires_wayfern_152()?; + let mut request = request.clone(); + request.container = canonical_locator(&request.container); + for field in &mut request.field_map { + field.locator = canonical_locator(&field.locator); + } + request.next_page = request.next_page.as_ref().map(canonical_locator); + let mut session = WayfernSession::open(&ctx.target).await?; + let extraction = wayfern_cdp::extract_structured(&mut session, &request).await; + session.close().await; + Ok(extraction?) +} + +/// Arm the picker and wait for the user's click. Wayfern 152 only. +pub(crate) async fn agent_pick_element( + ctx: &AgentContext, + timeout_ms: u64, +) -> Result { + ctx.requires_wayfern_152()?; + let mut session = WayfernSession::open(&ctx.target).await?; + let picked = + wayfern_cdp::pick_element(&mut session, Duration::from_millis(timeout_ms), true).await; + session.close().await; + Ok(picked?) +} + +/// The JSON schema of a locator argument, shared by every tool that takes one. +fn locator_schema(description: &str) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "description": description, + "properties": { + "role": { "type": "string", "description": "AX role token as the browser reports it (button, link, textField, heading, listItem, checkBox, comboBoxSelect, staticText); matched case- and separator-insensitively, and the ARIA names textbox, radio, img, progressbar, separator, generic and text are accepted as synonyms" }, + "name": { "type": "string", "description": "Exact accessible name, after whitespace collapse" }, + "nameContains": { "type": "string", "description": "Substring of the accessible name" }, + "text": { "type": "string", "description": "Exact visible text, from the live layout" }, + "textContains": { "type": "string", "description": "Substring of the visible text" }, + "attributes": { + "type": "array", + "description": "Attribute pairs that must all match", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "value": { "type": "string" } + }, + "required": ["name", "value"] + } + } + } + }) +} + +/// Where a profile's browser is, as the browser tools report it. +async fn resolve_target(profile: &BrowserProfile) -> Result { + crate::cdp_target::resolve(profile) + .await + .map_err(|e| McpError { + code: -32000, + message: e.to_string(), + data: None, + }) +} + +/// The tail of a page script that answers where `el` is, in viewport pixels. +const RETURN_RECT_JS: &str = "const r = el.getBoundingClientRect(); return JSON.stringify({x: r.left + r.width / 2, y: r.top + r.height / 2, width: r.width, height: r.height});"; + +/// A script that scrolls the element behind `selector_escaped` into view and +/// answers where it is. It clicks nothing. +fn element_rect_script(selector_escaped: &str) -> String { + format!( + r#"(() => {{ + const el = document.querySelector('{selector_escaped}'); + if (!el) throw new Error('Element not found: {selector_escaped}'); + el.scrollIntoView({{block: 'center', inline: 'center', behavior: 'instant'}}); + {RETURN_RECT_JS} + }})()"# + ) +} + +/// The same for the element at `index` of a caller's cached snapshot. +fn indexed_rect_script(cache: &str, index: u64) -> String { + format!( + r#"(() => {{ + const arr = window[{cache}]; + if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.'); + const el = arr[{index}]; + el.scrollIntoView({{block: 'center', inline: 'center', behavior: 'instant'}}); + {RETURN_RECT_JS} + }})()"# + ) +} + +/// The strike point a rect script answered with. +fn rect_from_script_result(result: &serde_json::Value) -> Result { + let rect = parse_json_script_result(result).map_err(AgentError::into_mcp)?; + let number = |key: &str| rect.get(key).and_then(|v| v.as_f64()).unwrap_or(0.0); + let point = ViewportTarget { + x: number("x"), + y: number("y"), + width: number("width"), + height: number("height"), + }; + if !(point.width > 0.0 && point.height > 0.0) { + return Err(McpError { + code: -32000, + message: "The element has no visible box to click; it may be hidden or collapsed".to_string(), + data: None, + }); + } + Ok(point) +} + +/// Run a rect script and answer the strike point it found. +async fn locate_by_script(target: &CdpTarget, script: String) -> Result { + let result = crate::cdp_target::run_command( + target, + "Runtime.evaluate", + serde_json::json!({ "expression": script, "returnByValue": true }), + ) + .await + .map_err(cdp_error)?; + rect_from_script_result(&result) +} + +/// What the selector and index typing tools do to the caret after the strike +/// that focuses the field. +/// +/// A cleared field has nowhere else to put it. One that keeps its text needs +/// the caret at the end, or the new text lands wherever the click did. +fn caret_preparation(clear_first: bool) -> FieldPreparation { + if clear_first { + FieldPreparation::None + } else { + FieldPreparation::Script(CARET_TO_END_JS.to_string()) + } +} + +fn click_report(prefix: &str, navigated: bool) -> String { + if navigated { + format!("{prefix} (a page load followed)") + } else { + prefix.to_string() + } +} + +fn typing_report(prefix: &str, inscription: &vellum::Inscription) -> String { + format!( + "{prefix} ({} characters, {} corrected, {:.0} ms)", + inscription.characters, inscription.corrections, inscription.duration_ms + ) +} + impl McpServer { fn new() -> Self { Self { @@ -161,6 +2131,7 @@ impl McpServer { sessions: HashMap::new(), })), is_running: AtomicBool::new(false), + engine_ready: AtomicBool::new(false), port: AtomicU16::new(0), } } @@ -173,6 +2144,45 @@ impl McpServer { self.is_running.load(Ordering::SeqCst) } + /// Hand the engine the app handle every tool needs, once, at startup. + /// + /// Called unconditionally rather than from `start`, because the bridge can be + /// the only transport in play and it must not have to boot a loopback + /// listener it does not use to get one. + pub async fn attach_app_handle(&self, app_handle: AppHandle) { + let mut inner = self.inner.lock().await; + if inner.app_handle.is_none() { + inner.app_handle = Some(app_handle); + } + self.engine_ready.store(true, Ordering::SeqCst); + } + + /// Whether the tool engine can answer a JSON-RPC message. + pub fn is_engine_ready(&self) -> bool { + self.engine_ready.load(Ordering::SeqCst) + } + + /// Let a test drive the engine without a Tauri `AppHandle`. + /// + /// Exists so the bridge's transport can be exercised end to end, a real + /// socket carrying a real `tools/list`, instead of only against a mock of + /// the thing under test. `attach_app_handle` needs an `AppHandle` no unit + /// test has, and the tools this unlocks (`ping`, `tools/list`) read no app + /// state; every tool that DOES need the handle still refuses without one, so + /// this cannot make a test pass that production would fail. + #[cfg(test)] + pub(crate) fn mark_engine_ready_for_tests(&self) { + self.engine_ready.store(true, Ordering::SeqCst); + } + + /// Let a test reach `stop()`, which early-returns unless the listener is up. + /// Only the flag is set: no socket is bound, so nothing here can make a test + /// pass that production would fail. + #[cfg(test)] + pub(crate) fn mark_running_for_tests(&self) { + self.is_running.store(true, Ordering::SeqCst); + } + /// Gate an MCP tool on a capability the caller already resolved (e.g. /// `CLOUD_AUTH.can_use_browser_automation().await`). Logs the rejected gate /// with enough state for support to diagnose, without leaking secrets. @@ -189,6 +2199,7 @@ impl McpServer { return Err(McpError { code: -32000, message: format!("{feature} requires a plan that includes this feature"), + data: None, }); } Ok(()) @@ -224,13 +2235,15 @@ impl McpServer { .ok() .flatten(); - let token = if let Some(t) = existing_token { - t - } else { - settings_manager - .generate_mcp_token(&app_handle) - .await - .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))? + let (token, _token_is_new) = match existing_token { + Some(t) => (t, false), + None => ( + settings_manager + .generate_mcp_token(&app_handle) + .await + .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?, + true, + ), }; // Determine port (use saved port, or try default, or random) @@ -242,7 +2255,8 @@ impl McpServer { .port(); // Save port if it changed - if settings.mcp_port != Some(actual_port) { + let port_changed = settings.mcp_port != Some(actual_port); + if port_changed { let mut new_settings = settings; new_settings.mcp_port = Some(actual_port); settings_manager @@ -250,6 +2264,8 @@ impl McpServer { .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?; } + let installer_handle = app_handle.clone(); + // Store state let mut inner = self.inner.lock().await; inner.app_handle = Some(app_handle); @@ -261,6 +2277,7 @@ impl McpServer { self.port.store(actual_port, Ordering::SeqCst); self.is_running.store(true, Ordering::SeqCst); + self.engine_ready.store(true, Ordering::SeqCst); // Start HTTP server in background let http_state = McpHttpState { @@ -268,8 +2285,15 @@ impl McpServer { token, }; tokio::spawn(Self::run_http_server(listener, http_state, shutdown_rx)); + drop(inner); log::info!("[mcp] Server started on port {}", actual_port); + + // Local MCP is removed: the listener above is a tombstone, so there is + // nothing to (re)install into a client here. Migrating clients that still + // point at the old local endpoint onto remote MCP is done once at startup + // (see `crate::migrate_local_mcp_clients`), not on every bind. + let _ = installer_handle; Ok(actual_port) } @@ -290,47 +2314,89 @@ impl McpServer { Err(crate::backend_error("MCP_PORT_UNAVAILABLE")) } + /// Serve the loopback port as a TOMBSTONE. + /// + /// Local MCP has been removed in favour of remote MCP, which a user can reach + /// from anywhere. The old port is still bound for legacy installs so a client + /// that still points at it gets a clear, actionable answer — a 410 with a + /// message, and a desktop dialog — instead of a silent connection refusal. + /// Nothing here touches the tool engine; the engine now serves the remote + /// bridge only (see [`crate::mcp_remote`]). + /// + /// TODO(local-mcp-removal): once enough releases have passed that no client + /// still points at the local port, delete this tombstone, the enable/install + /// paths that reach it, and the loopback engine handlers below entirely. async fn run_http_server( listener: TcpListener, - state: McpHttpState, + _state: McpHttpState, shutdown_rx: tokio::sync::oneshot::Receiver<()>, ) { - let app = Router::new() - .route( - "/mcp/{token}", - post(Self::handle_mcp_post) - .get(Self::handle_mcp_get) - .delete(Self::handle_mcp_delete), - ) - .route( - "/mcp", - post(Self::handle_mcp_post) - .get(Self::handle_mcp_get) - .delete(Self::handle_mcp_delete), - ) + let app: Router = Router::new() .route("/health", get(Self::handle_health)) - .layer(middleware::from_fn_with_state( - state.clone(), - Self::auth_middleware, - )) - .with_state(state); + .fallback(Self::handle_local_deprecated); let port = listener.local_addr().map(|addr| addr.port()).unwrap_or(0); let server = async move { - log::info!("[mcp] Server listening on http://127.0.0.1:{}/mcp", port); + log::info!( + "[mcp] Local MCP is removed; the loopback tombstone is listening on http://127.0.0.1:{}/mcp", + port + ); if let Err(e) = axum::serve(listener, app).await { - log::error!("[mcp] Server error: {}", e); + log::error!("[mcp] Tombstone server error: {}", e); } }; tokio::select! { _ = server => {}, _ = shutdown_rx => { - log::info!("[mcp] Server shutting down"); + log::info!("[mcp] Tombstone server shutting down"); }, } } + /// Answer any request to the removed local server, and raise the dialog once. + async fn handle_local_deprecated() -> Response { + Self::note_local_mcp_attempt(); + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": serde_json::Value::Null, + "error": { + // -32001: the reserved server-error range. The message is written for a + // human reading their MCP client's error, not just a machine. + "code": -32001, + "message": "Donut's local MCP server has been removed. Connect Donut over remote MCP from Settings > Integrations, then reach it from anywhere.", + } + }); + (StatusCode::GONE, Json(body)).into_response() + } + + /// Record that something tried to use the removed local server, and emit the + /// dialog event at most once per throttle window. + /// + /// Public so the enable/install commands can raise the same dialog when the + /// user asks for local MCP from inside the app. + pub fn note_local_mcp_attempt() { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let last = LAST_LOCAL_DEPRECATION_EMIT.load(Ordering::Relaxed); + if now.saturating_sub(last) < LOCAL_DEPRECATION_THROTTLE_SECS { + return; + } + // Compare-and-set so concurrent hits emit once, not once each. + if LAST_LOCAL_DEPRECATION_EMIT + .compare_exchange(last, now, Ordering::SeqCst, Ordering::Relaxed) + .is_err() + { + return; + } + let _ = crate::events::emit_empty(LOCAL_MCP_DEPRECATED_EVENT); + } + + // TODO(local-mcp-removal): dead once the loopback served a tombstone; kept + // beside it so the whole local transport is deleted in one change. + #[allow(dead_code)] async fn auth_middleware( State(state): State, req: Request, @@ -381,11 +2447,239 @@ impl McpServer { })) } + // TODO(local-mcp-removal): dead once the loopback served a tombstone; kept + // beside it so the whole local transport is deleted in one change. + #[allow(dead_code)] async fn handle_mcp_get() -> impl IntoResponse { // We don't support server-initiated SSE streams StatusCode::METHOD_NOT_ALLOWED } + /// Largest JSON-RPC payload any transport will parse. + /// + /// Shared rather than per-transport on purpose: a body the loopback listener + /// refuses must not become one the bridge accepts. + pub(crate) const MAX_MESSAGE_BYTES: usize = 1024 * 1024; + + /// The longest a session teardown will spend deleting page globals. + /// + /// Best effort and time-boxed on purpose. `end_session` is awaited by the + /// HTTP DELETE handler, and a browser that has stopped answering can hold a + /// single CDP call open for a minute, so an unbounded cleanup turns "forget + /// my session" into a hang. Losing a delete costs nothing worth waiting for: + /// a browser that cannot answer has no page left to leak, and the page-side + /// slot cap reclaims anything a missed delete leaves behind. + const CACHE_RELEASE_BUDGET: std::time::Duration = std::time::Duration::from_secs(5); + + /// Forget a session, on the server AND in the pages it left snapshots in. + /// Idempotent; an unknown id is not an error, because a caller tearing down a + /// session it already lost has nothing to fix. + pub(crate) async fn end_session(&self, session_id: &str) { + let cached_pages = { + let mut inner = self.inner.lock().await; + match inner.sessions.remove(session_id) { + Some(session) => { + log::info!("[mcp] Session terminated: {session_id}"); + session.cached_pages + } + None => return, + } + }; + + if cached_pages.is_empty() { + return; + } + + // Dropping the server-side session is only half of ending it: the other + // half is an array of live element references sitting in somebody's still + // open tab, which nothing else ever removes. + if tokio::time::timeout( + Self::CACHE_RELEASE_BUDGET, + self.release_cached_pages(cached_pages), + ) + .await + .is_err() + { + log::debug!( + "[mcp] Session {session_id} ended before its element caches could be cleared; the \ + page-side slot cap will reclaim them" + ); + } + } + + /// Delete the interactive-element slots a session wrote, page by page. + /// + /// Every failure here is logged and skipped rather than surfaced: the session + /// is already gone, the caller asked to forget it, and a closed browser is + /// the commonest reason a delete cannot land, which is also the case where + /// there is nothing left to clean. + async fn release_cached_pages(&self, cached_pages: HashSet<(String, String)>) { + let registry = INTERACTIVE_SLOT_REGISTRY; + for (profile_id, slot) in cached_pages { + let target = match self.resolve_cdp_target(&profile_id).await { + Ok(target) => target, + Err(e) => { + log::debug!( + "[mcp] Skipped clearing an element cache on profile {profile_id}: {}", + e.message + ); + continue; + } + }; + + let js = format!( + r#"(() => {{ + try {{ delete window[{slot}]; }} catch (e) {{ window[{slot}] = undefined; }} + const registry = window[{registry}]; + if (Array.isArray(registry)) window[{registry}] = registry.filter((s) => s !== {slot}); + return true; + }})()"# + ); + + if let Err(e) = self + .send_cdp( + &target, + "Runtime.evaluate", + serde_json::json!({ + "expression": js, + "returnByValue": true, + }), + ) + .await + { + log::debug!( + "[mcp] Could not clear an element cache on profile {profile_id}: {}", + e.message + ); + } + } + } + + /// Remember that this session left a snapshot on this profile's page, so + /// `end_session` knows which global to delete and where. + /// + /// Silently does nothing when the session has already been forgotten: the + /// teardown that removed it has already run, and re-adding the entry would + /// resurrect a session-shaped record nothing will ever clean up again. + async fn remember_cached_page(&self, session_id: &str, profile_id: &str, slot: &str) { + let mut inner = self.inner.lock().await; + if let Some(session) = inner.sessions.get_mut(session_id) { + session + .cached_pages + .insert((profile_id.to_string(), slot.to_string())); + } + } + + /// Answer one JSON-RPC message, whatever carried it here. + /// + /// This is the whole protocol: parse, route `initialize`, absorb + /// notifications, validate the session, meter the automation tools, dispatch. + /// Every transport calls exactly this, so none of them can drift away from + /// the others on a rule that matters (the session check and the rate limiter + /// were both HTTP-only before the bridge existed). + pub(crate) async fn handle_message( + &self, + origin: McpOrigin, + session_id: Option<&str>, + body: &[u8], + ) -> McpOutcome { + if body.len() > Self::MAX_MESSAGE_BYTES { + return McpOutcome::BadRequest; + } + + let request: McpRequest = match serde_json::from_slice(body) { + Ok(request) => request, + Err(_) => return McpOutcome::BadRequest, + }; + + if request.method == "initialize" { + return match self.handle_initialize(request).await { + Ok((new_session_id, (id, result))) => McpOutcome::Body { + body: serde_json::to_value(McpResponse { + jsonrpc: "2.0".to_string(), + id: Some(id), + result: Some(result), + error: None, + }) + .unwrap_or_else(|_| serde_json::json!({})), + new_session_id: Some(new_session_id), + }, + Err((id, error)) => McpOutcome::Body { + body: serde_json::to_value(McpResponse { + jsonrpc: "2.0".to_string(), + id: Some(id), + result: None, + error: Some(error), + }) + .unwrap_or_else(|_| serde_json::json!({})), + new_session_id: None, + }, + }; + } + + // A message with no id is a notification, and JSON-RPC forbids replying to + // one. `notifications/initialized` is the only one that carries meaning. + if request.id.is_none() { + if request.method == "notifications/initialized" { + if let Some(sid) = session_id { + let mut inner = self.inner.lock().await; + if let Some(session) = inner.sessions.get_mut(sid) { + session.initialized = true; + } + } + } + return McpOutcome::Accepted; + } + + // Validated only when the caller supplied one: a client that never called + // `initialize` is still served, exactly as it was over HTTP. That leniency + // stops at the tools whose ANSWER depends on which caller is asking, the + // index-based ones, because there is no such thing as a correct reply to + // "click element 3" from a caller whose snapshot the server cannot name. + // They refuse in the handler (`require_indexed_session`); everything else + // is a self-contained request that a sessionless caller can safely make. + if let Some(sid) = session_id { + let mut inner = self.inner.lock().await; + // Touched HERE, on the path every id-carrying request takes, so the cap + // evicts what is genuinely idle. Doing it in the + // `notifications/initialized` branch instead, as the first attempt did - + // touches a session exactly once in its life, which is no better than + // evicting by creation time. + match inner.sessions.get_mut(sid) { + Some(session) => session.last_used = std::time::Instant::now(), + None => return McpOutcome::UnknownSession, + } + } + + if Self::is_automation_tool_call(&request) { + if let crate::automation_rate_limiter::RateLimitOutcome::Limited { retry_after_secs } = + crate::automation_rate_limiter::check_automation_rate_limit().await + { + log::warn!( + "[mcp] Rejected tools/call: automation rate limit exceeded; retry in {retry_after_secs}s" + ); + return McpOutcome::RateLimited { retry_after_secs }; + } + } + + let response = self + .handle_request( + McpCaller { + origin, + session: session_id, + }, + request, + ) + .await; + McpOutcome::Body { + body: serde_json::to_value(response).unwrap_or_else(|_| serde_json::json!({})), + new_session_id: None, + } + } + + // TODO(local-mcp-removal): dead once the loopback served a tombstone; kept + // beside it so the whole local transport is deleted in one change. + #[allow(dead_code)] async fn handle_mcp_delete( State(state): State, req: Request, @@ -397,14 +2691,15 @@ impl McpServer { .map(|s| s.to_string()); if let Some(sid) = session_id { - let mut inner = state.server.inner.lock().await; - inner.sessions.remove(&sid); - log::info!("[mcp] Session terminated: {}", sid); + state.server.end_session(&sid).await; } StatusCode::OK } + // TODO(local-mcp-removal): dead once the loopback served a tombstone; kept + // beside it so the whole local transport is deleted in one change. + #[allow(dead_code)] async fn handle_mcp_post(State(state): State, req: Request) -> Response { let session_id = req .headers() @@ -412,90 +2707,42 @@ impl McpServer { .and_then(|h| h.to_str().ok()) .map(|s| s.to_string()); - let body_bytes = match axum::body::to_bytes(req.into_body(), 1024 * 1024).await { + let body_bytes = match axum::body::to_bytes(req.into_body(), Self::MAX_MESSAGE_BYTES).await { Ok(b) => b, Err(_) => { return (StatusCode::BAD_REQUEST, "Invalid request body").into_response(); } }; - let request: McpRequest = match serde_json::from_slice(&body_bytes) { - Ok(r) => r, - Err(_) => { - return (StatusCode::BAD_REQUEST, "Invalid JSON").into_response(); - } - }; - - let is_notification = request.id.is_none(); - let method = request.method.clone(); - - // Handle initialize (no session required) - if method == "initialize" { - let response = state.server.handle_initialize(request).await; - match response { - Ok((session_id, result)) => { - let body = McpResponse { - jsonrpc: "2.0".to_string(), - id: Some(result.0), - result: Some(result.1), - error: None, - }; - Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "application/json") - .header("mcp-session-id", &session_id) - .body(Body::from(serde_json::to_vec(&body).unwrap())) - .unwrap() - } - Err((id, error)) => { - let body = McpResponse { - jsonrpc: "2.0".to_string(), - id: Some(id), - result: None, - error: Some(error), - }; - Json(body).into_response() + match state + .server + .handle_message(McpOrigin::Loopback, session_id.as_deref(), &body_bytes) + .await + { + McpOutcome::Body { + body, + new_session_id, + } => { + let encoded = serde_json::to_vec(&body).unwrap_or_else(|_| b"{}".to_vec()); + let mut builder = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json"); + if let Some(sid) = new_session_id { + builder = builder.header("mcp-session-id", sid); } + builder + .body(Body::from(encoded)) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) } - } else if is_notification { - // Notifications (like notifications/initialized) -> 202 Accepted - if method == "notifications/initialized" { - if let Some(sid) = &session_id { - let mut inner = state.server.inner.lock().await; - if let Some(session) = inner.sessions.get_mut(sid) { - session.initialized = true; - } - } - } - StatusCode::ACCEPTED.into_response() - } else { - // Validate session exists - if let Some(sid) = &session_id { - let inner = state.server.inner.lock().await; - if !inner.sessions.contains_key(sid) { - return StatusCode::NOT_FOUND.into_response(); - } - } - - if Self::is_automation_tool_call(&request) { - if let crate::automation_rate_limiter::RateLimitOutcome::Limited { retry_after_secs } = - crate::automation_rate_limiter::check_automation_rate_limit().await - { - log::warn!( - "[mcp] Rejected tools/call: automation rate limit exceeded; retry in {}s", - retry_after_secs - ); - return ( - StatusCode::TOO_MANY_REQUESTS, - [(header::RETRY_AFTER, retry_after_secs.to_string())], - "automation request rate limit exceeded", - ) - .into_response(); - } - } - - let response = state.server.handle_request(request).await; - Json(response).into_response() + McpOutcome::Accepted => StatusCode::ACCEPTED.into_response(), + McpOutcome::UnknownSession => StatusCode::NOT_FOUND.into_response(), + McpOutcome::BadRequest => (StatusCode::BAD_REQUEST, "Invalid JSON").into_response(), + McpOutcome::RateLimited { retry_after_secs } => ( + StatusCode::TOO_MANY_REQUESTS, + [(header::RETRY_AFTER, retry_after_secs.to_string())], + "automation request rate limit exceeded", + ) + .into_response(), } } @@ -530,16 +2777,23 @@ impl McpServer { | "get_interactive_elements" | "click_by_index" | "type_by_index" + // The agent surface drives the same browser through the same paid + // gate; a native page read is automation exactly as a script one is. + | "perceive_page" + | "resolve_locator" + | "click_locator" + | "type_locator" + | "extract_structured" + | "pick_element" // Starting a bot run leases a remote host for up to two hours and // spends the account's pooled remote-hour budget, which makes it the // most expensive tool here. Cancelling one reaches the same fleet, and // is metered alongside the remote-session stop it mirrors. // // Deliberately absent: set_cookie_bot_schedule and - // delete_cookie_bot_schedule. They write one row in Donut cloud and - // lease nothing; metering them would throttle an agent enrolling a - // fleet of profiles, while the budget that actually guards the - // hardware is spent per RUN and enforced server-side. + // delete_cookie_bot_schedule. They are configuration and lease + // nothing; metering them would throttle an agent enrolling many + // profiles, and they are not what spends the account's hours. | "run_cookie_bot_now" | "cancel_cookie_bot_run" // Leasing a remote host is the single most expensive action here, and @@ -556,9 +2810,16 @@ impl McpServer { } let mut inner = self.inner.lock().await; - inner.app_handle = None; + // The bearer token is loopback-only, so it goes with the listener. inner.token = None; - inner.sessions.clear(); + // The session map is NOT cleared, for the same reason the app handle below + // survives: it is shared with the cloud bridge, and clearing it here made + // turning the local switch off answer 404 MCP_SESSION_NOT_FOUND to a remote + // caller that had nothing to do with the loopback transport. The website + // re-initializes on a 404 and self-heals, but the official MCP TypeScript + // SDK does not, it throws on any non-ok POST, so a third-party agent took + // a hard mid-run error from an unrelated toggle. A session holds only + // `initialized: bool`; `end_session` is the eviction path. // Send shutdown signal if let Some(tx) = inner.shutdown_tx.take() { @@ -568,6 +2829,11 @@ impl McpServer { self.port.store(0, Ordering::SeqCst); self.is_running.store(false, Ordering::SeqCst); + // The app handle and `engine_ready` deliberately survive. Closing the + // loopback listener is a statement about one transport; the cloud bridge + // may still be carrying tool calls, and dropping the handle here would + // break it with "MCP server not properly initialized" on the next call. + log::info!("[mcp] Server stopped"); Ok(()) } @@ -702,6 +2968,14 @@ impl McpServer { "type": "array", "items": { "type": "string" }, "description": "Optional tags for the profile" + }, + "ephemeral": { + "type": "boolean", + "description": "Keep this profile's browsing data in memory only, so nothing it browses reaches real disk (default: false)" + }, + "temporary": { + "type": "boolean", + "description": "Create a profile for one run: implies ephemeral, is deleted when its browser stops, and is swept at startup if it outlived a crash (default: false)" } }, "required": ["name", "browser"] @@ -956,6 +3230,38 @@ impl McpServer { "required": ["profile_ids"] }), }, + McpTool { + name: "distribute_proxies".to_string(), + description: "Give each profile its own proxy. Pairs are applied one \ + profile at a time and every outcome is reported, so a \ + profile whose browser is running is refused by name \ + instead of failing the whole request." + .to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "pairs": { + "type": "array", + "description": "Profile/proxy pairs to apply, one proxy per profile", + "items": { + "type": "object", + "properties": { + "profile_id": { + "type": "string", + "description": "The UUID of the profile to move" + }, + "proxy_id": { + "type": "string", + "description": "The UUID of the stored proxy to assign" + } + }, + "required": ["profile_id", "proxy_id"] + } + } + }, + "required": ["pairs"] + }), + }, // Full proxy management tools McpTool { name: "get_proxy".to_string(), @@ -983,7 +3289,7 @@ impl McpServer { }, "proxy_type": { "type": "string", - "enum": ["http", "https", "socks4", "socks5", "vless"], + "enum": ["http", "https", "httpstls", "socks4", "socks5", "vless"], "description": "The proxy protocol" }, "host": { @@ -1026,7 +3332,7 @@ impl McpServer { }, "proxy_type": { "type": "string", - "enum": ["http", "https", "socks4", "socks5", "vless"], + "enum": ["http", "https", "httpstls", "socks4", "socks5", "vless"], "description": "The proxy protocol" }, "host": { @@ -1230,6 +3536,15 @@ impl McpServer { "enum": ["windows", "macos", "linux"], "description": "Operating system for fingerprint generation" }, + "restore_session": { + "type": "boolean", + "description": "Reopen the windows and tabs of the last session on an interactive launch (default: true). Automation runs never restore." + }, + "webrtc_mode": { + "type": "string", + "enum": ["auto", "tcp_only", "block"], + "description": "How WebRTC may reach the network: auto (default), tcp_only (publish only the proxy's exit address), or block (no ICE candidates at all)" + }, "randomize_fingerprint_on_launch": { "type": "boolean", "description": "Whether to generate a new fingerprint on every launch" @@ -1864,7 +4179,7 @@ impl McpServer { }, McpTool { name: "set_cookie_bot_schedule".to_string(), - description: "Enrol a profile in the nightly cookie bot, or replace its enrolment. The profile must have cloud sync (not end-to-end encrypted), a recorded Windows or macOS operating system, and a proxy or VPN".to_string(), + description: "Enrol a profile in the nightly cookie bot, or replace its enrolment. The profile must have cloud sync (not end-to-end encrypted), a recorded Windows, macOS or Linux operating system, and a proxy or VPN".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { @@ -1878,7 +4193,7 @@ impl McpServer { }, "platform": { "type": "string", - "enum": ["windows", "macos"], + "enum": ["windows", "macos", "linux"], "description": "Must match the profile's own operating system; taken from the profile when omitted" }, "enabled": { @@ -2044,6 +4359,176 @@ impl McpServer { "required": [] }), }, + McpTool { + name: "perceive_page".to_string(), + description: "Read the page the way an agent needs it: every visible element with its role, accessible name, text, value, state and page-coordinate bounds, plus the readable text, in one call and with no script injected on Wayfern 152. Far more complete than get_interactive_elements. On a profile older than Wayfern 152 the same shape is synthesised from the DOM (engine: \"fallback\"). A truncated result carries a cursor; pass it back to continue.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "profile_id": { + "type": "string", + "description": "The UUID of the running profile" + }, + "max_bytes": { + "type": "integer", + "description": "Total byte cap for nodes and text (default: 1048576, ceiling: 4194304)" + }, + "budget_ms": { + "type": "integer", + "description": "Capture budget in milliseconds (default: 5000, clamped to 100-60000). Exceeding it truncates and paginates; it is never an error" + }, + "max_nodes": { + "type": "integer", + "description": "Per-frame node ceiling (default: 100000; 0 for no limit)" + }, + "include_text": { + "type": "boolean", + "description": "Include the readable text (default: true)" + }, + "viewport_only": { + "type": "boolean", + "description": "Drop nodes outside the viewport (default: false)" + }, + "text_order": { + "type": "string", + "enum": ["reading", "visual"], + "description": "Emit text in accessibility reading order (default) or re-sorted by geometry (Wayfern 152; the fallback engine always answers in reading order)" + }, + "cursor": { + "type": "string", + "description": "Continue a previous capture from the cursor it returned (Wayfern 152 only)" + } + }, + "required": ["profile_id"] + }), + }, + McpTool { + name: "resolve_locator".to_string(), + description: "Resolve a locator (role, accessible name, text, attributes) to EXACTLY ONE element and describe it. Ambiguity is an error whose data.candidates lists what matched, and a locator that matches nothing is an error too, so a click never lands on the wrong element. Use the result's signature or attributes to refine.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "profile_id": { + "type": "string", + "description": "The UUID of the running profile" + }, + "locator": locator_schema("How to name the element. Every part given must match"), + "candidate_limit": { + "type": "integer", + "description": "How many candidates an ambiguity error lists (default: 10, ceiling: 100)" + } + }, + "required": ["profile_id", "locator"] + }), + }, + McpTool { + name: "click_locator".to_string(), + description: "Resolve a locator to exactly one element and click it with a humanized pointer: on Wayfern 152 a real pointer glides to the element along a human path and presses with the profile's own timing (nothing is injected into the page); on older builds a trusted mouse event is dispatched at the element's centre. Waits for a page load when the click causes one.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "profile_id": { + "type": "string", + "description": "The UUID of the running profile" + }, + "locator": locator_schema("How to name the element to click"), + "button": { + "type": "string", + "enum": ["left", "middle", "right", "back", "forward"], + "description": "Mouse button (default: left)" + }, + "click_count": { + "type": "integer", + "description": "1 for a click (default), 2 for a double click, 3 for a triple" + } + }, + "required": ["profile_id", "locator"] + }), + }, + McpTool { + name: "type_locator".to_string(), + description: "Resolve a locator to exactly one field, focus it with a real click and type text into it one key at a time. On Wayfern 152 the keys are paced by the profile's own typing rhythm, with a few seed-determined typos corrected along the way when typos is on; on older builds the same human-typing model as type_text is used. The field is emptied first unless clear_first is false.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "profile_id": { + "type": "string", + "description": "The UUID of the running profile" + }, + "locator": locator_schema("How to name the field to type into"), + "text": { + "type": "string", + "description": "Text to type" + }, + "clear_first": { + "type": "boolean", + "description": "Empty the field before typing (default: true)" + }, + "typos": { + "type": "boolean", + "description": "Mistype and correct a few characters, as a hand does (default: true)" + }, + "wpm": { + "type": "number", + "description": "Target words per minute for the fallback engine (default: 80). Wayfern 152 types at the profile's own rhythm and ignores this" + } + }, + "required": ["profile_id", "locator", "text"] + }), + }, + McpTool { + name: "extract_structured".to_string(), + description: "Read rows off the live page natively, with no script injected: a container locator matches every row, each field locator is evaluated inside a row, and an optional next-page locator is clicked to advance. A missing container is a result (stopReason \"no-container\"), not an error. Every bound is reported through stopReason and truncated. Requires Wayfern 152.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "profile_id": { + "type": "string", + "description": "The UUID of the running profile" + }, + "container": locator_schema("Matches every row container; several matches are the expected case"), + "field_map": { + "type": "array", + "description": "The columns to read from each row", + "items": { + "type": "object", + "properties": { + "key": { "type": "string", "description": "The key this column appears under in each row's values" }, + "locator": locator_schema("Evaluated inside each container; the first match wins. A field that matches nothing is an absent key"), + "source": { "type": "string", "enum": ["text", "attribute", "link"], "description": "Visible text, one named attribute, or the resolved href/src" }, + "attribute": { "type": "string", "description": "The attribute to read; required when source is attribute" } + }, + "required": ["key", "locator", "source"] + } + }, + "next_page": locator_schema("The control clicked to advance a page; absent means one page"), + "max_pages": { "type": "integer", "description": "Default 1, ceiling 200" }, + "max_rows": { "type": "integer", "description": "Default 1000, ceiling 100000" }, + "max_bytes": { "type": "integer", "description": "Default 262144, ceiling 8388608" }, + "max_nodes": { "type": "integer", "description": "Node cap for each snapshot (default: 20000, ceiling: 200000)" }, + "time_budget_ms": { "type": "integer", "description": "Default 8000, ceiling 120000" } + }, + "required": ["profile_id", "container", "field_map"] + }), + }, + McpTool { + name: "pick_element".to_string(), + description: "Arm the browser's element picker and wait for the user to click an element in the page. Returns the smallest locator that resolves to what they clicked, plus its description, so a person can point at something an agent then acts on with click_locator or type_locator. Errors when the user presses Escape, navigates away, or nothing is picked within timeout_ms. Requires Wayfern 152.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "profile_id": { + "type": "string", + "description": "The UUID of the running profile" + }, + "timeout_ms": { + "type": "integer", + "description": "How long to wait for the click (default: 60000, ceiling: 300000)" + } + }, + "required": ["profile_id"] + }), + }, ] } @@ -2053,27 +4538,62 @@ impl McpServer { ) -> Result<(String, (serde_json::Value, serde_json::Value)), (serde_json::Value, McpError)> { let id = request.id.clone().unwrap_or(serde_json::Value::Null); - if !self.is_running() { + if !self.is_engine_ready() { return Err(( id, McpError { code: -32001, message: "MCP server is not running".to_string(), + data: None, }, )); } + let negotiated = negotiate_protocol_version( + request + .params + .as_ref() + .and_then(|params| params.get("protocolVersion")) + .and_then(serde_json::Value::as_str), + ); + // Create session let session_id = Uuid::new_v4().to_string(); { let mut inner = self.inner.lock().await; - inner - .sessions - .insert(session_id.clone(), McpSession { initialized: false }); + inner.sessions.insert( + session_id.clone(), + McpSession { + initialized: false, + last_used: std::time::Instant::now(), + cached_pages: HashSet::new(), + }, + ); + + // Evict the oldest rather than refusing the newest: a caller that just + // asked for a session is the one actually present, and refusing it would + // break a live customer to protect memory that is not under pressure. + // + // An evicted session's page snapshots are NOT deleted here. Doing it + // would hold this lock, and the caller's `initialize`, on CDP round trips + // to browsers that may be gone; the page-side slot cap + // (MAX_CACHE_SLOTS_PER_PAGE) is what bounds them in exactly this case. + while inner.sessions.len() > MAX_SESSIONS { + let Some(oldest) = inner + .sessions + .iter() + .min_by_key(|(_, session)| session.last_used) + .map(|(id, _)| id.clone()) + else { + break; + }; + log::warn!("[mcp] Session cap reached; evicting the oldest session"); + inner.sessions.remove(&oldest); + } } let result = serde_json::json!({ - "protocolVersion": PROTOCOL_VERSION, + "protocolVersion": negotiated, "capabilities": { "tools": { "listChanged": false @@ -2090,10 +4610,10 @@ impl McpServer { Ok((session_id, (id, result))) } - pub async fn handle_request(&self, request: McpRequest) -> McpResponse { + pub async fn handle_request(&self, caller: McpCaller<'_>, request: McpRequest) -> McpResponse { let id = request.id.clone().unwrap_or(serde_json::Value::Null); - if !self.is_running() { + if !self.is_engine_ready() { return McpResponse { jsonrpc: "2.0".to_string(), id: Some(id), @@ -2101,6 +4621,7 @@ impl McpServer { error: Some(McpError { code: -32001, message: "MCP server is not running".to_string(), + data: None, }), }; } @@ -2108,10 +4629,11 @@ impl McpServer { let result = match request.method.as_str() { "ping" => Ok(serde_json::json!({})), "tools/list" => self.handle_tools_list().await, - "tools/call" => self.handle_tool_call(request.params).await, + "tools/call" => self.handle_tool_call(caller, request.params).await, _ => Err(McpError { code: -32601, message: format!("Method not found: {}", request.method), + data: None, }), }; @@ -2139,11 +4661,13 @@ impl McpServer { async fn handle_tool_call( &self, + caller: McpCaller<'_>, params: Option, ) -> Result { let params = params.ok_or_else(|| McpError { code: -32602, message: "Missing parameters".to_string(), + data: None, })?; let tool_name = params @@ -2152,6 +4676,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing tool name".to_string(), + data: None, })?; let arguments = params @@ -2170,7 +4695,23 @@ impl McpServer { log::info!("[mcp] tools/call name={tool_name} profile_id={profile_id}"); let started = std::time::Instant::now(); - let result = self.dispatch_tool_call(tool_name, &arguments).await; + // Refused here, at the one place every tool call passes, rather than in + // each handler: a new path-taking tool added later inherits the rule + // instead of having to remember it. + if caller.origin == McpOrigin::Bridge + && (LOCAL_PATH_TOOLS.contains(&tool_name) || SECRET_EXPORT_TOOLS.contains(&tool_name)) + { + log::warn!( + "[mcp] Refused '{tool_name}' over the bridge: it takes a local filesystem path or exports stored secrets" + ); + return Err(McpError { + code: -32000, + message: crate::backend_error("TOOL_IS_LOCAL_ONLY"), + data: None, + }); + } + + let result = self.dispatch_tool_call(caller, tool_name, &arguments).await; let elapsed_ms = started.elapsed().as_millis(); match &result { Ok(_) => { @@ -2191,6 +4732,7 @@ impl McpServer { async fn dispatch_tool_call( &self, + caller: McpCaller<'_>, tool_name: &str, arguments: &serde_json::Value, ) -> Result { @@ -2236,7 +4778,7 @@ impl McpServer { "update_profile" => self.handle_update_profile(arguments).await, "delete_profile" => self.handle_delete_profile(arguments).await, "list_tags" => self.handle_list_tags().await, - "list_proxies" => self.handle_list_proxies().await, + "list_proxies" => self.handle_list_proxies(caller).await, "get_profile_status" => self.handle_get_profile_status(arguments).await, // Group management "list_groups" => self.handle_list_groups().await, @@ -2245,8 +4787,9 @@ impl McpServer { "update_group" => self.handle_update_group(arguments).await, "delete_group" => self.handle_delete_group(arguments).await, "assign_profiles_to_group" => self.handle_assign_profiles_to_group(arguments).await, + "distribute_proxies" => self.handle_distribute_proxies(arguments).await, // Full proxy management - "get_proxy" => self.handle_get_proxy(arguments).await, + "get_proxy" => self.handle_get_proxy(caller, arguments).await, "create_proxy" => self.handle_create_proxy(arguments).await, "update_proxy" => self.handle_update_proxy(arguments).await, "delete_proxy" => self.handle_delete_proxy(arguments).await, @@ -2352,7 +4895,7 @@ impl McpServer { CLOUD_AUTH.can_use_browser_automation().await, ) .await?; - self.handle_type_text(arguments).await + self.handle_type_text(caller, arguments).await } "get_page_content" => { Self::require_capability( @@ -2376,7 +4919,9 @@ impl McpServer { CLOUD_AUTH.can_use_browser_automation().await, ) .await?; - self.handle_get_interactive_elements(arguments).await + self + .handle_get_interactive_elements(caller, arguments) + .await } "click_by_index" => { Self::require_capability( @@ -2384,7 +4929,7 @@ impl McpServer { CLOUD_AUTH.can_use_browser_automation().await, ) .await?; - self.handle_click_by_index(arguments).await + self.handle_click_by_index(caller, arguments).await } "type_by_index" => { Self::require_capability( @@ -2392,7 +4937,58 @@ impl McpServer { CLOUD_AUTH.can_use_browser_automation().await, ) .await?; - self.handle_type_by_index(arguments).await + self.handle_type_by_index(caller, arguments).await + } + // The agent surface: perception, locators, extraction, the picker and + // humanized input. Gated exactly like click_element, because every one + // of them drives, or reads, the same browser. + "perceive_page" => { + Self::require_capability( + "Browser automation", + CLOUD_AUTH.can_use_browser_automation().await, + ) + .await?; + self.handle_perceive_page(arguments).await + } + "resolve_locator" => { + Self::require_capability( + "Browser automation", + CLOUD_AUTH.can_use_browser_automation().await, + ) + .await?; + self.handle_resolve_locator(arguments).await + } + "click_locator" => { + Self::require_capability( + "Browser automation", + CLOUD_AUTH.can_use_browser_automation().await, + ) + .await?; + self.handle_click_locator(arguments).await + } + "type_locator" => { + Self::require_capability( + "Browser automation", + CLOUD_AUTH.can_use_browser_automation().await, + ) + .await?; + self.handle_type_locator(caller, arguments).await + } + "extract_structured" => { + Self::require_capability( + "Browser automation", + CLOUD_AUTH.can_use_browser_automation().await, + ) + .await?; + self.handle_extract_structured(caller, arguments).await + } + "pick_element" => { + Self::require_capability( + "Browser automation", + CLOUD_AUTH.can_use_browser_automation().await, + ) + .await?; + self.handle_pick_element(caller, arguments).await } // Leasing a host is the most expensive thing this server can do, so it // is gated exactly like the local launch it replaces. @@ -2436,6 +5032,7 @@ impl McpServer { _ => Err(McpError { code: -32602, message: format!("Unknown tool: {tool_name}"), + data: None, }), } } @@ -2446,6 +5043,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to list profiles: {e}"), + data: None, })?; // Filter to only Wayfern profiles @@ -2470,6 +5068,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), + data: None, })?; let profiles = ProfileManager::instance() @@ -2477,6 +5076,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to list profiles: {e}"), + data: None, })?; let profile = profiles @@ -2485,6 +5085,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32000, message: format!("Profile not found: {profile_id}"), + data: None, })?; // Check if it's a Wayfern profile @@ -2492,6 +5093,7 @@ impl McpServer { return Err(McpError { code: -32000, message: "MCP only supports Wayfern profiles".to_string(), + data: None, }); } @@ -2520,9 +5122,13 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), + data: None, })?; let url = arguments.get("url").and_then(|v| v.as_str()); + if let Some(url) = url { + validate_navigable_url(url)?; + } let headless = arguments .get("headless") .and_then(|v| v.as_bool()) @@ -2534,6 +5140,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to list profiles: {e}"), + data: None, })?; let profile = profiles @@ -2542,6 +5149,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32000, message: format!("Profile not found: {profile_id}"), + data: None, })?; // Check if it's a Wayfern profile @@ -2549,6 +5157,7 @@ impl McpServer { return Err(McpError { code: -32000, message: "MCP only supports Wayfern profiles".to_string(), + data: None, }); } @@ -2558,14 +5167,31 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: e, + data: None, })?; // Get app handle to launch - let inner = self.inner.lock().await; - let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { - code: -32000, - message: "MCP server not properly initialized".to_string(), - })?; + // The guard is dropped before the await below. Binding `app_handle` as a + // REFERENCE out of `inner` keeps the engine's single global mutex locked + // for the whole operation — and `handle_message` needs that same mutex to + // validate the session on every request, and to serve `initialize`. So a + // launch that takes twenty seconds froze every other MCP message on this + // desktop for twenty seconds: the agent's own follow-up calls, a second + // agent, the website console, and even a brand-new client trying to open a + // session. The batch handlers already clone-and-drop for exactly this + // reason; the single-profile ones did not. + let app_handle = { + let inner = self.inner.lock().await; + inner + .app_handle + .as_ref() + .ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + data: None, + })? + .clone() + }; // Launch a fresh instance, honoring the requested headless mode. The CDP // port is self-allocated and discovered later via get_cdp_port_for_profile. @@ -2579,6 +5205,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to launch browser: {e}"), + data: None, })?; Ok(serde_json::json!({ @@ -2606,6 +5233,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), + data: None, })?; // Get the profile @@ -2614,6 +5242,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to list profiles: {e}"), + data: None, })?; let profile = profiles @@ -2622,6 +5251,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32000, message: format!("Profile not found: {profile_id}"), + data: None, })?; // Check if it's a Wayfern profile @@ -2629,15 +5259,32 @@ impl McpServer { return Err(McpError { code: -32000, message: "MCP only supports Wayfern profiles".to_string(), + data: None, }); } // Get app handle to kill - let inner = self.inner.lock().await; - let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { - code: -32000, - message: "MCP server not properly initialized".to_string(), - })?; + // The guard is dropped before the await below. Binding `app_handle` as a + // REFERENCE out of `inner` keeps the engine's single global mutex locked + // for the whole operation — and `handle_message` needs that same mutex to + // validate the session on every request, and to serve `initialize`. So a + // launch that takes twenty seconds froze every other MCP message on this + // desktop for twenty seconds: the agent's own follow-up calls, a second + // agent, the website console, and even a brand-new client trying to open a + // session. The batch handlers already clone-and-drop for exactly this + // reason; the single-profile ones did not. + let app_handle = { + let inner = self.inner.lock().await; + inner + .app_handle + .as_ref() + .ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + data: None, + })? + .clone() + }; // Kill the browser crate::browser_runner::BrowserRunner::instance() @@ -2646,6 +5293,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to kill browser: {e}"), + data: None, })?; crate::team_lock::release_team_lock_if_needed(profile).await; @@ -2679,9 +5327,13 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing profile_ids array".to_string(), + data: None, })?; let url = arguments.get("url").and_then(|v| v.as_str()); + if let Some(url) = url { + validate_navigable_url(url)?; + } let headless = arguments .get("headless") .and_then(|v| v.as_bool()) @@ -2692,6 +5344,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to list profiles: {e}"), + data: None, })?; // Clone the app handle and release the lock before the launch loop so we @@ -2704,6 +5357,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), + data: None, })? .clone() }; @@ -2770,6 +5424,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing profile_ids array".to_string(), + data: None, })?; let profiles = ProfileManager::instance() @@ -2777,6 +5432,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to list profiles: {e}"), + data: None, })?; let app_handle = { @@ -2787,6 +5443,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), + data: None, })? .clone() }; @@ -2829,6 +5486,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing name".to_string(), + data: None, })?; let browser = arguments .get("browser") @@ -2836,12 +5494,14 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing browser".to_string(), + data: None, })?; if browser != "wayfern" { return Err(McpError { code: -32602, message: "browser must be 'wayfern'".to_string(), + data: None, }); } @@ -2872,17 +5532,44 @@ impl McpServer { let version = versions.first().ok_or_else(|| McpError { code: -32000, message: format!("No downloaded version found for {browser}. Download it first."), + data: None, })?; - let inner = self.inner.lock().await; - let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { - code: -32000, - message: "MCP server not properly initialized".to_string(), - })?; + // The guard is dropped before the await below. Binding `app_handle` as a + // REFERENCE out of `inner` keeps the engine's single global mutex locked + // for the whole operation — and `handle_message` needs that same mutex to + // validate the session on every request, and to serve `initialize`. So a + // launch that takes twenty seconds froze every other MCP message on this + // desktop for twenty seconds: the agent's own follow-up calls, a second + // agent, the website console, and even a brand-new client trying to open a + // session. The batch handlers already clone-and-drop for exactly this + // reason; the single-profile ones did not. + let app_handle = { + let inner = self.inner.lock().await; + inner + .app_handle + .as_ref() + .ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + data: None, + })? + .clone() + }; + + let temporary = arguments + .get("temporary") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let ephemeral = temporary + || arguments + .get("ephemeral") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); let mut profile = ProfileManager::instance() .create_profile_with_group( - app_handle, + &app_handle, name, browser, version, @@ -2891,7 +5578,7 @@ impl McpServer { None, None, group_id, - false, + ephemeral, None, launch_hook, ) @@ -2899,11 +5586,22 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to create profile: {e}"), + data: None, })?; + if temporary { + profile = ProfileManager::instance() + .mark_profile_temporary(&profile.id.to_string()) + .map_err(|e| McpError { + code: -32000, + message: format!("Profile created but could not be marked temporary: {e}"), + data: None, + })?; + } + if let Some(tags) = tags { let _ = - ProfileManager::instance().update_profile_tags(app_handle, &profile.name, tags.clone()); + ProfileManager::instance().update_profile_tags(&app_handle, &profile.name, tags.clone()); profile.tags = tags; if let Ok(profiles) = ProfileManager::instance().list_profiles() { let _ = crate::tag_manager::TAG_MANAGER @@ -2930,34 +5628,49 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), + data: None, })?; - let inner = self.inner.lock().await; - let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { - code: -32000, - message: "MCP server not properly initialized".to_string(), - })?; + // The guard is dropped before the await below. Binding `app_handle` as a + // REFERENCE out of `inner` keeps the engine's single global mutex locked + // for the whole operation — and `handle_message` needs that same mutex to + // validate the session on every request, and to serve `initialize`. So a + // launch that takes twenty seconds froze every other MCP message on this + // desktop for twenty seconds: the agent's own follow-up calls, a second + // agent, the website console, and even a brand-new client trying to open a + // session. The batch handlers already clone-and-drop for exactly this + // reason; the single-profile ones did not. + let app_handle = { + let inner = self.inner.lock().await; + inner + .app_handle + .as_ref() + .ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + data: None, + })? + .clone() + }; let pm = ProfileManager::instance(); if let Some(new_name) = arguments.get("name").and_then(|v| v.as_str()) { - pm.rename_profile(app_handle, profile_id, new_name) + pm.rename_profile(&app_handle, profile_id, new_name) .map_err(|e| McpError { code: -32000, message: format!("Failed to rename profile: {e}"), + data: None, })?; } if let Some(proxy_id) = arguments.get("proxy_id").and_then(|v| v.as_str()) { - let pid = if proxy_id.is_empty() { - None - } else { - Some(proxy_id.to_string()) - }; - pm.update_profile_proxy(app_handle.clone(), profile_id, pid) + // An empty id detaches the proxy; the manager normalizes it. + pm.update_profile_proxy(app_handle.clone(), profile_id, Some(proxy_id.to_string())) .await .map_err(|e| McpError { code: -32000, message: format!("Failed to update proxy: {e}"), + data: None, })?; } @@ -2967,10 +5680,11 @@ impl McpServer { } else { Some(launch_hook.to_string()) }; - pm.update_profile_launch_hook(app_handle, profile_id, normalized) + pm.update_profile_launch_hook(&app_handle, profile_id, normalized) .map_err(|e| McpError { code: -32000, message: format!("Failed to update launch hook: {e}"), + data: None, })?; } @@ -2980,10 +5694,11 @@ impl McpServer { } else { Some(group_id.to_string()) }; - pm.assign_profiles_to_group(app_handle, vec![profile_id.to_string()], gid) + pm.assign_profiles_to_group(&app_handle, vec![profile_id.to_string()], gid) .map_err(|e| McpError { code: -32000, message: format!("Failed to update group: {e}"), + data: None, })?; } @@ -2992,10 +5707,11 @@ impl McpServer { .iter() .filter_map(|item| item.as_str().map(|s| s.to_string())) .collect(); - pm.update_profile_tags(app_handle, profile_id, tag_list) + pm.update_profile_tags(&app_handle, profile_id, tag_list) .map_err(|e| McpError { code: -32000, message: format!("Failed to update tags: {e}"), + data: None, })?; if let Ok(profiles) = pm.list_profiles() { let _ = crate::tag_manager::TAG_MANAGER @@ -3014,6 +5730,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to update extension group: {e}"), + data: None, })?; } @@ -3025,18 +5742,20 @@ impl McpServer { .iter() .filter_map(|item| item.as_str().map(|s| s.to_string())) .collect(); - pm.update_profile_proxy_bypass_rules(app_handle, profile_id, rule_list) + pm.update_profile_proxy_bypass_rules(&app_handle, profile_id, rule_list) .map_err(|e| McpError { code: -32000, message: format!("Failed to update proxy bypass rules: {e}"), + data: None, })?; } if let Some(clear_on_close) = arguments.get("clear_on_close").and_then(|v| v.as_bool()) { - pm.update_profile_clear_on_close(app_handle, profile_id, clear_on_close) + pm.update_profile_clear_on_close(&app_handle, profile_id, clear_on_close) .map_err(|e| McpError { code: -32000, message: format!("Failed to update clear-on-close: {e}"), + data: None, })?; } @@ -3058,19 +5777,37 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), + data: None, })?; - let inner = self.inner.lock().await; - let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { - code: -32000, - message: "MCP server not properly initialized".to_string(), - })?; + // The guard is dropped before the await below. Binding `app_handle` as a + // REFERENCE out of `inner` keeps the engine's single global mutex locked + // for the whole operation — and `handle_message` needs that same mutex to + // validate the session on every request, and to serve `initialize`. So a + // launch that takes twenty seconds froze every other MCP message on this + // desktop for twenty seconds: the agent's own follow-up calls, a second + // agent, the website console, and even a brand-new client trying to open a + // session. The batch handlers already clone-and-drop for exactly this + // reason; the single-profile ones did not. + let app_handle = { + let inner = self.inner.lock().await; + inner + .app_handle + .as_ref() + .ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + data: None, + })? + .clone() + }; ProfileManager::instance() - .delete_profile(app_handle, profile_id) + .delete_profile(&app_handle, profile_id) .map_err(|e| McpError { code: -32000, message: format!("Failed to delete profile: {e}"), + data: None, })?; Ok(serde_json::json!({ @@ -3087,11 +5824,13 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to access tag manager: {e}"), + data: None, })? .get_all_tags() .map_err(|e| McpError { code: -32000, message: format!("Failed to get tags: {e}"), + data: None, })?; Ok(serde_json::json!({ @@ -3102,8 +5841,21 @@ impl McpServer { })) } - async fn handle_list_proxies(&self) -> Result { - let proxies = PROXY_MANAGER.get_stored_proxies(); + async fn handle_list_proxies( + &self, + caller: McpCaller<'_>, + ) -> Result { + let mut proxies = + serde_json::to_value(PROXY_MANAGER.get_stored_proxies()).map_err(|e| McpError { + code: -32000, + message: format!("Failed to serialize proxies: {e}"), + data: None, + })?; + if caller.origin == McpOrigin::Bridge { + for proxy in proxies.as_array_mut().into_iter().flatten() { + redact_proxy_secrets(proxy); + } + } Ok(serde_json::json!({ "content": [{ @@ -3123,6 +5875,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), + data: None, })?; // Get the profile @@ -3131,6 +5884,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to list profiles: {e}"), + data: None, })?; let profile = profiles @@ -3139,6 +5893,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32000, message: format!("Profile not found: {profile_id}"), + data: None, })?; // Check if it's a Wayfern profile @@ -3146,6 +5901,7 @@ impl McpServer { return Err(McpError { code: -32000, message: "MCP only supports Wayfern profiles".to_string(), + data: None, }); } @@ -3203,11 +5959,13 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to lock group manager: {e}"), + data: None, })? .get_all_groups() .map_err(|e| McpError { code: -32000, message: format!("Failed to list groups: {e}"), + data: None, })?; Ok(serde_json::json!({ @@ -3228,6 +5986,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing group_id".to_string(), + data: None, })?; let groups = GROUP_MANAGER @@ -3235,11 +5994,13 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to lock group manager: {e}"), + data: None, })? .get_all_groups() .map_err(|e| McpError { code: -32000, message: format!("Failed to list groups: {e}"), + data: None, })?; let group = groups @@ -3248,6 +6009,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32000, message: format!("Group not found: {group_id}"), + data: None, })?; Ok(serde_json::json!({ @@ -3268,24 +6030,43 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing name".to_string(), + data: None, })?; - let inner = self.inner.lock().await; - let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { - code: -32000, - message: "MCP server not properly initialized".to_string(), - })?; + // The guard is dropped before the await below. Binding `app_handle` as a + // REFERENCE out of `inner` keeps the engine's single global mutex locked + // for the whole operation — and `handle_message` needs that same mutex to + // validate the session on every request, and to serve `initialize`. So a + // launch that takes twenty seconds froze every other MCP message on this + // desktop for twenty seconds: the agent's own follow-up calls, a second + // agent, the website console, and even a brand-new client trying to open a + // session. The batch handlers already clone-and-drop for exactly this + // reason; the single-profile ones did not. + let app_handle = { + let inner = self.inner.lock().await; + inner + .app_handle + .as_ref() + .ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + data: None, + })? + .clone() + }; let group = GROUP_MANAGER .lock() .map_err(|e| McpError { code: -32000, message: format!("Failed to lock group manager: {e}"), + data: None, })? - .create_group(app_handle, name.to_string()) + .create_group(&app_handle, name.to_string()) .map_err(|e| McpError { code: -32000, message: format!("Failed to create group: {e}"), + data: None, })?; Ok(serde_json::json!({ @@ -3306,6 +6087,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing group_id".to_string(), + data: None, })?; let name = arguments @@ -3314,24 +6096,43 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing name".to_string(), + data: None, })?; - let inner = self.inner.lock().await; - let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { - code: -32000, - message: "MCP server not properly initialized".to_string(), - })?; + // The guard is dropped before the await below. Binding `app_handle` as a + // REFERENCE out of `inner` keeps the engine's single global mutex locked + // for the whole operation — and `handle_message` needs that same mutex to + // validate the session on every request, and to serve `initialize`. So a + // launch that takes twenty seconds froze every other MCP message on this + // desktop for twenty seconds: the agent's own follow-up calls, a second + // agent, the website console, and even a brand-new client trying to open a + // session. The batch handlers already clone-and-drop for exactly this + // reason; the single-profile ones did not. + let app_handle = { + let inner = self.inner.lock().await; + inner + .app_handle + .as_ref() + .ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + data: None, + })? + .clone() + }; let group = GROUP_MANAGER .lock() .map_err(|e| McpError { code: -32000, message: format!("Failed to lock group manager: {e}"), + data: None, })? - .update_group(app_handle, group_id.to_string(), name.to_string()) + .update_group(&app_handle, group_id.to_string(), name.to_string()) .map_err(|e| McpError { code: -32000, message: format!("Failed to update group: {e}"), + data: None, })?; Ok(serde_json::json!({ @@ -3352,24 +6153,43 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing group_id".to_string(), + data: None, })?; - let inner = self.inner.lock().await; - let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { - code: -32000, - message: "MCP server not properly initialized".to_string(), - })?; + // The guard is dropped before the await below. Binding `app_handle` as a + // REFERENCE out of `inner` keeps the engine's single global mutex locked + // for the whole operation — and `handle_message` needs that same mutex to + // validate the session on every request, and to serve `initialize`. So a + // launch that takes twenty seconds froze every other MCP message on this + // desktop for twenty seconds: the agent's own follow-up calls, a second + // agent, the website console, and even a brand-new client trying to open a + // session. The batch handlers already clone-and-drop for exactly this + // reason; the single-profile ones did not. + let app_handle = { + let inner = self.inner.lock().await; + inner + .app_handle + .as_ref() + .ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + data: None, + })? + .clone() + }; GROUP_MANAGER .lock() .map_err(|e| McpError { code: -32000, message: format!("Failed to lock group manager: {e}"), + data: None, })? - .delete_group(app_handle, group_id.to_string()) + .delete_group(&app_handle, group_id.to_string()) .map_err(|e| McpError { code: -32000, message: format!("Failed to delete group: {e}"), + data: None, })?; Ok(serde_json::json!({ @@ -3390,6 +6210,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing profile_ids".to_string(), + data: None, })? .iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) @@ -3400,17 +6221,34 @@ impl McpServer { .and_then(|v| v.as_str()) .map(|s| s.to_string()); - let inner = self.inner.lock().await; - let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { - code: -32000, - message: "MCP server not properly initialized".to_string(), - })?; + // The guard is dropped before the await below. Binding `app_handle` as a + // REFERENCE out of `inner` keeps the engine's single global mutex locked + // for the whole operation — and `handle_message` needs that same mutex to + // validate the session on every request, and to serve `initialize`. So a + // launch that takes twenty seconds froze every other MCP message on this + // desktop for twenty seconds: the agent's own follow-up calls, a second + // agent, the website console, and even a brand-new client trying to open a + // session. The batch handlers already clone-and-drop for exactly this + // reason; the single-profile ones did not. + let app_handle = { + let inner = self.inner.lock().await; + inner + .app_handle + .as_ref() + .ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + data: None, + })? + .clone() + }; ProfileManager::instance() - .assign_profiles_to_group(app_handle, profile_ids.clone(), group_id.clone()) + .assign_profiles_to_group(&app_handle, profile_ids.clone(), group_id.clone()) .map_err(|e| McpError { code: -32000, message: format!("Failed to assign profiles to group: {e}"), + data: None, })?; let group_name = group_id.as_deref().unwrap_or("default"); @@ -3422,9 +6260,60 @@ impl McpServer { })) } + async fn handle_distribute_proxies( + &self, + arguments: &serde_json::Value, + ) -> Result { + let pairs: Vec = arguments + .get("pairs") + .cloned() + .map(serde_json::from_value) + .transpose() + .map_err(|e| McpError { + code: -32602, + message: format!("Invalid pairs: {e}"), + data: None, + })? + .ok_or_else(|| McpError { + code: -32602, + message: "Missing pairs".to_string(), + data: None, + })?; + + // Same reason the batch handlers clone and drop: the engine's single global + // mutex serves every other MCP message, and fifty profile writes must not + // hold it. + let app_handle = { + let inner = self.inner.lock().await; + inner + .app_handle + .as_ref() + .ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + data: None, + })? + .clone() + }; + + let results = crate::proxy_distribution::apply_pairs(app_handle, &pairs).await; + let assigned = results.iter().filter(|result| result.ok).count(); + Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": format!( + "{assigned} of {} profile(s) assigned a proxy:\n{}", + results.len(), + serde_json::to_string_pretty(&results).unwrap_or_default() + ) + }] + })) + } + // Full proxy management handlers async fn handle_get_proxy( &self, + caller: McpCaller<'_>, arguments: &serde_json::Value, ) -> Result { let proxy_id = arguments @@ -3433,6 +6322,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing proxy_id".to_string(), + data: None, })?; let proxies = PROXY_MANAGER.get_stored_proxies(); @@ -3442,8 +6332,18 @@ impl McpServer { .ok_or_else(|| McpError { code: -32000, message: format!("Proxy not found: {proxy_id}"), + data: None, })?; + let mut proxy = serde_json::to_value(proxy).map_err(|e| McpError { + code: -32000, + message: format!("Failed to serialize proxy: {e}"), + data: None, + })?; + if caller.origin == McpOrigin::Bridge { + redact_proxy_secrets(&mut proxy); + } + Ok(serde_json::json!({ "content": [{ "type": "text", @@ -3462,13 +6362,30 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing name".to_string(), + data: None, })?; - let inner = self.inner.lock().await; - let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { - code: -32000, - message: "MCP server not properly initialized".to_string(), - })?; + // The guard is dropped before the await below. Binding `app_handle` as a + // REFERENCE out of `inner` keeps the engine's single global mutex locked + // for the whole operation — and `handle_message` needs that same mutex to + // validate the session on every request, and to serve `initialize`. So a + // launch that takes twenty seconds froze every other MCP message on this + // desktop for twenty seconds: the agent's own follow-up calls, a second + // agent, the website console, and even a brand-new client trying to open a + // session. The batch handlers already clone-and-drop for exactly this + // reason; the single-profile ones did not. + let app_handle = { + let inner = self.inner.lock().await; + inner + .app_handle + .as_ref() + .ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + data: None, + })? + .clone() + }; let proxy_type = arguments .get("proxy_type") @@ -3476,14 +6393,20 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing proxy_type".to_string(), + data: None, })?; // The tool schema declares an enum, but JSON-Schema enums are advisory only; // enforce it here so a bad value can't produce a non-functional proxy. - if !matches!(proxy_type, "http" | "https" | "socks4" | "socks5" | "vless") { + if !matches!( + proxy_type, + "http" | "https" | "httpstls" | "socks4" | "socks5" | "vless" + ) { return Err(McpError { code: -32602, - message: "proxy_type must be one of: http, https, socks4, socks5, vless".to_string(), + message: "proxy_type must be one of: http, https, httpstls, socks4, socks5, vless" + .to_string(), + data: None, }); } @@ -3496,6 +6419,7 @@ impl McpServer { return Err(McpError { code: -32602, message: "Missing vless_uri".to_string(), + data: None, }); } (String::new(), 1) @@ -3506,6 +6430,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing host".to_string(), + data: None, })? .to_string(); let port = arguments @@ -3516,6 +6441,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing or invalid port".to_string(), + data: None, })?; (host, port) }; @@ -3539,10 +6465,11 @@ impl McpServer { }; let proxy = PROXY_MANAGER - .create_stored_proxy(app_handle, name.to_string(), proxy_settings) + .create_stored_proxy(&app_handle, name.to_string(), proxy_settings) .map_err(|e| McpError { code: -32000, message: format!("Failed to create proxy: {e}"), + data: None, })?; Ok(serde_json::json!({ @@ -3563,6 +6490,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing proxy_id".to_string(), + data: None, })?; let name = arguments @@ -3587,6 +6515,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32000, message: format!("Proxy not found: {proxy_id}"), + data: None, })?; let proxy_type = arguments @@ -3596,11 +6525,13 @@ impl McpServer { .unwrap_or_else(|| existing.proxy_settings.proxy_type.clone()); if !matches!( proxy_type.as_str(), - "http" | "https" | "socks4" | "socks5" | "vless" + "http" | "https" | "httpstls" | "socks4" | "socks5" | "vless" ) { return Err(McpError { code: -32602, - message: "proxy_type must be one of: http, https, socks4, socks5, vless".to_string(), + message: "proxy_type must be one of: http, https, httpstls, socks4, socks5, vless" + .to_string(), + data: None, }); } @@ -3618,6 +6549,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Invalid port".to_string(), + data: None, })?, None => existing.proxy_settings.port, }; @@ -3651,17 +6583,34 @@ impl McpServer { None }; - let inner = self.inner.lock().await; - let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { - code: -32000, - message: "MCP server not properly initialized".to_string(), - })?; + // The guard is dropped before the await below. Binding `app_handle` as a + // REFERENCE out of `inner` keeps the engine's single global mutex locked + // for the whole operation — and `handle_message` needs that same mutex to + // validate the session on every request, and to serve `initialize`. So a + // launch that takes twenty seconds froze every other MCP message on this + // desktop for twenty seconds: the agent's own follow-up calls, a second + // agent, the website console, and even a brand-new client trying to open a + // session. The batch handlers already clone-and-drop for exactly this + // reason; the single-profile ones did not. + let app_handle = { + let inner = self.inner.lock().await; + inner + .app_handle + .as_ref() + .ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + data: None, + })? + .clone() + }; let proxy = PROXY_MANAGER - .update_stored_proxy(app_handle, proxy_id, name, proxy_settings) + .update_stored_proxy(&app_handle, proxy_id, name, proxy_settings) .map_err(|e| McpError { code: -32000, message: format!("Failed to update proxy: {e}"), + data: None, })?; Ok(serde_json::json!({ @@ -3682,19 +6631,37 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing proxy_id".to_string(), + data: None, })?; - let inner = self.inner.lock().await; - let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { - code: -32000, - message: "MCP server not properly initialized".to_string(), - })?; + // The guard is dropped before the await below. Binding `app_handle` as a + // REFERENCE out of `inner` keeps the engine's single global mutex locked + // for the whole operation — and `handle_message` needs that same mutex to + // validate the session on every request, and to serve `initialize`. So a + // launch that takes twenty seconds froze every other MCP message on this + // desktop for twenty seconds: the agent's own follow-up calls, a second + // agent, the website console, and even a brand-new client trying to open a + // session. The batch handlers already clone-and-drop for exactly this + // reason; the single-profile ones did not. + let app_handle = { + let inner = self.inner.lock().await; + inner + .app_handle + .as_ref() + .ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + data: None, + })? + .clone() + }; PROXY_MANAGER - .delete_stored_proxy(app_handle, proxy_id) + .delete_stored_proxy(&app_handle, proxy_id) .map_err(|e| McpError { code: -32000, message: format!("Failed to delete proxy: {e}"), + data: None, })?; Ok(serde_json::json!({ @@ -3715,18 +6682,21 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing format".to_string(), + data: None, })?; let content = match format { "json" => PROXY_MANAGER.export_proxies_json().map_err(|e| McpError { code: -32000, message: format!("Failed to export proxies: {e}"), + data: None, })?, "txt" => PROXY_MANAGER.export_proxies_txt(), _ => { return Err(McpError { code: -32602, message: format!("Invalid format '{}', must be 'json' or 'txt'", format), + data: None, }) } }; @@ -3749,6 +6719,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing content".to_string(), + data: None, })?; let format = arguments @@ -3757,6 +6728,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing format".to_string(), + data: None, })?; let name_prefix = arguments @@ -3764,18 +6736,35 @@ impl McpServer { .and_then(|v| v.as_str()) .map(|s| s.to_string()); - let inner = self.inner.lock().await; - let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { - code: -32000, - message: "MCP server not properly initialized".to_string(), - })?; + // The guard is dropped before the await below. Binding `app_handle` as a + // REFERENCE out of `inner` keeps the engine's single global mutex locked + // for the whole operation — and `handle_message` needs that same mutex to + // validate the session on every request, and to serve `initialize`. So a + // launch that takes twenty seconds froze every other MCP message on this + // desktop for twenty seconds: the agent's own follow-up calls, a second + // agent, the website console, and even a brand-new client trying to open a + // session. The batch handlers already clone-and-drop for exactly this + // reason; the single-profile ones did not. + let app_handle = { + let inner = self.inner.lock().await; + inner + .app_handle + .as_ref() + .ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + data: None, + })? + .clone() + }; let result = match format { "json" => PROXY_MANAGER - .import_proxies_json(app_handle, content) + .import_proxies_json(&app_handle, content) .map_err(|e| McpError { code: -32000, message: format!("Failed to import proxies: {e}"), + data: None, })?, "txt" => { use crate::proxy_manager::{ProxyManager, ProxyParseResult}; @@ -3796,20 +6785,23 @@ impl McpServer { return Err(McpError { code: -32000, message: "No valid proxies found in content".to_string(), + data: None, }); } PROXY_MANAGER - .import_proxies_from_parsed(app_handle, parsed, name_prefix) + .import_proxies_from_parsed(&app_handle, parsed, name_prefix) .map_err(|e| McpError { code: -32000, message: format!("Failed to import proxies: {e}"), + data: None, })? } _ => { return Err(McpError { code: -32602, message: format!("Invalid format '{}', must be 'json' or 'txt'", format), + data: None, }) } }; @@ -3840,6 +6832,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to detect profiles: {e}"), + data: None, })?; Ok(serde_json::json!({ @@ -3860,11 +6853,13 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing items".to_string(), + data: None, }) .and_then(|v| { serde_json::from_value(v).map_err(|e| McpError { code: -32602, message: format!("Invalid items: {e}"), + data: None, }) })?; @@ -3881,6 +6876,7 @@ impl McpServer { .map_err(|e| McpError { code: -32602, message: format!("Invalid duplicate_strategy: {e}"), + data: None, })? .unwrap_or_default(); @@ -3891,6 +6887,7 @@ impl McpServer { inner.app_handle.clone().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), + data: None, })? }; @@ -3900,6 +6897,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to import profiles: {e}"), + data: None, })?; Ok(serde_json::json!({ @@ -3927,6 +6925,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), + data: None, })?; let content = arguments @@ -3935,6 +6934,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing content".to_string(), + data: None, })?; let app_handle = { @@ -3945,6 +6945,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), + data: None, })? .clone() }; @@ -3955,6 +6956,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to import cookies: {e}"), + data: None, })?; if let Some(scheduler) = crate::sync::get_global_scheduler() { @@ -3995,6 +6997,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing content".to_string(), + data: None, })?; let filename = arguments @@ -4003,6 +7006,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing filename".to_string(), + data: None, })?; let name = arguments @@ -4013,6 +7017,7 @@ impl McpServer { let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError { code: -32000, message: format!("Failed to lock VPN storage: {e}"), + data: None, })?; let config = storage @@ -4020,6 +7025,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to import VPN config: {e}"), + data: None, })?; Ok(serde_json::json!({ @@ -4039,11 +7045,13 @@ impl McpServer { let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError { code: -32000, message: format!("Failed to lock VPN storage: {e}"), + data: None, })?; let configs = storage.list_configs().map_err(|e| McpError { code: -32000, message: format!("Failed to list VPN configs: {e}"), + data: None, })?; Ok(serde_json::json!({ @@ -4064,6 +7072,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing vpn_id".to_string(), + data: None, })?; // First disconnect if connected (stop VPN worker) @@ -4072,11 +7081,13 @@ impl McpServer { let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError { code: -32000, message: format!("Failed to lock VPN storage: {e}"), + data: None, })?; storage.delete_config(vpn_id).map_err(|e| McpError { code: -32000, message: format!("Failed to delete VPN config: {e}"), + data: None, })?; Ok(serde_json::json!({ @@ -4097,6 +7108,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing vpn_id".to_string(), + data: None, })?; // Start VPN worker process @@ -4105,6 +7117,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to connect VPN: {e}"), + data: None, })?; // Update last_used timestamp @@ -4112,6 +7125,7 @@ impl McpServer { let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError { code: -32000, message: format!("Failed to lock VPN storage: {e}"), + data: None, })?; let _ = storage.update_last_used(vpn_id); } @@ -4134,6 +7148,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing vpn_id".to_string(), + data: None, })?; crate::vpn_worker_runner::stop_vpn_worker_by_vpn_id(vpn_id) @@ -4141,6 +7156,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to disconnect VPN: {e}"), + data: None, })?; Ok(serde_json::json!({ @@ -4161,6 +7177,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing vpn_id".to_string(), + data: None, })?; let connected = @@ -4201,6 +7218,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), + data: None, })?; let profiles = ProfileManager::instance() @@ -4208,6 +7226,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to list profiles: {e}"), + data: None, })?; let profile = profiles @@ -4216,6 +7235,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32000, message: format!("Profile not found: {profile_id}"), + data: None, })?; let fingerprint_info = match profile.browser.as_str() { @@ -4233,12 +7253,20 @@ impl McpServer { "screen_max_height": config.screen_max_height, "screen_min_width": config.screen_min_width, "screen_min_height": config.screen_min_height, + // The launch behaviour an agent can also set through + // `update_profile_fingerprint`, reported here so it never has to + // guess what a profile will do when it starts. + "restore_session": config.restore_session, + "webrtc_mode": config.webrtc_mode, + "camera_file": config.camera_file, + "camera_crop": config.camera_crop, }) } _ => { return Err(McpError { code: -32000, message: "MCP only supports Wayfern profiles".to_string(), + data: None, }) } }; @@ -4259,6 +7287,7 @@ impl McpServer { return Err(McpError { code: -32000, message: "Fingerprint editing requires a plan that includes it".to_string(), + data: None, }); } @@ -4268,10 +7297,27 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), + data: None, })?; let fingerprint = arguments.get("fingerprint").and_then(|v| v.as_str()); let os = arguments.get("os").and_then(|v| v.as_str()); + let restore_session = arguments + .get("restore_session") + .and_then(serde_json::Value::as_bool); + let webrtc_mode = match arguments.get("webrtc_mode").and_then(|v| v.as_str()) { + Some(mode) if crate::wayfern_manager::WebRtcMode::parse(mode).is_some() => { + Some(mode.to_string()) + } + Some(mode) => { + return Err(McpError { + code: -32602, + message: format!("Unknown webrtc_mode {mode:?}; expected auto, tcp_only or block"), + data: None, + }) + } + None => None, + }; let randomize = arguments .get("randomize_fingerprint_on_launch") .and_then(|v| v.as_bool()); @@ -4284,6 +7330,7 @@ impl McpServer { "OS spoofing to '{}' requires an active Pro subscription", os_val ), + data: None, }); } } @@ -4293,6 +7340,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to list profiles: {e}"), + data: None, })?; let profile = profiles @@ -4301,13 +7349,30 @@ impl McpServer { .ok_or_else(|| McpError { code: -32000, message: format!("Profile not found: {profile_id}"), + data: None, })?; - let inner = self.inner.lock().await; - let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { - code: -32000, - message: "MCP server not properly initialized".to_string(), - })?; + // The guard is dropped before the await below. Binding `app_handle` as a + // REFERENCE out of `inner` keeps the engine's single global mutex locked + // for the whole operation — and `handle_message` needs that same mutex to + // validate the session on every request, and to serve `initialize`. So a + // launch that takes twenty seconds froze every other MCP message on this + // desktop for twenty seconds: the agent's own follow-up calls, a second + // agent, the website console, and even a brand-new client trying to open a + // session. The batch handlers already clone-and-drop for exactly this + // reason; the single-profile ones did not. + let app_handle = { + let inner = self.inner.lock().await; + inner + .app_handle + .as_ref() + .ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + data: None, + })? + .clone() + }; match profile.browser.as_str() { "wayfern" => { @@ -4321,18 +7386,26 @@ impl McpServer { if let Some(r) = randomize { config.randomize_fingerprint_on_launch = Some(r); } + if let Some(restore) = restore_session { + config.restore_session = Some(restore); + } + if let Some(mode) = webrtc_mode { + config.webrtc_mode = Some(mode); + } ProfileManager::instance() .update_wayfern_config(app_handle.clone(), profile_id, config) .await .map_err(|e| McpError { code: -32000, message: format!("Failed to update wayfern config: {e}"), + data: None, })?; } _ => { return Err(McpError { code: -32000, message: "MCP only supports Wayfern profiles".to_string(), + data: None, }) } } @@ -4355,6 +7428,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), + data: None, })?; let rules: Vec = arguments @@ -4363,22 +7437,40 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing rules array".to_string(), + data: None, })? .iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect(); - let inner = self.inner.lock().await; - let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { - code: -32000, - message: "MCP server not properly initialized".to_string(), - })?; + // The guard is dropped before the await below. Binding `app_handle` as a + // REFERENCE out of `inner` keeps the engine's single global mutex locked + // for the whole operation — and `handle_message` needs that same mutex to + // validate the session on every request, and to serve `initialize`. So a + // launch that takes twenty seconds froze every other MCP message on this + // desktop for twenty seconds: the agent's own follow-up calls, a second + // agent, the website console, and even a brand-new client trying to open a + // session. The batch handlers already clone-and-drop for exactly this + // reason; the single-profile ones did not. + let app_handle = { + let inner = self.inner.lock().await; + inner + .app_handle + .as_ref() + .ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + data: None, + })? + .clone() + }; let profile = ProfileManager::instance() - .update_profile_proxy_bypass_rules(app_handle, profile_id, rules.clone()) + .update_profile_proxy_bypass_rules(&app_handle, profile_id, rules.clone()) .map_err(|e| McpError { code: -32000, message: format!("Failed to update proxy bypass rules: {e}"), + data: None, })?; Ok(serde_json::json!({ @@ -4403,6 +7495,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), + data: None, })?; let level = arguments @@ -4411,6 +7504,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing level".to_string(), + data: None, })?; let dns_blocklist = if level == "none" { @@ -4424,6 +7518,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to update DNS blocklist: {e}"), + data: None, })?; Ok(serde_json::json!({ @@ -4453,12 +7548,14 @@ impl McpServer { return Err(McpError { code: -32000, message: "Extension management requires an active Pro subscription".to_string(), + data: None, }); } let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); let extensions = mgr.list_extensions().map_err(|e| McpError { code: -32000, message: format!("Failed to list extensions: {e}"), + data: None, })?; Ok(serde_json::to_value(extensions).unwrap()) } @@ -4468,12 +7565,14 @@ impl McpServer { return Err(McpError { code: -32000, message: "Extension management requires an active Pro subscription".to_string(), + data: None, }); } let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); let groups = mgr.list_groups().map_err(|e| McpError { code: -32000, message: format!("Failed to list extension groups: {e}"), + data: None, })?; Ok(serde_json::to_value(groups).unwrap()) } @@ -4486,6 +7585,7 @@ impl McpServer { return Err(McpError { code: -32000, message: "Extension management requires an active Pro subscription".to_string(), + data: None, }); } let path = arguments @@ -4494,6 +7594,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing required parameter: path".to_string(), + data: None, })?; let name = arguments .get("name") @@ -4510,6 +7611,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to add extension: {e}"), + data: None, })?; Ok(serde_json::to_value(extension).unwrap()) } @@ -4522,6 +7624,7 @@ impl McpServer { return Err(McpError { code: -32000, message: "Extension management requires an active Pro subscription".to_string(), + data: None, }); } let extension_id = arguments @@ -4530,6 +7633,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing required parameter: extension_id".to_string(), + data: None, })?; let name = arguments .get("name") @@ -4540,6 +7644,7 @@ impl McpServer { return Err(McpError { code: -32602, message: "Provide at least one of: name, path".to_string(), + data: None, }); } let link = arguments @@ -4556,6 +7661,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to update extension: {e}"), + data: None, })?; Ok(serde_json::to_value(extension).unwrap()) } @@ -4568,6 +7674,7 @@ impl McpServer { return Err(McpError { code: -32000, message: "Extension management requires an active Pro subscription".to_string(), + data: None, }); } let name = arguments @@ -4576,11 +7683,13 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing required parameter: name".to_string(), + data: None, })?; let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); let group = mgr.create_group(name.to_string()).map_err(|e| McpError { code: -32000, message: format!("Failed to create extension group: {e}"), + data: None, })?; Ok(serde_json::to_value(group).unwrap()) } @@ -4593,6 +7702,7 @@ impl McpServer { return Err(McpError { code: -32000, message: "Extension management requires an active Pro subscription".to_string(), + data: None, }); } let group_id = arguments @@ -4601,6 +7711,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing required parameter: group_id".to_string(), + data: None, })?; let name = arguments .get("name") @@ -4621,6 +7732,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to update extension group: {e}"), + data: None, })?; Ok(serde_json::to_value(group).unwrap()) } @@ -4633,6 +7745,7 @@ impl McpServer { return Err(McpError { code: -32000, message: "Extension management requires an active Pro subscription".to_string(), + data: None, }); } let (group_id, extension_id) = Self::group_and_extension_ids(arguments)?; @@ -4642,6 +7755,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to add extension to group: {e}"), + data: None, })?; Ok(serde_json::to_value(group).unwrap()) } @@ -4654,6 +7768,7 @@ impl McpServer { return Err(McpError { code: -32000, message: "Extension management requires an active Pro subscription".to_string(), + data: None, }); } let (group_id, extension_id) = Self::group_and_extension_ids(arguments)?; @@ -4663,6 +7778,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to remove extension from group: {e}"), + data: None, })?; Ok(serde_json::to_value(group).unwrap()) } @@ -4674,6 +7790,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing required parameter: group_id".to_string(), + data: None, })?; let extension_id = arguments .get("extension_id") @@ -4681,6 +7798,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing required parameter: extension_id".to_string(), + data: None, })?; Ok((group_id, extension_id)) } @@ -4693,6 +7811,7 @@ impl McpServer { return Err(McpError { code: -32000, message: "Extension management requires an active Pro subscription".to_string(), + data: None, }); } let extension_id = arguments @@ -4701,6 +7820,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing required parameter: extension_id".to_string(), + data: None, })?; let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); mgr @@ -4708,6 +7828,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to delete extension: {e}"), + data: None, })?; Ok(serde_json::json!({"success": true})) } @@ -4720,6 +7841,7 @@ impl McpServer { return Err(McpError { code: -32000, message: "Extension management requires an active Pro subscription".to_string(), + data: None, }); } let group_id = arguments @@ -4728,6 +7850,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing required parameter: group_id".to_string(), + data: None, })?; let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); // For MCP, we don't have an app_handle, but we need one for sync deletion. @@ -4735,6 +7858,7 @@ impl McpServer { mgr.delete_group_internal(group_id).map_err(|e| McpError { code: -32000, message: format!("Failed to delete extension group: {e}"), + data: None, })?; if let Err(e) = crate::events::emit_empty("extensions-changed") { log::error!("Failed to emit extensions-changed event: {e}"); @@ -4750,6 +7874,7 @@ impl McpServer { return Err(McpError { code: -32000, message: "Extension management requires an active Pro subscription".to_string(), + data: None, }); } let profile_id = arguments @@ -4758,6 +7883,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing required parameter: profile_id".to_string(), + data: None, })?; let extension_group_id = arguments .get("extension_group_id") @@ -4777,6 +7903,7 @@ impl McpServer { let profiles = profile_manager.list_profiles().map_err(|e| McpError { code: -32000, message: format!("Failed to list profiles: {e}"), + data: None, })?; let profile = profiles .iter() @@ -4784,6 +7911,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32000, message: format!("Profile '{profile_id}' not found"), + data: None, })?; let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); mgr @@ -4791,6 +7919,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("{e}"), + data: None, })?; } @@ -4800,6 +7929,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to assign extension group: {e}"), + data: None, })?; Ok(serde_json::to_value(profile).unwrap()) } @@ -4809,6 +7939,7 @@ impl McpServer { return Err(McpError { code: -32000, message: "Team features require an active team plan".to_string(), + data: None, }); } let locks = crate::team_lock::TEAM_LOCK.get_locks().await; @@ -4828,6 +7959,7 @@ impl McpServer { return Err(McpError { code: -32000, message: "Team features require an active team plan".to_string(), + data: None, }); } let profile_id = arguments @@ -4836,6 +7968,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), + data: None, })?; let lock_status = crate::team_lock::TEAM_LOCK .get_lock_status(profile_id) @@ -4863,6 +7996,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: e.to_string(), + data: None, }) } @@ -4877,83 +8011,14 @@ impl McpServer { .map_err(cdp_error) } - async fn send_human_keystrokes( + async fn send_planned_keystrokes( &self, target: &CdpTarget, - text: &str, - wpm: Option, + events: &[crate::human_typing::TypingEvent], ) -> Result<(), McpError> { - use crate::human_typing::{MarkovTyper, TypingAction}; - - let events = MarkovTyper::new(text, wpm).run(); - let mut connection = target.connect().await.map_err(cdp_error)?; - - let mut cmd_id = 1u64; - let mut last_time = 0.0; - - for event in &events { - let delay = event.time - last_time; - if delay > 0.0 { - tokio::time::sleep(std::time::Duration::from_secs_f64(delay)).await; - } - last_time = event.time; - - let (down, up) = match &event.action { - TypingAction::Char(ch) => { - let ch = ch.to_string(); - ( - serde_json::json!({ - "type": "keyDown", - "text": ch, - "key": ch, - "unmodifiedText": ch, - }), - serde_json::json!({ "type": "keyUp", "key": ch }), - ) - } - TypingAction::Backspace => ( - serde_json::json!({ - "type": "keyDown", - "key": "Backspace", - "code": "Backspace", - "windowsVirtualKeyCode": 8, - "nativeVirtualKeyCode": 8, - }), - serde_json::json!({ - "type": "keyUp", - "key": "Backspace", - "code": "Backspace", - "windowsVirtualKeyCode": 8, - "nativeVirtualKeyCode": 8, - }), - ), - }; - - for params in [down, up] { - if let Err(e) = connection - .send_command(cmd_id, "Input.dispatchKeyEvent", params) - .await - { - return Err(cdp_error(e)); - } - // Drained rather than matched: the point is to keep reading so the - // browser is never writing into a full socket while the next keystroke - // is being timed. Bounded, because a reply that never comes must not - // freeze typing forever — the keystroke itself was already delivered. - let _ = tokio::time::timeout(KEYSTROKE_ACK_TIMEOUT, connection.next_text()).await; - cmd_id += 1; - } - } - - connection.close().await; - Ok(()) + dispatch_keystrokes(target, events).await.map_err(cdp_error) } - /// Send a CDP command and wait for the page to finish loading. - /// - /// Thin over the shared runner so a local and a remote profile take exactly - /// the same path: one implementation of "navigate then wait", not two that - /// drift. async fn send_cdp_and_wait_for_load( &self, target: &CdpTarget, @@ -4979,6 +8044,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to list profiles: {e}"), + data: None, })?; let profile = profiles @@ -4987,12 +8053,14 @@ impl McpServer { .ok_or_else(|| McpError { code: -32000, message: format!("Profile not found: {profile_id}"), + data: None, })?; if profile.browser != "wayfern" { return Err(McpError { code: -32000, message: "MCP only supports Wayfern profiles".to_string(), + data: None, }); } @@ -5011,14 +8079,10 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), + data: None, })?; - let url = arguments - .get("url") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError { - code: -32602, - message: "Missing url".to_string(), - })?; + // Read and validated in one step, so the safe path is the short one. + let url = Self::require_navigable_url(arguments, "url")?; let target = self.resolve_cdp_target(profile_id).await?; @@ -5049,6 +8113,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), + data: None, })?; let format = arguments .get("format") @@ -5113,6 +8178,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), + data: None, })?; let expression = arguments .get("expression") @@ -5120,6 +8186,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing expression".to_string(), + data: None, })?; let await_promise = arguments .get("await_promise") @@ -5184,6 +8251,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), + data: None, })?; let selector = arguments .get("selector") @@ -5191,11 +8259,29 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing selector".to_string(), + data: None, })?; - let target = self.resolve_cdp_target(profile_id).await?; - + let ctx = self.agent_context(profile_id).await?; let selector_escaped = selector.replace('\\', "\\\\").replace('\'', "\\'"); + + if ctx.engine.is_wayfern() { + // On 152 no script clicks anything. The page only says where the + // element is; the click comes from a real pointer that glides there + // and presses with the profile's own timing, through the same input + // path a mouse uses. + let point = locate_by_script(&ctx.target, element_rect_script(&selector_escaped)).await?; + let (_, navigated) = vellum_click(&ctx.target, point, None, None) + .await + .map_err(AgentError::into_mcp)?; + return Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": click_report(&format!("Clicked element: {selector}"), navigated) + }] + })); + } + let js = format!( r#"(() => {{ const el = document.querySelector('{}'); @@ -5212,7 +8298,7 @@ impl McpServer { // and we return immediately. let result = self .send_cdp_and_wait_for_load( - &target, + &ctx.target, "Runtime.evaluate", serde_json::json!({ "expression": js, @@ -5232,6 +8318,7 @@ impl McpServer { return Err(McpError { code: -32000, message: msg.to_string(), + data: None, }); } @@ -5245,6 +8332,7 @@ impl McpServer { async fn handle_type_text( &self, + caller: McpCaller<'_>, arguments: &serde_json::Value, ) -> Result { let profile_id = arguments @@ -5253,6 +8341,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), + data: None, })?; let selector = arguments .get("selector") @@ -5260,6 +8349,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing selector".to_string(), + data: None, })?; let text = arguments .get("text") @@ -5267,6 +8357,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing text".to_string(), + data: None, })?; let clear_first = arguments .get("clear_first") @@ -5277,8 +8368,38 @@ impl McpServer { .and_then(|v| v.as_bool()) .unwrap_or(false); let wpm = arguments.get("wpm").and_then(|v| v.as_f64()); + let typos = arguments + .get("typos") + .and_then(|v| v.as_bool()) + .unwrap_or(true); - let target = self.resolve_cdp_target(profile_id).await?; + // The engine is read off the profile before any budget is decided. A 152 + // browser types through Vellum, which paces the keys itself, so the plan + // below is only built for the fallback engine. + let profile = self.get_wayfern_profile(profile_id)?; + let humanized = Engine::for_version(&profile.version).is_wayfern() && !instant; + + // PLANNED FIRST, before anything touches the page. The planner can refuse + // (TYPING_TOO_LONG), and the focus step below empties the field when + // `clear_first` is set — so planning after it meant the server answered + // "refused, nothing happened" having already wiped the customer's form + // field. The refusal now precedes every mutation on every branch, + // including the Vellum budget, which is decided in the same place. + let plan = if instant || humanized { + None + } else { + Some(plan_typing(text, wpm, max_typing_seconds(caller.origin))?) + }; + let inscribe_timeout = if humanized { + Some(vellum_typing_budget( + text, + max_typing_seconds(caller.origin), + )?) + } else { + None + }; + + let target = resolve_target(&profile).await?; let selector_escaped = selector.replace('\\', "\\\\").replace('\'', "\\'"); let focus_js = if clear_first { @@ -5290,9 +8411,9 @@ impl McpServer { el.focus(); el.value = ''; el.dispatchEvent(new Event('input', {{bubbles: true}})); - return true; + {} }})()"#, - selector_escaped, selector_escaped + selector_escaped, selector_escaped, RETURN_RECT_JS ) } else { format!( @@ -5301,9 +8422,9 @@ impl McpServer { if (!el) throw new Error('Element not found: {}'); el.scrollIntoView({{block: 'center'}}); el.focus(); - return true; + {} }})()"#, - selector_escaped, selector_escaped + selector_escaped, selector_escaped, RETURN_RECT_JS ) }; @@ -5328,19 +8449,41 @@ impl McpServer { return Err(McpError { code: -32000, message: msg.to_string(), + data: None, }); } - if instant { - self - .send_cdp( - &target, - "Input.insertText", - serde_json::json!({ "text": text }), - ) - .await?; - } else { - self.send_human_keystrokes(&target, text, wpm).await?; + if let Some(timeout) = inscribe_timeout { + let point = rect_from_script_result(&focus_result)?; + let inscription = vellum_type( + &target, + point, + text, + typos, + caret_preparation(clear_first), + timeout, + ) + .await + .map_err(AgentError::into_mcp)?; + return Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": typing_report(&format!("Typed text into element: {selector}"), &inscription) + }] + })); + } + + match &plan { + Some(events) => self.send_planned_keystrokes(&target, events).await?, + None => { + self + .send_cdp( + &target, + "Input.insertText", + serde_json::json!({ "text": text }), + ) + .await?; + } } Ok(serde_json::json!({ @@ -5361,6 +8504,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), + data: None, })?; let format = arguments .get("format") @@ -5453,6 +8597,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), + data: None, })?; let target = self.resolve_cdp_target(profile_id).await?; @@ -5486,14 +8631,21 @@ impl McpServer { async fn handle_get_interactive_elements( &self, + caller: McpCaller<'_>, arguments: &serde_json::Value, ) -> Result { + // FIRST, before the page is touched. The indices this hands back are only + // usable by a session, and a sessionless caller would otherwise write a + // snapshot into a slot it shares with every other sessionless caller, the + // exact array-mixing that makes click_by_index click the wrong element. + let session = require_indexed_session(caller)?; let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), + data: None, })?; let max_chars = arguments .get("max_chars") @@ -5504,10 +8656,22 @@ impl McpServer { let target = self.resolve_cdp_target(profile_id).await?; // Walk the DOM for visible, non-disabled interactive elements, label them - // with a zero-based index, and cache the live references on - // `window.__donut_interactive` so click_by_index / type_by_index can - // resolve the index → Element without round-tripping a selector. - let js = INTERACTIVE_ELEMENTS_JS.replace("__MAX_CHARS__", &max_chars.to_string()); + // with a zero-based index, and cache the live references on THIS CALLER'S + // slot so click_by_index / type_by_index can resolve the index → Element + // without round-tripping a selector. + // + // Per session, because the slot used to be one shared `__donut_interactive` + // array on the page, and then one per transport. Either way a second client + // listing elements overwrote the array the first had just built, so that + // client's next `click_by_index(3)` clicked whatever happened to be third + // in the OTHER caller's snapshot, a wrong click reported as a successful + // one, which is the worst shape a failure can take on somebody's browser. + let slot = interactive_cache_slot(caller.origin, session); + let js = INTERACTIVE_ELEMENTS_JS + .replace("__MAX_CHARS__", &max_chars.to_string()) + .replace("__CACHE__", &slot) + .replace("__REGISTRY__", INTERACTIVE_SLOT_REGISTRY) + .replace("__MAX_SLOTS__", &MAX_CACHE_SLOTS_PER_PAGE.to_string()); let result = self .send_cdp( @@ -5530,9 +8694,15 @@ impl McpServer { return Err(McpError { code: -32000, message: msg.to_string(), + data: None, }); } + // Recorded only once the write has actually landed, so `end_session` cleans + // up pages this session really wrote to rather than every page it asked + // about. + self.remember_cached_page(session, profile_id, &slot).await; + let payload_str = result .get("result") .and_then(|r| r.get("value")) @@ -5567,14 +8737,20 @@ impl McpServer { async fn handle_click_by_index( &self, + caller: McpCaller<'_>, arguments: &serde_json::Value, ) -> Result { + // Refused before anything else runs: an index names a position in an array + // a PREVIOUS call left on the page, and without a session there is no + // answer to whose array that is, only a shared slot to guess against. + let cache = interactive_cache_slot(caller.origin, require_indexed_session(caller)?); let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), + data: None, })?; let index = arguments .get("index") @@ -5582,13 +8758,29 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing index".to_string(), + data: None, })?; - let target = self.resolve_cdp_target(profile_id).await?; + let ctx = self.agent_context(profile_id).await?; + + if ctx.engine.is_wayfern() { + // As for click_element: the cached reference only says where the + // element is, and a real pointer does the clicking. + let point = locate_by_script(&ctx.target, indexed_rect_script(&cache, index)).await?; + let (_, navigated) = vellum_click(&ctx.target, point, None, None) + .await + .map_err(AgentError::into_mcp)?; + return Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": click_report(&format!("Clicked element at index {index}"), navigated) + }] + })); + } let js = format!( r#"(() => {{ - const arr = window.__donut_interactive; + const arr = window[{cache}]; if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.'); const el = arr[{index}]; el.scrollIntoView({{block: 'center'}}); @@ -5599,7 +8791,7 @@ impl McpServer { let result = self .send_cdp_and_wait_for_load( - &target, + &ctx.target, "Runtime.evaluate", serde_json::json!({ "expression": js, @@ -5619,6 +8811,7 @@ impl McpServer { return Err(McpError { code: -32000, message: msg.to_string(), + data: None, }); } @@ -5632,14 +8825,20 @@ impl McpServer { async fn handle_type_by_index( &self, + caller: McpCaller<'_>, arguments: &serde_json::Value, ) -> Result { + // Same refusal as click_by_index, and for the same reason: typing into + // whatever happens to sit at index 3 of somebody else's snapshot is a + // wrong action reported as a successful one. + let cache = interactive_cache_slot(caller.origin, require_indexed_session(caller)?); let profile_id = arguments .get("profile_id") .and_then(|v| v.as_str()) .ok_or_else(|| McpError { code: -32602, message: "Missing profile_id".to_string(), + data: None, })?; let index = arguments .get("index") @@ -5647,6 +8846,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing index".to_string(), + data: None, })?; let text = arguments .get("text") @@ -5654,6 +8854,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing text".to_string(), + data: None, })?; let clear_first = arguments .get("clear_first") @@ -5664,33 +8865,60 @@ impl McpServer { .and_then(|v| v.as_bool()) .unwrap_or(false); let wpm = arguments.get("wpm").and_then(|v| v.as_f64()); + let typos = arguments + .get("typos") + .and_then(|v| v.as_bool()) + .unwrap_or(true); - let target = self.resolve_cdp_target(profile_id).await?; + // Engine first, then budgets, then the page: see handle_type_text. + let profile = self.get_wayfern_profile(profile_id)?; + let humanized = Engine::for_version(&profile.version).is_wayfern() && !instant; + + // PLANNED FIRST, before anything touches the page. The planner can refuse + // (TYPING_TOO_LONG), and the focus step below empties the field when + // `clear_first` is set — so planning after it meant the server answered + // "refused, nothing happened" having already wiped the customer's form + // field. The refusal now precedes every mutation on every branch. + let plan = if instant || humanized { + None + } else { + Some(plan_typing(text, wpm, max_typing_seconds(caller.origin))?) + }; + let inscribe_timeout = if humanized { + Some(vellum_typing_budget( + text, + max_typing_seconds(caller.origin), + )?) + } else { + None + }; + + let target = resolve_target(&profile).await?; // Mirrors handle_type_text's focus step but resolves the element via the // cached index instead of a CSS selector. let focus_js = if clear_first { format!( r#"(() => {{ - const arr = window.__donut_interactive; + const arr = window[{cache}]; if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.'); const el = arr[{index}]; el.scrollIntoView({{block: 'center'}}); el.focus(); el.value = ''; el.dispatchEvent(new Event('input', {{bubbles: true}})); - return true; + {RETURN_RECT_JS} }})()"# ) } else { format!( r#"(() => {{ - const arr = window.__donut_interactive; + const arr = window[{cache}]; if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.'); const el = arr[{index}]; el.scrollIntoView({{block: 'center'}}); el.focus(); - return true; + {RETURN_RECT_JS} }})()"# ) }; @@ -5716,19 +8944,41 @@ impl McpServer { return Err(McpError { code: -32000, message: msg.to_string(), + data: None, }); } - if instant { - self - .send_cdp( - &target, - "Input.insertText", - serde_json::json!({ "text": text }), - ) - .await?; - } else { - self.send_human_keystrokes(&target, text, wpm).await?; + if let Some(timeout) = inscribe_timeout { + let point = rect_from_script_result(&focus_result)?; + let inscription = vellum_type( + &target, + point, + text, + typos, + caret_preparation(clear_first), + timeout, + ) + .await + .map_err(AgentError::into_mcp)?; + return Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": typing_report(&format!("Typed text into element at index {index}"), &inscription) + }] + })); + } + + match &plan { + Some(events) => self.send_planned_keystrokes(&target, events).await?, + None => { + self + .send_cdp( + &target, + "Input.insertText", + serde_json::json!({ "text": text }), + ) + .await?; + } } Ok(serde_json::json!({ @@ -5739,6 +8989,124 @@ impl McpServer { })) } + // --- Agent handlers: perception, locators, extraction, picker --- + + /// The running browser behind `profile_id`, and the engine its version gets. + async fn agent_context(&self, profile_id: &str) -> Result { + let profile = self.get_wayfern_profile(profile_id)?; + let target = resolve_target(&profile).await?; + Ok(AgentContext::new(profile, target)) + } + + /// Read a tool's arguments into the request type the shared operation takes. + /// + /// `profile_id` and any other argument the type does not name are ignored, + /// which is what lets one struct serve both the MCP arguments and the REST + /// body. + fn agent_arguments( + arguments: &serde_json::Value, + ) -> Result { + serde_json::from_value(arguments.clone()).map_err(|e| McpError { + code: -32602, + message: format!("Invalid arguments: {e}"), + data: None, + }) + } + + async fn handle_perceive_page( + &self, + arguments: &serde_json::Value, + ) -> Result { + let profile_id = Self::require_str(arguments, "profile_id")?; + let request: PerceptionRequest = Self::agent_arguments(arguments)?; + let ctx = self.agent_context(profile_id).await?; + let page = agent_perceive(&ctx, &request) + .await + .map_err(AgentError::into_mcp)?; + Self::json_content(&page) + } + + async fn handle_resolve_locator( + &self, + arguments: &serde_json::Value, + ) -> Result { + let profile_id = Self::require_str(arguments, "profile_id")?; + let request: AgentResolveRequest = Self::agent_arguments(arguments)?; + let ctx = self.agent_context(profile_id).await?; + let resolved = agent_resolve_locator(&ctx, &request) + .await + .map_err(AgentError::into_mcp)?; + Self::json_content(&resolved) + } + + async fn handle_click_locator( + &self, + arguments: &serde_json::Value, + ) -> Result { + let profile_id = Self::require_str(arguments, "profile_id")?; + let request: AgentClickRequest = Self::agent_arguments(arguments)?; + let ctx = self.agent_context(profile_id).await?; + let clicked = agent_click_locator(&ctx, &request) + .await + .map_err(AgentError::into_mcp)?; + Self::json_content(&clicked) + } + + async fn handle_type_locator( + &self, + caller: McpCaller<'_>, + arguments: &serde_json::Value, + ) -> Result { + let profile_id = Self::require_str(arguments, "profile_id")?; + let request: AgentTypeRequest = Self::agent_arguments(arguments)?; + let ctx = self.agent_context(profile_id).await?; + let typed = agent_type_locator(&ctx, &request, max_typing_seconds(caller.origin)) + .await + .map_err(AgentError::into_mcp)?; + Self::json_content(&typed) + } + + async fn handle_extract_structured( + &self, + caller: McpCaller<'_>, + arguments: &serde_json::Value, + ) -> Result { + let profile_id = Self::require_str(arguments, "profile_id")?; + let mut request: ExtractionRequest = Self::agent_arguments(arguments)?; + // Clamped to what the transport can carry, like the picker's wait. + request.time_budget_ms = Some( + request + .time_budget_ms + .unwrap_or(8_000) + .min(max_extraction_budget_ms(caller.origin)), + ); + let ctx = self.agent_context(profile_id).await?; + let extraction = agent_extract(&ctx, &request) + .await + .map_err(AgentError::into_mcp)?; + Self::json_content(&extraction) + } + + async fn handle_pick_element( + &self, + caller: McpCaller<'_>, + arguments: &serde_json::Value, + ) -> Result { + let profile_id = Self::require_str(arguments, "profile_id")?; + let request: AgentPickRequest = Self::agent_arguments(arguments)?; + // Clamped rather than refused: a caller asking for ten minutes over the + // bridge gets the longest wait the relay will actually carry. + let timeout_ms = request + .timeout_ms + .unwrap_or(DEFAULT_PICK_TIMEOUT_MS) + .clamp(1_000, max_pick_timeout_ms(caller.origin)); + let ctx = self.agent_context(profile_id).await?; + let picked = agent_pick_element(&ctx, timeout_ms) + .await + .map_err(AgentError::into_mcp)?; + Self::json_content(&picked) + } + // --- Synchronizer handlers --- async fn handle_start_sync_session( @@ -5751,6 +9119,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing leader_profile_id".to_string(), + data: None, })?; let follower_ids: Vec = arguments .get("follower_profile_ids") @@ -5758,6 +9127,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing follower_profile_ids".to_string(), + data: None, })? .iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) @@ -5768,6 +9138,7 @@ impl McpServer { inner.app_handle.clone().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), + data: None, })? }; @@ -5777,6 +9148,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: e, + data: None, })?; Ok(serde_json::json!({ @@ -5797,6 +9169,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing session_id".to_string(), + data: None, })?; let app = { @@ -5804,6 +9177,7 @@ impl McpServer { inner.app_handle.clone().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), + data: None, })? }; @@ -5813,6 +9187,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: e, + data: None, })?; Ok(serde_json::json!({ @@ -5846,6 +9221,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing session_id".to_string(), + data: None, })?; let follower_id = arguments .get("follower_profile_id") @@ -5853,6 +9229,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing follower_profile_id".to_string(), + data: None, })?; let app = { @@ -5860,6 +9237,7 @@ impl McpServer { inner.app_handle.clone().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), + data: None, })? }; @@ -5869,6 +9247,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: e, + data: None, })?; Ok(serde_json::json!({ @@ -5891,10 +9270,28 @@ impl McpServer { let text = serde_json::to_string_pretty(value).map_err(|e| McpError { code: -32000, message: format!("Failed to encode response: {e}"), + data: None, })?; Ok(serde_json::json!({ "content": [{ "type": "text", "text": text }] })) } + /// Read a caller-supplied URL and validate its scheme in one step. + /// + /// The guard belongs HERE rather than at each call site: a regression test + /// that scans for `get("url")` cannot see a handler that reads the same + /// argument through `require_str`, and a handler that reads a URL without + /// validating it is how `file:///…` plus a content tool became a remote file + /// read. Reaching for this instead of `require_str` makes the safe path the + /// short one. + fn require_navigable_url<'a>( + arguments: &'a serde_json::Value, + key: &str, + ) -> Result<&'a str, McpError> { + let url = Self::require_str(arguments, key)?; + validate_navigable_url(url)?; + Ok(url) + } + fn require_str<'a>(arguments: &'a serde_json::Value, key: &str) -> Result<&'a str, McpError> { arguments .get(key) @@ -5903,6 +9300,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: format!("Missing {key}"), + data: None, }) } @@ -5914,6 +9312,7 @@ impl McpServer { Self::optional_u16(arguments, key)?.ok_or_else(|| McpError { code: -32602, message: format!("Missing {key}"), + data: None, }) } @@ -5928,6 +9327,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: format!("{key} must be a whole number between 0 and 65535"), + data: None, }) } @@ -5942,6 +9342,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: format!("{key} must be a whole number between 0 and 255"), + data: None, }) } @@ -5956,6 +9357,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: format!("{key} must be a whole number between 0 and 4294967295"), + data: None, }) } @@ -5968,6 +9370,7 @@ impl McpServer { McpError { code: -32000, message: err.to_error_json(), + data: None, } } @@ -5985,6 +9388,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: format!("Failed to list profiles: {e}"), + data: None, })?; let profile = profiles @@ -5993,12 +9397,14 @@ impl McpServer { .ok_or_else(|| McpError { code: -32000, message: format!("Profile not found: {profile_id}"), + data: None, })?; crate::cookie_bot::bot_precondition(&profile, &crate::cookie_bot::exit_reachability(&profile)) .map_err(|message| McpError { code: -32000, message, + data: None, })?; Ok(profile) } @@ -6017,6 +9423,9 @@ impl McpServer { .get("url") .and_then(|v| v.as_str()) .map(str::to_string); + if let Some(url) = url.as_deref() { + validate_navigable_url(url)?; + } let profile = self.get_wayfern_profile(profile_id)?; // The host pulls the profile from cloud storage, so one that has never @@ -6027,6 +9436,7 @@ impl McpServer { .map_err(|message| McpError { code: -32000, message, + data: None, })?; let app = { @@ -6034,6 +9444,7 @@ impl McpServer { inner.app_handle.clone().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), + data: None, })? }; @@ -6042,6 +9453,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: e.to_error_json(), + data: None, })?; Self::json_content(&outcome) } @@ -6056,6 +9468,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: e.to_error_json(), + data: None, })?; Self::json_content(&outcome) } @@ -6066,6 +9479,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: e.to_error_json(), + data: None, })?; Self::json_content(&sessions) } @@ -6079,6 +9493,7 @@ impl McpServer { .map_err(|e| McpError { code: -32000, message: e.to_error_json(), + data: None, })?; Self::json_content(&state) } @@ -6126,6 +9541,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32000, message: "Profile has no recorded operating system".to_string(), + data: None, })? .to_string(); @@ -6136,6 +9552,7 @@ impl McpServer { message: format!( "platform {requested:?} does not match the profile's own operating system {platform:?}" ), + data: None, }); } } @@ -6146,6 +9563,7 @@ impl McpServer { .ok_or_else(|| McpError { code: -32602, message: "Missing enabled".to_string(), + data: None, })?; let sites = arguments @@ -6170,12 +9588,14 @@ impl McpServer { days_mask: Self::optional_u8(arguments, "days_mask")?.ok_or_else(|| McpError { code: -32602, message: "Missing days_mask".to_string(), + data: None, })?, timezone: Self::require_str(arguments, "timezone")?.to_string(), preset: Self::require_str(arguments, "preset")?.to_string(), max_minutes: Self::optional_u32(arguments, "max_minutes")?.ok_or_else(|| McpError { code: -32602, message: "Missing max_minutes".to_string(), + data: None, })?, sites, jitter_seconds: Self::optional_u32(arguments, "jitter_seconds")?, @@ -6261,9 +9681,8 @@ impl McpServer { } async fn handle_list_cookie_bot_presets() -> Result { - // Ids and a rough duration only. What a preset expands to — the site - // ordering, the dwell model, the scroll and click programme — is the - // server's, and stays there. + // Ids and a rough duration only. What a preset expands to is the server's, + // and stays there. let presets = crate::cookie_bot::list_presets() .await .map_err(Self::cloud_error)?; @@ -6288,14 +9707,57 @@ lazy_static::lazy_static! { mod tests { use super::*; + #[tokio::test] + async fn the_local_tombstone_answers_gone_with_a_removal_message() { + // Local MCP is removed; anything that still reaches the loopback port must + // get a clear 410 with an actionable message, not the tool engine. + let response = McpServer::handle_local_deprecated().await; + assert_eq!(response.status(), StatusCode::GONE); + let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .expect("tombstone body"); + let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json body"); + assert_eq!(body["error"]["code"], -32001); + let message = body["error"]["message"].as_str().unwrap_or_default(); + assert!( + message.contains("removed") && message.to_lowercase().contains("remote"), + "the removal message must name the removal and point at remote MCP: {message}" + ); + } + + #[test] + fn the_deprecation_dialog_is_throttled_to_one_per_window() { + // A client retry loop hitting the dead port must not pop the dialog on + // every attempt: the first attempt in a window advances the clock, the + // next one inside it is a no-op. + LAST_LOCAL_DEPRECATION_EMIT.store(0, Ordering::SeqCst); + McpServer::note_local_mcp_attempt(); + let first = LAST_LOCAL_DEPRECATION_EMIT.load(Ordering::SeqCst); + assert!(first > 0, "the first attempt records a timestamp"); + McpServer::note_local_mcp_attempt(); + let second = LAST_LOCAL_DEPRECATION_EMIT.load(Ordering::SeqCst); + assert_eq!( + first, second, + "a second attempt inside the window does not re-emit" + ); + } + #[test] fn test_mcp_tools_count() { let server = McpServer::new(); let tools = server.get_tools(); - // Should have at least 59 tools (39 + 7 browser interaction + 13 remote - // fleet and cookie-bot tools) - assert!(tools.len() >= 59); + // PINNED, not a floor. This read `>= 59` while the server actually served + // 80, so twenty-one tools could be deleted without the assertion moving - + // and every one of them is a published contract an MCP client is written + // against. A floor that sits far below the real number is not a test, it + // is a comment. Changing this number is the deliberate edit that says a + // tool was added or removed on purpose. + assert_eq!( + tools.len(), + 87, + "the tool list is a published contract; update this number deliberately" + ); // Names are the contract an MCP client is written against, so a duplicate // silently shadows one of the two in dispatch and the tool that loses is @@ -6328,6 +9790,7 @@ mod tests { assert!(tool_names.contains(&"delete_group")); assert!(tool_names.contains(&"assign_profiles_to_group")); // Proxy tools + assert!(tool_names.contains(&"distribute_proxies")); assert!(tool_names.contains(&"list_proxies")); assert!(tool_names.contains(&"get_proxy")); assert!(tool_names.contains(&"create_proxy")); @@ -6377,6 +9840,14 @@ mod tests { assert!(tool_names.contains(&"type_text")); assert!(tool_names.contains(&"get_page_content")); assert!(tool_names.contains(&"get_page_info")); + // The agent surface: what an agent reads, how it names things, and how it + // acts on them without a selector. + assert!(tool_names.contains(&"perceive_page")); + assert!(tool_names.contains(&"resolve_locator")); + assert!(tool_names.contains(&"click_locator")); + assert!(tool_names.contains(&"type_locator")); + assert!(tool_names.contains(&"extract_structured")); + assert!(tool_names.contains(&"pick_element")); // Remote fleet: an agent must be able to start a session, see it become // usable, drive it with the tools above, and stop it. Any one of those // missing makes remote driving unusable from MCP alone. @@ -6469,8 +9940,16 @@ mod tests { linux.host_os = Some("linux".to_string()); assert!( crate::cookie_bot::bot_precondition(&linux, &crate::remote_exit::ExitReachability::Remote) + .is_ok(), + "the fleet serves linux from a linux instance" + ); + + let mut android = eligible(); + android.host_os = Some("android".to_string()); + assert!( + crate::cookie_bot::bot_precondition(&android, &crate::remote_exit::ExitReachability::Remote) .is_err(), - "the fleet cannot lease a linux host" + "the fleet has no android host to lease" ); let mut datacenter_egress = eligible(); @@ -6486,9 +9965,9 @@ mod tests { ); } - // Enrolment carries only the user's own scalars. A site list, a dwell range - // or a step programme appearing in the schema would mean the browsing model - // had leaked out of the server and into this AGPL client. + // Enrolment carries only the user's own scalars. Anything describing what a + // run actually does appearing in the schema would mean the browsing model had + // leaked out of the server and into this AGPL client. #[test] fn the_bot_tools_expose_choices_not_behaviour() { let server = McpServer::new(); @@ -6540,12 +10019,1617 @@ mod tests { assert!(required.iter().any(|field| field == "preset")); } + #[tokio::test] + async fn a_body_past_the_shared_cap_is_refused_by_the_engine_itself() { + // MAX_MESSAGE_BYTES is documented as one number for every transport, and it + // must match what the cloud endpoint accepts. Nothing exercised the + // check: deleting it left every test green, so the loopback listener and + // the bridge could quietly drift apart again, which is exactly the defect + // that made `import_profile_cookies` work locally and 413 remotely. + let server = McpServer::instance(); + server.mark_engine_ready_for_tests(); + + // Deliberately VALID JSON-RPC, just too big. A body of junk bytes would be + // refused as unparsable whether or not the cap exists, so it would assert + // nothing about the cap, the same two-paths-one-outcome trap that made an + // earlier test in this feature pass against deleted code. + let filler = "x".repeat(McpServer::MAX_MESSAGE_BYTES); + let oversized = format!( + r#"{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"navigate","arguments":{{"url":"{filler}"}}}}}}"# + ); + assert!(oversized.len() > McpServer::MAX_MESSAGE_BYTES); + assert!( + serde_json::from_str::(&oversized).is_ok(), + "the fixture must be parseable, or the cap is not what rejects it" + ); + assert!(matches!( + server + .handle_message(McpOrigin::Loopback, None, oversized.as_bytes()) + .await, + McpOutcome::BadRequest + )); + + // And a body just under it is judged on its content, not its size, the cap + // must not be doing the rejecting for ordinary calls. + let ok = br#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#; + assert!(ok.len() < McpServer::MAX_MESSAGE_BYTES); + assert!(matches!( + server.handle_message(McpOrigin::Loopback, None, ok).await, + McpOutcome::Body { .. } + )); + } + + /// Blank out comments and string literals so brace counting sees only code. + /// + /// Lengths and line breaks are preserved, so offsets and line numbers still + /// line up with the original source. + fn code_only(source: &str) -> String { + let chars: Vec = source.chars().collect(); + let mut out = String::with_capacity(chars.len()); + let mut i = 0; + + // Blank a span, keeping newlines so line numbers survive. + let blank = |out: &mut String, from: usize, to: usize| { + for &c in &chars[from..to] { + out.push(if c == '\n' { '\n' } else { ' ' }); + } + }; + + while i < chars.len() { + let c = chars[i]; + + // A line comment: everything to the end of the line. + if c == '/' && chars.get(i + 1) == Some(&'/') { + let mut end = i; + while end < chars.len() && chars[end] != '\n' { + end += 1; + } + blank(&mut out, i, end); + i = end; + continue; + } + + // A raw string: `r`, any number of `#`, then the quote. Only when the `r` + // starts a token, or the tail of an identifier such as `for` opens one. + let starts_token = i == 0 || !(chars[i - 1].is_alphanumeric() || chars[i - 1] == '_'); + if c == 'r' && starts_token { + let mut hashes = 0; + while chars.get(i + 1 + hashes) == Some(&'#') { + hashes += 1; + } + if chars.get(i + 1 + hashes) == Some(&'"') { + let closing = format!("\"{}", "#".repeat(hashes)); + let body_start = i + 2 + hashes; + let rest: String = chars[body_start..].iter().collect(); + let end = rest + .find(&closing) + .map_or(chars.len(), |at| body_start + rest[..at].chars().count()); + blank(&mut out, i, end); + i = end + closing.chars().count(); + blank(&mut out, end, i.min(chars.len())); + continue; + } + } + + // An ordinary string, with backslash escapes. + if c == '"' { + let mut end = i + 1; + while end < chars.len() && chars[end] != '"' { + end += if chars[end] == '\\' { 2 } else { 1 }; + } + let end = end.min(chars.len()); + blank(&mut out, i, end); + i = end; + if i < chars.len() { + out.push(' '); + i += 1; + } + continue; + } + + out.push(c); + i += 1; + } + + out + } + + /// Every line (1-based) that binds a guard on the engine mutex and then + /// reaches an `.await` while that guard is still alive. + /// + /// The discriminator is SCOPE, not spelling. A guard lives until the block + /// that owns it closes, so the question is whether that closing brace comes + /// before the next await: + /// + /// ```ignore + /// let handle = { // safe: the block ends first + /// let inner = self.inner.lock().await; + /// inner.app_handle.as_ref().ok_or(..)?.clone() + /// }; + /// something(&handle).await; + /// + /// let inner = self.inner.lock().await; // held: no enclosing block + /// let handle = inner.app_handle.as_ref().ok_or(..)?.clone(); + /// something(&handle).await; // the whole engine is frozen + /// ``` + /// + /// The previous version keyed on `.clone()` in the binding statement, which + /// the second shape also has, so it skipped a real violation as though it + /// were the safe one. + fn engine_locks_held_across_an_await(source: &str) -> (Vec, usize) { + let code = code_only(source); + let chars: Vec = code.chars().collect(); + + // Char offset where each line begins, so a hit can be reported by line. + let mut line_of = Vec::with_capacity(chars.len() + 1); + let mut line = 1usize; + for &c in &chars { + line_of.push(line); + if c == '\n' { + line += 1; + } + } + line_of.push(line); + + let needle: Vec = "self.inner.lock().await;".chars().collect(); + let at = |i: usize, pat: &[char]| chars[i..].starts_with(pat); + let await_pat: Vec = ".await".chars().collect(); + let drop_pat: Vec = "drop(inner)".chars().collect(); + + let mut offenders = Vec::new(); + let mut sites = 0usize; + + let mut i = 0; + while i < chars.len() { + if !at(i, &needle) { + i += 1; + continue; + } + // Only a BOUND guard outlives its statement. `self.inner.lock().await.x` + // is a temporary that dies at the semicolon. + let line_start = chars[..i] + .iter() + .rposition(|&c| c == '\n') + .map_or(0, |newline| newline + 1); + let prefix: String = chars[line_start..i].iter().collect(); + if !prefix.trim_start().starts_with("let ") { + i += 1; + continue; + } + sites += 1; + + // Walk forward from the end of the lock statement. `depth` counts how + // deep we are INSIDE the guard's own block; the `}` that takes it to -1 + // is the one that drops the guard. + let mut depth = 0i32; + let mut cursor = i + needle.len(); + while cursor < chars.len() { + match chars[cursor] { + '{' => depth += 1, + '}' => { + if depth == 0 { + break; // the enclosing block closed: the guard is gone + } + depth -= 1; + } + _ => { + if at(cursor, &drop_pat) { + break; // released by hand before the await + } + if at(cursor, &await_pat) { + offenders.push(line_of[i]); + break; + } + } + } + cursor += 1; + } + + i += needle.len(); + } + + (offenders, sites) + } + + #[test] + fn no_handler_holds_the_engine_lock_across_an_await() { + // `handle_message` takes this same mutex to validate the session on EVERY + // request, and to serve `initialize`. A handler that keeps the guard alive + // across a browser launch therefore freezes the whole engine for the + // duration: the launching agent's follow-up calls, a second agent, the + // website console, and even a new client trying to open a session. + // + // The detector is exercised in BOTH directions first, because the previous + // one could only be pointed at this file, and a rule that has never been + // shown to fire is indistinguishable from one that cannot. + let unscoped = r#" + async fn bad(&self) -> Result<(), McpError> { + let inner = self.inner.lock().await; + let app_handle = inner + .app_handle + .as_ref() + .ok_or_else(|| McpError { code: -32000, message: "no handle".to_string(), data: None })? + .clone(); + something(&app_handle).await; + Ok(()) + } +"#; + let (flagged, sites) = engine_locks_held_across_an_await(unscoped); + assert_eq!(sites, 1, "the fixture must be seen as a lock site at all"); + assert_eq!( + flagged, + vec![3], + "a guard bound at statement level is alive at the await, whatever the \ + binding does with `.clone()`" + ); + + let scoped = r#" + async fn good(&self) -> Result<(), McpError> { + let app_handle = { + let inner = self.inner.lock().await; + inner + .app_handle + .as_ref() + .ok_or_else(|| McpError { code: -32000, message: "no handle".to_string(), data: None })? + .clone() + }; + something(&app_handle).await; + Ok(()) + } +"#; + let (flagged, sites) = engine_locks_held_across_an_await(scoped); + assert_eq!(sites, 1); + assert!( + flagged.is_empty(), + "the block expression drops the guard before the await: {flagged:?}" + ); + + // And a guard let go by hand is not a violation either. + let released = r#" + async fn also_good(&self) -> Result<(), McpError> { + let inner = self.inner.lock().await; + let app_handle = inner.app_handle.clone(); + drop(inner); + something(&app_handle).await; + Ok(()) + } +"#; + let (flagged, _) = engine_locks_held_across_an_await(released); + assert!( + flagged.is_empty(), + "an explicit drop releases it: {flagged:?}" + ); + + // Now the file itself. + let source = include_str!("mcp_server.rs"); + let (offenders, sites) = engine_locks_held_across_an_await(source); + assert!( + sites >= 25, + "the scan found only {sites} lock sites, so it has gone blind to most of \ + the file, the same way the line-window version silently matched none" + ); + assert!( + offenders.is_empty(), + "these lines hold the engine mutex across an await, freezing every \ + other MCP message for the duration: {offenders:?}. Clone the handle \ + inside a block expression so the guard drops first." + ); + } + + #[test] + fn initialize_echoes_a_protocol_version_the_client_can_accept() { + // The client's check is unforgiving: the official SDK compares the + // answer against ITS OWN supported list and throws + // "Server's protocol version is not supported" on a miss. Answering with + // our newest regardless made the whole remote feature unreachable from any + // agent that had not upgraded in lockstep with us. + for asked in SUPPORTED_PROTOCOL_VERSIONS { + assert_eq!( + negotiate_protocol_version(Some(asked)), + *asked, + "a version we speak must be echoed back verbatim" + ); + } + + // Unknown or absent: answer with one we do support, so the client can + // decide for itself rather than be handed something meaningless. + assert_eq!(negotiate_protocol_version(None), PROTOCOL_VERSION); + assert_eq!( + negotiate_protocol_version(Some("1999-01-01")), + PROTOCOL_VERSION + ); + assert_eq!(negotiate_protocol_version(Some("")), PROTOCOL_VERSION); + assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&PROTOCOL_VERSION)); + } + + #[tokio::test] + async fn an_older_client_is_answered_in_its_own_dialect() { + let server = McpServer::new(); + server.mark_engine_ready_for_tests(); + + let init = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{}}}"#; + let McpOutcome::Body { body, .. } = + server.handle_message(McpOrigin::Loopback, None, init).await + else { + panic!("initialize must answer"); + }; + assert_eq!( + body["result"]["protocolVersion"], "2024-11-05", + "the client asked in 2024-11-05 and must be answered in it: {body}" + ); + + // And a client that names nothing still gets a usable answer. + let bare = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#; + let McpOutcome::Body { body, .. } = + server.handle_message(McpOrigin::Loopback, None, bare).await + else { + panic!("initialize must answer"); + }; + assert_eq!(body["result"]["protocolVersion"], PROTOCOL_VERSION); + } + + #[test] + fn the_browser_is_never_told_to_open_a_local_file() { + // Over the CLOUD BRIDGE this is the difference between "control your + // browser" and an arbitrary local-file read reachable from the internet: + // `Page.navigate` loads `file:///…/.ssh/id_rsa` happily, and + // `get_page_content` hands the bytes back to whoever asked. + for blocked in [ + "file:///etc/passwd", + "FILE:///etc/passwd", + " file:///etc/passwd ", + "file://localhost/etc/passwd", + "data:text/html,", + "javascript:alert(1)", + "chrome://settings", + "devtools://devtools/bundled/inspector.html", + "blob:https://example.com/abc", + "view-source:file:///etc/passwd", + "", + "/etc/passwd", + "\\\\server\\share", + ] { + let refused = validate_navigable_url(blocked); + assert!(refused.is_err(), "{blocked:?} must be refused"); + assert!( + refused + .unwrap_err() + .message + .contains("URL_SCHEME_NOT_ALLOWED"), + "{blocked:?} must refuse with a code the UI can translate" + ); + } + + // And the ones a browser is actually asked to browse still work. + for allowed in [ + "http://example.com", + "https://example.com/path?q=1#frag", + "HTTPS://EXAMPLE.COM", + "about:blank", + " https://example.com ", + ] { + assert!( + validate_navigable_url(allowed).is_ok(), + "{allowed:?} must be allowed" + ); + } + } + + #[test] + fn every_url_entry_point_is_guarded() { + // Derived from the source, and deliberately BROAD. Two earlier versions + // could not fail for the drift they existed to catch: + // - `count >= 6` plus four hand-typed handler names saw nothing new; + // - keying on the literal `get("url")` skipped any handler reading the + // same argument through `require_str`, which is this file's newer + // idiom, and the scan counted ITSELF as a fifth guarded handler, so + // the floor tolerated a real one silently dropping out. + // The test module is therefore cut off before scanning, and any method + // mentioning a "url" argument at all must validate. + let full = include_str!("mcp_server.rs"); + let source = full + .split_once("\n#[cfg(test)]") + .map(|(code, _)| code) + .unwrap_or(full); + + let starts: Vec = source + .match_indices("\n ") + .filter(|(i, _)| { + let rest = &source[i + 3..]; + [ + "fn ", + "async fn ", + "pub fn ", + "pub async fn ", + "pub(crate) fn ", + "pub(crate) async fn ", + ] + .iter() + .any(|p| rest.starts_with(p)) + }) + .map(|(i, _)| i) + .collect(); + assert!(starts.len() > 20, "method scan found {}", starts.len()); + + let mut unguarded = Vec::new(); + let mut guarded = Vec::new(); + for (n, &begin) in starts.iter().enumerate() { + let stop = starts.get(n + 1).copied().unwrap_or(source.len()); + let body = &source[begin..stop]; + // ANY method that reads a "url" argument from the caller, however it + // spells the read. + if !(body.contains("arguments") && body.contains(r#""url""#)) { + continue; + } + let name = body + .trim_start() + .trim_start_matches("pub(crate) ") + .trim_start_matches("pub ") + .trim_start_matches("async ") + .trim_start_matches("fn ") + .split(['(', '<']) + .next() + .unwrap_or("?") + .to_string(); + if body.contains("validate_navigable_url") || body.contains("require_navigable_url") { + guarded.push(name); + } else { + unguarded.push(name); + } + } + + assert!( + unguarded.is_empty(), + "these handlers take a url from the caller and never validate its \ + scheme, which is how `file:///…` plus a content tool became a remote \ + file read: {unguarded:?}" + ); + // Pinned exactly, so a handler that DISAPPEARS from the scan, renamed, + // reformatted past the matcher, or deleted, fails loudly instead of + // shrinking the set the test believes it is protecting. + guarded.sort(); + assert_eq!( + guarded, + vec![ + "handle_batch_run_profiles", + "handle_navigate", + "handle_run_profile", + "handle_run_profile_remote", + ], + "the set of url-taking handlers changed; add the new one here once it \ + validates, or find out why one stopped being seen" + ); + } + + #[test] + fn two_callers_never_share_an_element_index_cache() { + // `get_interactive_elements` stashes live element references on the page and + // hands back indices; `click_by_index` resolves an index against that stash. + // It used to be ONE `window.__donut_interactive` array for the whole page, + // and then one per TRANSPORT, which is still shared, because the website + // console and an agent both arrive over the bridge, as do two runs of the + // same agent. Session A lists elements, B lists them, A's + // `click_by_index(3)` resolves against B's array and clicks the wrong + // thing while reporting success, on somebody's real browser. + fn slot(origin: McpOrigin, session: &str) -> String { + interactive_cache_slot(origin, session) + } + + let a = slot(McpOrigin::Bridge, "11111111-2222-3333-4444-555555555555"); + let b = slot(McpOrigin::Bridge, "66666666-7777-8888-9999-000000000000"); + assert_ne!( + a, b, + "two sessions on ONE transport must not share a slot, this is the \ + collision that origin-only keying could not see" + ); + + // Stable for one session, or an agent's own second call would lose the + // array its first call built. + assert_eq!( + a, + slot(McpOrigin::Bridge, "11111111-2222-3333-4444-555555555555"), + "the same session must resolve to the same slot every time" + ); + + // The transport still separates callers that share a session id. + assert_ne!( + a, + slot(McpOrigin::Loopback, "11111111-2222-3333-4444-555555555555") + ); + + // DISTINCTNESS, which the previous key only claimed. Truncating at 64 + // characters and mapping everything outside [A-Za-z0-9_] to `_` collapsed + // both of these pairs onto ONE slot, and a collision here is precisely the + // wrong click the key exists to prevent. + let shared_prefix = "s".repeat(64); + assert_ne!( + slot(McpOrigin::Bridge, &format!("{shared_prefix}-one")), + slot(McpOrigin::Bridge, &format!("{shared_prefix}-two")), + "two ids agreeing on a 64-character prefix must not share a slot" + ); + assert_ne!( + slot(McpOrigin::Bridge, "abc-def"), + slot(McpOrigin::Bridge, "abc_def"), + "ids differing only in a character the old key erased must not share a slot" + ); + + // Quoted JS string literals, because they are substituted into + // `window[...]`. An unquoted identifier would read a different global, and + // the session half is the only caller-supplied part, so nothing in it may + // close the quote or paste an expression. + let overlong = "z".repeat(5_000); + for candidate in [ + a, + slot(McpOrigin::Bridge, "'; window.x = 1; //"), + slot(McpOrigin::Loopback, "../../etc\\passwd"), + slot(McpOrigin::Bridge, ""), + slot(McpOrigin::Bridge, overlong.as_str()), + ] { + assert!( + candidate.starts_with('\'') && candidate.ends_with('\''), + "the slot must be a quoted JS string, got {candidate}" + ); + let inside = &candidate[1..candidate.len() - 1]; + assert!( + inside + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_'), + "a caller-supplied id must not survive into the slot unsanitised: {candidate}" + ); + assert!( + candidate.len() < 128, + "a caller must not be able to grow the evaluated script: {} chars", + candidate.len() + ); + } + + // And every script that touches the cache goes through the substitution + // rather than naming a slot directly, or one of them would keep writing to + // the shared global while the others moved. + let source = include_str!("mcp_server.rs"); + let production = source + .split_once("\n#[cfg(test)]") + .map_or(source, |(code, _)| code); + assert!( + !production.contains("window.__donut_interactive"), + "no script may name the shared global directly any more" + ); + assert_eq!( + production.matches("window[__CACHE__]").count() + + production.matches("window[{cache}]").count() + + production.matches("window[{slot}]").count(), + 7, + "the seven cache sites (one write, four reads, two in the teardown) must \ + all be parameterised" + ); + } + + #[tokio::test] + async fn an_index_tool_without_a_session_is_refused_not_guessed_at() { + // The hole the per-session key left open. `handle_message` serves a client + // that never called `initialize`, and the bridge passes whatever `sessionId` + // the relay frame carried, including none, so every sessionless caller + // used to land on ONE literal `anon` slot per transport. Two of them then + // shared an array, and session A's `click_by_index(3)` clicked whatever sat + // third in B's snapshot and reported "Clicked element at index 3". + // + // There is no safe slot to give such a caller, so it is refused. The + // refusal has to come FIRST, before argument parsing and before anything + // touches the page. + let server = McpServer::new(); + let sessionless = McpCaller { + origin: McpOrigin::Bridge, + session: None, + }; + let with_session = McpCaller { + origin: McpOrigin::Bridge, + session: Some("11111111-2222-3333-4444-555555555555"), + }; + // Deliberately complete arguments: the refusal must be about the missing + // session, not about anything the caller forgot to send. + let args = serde_json::json!({ + "profile_id": "00000000-0000-0000-0000-000000000000", + "index": 3, + "text": "hello", + }); + + let refusals = [ + server + .handle_get_interactive_elements(sessionless, &args) + .await + .expect_err("listing must refuse a sessionless caller"), + server + .handle_click_by_index(sessionless, &args) + .await + .expect_err("clicking by index must refuse a sessionless caller"), + server + .handle_type_by_index(sessionless, &args) + .await + .expect_err("typing by index must refuse a sessionless caller"), + ]; + for refusal in &refusals { + assert_eq!( + refusal.code, -32600, + "a missing session is a bad request, not a tool failure: {}", + refusal.message + ); + assert!( + refusal.message.contains("initialize"), + "the refusal must tell the agent what to do about it: {}", + refusal.message + ); + } + + // And the refusal is about the SESSION, not a coincidence of this fixture: + // the same call WITH a session gets past the gate and fails later, on the + // profile that does not exist. + let later = server + .handle_click_by_index(with_session, &args) + .await + .expect_err("no such profile in a unit test"); + assert!( + !later.message.contains("initialize"), + "a caller that HAS a session must not be refused for lacking one: {}", + later.message + ); + } + + #[tokio::test] + async fn ending_a_session_releases_the_page_globals_it_left_behind() { + // Every session mints its own page global, and before this nothing ever + // deleted one: `end_session` dropped the server-side record only, and no + // CDP call removed the array. A long-lived tab therefore accumulated one + // array of live element references per session that had ever listed it, + // pinning detached nodes for as long as the page stayed open. + let server = McpServer::new(); + server.mark_engine_ready_for_tests(); + + let init = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#; + let McpOutcome::Body { new_session_id, .. } = + server.handle_message(McpOrigin::Bridge, None, init).await + else { + panic!("initialize must mint a session"); + }; + let session = new_session_id.expect("session id"); + let slot = interactive_cache_slot(McpOrigin::Bridge, &session); + + server + .remember_cached_page(&session, "profile-a", &slot) + .await; + server + .remember_cached_page(&session, "profile-b", &slot) + .await; + // Recorded once per page, not once per call. + server + .remember_cached_page(&session, "profile-a", &slot) + .await; + assert_eq!( + server.inner.lock().await.sessions[&session] + .cached_pages + .len(), + 2, + "a session must remember every page it wrote a snapshot to, once each" + ); + + // Ending it takes the pages with it. Neither profile exists here, so the + // CDP delete cannot land, which is exactly the path that must still leave + // no server-side record behind, and must still return promptly. + server.end_session(&session).await; + let inner = server.inner.lock().await; + assert!( + !inner.sessions.contains_key(&session), + "the session itself must be gone" + ); + drop(inner); + + // A snapshot taken after the teardown must not resurrect the session. + server + .remember_cached_page(&session, "profile-c", &slot) + .await; + assert!( + !server.inner.lock().await.sessions.contains_key(&session), + "a late write must not recreate a session nothing will ever clean up" + ); + } + + #[test] + fn one_page_cannot_accumulate_unbounded_element_caches() { + // The teardown above only fires when a session ENDS, and no first-party + // client sends `end_session`, an evicted, crashed or simply abandoned + // session never will. So the page bounds itself too: the enumeration + // script keeps a registry of the slots it has written and deletes the + // oldest once the cap is passed. + let script = INTERACTIVE_ELEMENTS_JS + .replace("__MAX_CHARS__", "40000") + .replace("__CACHE__", "'__donut_interactive_bridge_abc'") + .replace("__REGISTRY__", INTERACTIVE_SLOT_REGISTRY) + .replace("__MAX_SLOTS__", &MAX_CACHE_SLOTS_PER_PAGE.to_string()); + + assert!( + !script.contains("__MAX_CHARS__") + && !script.contains("__CACHE__") + && !script.contains("__REGISTRY__") + && !script.contains("__MAX_SLOTS__"), + "every placeholder must be substituted, or the page throws instead of listing" + ); + assert!( + script.contains(&format!("kept.length > {MAX_CACHE_SLOTS_PER_PAGE}")), + "the cap must be a real number in the emitted script" + ); + assert!( + script.contains("delete window[evicted]"), + "passing the cap must actually delete the evicted slot, not merely stop \ + tracking it" + ); + assert!( + (1..=32).contains(&MAX_CACHE_SLOTS_PER_PAGE), + "a cap of {MAX_CACHE_SLOTS_PER_PAGE} is either no cap at all or too tight \ + for the callers legitimately driving one page" + ); + + // The teardown deletes the same global the script writes, or `end_session` + // clears a slot nobody uses while the real one leaks on. + let production = include_str!("mcp_server.rs") + .split_once("\n#[cfg(test)]") + .map_or("", |(code, _)| code); + assert!( + production.contains("delete window[{slot}]"), + "end_session must delete the page global itself" + ); + assert!( + production.contains("session.cached_pages"), + "end_session must take the pages from the session it removes" + ); + } + + #[tokio::test] + async fn the_session_the_transport_validated_is_the_one_the_cache_keys_on() { + // The slot is only per-session if the session actually REACHES the cache. + // `handle_message` is the one place both transports hand a session id in, + // so the wiring from there down to the three cache sites is asserted here: + // without it `interactive_cache_slot` could be perfectly correct and every + // caller would still be handed a slot chosen from nothing. + let production = include_str!("mcp_server.rs") + .split_once("\n#[cfg(test)]") + .map_or("", |(code, _)| code); + + let flattened: String = production.split_whitespace().collect::>().join(" "); + assert!( + flattened.contains("McpCaller { origin, session: session_id"), + "handle_message must pass the caller's session on to the dispatcher, or \ + every caller is refused the index tools it is entitled to" + ); + for handler in [ + "handle_get_interactive_elements", + "handle_click_by_index", + "handle_type_by_index", + ] { + let body = production + .split(&format!("async fn {handler}(")) + .nth(1) + .unwrap_or_else(|| panic!("{handler} must exist")); + let signature = &body[..body.find(") ->").unwrap_or(body.len())]; + assert!( + signature.contains("caller: McpCaller"), + "{handler} must be told WHO is asking, not just how they got here: \ + {signature}" + ); + } + + // End to end: two sessions, one transport, and the engine must not answer + // the second one's `click_by_index` from the first one's snapshot. Both + // calls fail here (no browser in a unit test), so what is asserted is that + // the sessions are distinct and both survive the session check, the + // per-session slot is asserted directly above. + let server = McpServer::new(); + server.mark_engine_ready_for_tests(); + let init = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#; + + let mut ids = Vec::new(); + for _ in 0..2 { + let McpOutcome::Body { new_session_id, .. } = + server.handle_message(McpOrigin::Bridge, None, init).await + else { + panic!("initialize must answer"); + }; + ids.push(new_session_id.expect("initialize must mint a session id")); + } + assert_ne!( + ids[0], ids[1], + "two initializes on one transport must be two different sessions" + ); + assert_ne!( + interactive_cache_slot(McpOrigin::Bridge, &ids[0]), + interactive_cache_slot(McpOrigin::Bridge, &ids[1]), + "two live sessions on one transport must not resolve to one slot" + ); + + // Both survive the session check, so neither is refused for a reason other + // than the one under test. + let ping = br#"{"jsonrpc":"2.0","id":2,"method":"ping"}"#; + for id in &ids { + assert!( + matches!( + server + .handle_message(McpOrigin::Bridge, Some(id.as_str()), ping) + .await, + McpOutcome::Body { .. } + ), + "a freshly minted session must be usable" + ); + } + } + + #[test] + fn a_typing_request_that_would_run_for_hours_is_refused_before_it_starts() { + // The DECISION, exercised. An earlier version of this test asserted only + // that the plan arithmetic crossed the bound and that the constant appeared + // before `target.connect()` in the source, and neither noticed the guard + // being turned into `if false && planned > MAX_TYPING_SECONDS`, nor the + // bound being raised past anything reachable. Both mutations were MISSED. + // Calling the function that makes the decision catches both. + let ordinary = plan_typing("hello there", None, MAX_TYPING_SECONDS).expect("an ordinary field"); + assert!(!ordinary.is_empty(), "a real plan must be produced"); + + // `session_wpm` is floored at 10 in human_typing.rs, so this is the slowest + // a plan can be, and the text length, which nothing bounds, is what makes + // the hours reachable. + // 1,000 characters, not 20,000. At the wpm floor a keystroke costs + // 60 / (10 * 5) = 1.2s, so ~250 characters already crosses the 300s bound - + // and generating a 20,000 character plan took this one test 24 seconds, on + // a suite that otherwise runs in seven. + // Still within MAX_TYPING_CHARS, so this exercises the DURATION bound + // rather than the length one: 1,000 characters at the wpm floor plans in + // well under a second but would take over an hour to type. + let refusal = plan_typing(&"a".repeat(1_000), Some(10.0), MAX_TYPING_SECONDS) + .expect_err("1,000 characters at the slowest rate must be refused"); + assert_eq!(refusal.code, -32602); + assert!( + refusal.message.contains("TYPING_TOO_LONG"), + "the refusal must carry a translatable code: {}", + refusal.message + ); + // The numbers travel with it, so the caller is told what to change rather + // than only that it was too much. + let body: serde_json::Value = + serde_json::from_str(&refusal.message).expect("the code envelope is JSON"); + assert_eq!(body["params"]["limit"], "300"); + assert!( + body["params"]["seconds"] + .as_str() + .and_then(|s| s.parse::().ok()) + .is_some_and(|seconds| seconds > MAX_TYPING_SECONDS), + "the reported duration must be the one that broke the bound: {body}" + ); + + // The LENGTH bound must bite before the plan is built, or the caller pays + // the superlinear planning cost of an arbitrarily long string first. Timed, + // because "it refuses" is not the property, "it refuses CHEAPLY" is. + let huge = "a".repeat(400_000); + let started = std::time::Instant::now(); + let long_refusal = plan_typing(&huge, None, MAX_TYPING_SECONDS).expect_err("400,000 chars"); + let took = started.elapsed(); + assert!( + long_refusal.message.contains("TYPING_TOO_LONG"), + "{}", + long_refusal.message + ); + assert!( + took < std::time::Duration::from_millis(500), + "the refusal must not require planning the text first; took {took:?}" + ); + + // And the sending path can no longer refuse at all: it is handed a plan + // that was already accepted. + let source = include_str!("mcp_server.rs"); + let production = source + .split_once("\n#[cfg(test)]") + .map_or(source, |(code, _)| code); + let sender = production + .split("async fn send_planned_keystrokes(") + .nth(1) + .expect("send_planned_keystrokes must exist"); + let sender = &sender[..sender.find("\n }").unwrap_or(sender.len())]; + assert!( + !sender.contains("plan_typing"), + "the sender must take the accepted plan, not decide for itself, \ + deciding here is what put the refusal after the field was emptied" + ); + assert!( + !production.contains("send_human_keystrokes"), + "the plan-it-yourself entry point must be gone, or a handler can call \ + it and reintroduce the late refusal" + ); + } + + #[test] + fn a_refused_typing_request_has_not_already_emptied_the_field() { + // The ORDER, which the duration bound alone never pinned. Both typing + // handlers used to focus the element and run `el.value = ''` first and call + // `plan_typing` only afterwards, so a TYPING_TOO_LONG was a lie: the server + // answered "refused" with the customer's form field already wiped. Nothing + // may touch the page before the plan is accepted, on EITHER branch of + // `clear_first`. + let production = include_str!("mcp_server.rs") + .split_once("\n#[cfg(test)]") + .map_or("", |(code, _)| code); + + for handler in ["handle_type_text", "handle_type_by_index"] { + let rest = production + .split(&format!("async fn {handler}(")) + .nth(1) + .unwrap_or_else(|| panic!("{handler} must exist")); + let body = &rest[..rest.find("\n async fn ").unwrap_or(rest.len())]; + + let plan = body + .find("plan_typing(text, wpm, max_typing_seconds(caller.origin))?") + .unwrap_or_else(|| panic!("{handler} must plan through the bounded builder")); + assert_eq!( + body.matches("plan_typing(").count(), + 1, + "{handler} must plan exactly once, before the page is touched" + ); + + // Both branches of `clear_first` are present, and BOTH are built after + // the plan. The clearing branch is the one that destroys data; the other + // still focuses and scrolls, which is a mutation the caller can see. + assert_eq!( + body.matches("el.value = ''").count(), + 1, + "{handler} must still have exactly one clearing branch to order" + ); + assert_eq!( + body.matches("el.focus();").count(), + 2, + "{handler} must have both a clearing and a non-clearing focus branch" + ); + + for mutation in [ + "el.value = ''", + "el.focus();", + "scrollIntoView", + "let focus_js", + ".send_cdp(", + "send_planned_keystrokes", + "Input.insertText", + ] { + let at = body + .find(mutation) + .unwrap_or_else(|| panic!("{handler} no longer contains {mutation}")); + assert!( + plan < at, + "{handler} reaches {mutation:?} at {at} before planning at {plan}: a \ + refusal raised after that point has already changed the page it \ + claims it did not touch" + ); + } + + // And the refusal really is a refusal: `?` on the plan, not a swallowed + // error that lets the handler carry on and clear the field anyway. + assert!( + body.contains("Some(plan_typing(text, wpm, max_typing_seconds(caller.origin))?)"), + "{handler} must propagate the refusal rather than absorb it" + ); + } + } + + #[test] + fn a_bridge_caller_may_type_for_less_than_the_relay_will_wait() { + // A call over the bridge is answered with a timeout if it runs too long, so + // a plan that would type for longer is a guaranteed failure that still holds + // a process-wide permit for its whole duration. Loopback keeps the five + // minutes: nothing upstream times it out. + assert_eq!(max_typing_seconds(McpOrigin::Loopback), 300.0); + assert_eq!(max_typing_seconds(McpOrigin::Bridge), 80.0); + assert!( + max_typing_seconds(McpOrigin::Bridge) < 90.0, + "the bridge budget must stay under the relay's 90 s call budget" + ); + + // Exercised, not merely asserted: the same text is refused over the + // bridge and accepted over loopback. Each plan is built afresh with its + // own randomness, so the fixture sits far from both bounds: 1,200 + // characters at 200 wpm plan to roughly 160 seconds, and the session rate + // is sampled with a standard deviation of 10 wpm (five percent here; at + // the 10 wpm floor it was a factor of three, which is why the fixture is + // not typed at the floor). Fatigue makes the plan superlinear, so the + // count is not to be scaled by eye. + let text = "a".repeat(1_200); + let over_loopback = plan_typing(&text, Some(200.0), max_typing_seconds(McpOrigin::Loopback)) + .expect("about 160 seconds is inside the loopback budget"); + let planned = over_loopback.last().map_or(0.0, |event| event.time); + assert!( + planned > 110.0 && planned < 250.0, + "the fixture must sit well clear of both budgets, planned {planned}s" + ); + + let over_bridge = plan_typing(&text, Some(200.0), max_typing_seconds(McpOrigin::Bridge)) + .expect_err("the same text must be refused over the bridge"); + let body: serde_json::Value = serde_json::from_str(&over_bridge.message).unwrap(); + assert_eq!(body["code"], "TYPING_TOO_LONG"); + assert_eq!( + body["params"]["limit"], "80", + "the refusal must name the budget that applied, not the loopback one" + ); + } + + #[test] + fn a_stored_proxy_loses_its_secrets_and_nothing_else_under_redaction() { + let mut proxy = serde_json::json!({ + "id": "p1", + "name": "Berlin", + "proxy_settings": { + "proxy_type": "socks5", + "host": "proxy.example", + "port": 1080, + "username": "user", + "password": "hunter2", + "vless_uri": "vless://uuid@host:443?security=reality#name" + }, + "geo_country": "DE", + "dynamic_proxy_url": "https://lists.example/rotate?key=SECRET" + }); + redact_proxy_secrets(&mut proxy); + + assert_eq!(proxy["proxy_settings"]["password"], "[redacted]"); + assert_eq!(proxy["proxy_settings"]["vless_uri"], "[redacted]"); + assert_eq!(proxy["dynamic_proxy_url"], "[redacted]"); + // Everything an agent needs to choose a proxy survives. + assert_eq!(proxy["id"], "p1"); + assert_eq!(proxy["name"], "Berlin"); + assert_eq!(proxy["proxy_settings"]["host"], "proxy.example"); + assert_eq!(proxy["proxy_settings"]["port"], 1080); + assert_eq!(proxy["proxy_settings"]["username"], "user"); + assert_eq!(proxy["geo_country"], "DE"); + let text = proxy.to_string(); + assert!(!text.contains("hunter2") && !text.contains("SECRET") && !text.contains("vless://")); + + // A proxy with no secrets is left exactly as it was: no field is invented + // just to say it was redacted. + let mut bare = serde_json::json!({ + "id": "p2", + "name": "Plain", + "proxy_settings": { + "proxy_type": "http", + "host": "h", + "port": 8080, + "username": null, + "password": null + } + }); + let before = bare.clone(); + redact_proxy_secrets(&mut bare); + assert_eq!(bare, before); + } + + #[test] + fn the_proxy_readers_redact_for_the_bridge_and_only_for_the_bridge() { + // `handle_list_proxies` and `handle_get_proxy` serialize a `StoredProxy` + // whole. Both must route a BRIDGE caller through the redaction and leave + // a loopback caller's answer untouched: the local agent is on the machine + // that stores the password, and truncating its view would break the + // existing export/import round trip over loopback. + let production = include_str!("mcp_server.rs") + .split_once("\n#[cfg(test)]") + .map_or("", |(code, _)| code); + for handler in ["handle_list_proxies", "handle_get_proxy"] { + let rest = production + .split(&format!("async fn {handler}(")) + .nth(1) + .unwrap_or_else(|| panic!("{handler} must exist")); + let body = &rest[..rest.find("\n async fn ").unwrap_or(rest.len())]; + assert!( + body.contains("caller: McpCaller<'_>"), + "{handler} must know who is asking" + ); + let gate = body + .find("if caller.origin == McpOrigin::Bridge") + .unwrap_or_else(|| panic!("{handler} must gate the redaction on the bridge origin")); + let redact = body + .find("redact_proxy_secrets(") + .unwrap_or_else(|| panic!("{handler} must redact")); + assert!( + gate < redact, + "{handler} must redact inside the bridge gate" + ); + assert_eq!( + body.matches("redact_proxy_secrets(").count(), + 1, + "{handler} must redact in exactly one place, under the gate" + ); + } + } + + #[tokio::test] + async fn exporting_proxies_is_refused_over_the_bridge_before_the_arguments_are_read() { + // The export exists to write every password and VLESS URI out in full, + // which is what the redaction above withholds from a remote caller. The + // refusal sits at the gate, so a well-formed export and a malformed one + // are indistinguishable from outside. + let server = McpServer::new(); + server.mark_engine_ready_for_tests(); + + for arguments in [r#"{"format":"json"}"#, r#"{}"#] { + let call = format!( + r#"{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"export_proxies","arguments":{arguments}}}}}"# + ); + let McpOutcome::Body { body, .. } = server + .handle_message(McpOrigin::Bridge, None, call.as_bytes()) + .await + else { + panic!("expected an answer"); + }; + assert!( + body["error"]["message"] + .as_str() + .unwrap_or_default() + .contains("TOOL_IS_LOCAL_ONLY"), + "export_proxies must be refused over the bridge: {body}" + ); + } + + // Over loopback it is not refused for that reason. With no format it + // fails on its own terms, which proves the gate did not fire. + let probe = r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"export_proxies","arguments":{}}}"#; + let McpOutcome::Body { body, .. } = server + .handle_message(McpOrigin::Loopback, None, probe.as_bytes()) + .await + else { + panic!("expected an answer"); + }; + let local = body["error"]["message"].as_str().unwrap_or_default(); + assert!( + !local.contains("TOOL_IS_LOCAL_ONLY"), + "a caller on this machine may still export: {body}" + ); + assert!(local.contains("Missing format"), "{body}"); + + // And the list is pinned: only the export is refused by name, and it is. + assert_eq!(SECRET_EXPORT_TOOLS, &["export_proxies"]); + } + + #[test] + fn every_tool_that_reads_a_local_path_is_on_the_local_only_list() { + // Derived from the source, and the list is PINNED. Iterating + // LOCAL_PATH_TOOLS to check each entry is refused is a tautology - + // deleting an entry just shortens the loop, so the membership itself is + // asserted, and a handler that reads a path without being listed fails. + let full = include_str!("mcp_server.rs"); + let source = full + .split_once("\n#[cfg(test)]") + .map(|(code, _)| code) + .unwrap_or(full); + + let starts: Vec = source + .match_indices("\n ") + .filter(|(i, _)| { + let rest = &source[i + 3..]; + ["fn ", "async fn ", "pub fn ", "pub async fn "] + .iter() + .any(|p| rest.starts_with(p)) + }) + .map(|(i, _)| i) + .collect(); + + let mut readers = Vec::new(); + for (n, &begin) in starts.iter().enumerate() { + let stop = starts.get(n + 1).copied().unwrap_or(source.len()); + let body = &source[begin..stop]; + if !body.contains("arguments") { + continue; + } + if body.contains(r#""path""#) || body.contains(r#""folder""#) { + let name = body + .trim_start() + .trim_start_matches("pub ") + .trim_start_matches("async ") + .trim_start_matches("fn ") + .split(['(', '<']) + .next() + .unwrap_or("?") + .trim_start_matches("handle_") + .to_string(); + readers.push(name); + } + } + + // The scan above only sees a path named as a LITERAL argument key in the + // handler. `import_browser_profiles` takes its path as `items[].source_path` + // on a deserialized struct, so no such literal appears anywhere in its body + // and it sat off the list, reachable from the internet, until a review + // caught it by reading the struct instead. The declared schema is the + // contract that does show nested arguments, so it is walked here too and + // the two sources are unioned: a path reachable through EITHER the argument + // keys or the published schema has to be refused over the bridge. + fn path_like(name: &str) -> bool { + let lowered = name.to_ascii_lowercase(); + lowered.contains("path") + || lowered.contains("folder") + || lowered.contains("directory") + || lowered == "dir" + || lowered.ends_with("_dir") + } + + fn walk(schema: &serde_json::Value, found: &mut bool) { + let serde_json::Value::Object(map) = schema else { + return; + }; + if let Some(serde_json::Value::Object(props)) = map.get("properties") { + for (key, value) in props { + if path_like(key) { + *found = true; + } + walk(value, found); + } + } + if let Some(items) = map.get("items") { + walk(items, found); + } + for branch in ["anyOf", "oneOf", "allOf"] { + if let Some(serde_json::Value::Array(options)) = map.get(branch) { + for option in options { + walk(option, found); + } + } + } + } + + for tool in McpServer::new().get_tools() { + let mut found = false; + walk(&tool.input_schema, &mut found); + if found { + readers.push(tool.name.clone()); + } + } + + readers.sort(); + readers.dedup(); + + let mut listed: Vec = LOCAL_PATH_TOOLS.iter().map(|t| t.to_string()).collect(); + listed.sort(); + assert_eq!( + readers, listed, + "a handler reads a caller-supplied filesystem path but is not refused \ + over the bridge (or the list names one that no longer reads a path)" + ); + } + + #[test] + fn the_bridge_declares_itself_as_the_bridge() { + // The whole property rests on the transport telling the truth about where + // a message came from. Nothing else in the tree asserts this wiring, so a + // one-word edit in mcp_remote.rs would silently reopen every local-path + // tool to the internet. + let remote = include_str!("mcp_remote.rs"); + assert!( + remote.contains("McpOrigin::Bridge"), + "the bridge must declare its own origin when handing a message to the engine" + ); + assert!( + !remote.contains("McpOrigin::Loopback"), + "the bridge must never claim to be a caller standing on this machine" + ); + } + + #[tokio::test] + async fn a_tool_that_reads_this_machine_is_refused_over_the_bridge() { + // `add_extension` takes a caller-supplied path, `fs::read`s it, stores the + // bytes, and the sync engine uploads them, an arbitrary local-file read + // with the same shape as the `file://` hole the URL allowlist closed, but + // reachable from the internet with an account credential. + // + // A remote caller cannot know this machine's filesystem, so there is no + // legitimate remote use to preserve: refusing is strictly better than + // guessing at safe roots. + let server = McpServer::new(); + server.mark_engine_ready_for_tests(); + + for tool in LOCAL_PATH_TOOLS { + let call = format!( + r#"{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"{tool}","arguments":{{"path":"/etc/passwd","folder":"/etc"}}}}}}"# + ); + + let McpOutcome::Body { body, .. } = server + .handle_message(McpOrigin::Bridge, None, call.as_bytes()) + .await + else { + panic!("expected an answer for {tool}"); + }; + let message = body["error"]["message"].as_str().unwrap_or_default(); + assert!( + message.contains("TOOL_IS_LOCAL_ONLY"), + "{tool} must be refused over the bridge with a translatable code: {body}" + ); + + // And the SAME call over loopback is not refused for that reason: the + // caller is already on the machine. It may fail for its own reasons - + // the path does not exist, but never with this code. + let McpOutcome::Body { body, .. } = server + .handle_message(McpOrigin::Loopback, None, call.as_bytes()) + .await + else { + panic!("expected an answer for {tool}"); + }; + let local = body["error"]["message"].as_str().unwrap_or_default(); + assert!( + !local.contains("TOOL_IS_LOCAL_ONLY"), + "{tool} must still be available to a caller on this machine: {body}" + ); + } + } + + #[tokio::test] + async fn a_nested_path_is_refused_over_the_bridge_in_its_real_shape() { + // The loop above sends `{"path": ..., "folder": ...}` to every listed + // tool, which is not the shape `import_browser_profiles` actually takes - + // its path rides inside `items[].source_path`. Sending the real payload is + // the difference between proving the NAME is on a list and proving the + // CALL an attacker would make is refused. + let server = McpServer::new(); + server.mark_engine_ready_for_tests(); + + let call = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "import_browser_profiles", + "arguments": { + "items": [{ + "source_path": "/Users/someone/Library/Application Support/Google/Chrome/Default", + "new_profile_name": "stolen" + }] + } + } + }) + .to_string(); + + let McpOutcome::Body { body, .. } = server + .handle_message(McpOrigin::Bridge, None, call.as_bytes()) + .await + else { + panic!("expected an answer"); + }; + assert!( + body["error"]["message"] + .as_str() + .unwrap_or_default() + .contains("TOOL_IS_LOCAL_ONLY"), + "a remote caller must not be able to name a directory on this disk: {body}" + ); + + // The refusal must happen BEFORE the arguments are even parsed, so a + // malformed remote payload cannot be told apart from a well-formed one. + // Otherwise the error text itself answers "does this path exist". + let probe = serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { "name": "import_browser_profiles", "arguments": {} } + }) + .to_string(); + let McpOutcome::Body { body, .. } = server + .handle_message(McpOrigin::Bridge, None, probe.as_bytes()) + .await + else { + panic!("expected an answer"); + }; + assert!( + body["error"]["message"] + .as_str() + .unwrap_or_default() + .contains("TOOL_IS_LOCAL_ONLY"), + "the gate must sit ahead of argument parsing: {body}" + ); + } + #[test] fn test_mcp_server_initial_state() { let server = McpServer::new(); assert!(!server.is_running()); } + #[tokio::test] + async fn the_session_map_is_bounded() { + // Nothing evicts a session except an explicit `end_session`, and no + // first-party client sends one, so without a cap the map grows for the + // life of the process. The oldest is evicted rather than the newest + // refused: the caller asking for a session is the one actually present. + let server = McpServer::new(); + server.mark_engine_ready_for_tests(); + + let init = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#; + let mut first = None; + for i in 0..(MAX_SESSIONS + 8) { + let McpOutcome::Body { new_session_id, .. } = + server.handle_message(McpOrigin::Loopback, None, init).await + else { + panic!("initialize must mint a session"); + }; + if i == 0 { + first = new_session_id; + } + } + + assert!( + server.inner.lock().await.sessions.len() <= MAX_SESSIONS, + "the session map must stay bounded" + ); + + // The oldest went first, and the map still works for a fresh session. + let ping = br#"{"jsonrpc":"2.0","id":2,"method":"ping"}"#; + assert!(matches!( + server + .handle_message( + McpOrigin::Loopback, + Some(&first.expect("first session")), + ping + ) + .await, + McpOutcome::UnknownSession + )); + } + + #[tokio::test] + async fn the_cap_evicts_what_is_idle_not_what_is_busy() { + // Evicting by CREATION time threw out the wrong session every time: a + // long-lived agent's is by definition the oldest, so a wall of abandoned + // sessions from closed browser tabs would evict the one client actually + // working, and the official MCP SDK does not re-initialize on the 404 + // that follows, so that agent stays wedged. + let server = McpServer::new(); + server.mark_engine_ready_for_tests(); + + let init = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#; + let ping = br#"{"jsonrpc":"2.0","id":2,"method":"ping"}"#; + + let McpOutcome::Body { new_session_id, .. } = + server.handle_message(McpOrigin::Loopback, None, init).await + else { + panic!("initialize must mint a session"); + }; + let agent = new_session_id.expect("session id"); + + // Fill the map, keeping the agent's session in active use throughout. + let mut newest = None; + for i in 0..(MAX_SESSIONS + 16) { + if let McpOutcome::Body { new_session_id, .. } = + server.handle_message(McpOrigin::Loopback, None, init).await + { + newest = new_session_id; + } + if i % 4 == 0 { + assert!( + matches!( + server + .handle_message(McpOrigin::Loopback, Some(&agent), ping) + .await, + McpOutcome::Body { .. } + ), + "the busy session must survive: it is the one being used" + ); + } + } + + assert!( + server.inner.lock().await.sessions.len() <= MAX_SESSIONS, + "still bounded" + ); + assert!( + matches!( + server + .handle_message(McpOrigin::Loopback, Some(&agent), ping) + .await, + McpOutcome::Body { .. } + ), + "the session in continuous use must outlive the idle ones" + ); + + // And the newest survives too. Without this the test passes for an + // eviction policy that throws out whatever just arrived, which keeps the + // map bounded and the busy session alive while making every new client + // unable to hold a session at all. + assert!( + matches!( + server + .handle_message( + McpOrigin::Loopback, + Some(&newest.expect("a newest session")), + ping + ) + .await, + McpOutcome::Body { .. } + ), + "a freshly minted session must not be the one evicted" + ); + } + + #[tokio::test] + async fn closing_the_local_listener_keeps_sessions_the_bridge_is_using() { + // One engine serves two transports. `stop()` is a statement about the + // loopback listener only, the cloud bridge may be mid-conversation, but + // it used to clear the shared session map, so turning the local switch off + // answered 404 MCP_SESSION_NOT_FOUND to a remote caller that had nothing to + // do with the local one. The website re-initializes on a 404 and self-heals; + // the official MCP TypeScript SDK throws on any non-ok POST, so a + // third-party agent took a hard mid-run error from an unrelated toggle. + let server = McpServer::new(); + server.mark_engine_ready_for_tests(); + + let init = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#; + let McpOutcome::Body { new_session_id, .. } = + server.handle_message(McpOrigin::Loopback, None, init).await + else { + panic!("initialize must mint a session"); + }; + let session = new_session_id.expect("initialize must return a session id"); + + server.mark_running_for_tests(); + server + .stop() + .await + .expect("stop should succeed once running"); + + // The session must still be usable over the bridge. Destructured rather + // than matched on the variant alone: `Body` is ALSO what an engine-not- + // ready error comes back as, so a bare `matches!` would still pass if + // stop() had torn down the engine, pinning only half of what it promises. + let ping = br#"{"jsonrpc":"2.0","id":2,"method":"ping"}"#; + let McpOutcome::Body { body, .. } = server + .handle_message(McpOrigin::Loopback, Some(&session), ping) + .await + else { + panic!("stopping the loopback listener must not invalidate a bridge session"); + }; + assert!( + body.get("error").is_none(), + "stop() must leave the engine able to answer, not just the session id valid: {body}" + ); + assert!( + body.get("result").is_some(), + "expected a real answer: {body}" + ); + + // And an id that was never minted is still rejected, so the check above is + // not passing merely because session validation stopped happening. + assert!(matches!( + server + .handle_message( + McpOrigin::Loopback, + Some("00000000-0000-4000-8000-000000000000"), + ping + ) + .await, + McpOutcome::UnknownSession + )); + } + + #[tokio::test] + async fn the_launch_settings_an_agent_can_set_are_the_ones_it_can_read() { + let server = McpServer::new(); + let tools = server.handle_tools_list().await.expect("tools list"); + let update = tools["tools"] + .as_array() + .expect("tools array") + .iter() + .find(|tool| tool["name"] == "update_profile_fingerprint") + .expect("update_profile_fingerprint is advertised"); + let properties = &update["inputSchema"]["properties"]; + for field in ["restore_session", "webrtc_mode"] { + assert!( + properties.get(field).is_some(), + "{field} must be settable through the tool that owns the Wayfern config" + ); + } + assert_eq!( + properties["webrtc_mode"]["enum"], + serde_json::json!(["auto", "tcp_only", "block"]), + "the modes offered must be the modes the launcher understands" + ); + // A mode the launcher would silently read as `auto` is refused instead. + assert!(crate::wayfern_manager::WebRtcMode::parse("sideways").is_none()); + } + #[test] fn proxy_tool_schema_exposes_vless_reality_without_requiring_regular_endpoint_fields() { let server = McpServer::new(); @@ -6594,6 +11678,13 @@ mod tests { "get_interactive_elements", "click_by_index", "type_by_index", + // The agent surface reads and drives the same browser. + "perceive_page", + "resolve_locator", + "click_locator", + "type_locator", + "extract_structured", + "pick_element", // Leases a remote host for up to two hours and spends the pooled // remote-hour budget. "run_cookie_bot_now", @@ -6610,9 +11701,9 @@ mod tests { for name in [ "list_profiles", - // Configuration, not automation: one row in Donut cloud, no hardware - // leased. Metering it would throttle an agent enrolling a fleet of - // profiles, while the budget that guards the hardware is spent per run. + // Configuration, not automation: nothing is leased. Metering it would + // throttle an agent enrolling many profiles, and it is not what spends + // the account's hours. "set_cookie_bot_schedule", "delete_cookie_bot_schedule", "list_cookie_bot_schedules", @@ -6636,4 +11727,536 @@ mod tests { None ))); } + + // --- The agent surface -------------------------------------------------- + // + // Driven against the fake Wayfern socket in `wayfern_cdp::test_support`, + // because the property that matters, WHICH engine a profile gets and what + // that engine puts on the wire, only shows up when something answers. + + fn agent_context_for(version: &str, target: CdpTarget) -> AgentContext { + AgentContext::new( + BrowserProfile { + id: uuid::Uuid::nil(), + name: "p".to_string(), + browser: "wayfern".to_string(), + version: version.to_string(), + ..Default::default() + }, + target, + ) + } + + #[tokio::test] + async fn a_152_profile_clicks_through_vellum_and_an_older_one_through_dispatch() { + use crate::wayfern_cdp::test_support::{fake_browser, methods, Fake}; + + let request: AgentClickRequest = serde_json::from_value(serde_json::json!({ + "locator": { "role": "button", "name": "Save" } + })) + .unwrap(); + + // The 152 engine: the native resolver names the node, the DOM says where + // it is on screen, and a real pointer strikes it. No script runs in the + // page and nothing is dispatched synthetically. + let (target, frames) = fake_browser(Fake::Cooperative).await; + let ctx = agent_context_for("152.0.7977.64", target); + assert_eq!(ctx.engine, Engine::Wayfern); + let clicked = agent_click_locator(&ctx, &request) + .await + .expect("a cooperative browser completes the click"); + assert!(clicked.clicked); + assert_eq!(clicked.engine, Engine::Wayfern); + assert_eq!(clicked.matched.signature, "s7"); + let sent = methods(&frames); + let vellum: Vec<&str> = sent + .iter() + .filter(|m| m.starts_with("Vellum.")) + .map(String::as_str) + .collect(); + assert_eq!( + vellum, + vec![ + "Vellum.acquire", + "Vellum.glide", + "Vellum.strike", + "Vellum.release" + ] + ); + assert!(sent.iter().any(|m| m == "Wayfern.resolveLocator")); + assert!(sent.iter().any(|m| m == "DOM.getContentQuads")); + assert!( + !sent + .iter() + .any(|m| m == "Input.dispatchMouseEvent" || m == "Runtime.evaluate"), + "the native path must inject nothing and dispatch nothing: {sent:?}" + ); + // The page was told to expect a navigation, and told to stop afterwards. + assert_eq!(sent.first().map(String::as_str), Some("Page.enable")); + assert_eq!(sent.last().map(String::as_str), Some("Page.disable")); + let wire = serde_json::to_value(&clicked).unwrap(); + assert_eq!(wire["match"]["backendNodeId"], 7); + assert_eq!(wire["engine"], "wayfern"); + assert_eq!(wire["navigated"], false); + + // The fallback engine: the locator is applied by a script and the click + // is a trusted mouse event at the element's centre, exactly what the + // selector tools have always done. + let (target, frames) = fake_browser(Fake::Cooperative).await; + let ctx = agent_context_for("151.0.7922.76", target); + assert_eq!(ctx.engine, Engine::Fallback); + let clicked = agent_click_locator(&ctx, &request).await.unwrap(); + assert_eq!(clicked.engine, Engine::Fallback); + assert_eq!(clicked.matched.signature, "s7"); + let sent = methods(&frames); + assert!(sent.iter().any(|m| m == "Runtime.evaluate")); + let mouse: Vec = frames + .lock() + .unwrap() + .iter() + .filter(|f| f["method"] == "Input.dispatchMouseEvent") + .cloned() + .collect(); + assert_eq!(mouse.len(), 3, "move, press, release: {sent:?}"); + assert_eq!(mouse[0]["params"]["type"], "mouseMoved"); + assert_eq!(mouse[1]["params"]["type"], "mousePressed"); + assert_eq!(mouse[2]["params"]["type"], "mouseReleased"); + assert_eq!(mouse[1]["params"]["x"], 140.0); + assert_eq!(mouse[1]["params"]["button"], "left"); + assert!( + !sent + .iter() + .any(|m| m.starts_with("Vellum.") || m.starts_with("Wayfern.")), + "an older browser must never be sent the domains it lacks: {sent:?}" + ); + } + + #[tokio::test] + async fn a_152_profile_types_through_inscribe_and_an_older_one_through_key_events() { + use crate::wayfern_cdp::test_support::{fake_browser, methods, Fake}; + + let request: AgentTypeRequest = serde_json::from_value(serde_json::json!({ + "locator": { "role": "textbox", "name": "Email" }, + "text": "hi", + "clear_first": true + })) + .unwrap(); + + let (target, frames) = fake_browser(Fake::Cooperative).await; + let ctx = agent_context_for("152.0.7977.64", target); + let typed = agent_type_locator(&ctx, &request, 300.0).await.unwrap(); + assert!(typed.typed); + assert_eq!(typed.engine, Engine::Wayfern); + assert_eq!(typed.characters, 2); + assert_eq!(typed.corrections, Some(1)); + let sent = methods(&frames); + let native: Vec<&str> = sent + .iter() + .filter(|m| { + m.starts_with("Vellum.") || m.starts_with("DOM.resolveNode") || m.starts_with("Runtime.") + }) + .map(String::as_str) + .collect(); + // Focus by a real strike, THEN the field is emptied on the resolved node, + // then the keys: clearing before the click would leave the caret wherever + // the click landed in a field that is no longer empty. + assert_eq!( + native, + vec![ + "Vellum.acquire", + "Vellum.glide", + "Vellum.strike", + "DOM.resolveNode", + "Runtime.callFunctionOn", + "Vellum.inscribe", + "Vellum.release" + ] + ); + { + let sent_frames = frames.lock().unwrap(); + let inscribe = sent_frames + .iter() + .find(|f| f["method"] == "Vellum.inscribe") + .unwrap(); + assert_eq!(inscribe["params"]["text"], "hi"); + assert_eq!(inscribe["params"]["typos"], true); + let clear = sent_frames + .iter() + .find(|f| f["method"] == "Runtime.callFunctionOn") + .unwrap(); + assert_eq!(clear["params"]["arguments"][0]["value"], true); + assert_eq!(clear["params"]["objectId"], "obj-7"); + } + + let (target, frames) = fake_browser(Fake::Cooperative).await; + let ctx = agent_context_for("151.0.7922.76", target); + let typed = agent_type_locator(&ctx, &request, 300.0).await.unwrap(); + assert_eq!(typed.engine, Engine::Fallback); + assert_eq!(typed.characters, 2); + assert_eq!(typed.corrections, None); + let sent = methods(&frames); + assert_eq!(sent.first().map(String::as_str), Some("Runtime.evaluate")); + assert!( + sent + .iter() + .filter(|m| *m == "Input.dispatchKeyEvent") + .count() + >= 4, + "two characters are at least two key downs and two key ups: {sent:?}" + ); + assert!(!sent.iter().any(|m| m.starts_with("Vellum."))); + } + + #[tokio::test] + async fn the_152_only_tools_refuse_an_older_profile_before_touching_the_browser() { + use crate::wayfern_cdp::test_support::{fake_browser, methods, Fake}; + + let (target, frames) = fake_browser(Fake::Cooperative).await; + let ctx = agent_context_for("151.0.7922.76", target); + + let extraction: ExtractionRequest = serde_json::from_value(serde_json::json!({ + "container": { "role": "listitem" }, + "field_map": [{ "key": "title", "locator": { "role": "link" }, "source": "text" }] + })) + .unwrap(); + let refused = agent_extract(&ctx, &extraction) + .await + .expect_err("extraction has no fallback"); + assert!( + matches!(&refused, AgentError::RequiresWayfern152 { version } if version == "151.0.7922.76") + ); + let error = refused.into_mcp(); + assert_eq!(error.code, -32000); + assert!(error.message.contains("Wayfern 152")); + assert_eq!(error.data.as_ref().unwrap()["code"], "WAYFERN_152_REQUIRED"); + + let refused = agent_pick_element(&ctx, 5_000) + .await + .expect_err("the picker has no fallback"); + assert!(matches!(refused, AgentError::RequiresWayfern152 { .. })); + + assert!( + methods(&frames).is_empty(), + "a refusal for the browser's version must not open a socket to it" + ); + + // And a request that cannot mean anything is refused on either engine, + // before the version is even consulted. + let malformed: ExtractionRequest = serde_json::from_value(serde_json::json!({ + "container": { "role": "listitem" }, + "field_map": [{ "key": "price", "locator": { "role": "cell" }, "source": "attribute" }] + })) + .unwrap(); + let ctx = agent_context_for( + "152.0.7977.64", + CdpTarget::Local { + ws_url: "ws://127.0.0.1:1/never".to_string(), + }, + ); + let refused = agent_extract(&ctx, &malformed) + .await + .expect_err("no attribute named"); + assert!( + matches!(refused, AgentError::InvalidArgument(_)), + "{refused:?}" + ); + assert_eq!(refused.into_mcp().code, -32602); + } + + #[tokio::test] + async fn an_ambiguous_locator_carries_its_candidates_in_the_error_data() { + use crate::wayfern_cdp::test_support::{fake_browser, Fake}; + + let request: AgentResolveRequest = serde_json::from_value(serde_json::json!({ + "locator": { "role": "button", "name": "Save" }, + "candidate_limit": 5 + })) + .unwrap(); + + for version in ["152.0.7977.64", "151.0.7922.76"] { + let (target, _) = fake_browser(Fake::AmbiguousLocator).await; + let ctx = agent_context_for(version, target); + let refused = agent_resolve_locator(&ctx, &request) + .await + .expect_err("two matches must be refused on both engines"); + let error = refused.into_mcp(); + assert_eq!(error.code, -32000, "{version}"); + assert!( + error + .message + .starts_with("Ambiguous locator: 2 nodes match"), + "{version}: {}", + error.message + ); + let data = error.data.expect("the candidates travel in data"); + assert_eq!(data["code"], "LOCATOR_AMBIGUOUS"); + assert_eq!(data["matchCount"], 2); + assert_eq!( + data["candidates"].as_array().map(Vec::len), + Some(2), + "{version}" + ); + assert_eq!(data["candidates"][0]["backendNodeId"], 7); + } + + // A clean resolution on both engines carries the engine that answered. + for (version, engine) in [("152.0.7977.64", "wayfern"), ("151.0.7922.76", "fallback")] { + let (target, _) = fake_browser(Fake::Cooperative).await; + let ctx = agent_context_for(version, target); + let resolved = agent_resolve_locator(&ctx, &request).await.unwrap(); + let wire = serde_json::to_value(&resolved).unwrap(); + assert_eq!(wire["engine"], engine); + assert_eq!(wire["matchCount"], 1); + assert_eq!(wire["match"]["signature"], "s7"); + assert_eq!(wire["locator"]["name"], "Save"); + } + + // An empty locator matches everything, which is never what was meant. + let empty: AgentResolveRequest = + serde_json::from_value(serde_json::json!({ "locator": {} })).unwrap(); + let (target, frames) = fake_browser(Fake::Cooperative).await; + let ctx = agent_context_for("152.0.7977.64", target); + let refused = agent_resolve_locator(&ctx, &empty) + .await + .expect_err("empty"); + assert!(matches!(refused, AgentError::InvalidArgument(_))); + assert!(crate::wayfern_cdp::test_support::methods(&frames).is_empty()); + } + + #[tokio::test] + async fn perception_keeps_the_browsers_shape_on_both_engines() { + use crate::wayfern_cdp::test_support::{fake_browser, methods, Fake}; + + let request = PerceptionRequest::default(); + let (target, frames) = fake_browser(Fake::Cooperative).await; + let ctx = agent_context_for("152.0.7977.64", target); + let page = agent_perceive(&ctx, &request).await.unwrap(); + assert_eq!(page.engine, Engine::Wayfern); + assert_eq!(page.nodes[0].name.as_deref(), Some("Save")); + assert_eq!(methods(&frames), vec!["Wayfern.capturePagePerception"]); + + let (target, frames) = fake_browser(Fake::Cooperative).await; + let ctx = agent_context_for("151.0.7922.76", target); + let page = agent_perceive(&ctx, &request).await.unwrap(); + assert_eq!(page.engine, Engine::Fallback); + assert!(page.snapshot_id.starts_with("fallback-")); + assert_eq!(page.nodes[0].name.as_deref(), Some("Save")); + assert_eq!(page.frames[0].frame_id, "f0"); + assert!(page.cursor.is_none()); + assert_eq!(methods(&frames), vec!["Runtime.evaluate"]); + let wire = serde_json::to_value(&page).unwrap(); + assert_eq!(wire["engine"], "fallback"); + assert_eq!(wire["nodes"][0]["inViewport"], true); + assert_eq!(wire["stats"]["returnedNodes"], 1); + + // A cursor is a 152 feature: the fallback cannot continue anything. + let continuation: PerceptionRequest = + serde_json::from_value(serde_json::json!({ "cursor": "snap-1.2" })).unwrap(); + let refused = agent_perceive(&ctx, &continuation) + .await + .expect_err("no cursors"); + assert!(matches!(refused, AgentError::InvalidArgument(_))); + } + + #[test] + fn the_picker_waits_less_over_the_bridge_than_the_relay_does() { + assert_eq!(max_pick_timeout_ms(McpOrigin::Loopback), 300_000); + assert_eq!(max_pick_timeout_ms(McpOrigin::Bridge), 80_000); + assert!( + max_pick_timeout_ms(McpOrigin::Bridge) < 90_000, + "a wait that outlives the relay's 90 s call budget is a guaranteed failure" + ); + assert!(DEFAULT_PICK_TIMEOUT_MS <= max_pick_timeout_ms(McpOrigin::Bridge)); + // Extraction pages through rows for up to two minutes on loopback and + // is held to the same bridge margin as typing and the picker. + assert_eq!(max_extraction_budget_ms(McpOrigin::Loopback), 120_000); + assert!(max_extraction_budget_ms(McpOrigin::Bridge) < 90_000); + } + + #[test] + fn the_vellum_typing_budget_refuses_before_the_page_is_touched() { + // The browser paces the keys itself, so the refusal has to be estimated + // from the length; it must be conservative, and it must be the same + // envelope the planner answers with. + let budget = vellum_typing_budget("hello there", 300.0).expect("an ordinary field"); + assert_eq!(budget, Duration::from_secs(300)); + + let refusal = vellum_typing_budget(&"a".repeat(2_000), 300.0).expect_err("too long"); + assert_eq!(refusal.code, -32602); + let body: serde_json::Value = serde_json::from_str(&refusal.message).unwrap(); + assert_eq!(body["code"], "TYPING_TOO_LONG"); + assert_eq!(body["params"]["limit"], "300"); + + // Over the bridge the same text is refused sooner. + assert!(vellum_typing_budget(&"a".repeat(400), 300.0).is_ok()); + assert!(vellum_typing_budget(&"a".repeat(400), 80.0).is_err()); + + // And the length bound bites first, whatever the budget. + let huge = "a".repeat(MAX_TYPING_CHARS + 1); + assert!(vellum_typing_budget(&huge, f64::MAX).is_err()); + + // The refusal is the structured error both doors understand. + let agent: AgentError = refusal.into(); + assert!(matches!(agent, AgentError::TypingTooLong { limit, .. } if limit == 300.0)); + } + + #[test] + fn the_typing_tools_plan_only_for_the_engine_that_needs_a_plan() { + // The selector and index typing tools keep their whole pre-152 body, and + // the source-scanning test above pins its order. What is pinned here is + // that the 152 branch is decided from the PROFILE'S version, read at the + // point of use, and that the Vellum budget is decided in the same place as + // the plan, before the focus script. + let production = include_str!("mcp_server.rs") + .split_once("\n#[cfg(test)]") + .map_or("", |(code, _)| code); + for handler in ["handle_type_text", "handle_type_by_index"] { + let rest = production + .split(&format!("async fn {handler}(")) + .nth(1) + .unwrap_or_else(|| panic!("{handler} must exist")); + let body = &rest[..rest.find("\n async fn ").unwrap_or(rest.len())]; + let engine = body + .find("Engine::for_version(&profile.version)") + .unwrap_or_else(|| panic!("{handler} must read the engine off the profile")); + let budget = body + .find("vellum_typing_budget(") + .unwrap_or_else(|| panic!("{handler} must budget Vellum typing")); + assert!( + body.matches("max_typing_seconds(caller.origin)").count() >= 2, + "{handler} must budget both engines on the caller's transport" + ); + let focus = body.find("let focus_js").unwrap(); + assert!( + engine < budget && budget < focus, + "{handler}: engine, budget, then the page" + ); + assert!( + body.contains("vellum_type("), + "{handler} must type through Vellum on a 152 profile" + ); + assert!( + body.contains("Input.insertText"), + "{handler} must keep the instant path for every engine" + ); + } + for handler in ["handle_click_element", "handle_click_by_index"] { + let rest = production + .split(&format!("async fn {handler}(")) + .nth(1) + .unwrap_or_else(|| panic!("{handler} must exist")); + let body = &rest[..rest.find("\n async fn ").unwrap_or(rest.len())]; + assert!(body.contains("ctx.engine.is_wayfern()"), "{handler}"); + assert!(body.contains("vellum_click("), "{handler}"); + assert!( + body.contains("el.click()"), + "{handler} must keep the pre-152 click" + ); + } + } + + #[tokio::test] + async fn every_agent_tool_is_advertised_gated_and_dispatchable() { + let server = McpServer::new(); + let advertised: Vec = server.get_tools().into_iter().map(|t| t.name).collect(); + let production = include_str!("mcp_server.rs") + .split_once("\n#[cfg(test)]") + .map_or("", |(code, _)| code); + let dispatch = production + .split("async fn dispatch_tool_call(") + .nth(1) + .expect("dispatch must exist"); + let dispatch = &dispatch[..dispatch.find("\n async fn ").unwrap_or(dispatch.len())]; + + for tool in [ + "perceive_page", + "resolve_locator", + "click_locator", + "type_locator", + "extract_structured", + "pick_element", + ] { + assert!( + advertised.iter().any(|t| t == tool), + "{tool} is not advertised" + ); + let arm = dispatch + .split(&format!("\"{tool}\" => {{")) + .nth(1) + .unwrap_or_else(|| panic!("{tool} is not dispatched")); + let arm = &arm[..arm.find("\n }").unwrap_or(arm.len())]; + assert!( + arm.contains("can_use_browser_automation"), + "{tool} must sit behind the browser-automation gate" + ); + // Every one of them takes a profile id and nothing from this disk, so + // the bridge is allowed to call them. + assert!(!LOCAL_PATH_TOOLS.contains(&tool)); + assert!(!SECRET_EXPORT_TOOLS.contains(&tool)); + } + + // And over the bridge they are answered, not refused as local-only. They + // fail here for want of a browser, never with the local-only code. + server.mark_engine_ready_for_tests(); + let call = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { "name": "perceive_page", "arguments": { "profile_id": "00000000-0000-0000-0000-000000000000" } } + }) + .to_string(); + let McpOutcome::Body { body, .. } = server + .handle_message(McpOrigin::Bridge, None, call.as_bytes()) + .await + else { + panic!("expected an answer"); + }; + let message = body["error"]["message"].as_str().unwrap_or_default(); + assert!( + !message.contains("TOOL_IS_LOCAL_ONLY"), + "the agent surface must be reachable over the bridge: {body}" + ); + } + + #[test] + fn aria_role_names_are_translated_to_the_browsers_tokens() { + // Wayfern indexes Blink's own role tokens, so `textbox` matches nothing + // there while `textField` does. An agent that has read ARIA writes the + // former; the translation is the client's job, and only for the tokens + // that differ in substance, since the browser already ignores case and + // separators. + let role = |name: &str| { + canonical_locator(&LocatorDescription { + role: Some(name.to_string()), + ..Default::default() + }) + .role + .unwrap() + }; + assert_eq!(role("textbox"), "textField"); + assert_eq!(role("TextBox"), "textField"); + assert_eq!(role("text-box"), "textField"); + assert_eq!(role("radio"), "radioButton"); + assert_eq!(role("img"), "image"); + assert_eq!(role("text"), "staticText"); + // What the browser already understands passes through untouched. + assert_eq!(role("textField"), "textField"); + assert_eq!(role("text_field"), "text_field"); + assert_eq!(role("button"), "button"); + assert_eq!(role("listitem"), "listitem"); + // And nothing else about the locator moves. + let full = LocatorDescription { + role: Some("textbox".into()), + name: Some("Email".into()), + attributes: Some(vec![crate::wayfern_cdp::LocatorAttribute { + name: "id".into(), + value: "email".into(), + }]), + ..Default::default() + }; + let canonical = canonical_locator(&full); + assert_eq!(canonical.name.as_deref(), Some("Email")); + assert_eq!(canonical.attributes, full.attributes); + assert!(canonical_locator(&LocatorDescription::default()).is_empty()); + } } diff --git a/src-tauri/src/platform_browser.rs b/src-tauri/src/platform_browser.rs index 3ee57bb..c44d707 100644 --- a/src-tauri/src/platform_browser.rs +++ b/src-tauri/src/platform_browser.rs @@ -4,38 +4,87 @@ use std::path::Path; use std::process::Command; /// True if a process command line refers to `profile_path` as a real browser -/// profile/data-dir argument, NOT merely a substring. A bare `contains` match -/// force-killed unrelated processes that happened to mention the path (editors, -/// `tail`, a terminal that `cd`'d there, or another profile whose path has this -/// one as a prefix). Mirrors the precise matching in browser_runner/wayfern_manager. +/// profile/data-dir argument. Only the `--user-data-dir=` / +/// `-profile=` flag form counts, because the results feed a SIGKILL loop: +/// a substring match, or the path accepted as a standalone argv token, also +/// caught unrelated processes that legitimately name the directory (`du -sh +/// `, `tar czf backup.tgz `, an editor, a sibling profile +/// whose path has this one as a prefix). `browser.rs` only ever emits the flag +/// form, so nothing Donut launches is missed. /// /// Only the macOS and Linux process-kill paths use this; Windows has no /// `find_processes_by_profile_path`, so gate it to avoid a dead-code error there. #[cfg(any(target_os = "macos", target_os = "linux"))] fn cmd_matches_profile_path(cmd: &[std::ffi::OsString], profile_path: &str) -> bool { - let args: Vec<&str> = cmd.iter().filter_map(|a| a.to_str()).collect(); - for (i, arg) in args.iter().enumerate() { - // Exact argument equality (some launchers pass the path as its own arg). - if *arg == profile_path { - return true; - } - // `--user-data-dir=` (Chromium/Wayfern) or `-profile=`. - if let Some(val) = arg + cmd.iter().filter_map(|a| a.to_str()).any(|arg| { + arg .strip_prefix("--user-data-dir=") .or_else(|| arg.strip_prefix("-profile=")) - { - if val == profile_path { - return true; - } - } - // Flag followed by the path as the next argument. - if (*arg == "-profile" || *arg == "--user-data-dir") - && args.get(i + 1).is_some_and(|next| *next == profile_path) - { - return true; - } + .is_some_and(|val| val == profile_path) + }) +} + +/// The profile sweep only ever wants browser processes. Every other sweep in +/// the app (`wayfern_manager`, `profile::manager`) already filters on the +/// executable name; without it a stray match here becomes a SIGKILL on an +/// unrelated process. +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn is_browser_process_name(name: &std::ffi::OsStr) -> bool { + let exe_name = name.to_string_lossy().to_lowercase(); + exe_name.contains("wayfern") || exe_name.contains("chromium") || exe_name.contains("chrome") +} + +#[cfg(all(test, any(target_os = "macos", target_os = "linux")))] +mod profile_path_match_tests { + use super::{cmd_matches_profile_path, is_browser_process_name}; + use std::ffi::{OsStr, OsString}; + + fn cmd(args: &[&str]) -> Vec { + args.iter().map(OsString::from).collect() + } + + #[test] + fn bare_path_argument_does_not_match() { + let profile = "/tmp/donut/profiles/work"; + assert!(!cmd_matches_profile_path( + &cmd(&["du", "-sh", profile]), + profile + )); + assert!(!cmd_matches_profile_path( + &cmd(&["tar", "czf", "backup.tgz", profile]), + profile + )); + } + + #[test] + fn user_data_dir_flag_matches() { + let profile = "/tmp/donut/profiles/work"; + assert!(cmd_matches_profile_path( + &cmd(&["wayfern", &format!("--user-data-dir={profile}")]), + profile + )); + assert!(cmd_matches_profile_path( + &cmd(&["wayfern", &format!("-profile={profile}")]), + profile + )); + } + + #[test] + fn a_sibling_profile_prefix_does_not_match() { + let profile = "/tmp/donut/profiles/work"; + assert!(!cmd_matches_profile_path( + &cmd(&["wayfern", "--user-data-dir=/tmp/donut/profiles/work-2"]), + profile + )); + } + + #[test] + fn only_browser_executables_are_swept() { + assert!(is_browser_process_name(OsStr::new("Wayfern Helper"))); + assert!(is_browser_process_name(OsStr::new("chromium"))); + assert!(!is_browser_process_name(OsStr::new("du"))); + assert!(!is_browser_process_name(OsStr::new("rsync"))); } - false } // Platform-specific modules @@ -197,6 +246,10 @@ pub mod macos { continue; } + if !is_browser_process_name(process.name()) { + continue; + } + if cmd_matches_profile_path(cmd, profile_path) { pids.push(pid.as_u32()); } @@ -704,6 +757,10 @@ pub mod linux { continue; } + if !is_browser_process_name(process.name()) { + continue; + } + if cmd_matches_profile_path(cmd, profile_path) { pids.push(pid.as_u32()); } diff --git a/src-tauri/src/profile/clear_on_close.rs b/src-tauri/src/profile/clear_on_close.rs index c63e0aa..5f3010f 100644 --- a/src-tauri/src/profile/clear_on_close.rs +++ b/src-tauri/src/profile/clear_on_close.rs @@ -20,6 +20,10 @@ const PROFILE_KEEP: &[&str] = &[ "Extension Scripts", "Extension Cookies", "Local Extension Settings", + // Backs chrome.storage.sync for every extension. Without a Chrome Sync + // account this directory is the only copy of that data, so wiping it resets + // every extension to defaults on each close. + "Sync Extension Settings", "Managed Extension Settings", // Preferences hold the extension registry + user settings; deleting them // disables every installed extension, so they stay. @@ -149,6 +153,33 @@ mod tests { fs::create_dir_all(dir.join(name)).unwrap(); } + /// The per-extension settings stores. Wiping any of them resets every + /// installed extension to its defaults on the next launch. + const EXTENSION_STORES: [&str; 3] = [ + "Local Extension Settings", + "Sync Extension Settings", + "Managed Extension Settings", + ]; + + fn seed_extension_stores(dir: &Path) { + for store in EXTENSION_STORES { + let store_dir = dir.join(store).join("abcdefghijklmnop"); + fs::create_dir_all(&store_dir).unwrap(); + fs::write(store_dir.join("000003.ldb"), "settings").unwrap(); + } + } + + fn assert_extension_stores_survived(dir: &Path) { + for store in EXTENSION_STORES { + let file = dir.join(store).join("abcdefghijklmnop").join("000003.ldb"); + assert_eq!( + fs::read_to_string(&file).ok().as_deref(), + Some("settings"), + "{store} must survive clear-on-close" + ); + } + } + #[test] fn clears_root_profile_layout_keeping_extensions_and_bookmarks() { let tmp = TempDir::new().unwrap(); @@ -160,7 +191,7 @@ mod tests { touch(dir, "Web Data"); touch(dir, "Login Data"); mkdir(dir, "Extensions"); - mkdir(dir, "Local Extension Settings"); + seed_extension_stores(dir); mkdir(dir, "Cache"); mkdir(dir, "Network"); touch(&dir.join("Network"), "Cookies"); @@ -175,7 +206,7 @@ mod tests { assert!(dir.join("Preferences").exists()); assert!(dir.join("Bookmarks").exists()); assert!(dir.join("Extensions").exists()); - assert!(dir.join("Local Extension Settings").exists()); + assert_extension_stores_survived(dir); assert!(!dir.join("History").exists()); assert!(!dir.join("Web Data").exists()); assert!(!dir.join("Login Data").exists()); @@ -196,6 +227,7 @@ mod tests { touch(&default, "Bookmarks"); touch(&default, "History"); mkdir(&default, "Extensions"); + seed_extension_stores(&default); mkdir(&default, "IndexedDB"); clear_user_data_dir(dir); @@ -205,6 +237,7 @@ mod tests { assert!(default.join("Preferences").exists()); assert!(default.join("Bookmarks").exists()); assert!(default.join("Extensions").exists()); + assert_extension_stores_survived(&default); assert!(!default.join("History").exists()); assert!(!default.join("IndexedDB").exists()); } @@ -222,7 +255,7 @@ mod tests { touch(&default, "Bookmarks"); touch(&default, "History"); mkdir(&default, "Extensions"); - mkdir(&default, "Local Extension Settings"); + seed_extension_stores(&default); mkdir(&default, "IndexedDB"); clear_user_data_dir(dir); @@ -230,7 +263,7 @@ mod tests { assert!(default.exists(), "the profile dir must survive"); assert!(default.join("Bookmarks").exists()); assert!(default.join("Extensions").exists()); - assert!(default.join("Local Extension Settings").exists()); + assert_extension_stores_survived(&default); // Browsing data inside it is still cleared. assert!(!default.join("History").exists()); assert!(!default.join("IndexedDB").exists()); @@ -244,10 +277,12 @@ mod tests { let p2 = dir.join("Profile 2"); touch(&p2, "Bookmarks"); touch(&p2, "History"); + seed_extension_stores(&p2); clear_user_data_dir(dir); assert!(p2.join("Bookmarks").exists()); + assert_extension_stores_survived(&p2); assert!(!p2.join("History").exists()); } diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 8fb3e95..6877906 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -24,6 +24,17 @@ fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> { fs::rename(&tmp, path) } +/// Collapse an empty proxy/VPN id to `None`. +/// +/// REST and MCP clients send `""` to detach a proxy or VPN, since omitting the +/// field means "leave unchanged". Stored as `Some("")` it resolves to no +/// upstream while every `proxy_id.is_some()` check still reads the profile as +/// routed, so the launch gate probes a direct exit and can refuse the launch of +/// a profile that has no proxy at all. +fn normalize_network_id(id: Option) -> Option { + id.filter(|id| !id.is_empty()) +} + pub struct ProfileManager { wayfern_manager: &'static crate::wayfern_manager::WayfernManager, } @@ -90,6 +101,12 @@ impl ProfileManager { ); } + // Normalize before the mutual-exclusion check, not per caller: REST, MCP, + // the importer and the Tauri commands all funnel through here, and a client + // saying "neither" with two empty strings must not read as "both". + let proxy_id = normalize_network_id(proxy_id); + let vpn_id = normalize_network_id(vpn_id); + if proxy_id.is_some() && vpn_id.is_some() { return Err("Cannot set both proxy_id and vpn_id".into()); } @@ -208,7 +225,13 @@ impl ProfileManager { browser: browser.to_string(), version: version.to_string(), proxy_id: proxy_id.clone(), - vpn_id: None, + // Carried, not None. Fingerprint generation reads this to decide + // whether the profile routes its traffic at all; hardcoding None made + // a VPN profile look direct, so its geolocation probe went out from + // the user's real address and that location was baked into the + // fingerprint, which the launch-time gate then rejects as a mismatch + // the profile should never have had. + vpn_id: vpn_id.clone(), launch_hook: launch_hook.clone(), process_id: None, last_launch: None, @@ -223,6 +246,7 @@ impl ProfileManager { last_sync: None, host_os: None, ephemeral: false, + temporary: false, extension_group_id: None, proxy_bypass_rules: Vec::new(), created_by_id: None, @@ -332,6 +356,7 @@ impl ProfileManager { last_sync: None, host_os: Some(get_host_os()), ephemeral, + temporary: false, extension_group_id: None, proxy_bypass_rules: Vec::new(), created_by_id: None, @@ -506,70 +531,168 @@ impl ProfileManager { Ok(profile) } + /// Delete a profile the recoverable way: it is moved to the trash and can + /// be restored until it expires. Ephemeral profiles have nothing to keep + /// and are destroyed outright. pub fn delete_profile( &self, app_handle: &tauri::AppHandle, profile_id: &str, ) -> Result<(), Box> { - log::info!("Attempting to delete profile with ID: {profile_id}"); + self.remove_profile(app_handle, profile_id, false, true) + } - // Find the profile by ID + /// Destroy a profile and its data for good, bypassing the trash. + /// Mark a freshly created profile as belonging to one automation run. + /// + /// Set after creation rather than threaded through every creation signature: + /// only the REST and MCP paths can ask for it, and both already hold the + /// profile they just made. Implies `ephemeral`, because a disposable profile + /// must not leave a data directory on real disk either. + pub fn mark_profile_temporary( + &self, + profile_id: &str, + ) -> Result> { + let mut profile = self.find_profile(profile_id)?; + profile.temporary = true; + profile.ephemeral = true; + self.save_profile(&profile)?; + Ok(profile) + } + + /// Which temporary profiles a startup sweep should destroy. + /// + /// A temporary profile is destroyed when its browser stops, so one still + /// here at startup either outlived a crash or is being used by a browser + /// this app did not start. `is_running` decides between the two, and is a + /// parameter so the rule can be tested without a process table. + pub fn temporary_profiles_to_sweep( + profiles: &[BrowserProfile], + is_running: impl Fn(u32) -> bool, + ) -> Vec { + profiles + .iter() + .filter(|profile| profile.temporary) + .filter(|profile| !profile.process_id.is_some_and(&is_running)) + .map(|profile| profile.id.to_string()) + .collect() + } + + /// Destroy every temporary profile that no live browser is using. + /// + /// Runs at startup: a crash, a kill -9 or a power cut leaves a temporary + /// profile behind, and nothing else would ever remove it. Returns how many + /// were destroyed. + pub fn sweep_temporary_profiles(&self, app_handle: &tauri::AppHandle) -> usize { + let Ok(profiles) = self.list_profiles() else { + return 0; + }; + let stale = + Self::temporary_profiles_to_sweep(&profiles, crate::proxy_storage::is_process_running); + let mut swept = 0; + for profile_id in stale { + match self.delete_profile_permanently(app_handle, &profile_id) { + Ok(()) => { + swept += 1; + log::info!("Swept temporary profile {profile_id} left by an earlier run"); + } + Err(e) => log::warn!("Could not sweep temporary profile {profile_id}: {e}"), + } + } + swept + } + + pub fn delete_profile_permanently( + &self, + app_handle: &tauri::AppHandle, + profile_id: &str, + ) -> Result<(), Box> { + self.remove_profile(app_handle, profile_id, true, true) + } + + fn find_profile(&self, profile_id: &str) -> Result> { let profile_uuid = uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?; - let profiles = self.list_profiles()?; - let profile = profiles + self + .list_profiles()? .into_iter() .find(|p| p.id == profile_uuid) - .ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?; + .ok_or_else(|| format!("Profile with ID '{profile_id}' not found").into()) + } - // Check if browser is running (cross-OS profiles can't be running locally) - if profile.process_id.is_some() && !profile.is_cross_os() { - return Err( - "Cannot delete profile while browser is running. Please stop the browser first.".into(), - ); + /// The one removal path. `permanent` destroys the directory; otherwise it + /// moves to the trash. Either way the cloud sees a delete (tombstone) and + /// any team lock is released, so a trashed profile is indistinguishable + /// from a deleted one for every other device. + fn remove_profile( + &self, + app_handle: &tauri::AppHandle, + profile_id: &str, + permanent: bool, + emit_events: bool, + ) -> Result<(), Box> { + log::info!("Attempting to delete profile with ID: {profile_id} (permanent: {permanent})"); + let profile = self.find_profile(profile_id)?; + + if crate::profile::trash::is_running_locally(&profile) { + return Err(crate::backend_error("PROFILE_RUNNING").into()); } - // Launch-gate acknowledgements are keyed by profile id and are not synced, - // so nothing else would ever clean them up. - crate::launch_gate_prefs::forget_profile(profile_id); + // An ephemeral profile keeps its data in RAM; there is nothing to trash. + let permanent = permanent || profile.ephemeral; - // Deleting the profile never touched its ephemeral directory, so a - // decrypted or in-memory copy outlived the profile it belonged to with - // nothing left that knew to reap it. The running-browser guard above only - // rejects a live process_id, and the keep-decrypted path deliberately - // clears process_id while leaving the plaintext tree populated. No-ops - // when the profile has no ephemeral directory. + // A decrypted or in-memory copy must not outlive the profile it belonged + // to. The running-browser guard above only rejects a live process, and + // the keep-decrypted path deliberately clears process_id while leaving + // the plaintext tree populated. No-ops when there is no ephemeral dir. crate::ephemeral_dirs::remove_ephemeral_dir(profile_id); + if profile.password_protected { + crate::profile::encryption::drop_cached_key(&profile.id); + } - // Per-domain traffic history lives outside the profile directory, so it - // survives the delete otherwise. It is already zero-overwritten on removal. - crate::traffic_stats::delete_traffic_stats(profile_id); - - // Remember sync mode before deleting local files let was_sync_enabled = profile.is_sync_enabled(); - let profiles_dir = self.get_profiles_dir(); let profile_uuid_dir = profiles_dir.join(profile.id.to_string()); - // Delete the entire UUID directory (contains both metadata.json and profile data) - if profile_uuid_dir.exists() { - log::info!("Deleting profile directory: {}", profile_uuid_dir.display()); - fs::remove_dir_all(&profile_uuid_dir)?; - log::info!("Profile directory deleted successfully"); - } - - // Verify deletion was successful - if profile_uuid_dir.exists() { - return Err(format!("Failed to completely delete profile '{}'", profile.name).into()); + if permanent { + self.forget_profile_side_state(profile_id); + if profile_uuid_dir.exists() { + log::info!("Deleting profile directory: {}", profile_uuid_dir.display()); + fs::remove_dir_all(&profile_uuid_dir)?; + } + if profile_uuid_dir.exists() { + return Err(format!("Failed to completely delete profile '{}'", profile.name).into()); + } + } else { + let _guard = crate::profile::trash::mutation_lock(); + crate::profile::trash::trash_profile( + &profiles_dir, + &crate::profile::trash::trash_dir(), + &profile, + crate::profile::trash::configured_retention_days(), + crate::proxy_manager::now_secs(), + )?; } log::info!( - "Profile '{}' (ID: {}) deleted successfully", + "Profile '{}' (ID: {}) {} successfully", profile.name, - profile_id + profile_id, + if permanent { + "deleted" + } else { + "moved to trash" + } ); - // If sync was enabled, also delete from S3 + // The browser is not running, so the team lock is normally released + // already; this only drops a lock a crash left behind. + let lock_profile = profile.clone(); + tauri::async_runtime::spawn(async move { + crate::team_lock::release_team_lock_if_needed(&lock_profile).await; + }); + + // From the cloud's point of view a trashed profile is deleted. if was_sync_enabled { let profile_id_owned = profile_id.to_string(); let app_handle_clone = app_handle.clone(); @@ -593,24 +716,132 @@ impl ProfileManager { }); } - // Rebuild tag suggestions after deletion + if emit_events { + self.after_profiles_removed(!permanent); + } + + Ok(()) + } + + /// State that lives outside the profile directory and only makes sense + /// while the profile can still come back. Dropped when it cannot. + fn forget_profile_side_state(&self, profile_id: &str) { + // Launch-gate acknowledgements are keyed by profile id and are not synced, + // so nothing else would ever clean them up. + crate::launch_gate_prefs::forget_profile(profile_id); + // Per-domain traffic history is zero-overwritten on removal. + crate::traffic_stats::delete_traffic_stats(profile_id); + } + + /// Bookkeeping after one or more profiles left the live list. + fn after_profiles_removed(&self, trashed: bool) { let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| { let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); }); - // Always perform cleanup after profile deletion to remove unused binaries if let Err(e) = DownloadedBrowsersRegistry::instance().cleanup_unused_binaries() { log::warn!("Warning: Failed to cleanup unused binaries after profile deletion: {e}"); } - // Emit profile deletion event if let Err(e) = events::emit_empty("profiles-changed") { log::warn!("Warning: Failed to emit profiles-changed event: {e}"); } + if trashed { + if let Err(e) = events::emit_empty("trash-changed") { + log::warn!("Warning: Failed to emit trash-changed event: {e}"); + } + } + } + /// Move a trashed profile back into the live list under its original id. + /// + /// Sync is NOT re-enabled here: the caller (the Tauri command) routes the + /// restored profile through `set_profile_sync_mode`, which clears the + /// tombstone the trash wrote and queues the re-upload. + pub fn restore_trashed_profile( + &self, + profile_id: &str, + ) -> Result> { + let _guard = crate::profile::trash::mutation_lock(); + let live = self.list_profiles()?; + let groups: std::collections::HashSet = crate::group_manager::GROUP_MANAGER + .lock() + .map(|gm| { + gm.get_all_groups() + .unwrap_or_default() + .into_iter() + .map(|g| g.id) + .collect() + }) + .unwrap_or_default(); + + let profile = crate::profile::trash::restore_profile( + &self.get_profiles_dir(), + &crate::profile::trash::trash_dir(), + profile_id, + &live, + &|group_id| groups.contains(group_id), + crate::proxy_manager::now_secs(), + )?; + // The normal save path, so tag suggestions pick the profile up again. + self.save_profile(&profile)?; + + log::info!( + "Profile '{}' (ID: {}) restored from trash", + profile.name, + profile_id + ); + if let Err(e) = events::emit_empty("profiles-changed") { + log::warn!("Warning: Failed to emit profiles-changed event: {e}"); + } + if let Err(e) = events::emit_empty("trash-changed") { + log::warn!("Warning: Failed to emit trash-changed event: {e}"); + } + Ok(profile) + } + + pub fn purge_trashed_profile(&self, profile_id: &str) -> Result<(), Box> { + let _guard = crate::profile::trash::mutation_lock(); + crate::profile::trash::purge_entry(&crate::profile::trash::trash_dir(), profile_id)?; + self.after_trash_purged(std::slice::from_ref(&profile_id.to_string())); Ok(()) } + /// Destroy every trashed profile. Returns how many were removed. + pub fn empty_trash(&self) -> Result> { + let _guard = crate::profile::trash::mutation_lock(); + let purged = crate::profile::trash::purge_all(&crate::profile::trash::trash_dir())?; + self.after_trash_purged(&purged); + Ok(purged.len()) + } + + /// Destroy every trashed profile whose retention has run out. Returns how + /// many were removed. + pub fn purge_expired_trash(&self) -> usize { + let _guard = crate::profile::trash::mutation_lock(); + let purged = crate::profile::trash::purge_expired( + &crate::profile::trash::trash_dir(), + crate::proxy_manager::now_secs(), + ); + self.after_trash_purged(&purged); + purged.len() + } + + fn after_trash_purged(&self, purged_ids: &[String]) { + if purged_ids.is_empty() { + return; + } + for id in purged_ids { + self.forget_profile_side_state(id); + } + if let Err(e) = DownloadedBrowsersRegistry::instance().cleanup_unused_binaries() { + log::warn!("Warning: Failed to cleanup unused binaries after purging the trash: {e}"); + } + if let Err(e) = events::emit_empty("trash-changed") { + log::warn!("Warning: Failed to emit trash-changed event: {e}"); + } + } + /// Delete a profile from the local filesystem only, without triggering remote sync deletion. /// Used when a profile was deleted on another device and the local copy should be cleaned up. pub fn delete_profile_local_only( @@ -1001,66 +1232,26 @@ impl ProfileManager { Ok(profile) } + /// Trash several profiles at once. Every profile is checked before any of + /// them moves, so one running browser blocks the whole batch instead of + /// leaving it half done. pub fn delete_multiple_profiles( &self, app_handle: &tauri::AppHandle, profile_ids: Vec, ) -> Result<(), Box> { - let profiles = self.list_profiles()?; - let mut sync_enabled_ids: Vec = Vec::new(); - - for profile_id in profile_ids { - let profile_uuid = uuid::Uuid::parse_str(&profile_id) - .map_err(|_| format!("Invalid profile ID: {profile_id}"))?; - let profile = profiles - .iter() - .find(|p| p.id == profile_uuid) - .ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?; - - // Check if browser is running (cross-OS profiles can't be running locally) - if profile.process_id.is_some() && !profile.is_cross_os() { - return Err( - format!( - "Cannot delete profile '{}' while browser is running. Please stop the browser first.", - profile.name - ) - .into(), - ); - } - - // Track sync-enabled profiles for remote deletion - if profile.is_sync_enabled() { - sync_enabled_ids.push(profile_id.clone()); - } - - // Delete the profile - let profiles_dir = self.get_profiles_dir(); - let profile_uuid_dir = profiles_dir.join(profile.id.to_string()); - - if profile_uuid_dir.exists() { - std::fs::remove_dir_all(&profile_uuid_dir)?; + for profile_id in &profile_ids { + let profile = self.find_profile(profile_id)?; + if crate::profile::trash::is_running_locally(&profile) { + return Err(crate::backend_error("PROFILE_RUNNING").into()); } } - // Delete sync-enabled profiles from S3 - if !sync_enabled_ids.is_empty() { - let app_handle_clone = app_handle.clone(); - tauri::async_runtime::spawn(async move { - if let Ok(engine) = crate::sync::SyncEngine::create_from_settings(&app_handle_clone).await { - for profile_id in sync_enabled_ids { - if let Err(e) = engine.delete_profile(&profile_id).await { - log::warn!("Failed to delete profile {} from sync: {}", profile_id, e); - } - } - } - }); - } - - // Emit profile deletion event - if let Err(e) = events::emit_empty("profiles-changed") { - log::warn!("Warning: Failed to emit profiles-changed event: {e}"); + for profile_id in &profile_ids { + self.remove_profile(app_handle, profile_id, false, false)?; } + self.after_profiles_removed(true); Ok(()) } @@ -1140,6 +1331,7 @@ impl ProfileManager { last_sync: None, host_os: Some(get_host_os()), ephemeral: false, + temporary: false, extension_group_id: source.extension_group_id, proxy_bypass_rules: source.proxy_bypass_rules, created_by_id: None, @@ -1295,6 +1487,7 @@ impl ProfileManager { profile_id: &str, proxy_id: Option, ) -> Result> { + let proxy_id = normalize_network_id(proxy_id); // Find the profile by ID let profile_uuid = uuid::Uuid::parse_str(profile_id).map_err( |_| -> Box { @@ -1334,8 +1527,8 @@ impl ProfileManager { // The cookie bot refuses a run on a profile with no exit node, using the // copy of that fact the desktop last declared. Detaching a proxy has to - // move that copy, or tonight's run egresses from the leased host's own - // datacenter address. + // move that copy, or tonight's run egresses from the remote host's own + // address instead of the user's exit. crate::cookie_bot::report_profile_state(&profile); // Auto-enable sync for new proxy if profile has sync enabled @@ -1367,6 +1560,7 @@ impl ProfileManager { profile_id: &str, vpn_id: Option, ) -> Result> { + let vpn_id = normalize_network_id(vpn_id); let profile_uuid = uuid::Uuid::parse_str(profile_id).map_err( |_| -> Box { format!("Invalid profile ID: {profile_id}").into() @@ -1747,6 +1941,48 @@ mod tests { (profile_manager, temp_dir) } + fn temporary_profile(name: &str, process_id: Option) -> BrowserProfile { + BrowserProfile { + id: uuid::Uuid::new_v4(), + name: name.to_string(), + browser: "wayfern".to_string(), + temporary: true, + ephemeral: true, + process_id, + ..BrowserProfile::default() + } + } + + #[test] + fn a_startup_sweep_takes_the_temporary_profiles_nothing_is_running() { + let ordinary = BrowserProfile { + id: uuid::Uuid::new_v4(), + name: "Kept".to_string(), + ..BrowserProfile::default() + }; + let crashed = temporary_profile("Crashed", Some(4242)); + let never_started = temporary_profile("Never started", None); + let live = temporary_profile("Live", Some(4243)); + let profiles = vec![ + ordinary.clone(), + crashed.clone(), + never_started.clone(), + live.clone(), + ]; + + let swept = ProfileManager::temporary_profiles_to_sweep(&profiles, |pid| pid == 4243); + assert_eq!( + swept, + vec![crashed.id.to_string(), never_started.id.to_string()], + "a live browser keeps its profile; an ordinary profile is never swept" + ); + + // Nothing running at all: every temporary profile goes, and only those. + let swept = ProfileManager::temporary_profiles_to_sweep(&profiles, |_| false); + assert_eq!(swept.len(), 3); + assert!(!swept.contains(&ordinary.id.to_string())); + } + #[test] fn test_profile_manager_creation() { let (_manager, _temp_dir) = create_test_profile_manager(); @@ -1785,6 +2021,26 @@ mod tests { ); } + #[test] + fn empty_network_ids_normalize_to_none() { + assert_eq!(normalize_network_id(Some(String::new())), None); + assert_eq!(normalize_network_id(None), None); + assert_eq!( + normalize_network_id(Some("proxy-1".to_string())), + Some("proxy-1".to_string()) + ); + + // A client saying "neither" with two empty strings must not trip the + // mutual-exclusion check in create_profile_with_group. + assert_eq!( + ( + normalize_network_id(Some(String::new())), + normalize_network_id(Some(String::new())) + ), + (None, None) + ); + } + #[test] fn test_normalize_launch_hook_accepts_http_and_https() { let http = @@ -2128,11 +2384,27 @@ pub fn clone_profile(profile_id: String, name: Option) -> Result Result<(), String> { - ProfileManager::instance() - .delete_profile(&app_handle, &profile_id) - .map_err(|e| format!("Failed to delete profile: {e}")) +pub fn delete_profile( + app_handle: tauri::AppHandle, + profile_id: String, + permanent: Option, +) -> Result<(), String> { + let manager = ProfileManager::instance(); + let result = if permanent.unwrap_or(false) { + manager.delete_profile_permanently(&app_handle, &profile_id) + } else { + manager.delete_profile(&app_handle, &profile_id) + }; + result.map_err(|e| { + let msg = e.to_string(); + if msg.starts_with('{') { + msg + } else { + format!("Failed to delete profile: {msg}") + } + }) } lazy_static::lazy_static! { diff --git a/src-tauri/src/profile/mod.rs b/src-tauri/src/profile/mod.rs index be542ff..40dc0c2 100644 --- a/src-tauri/src/profile/mod.rs +++ b/src-tauri/src/profile/mod.rs @@ -2,6 +2,8 @@ pub mod clear_on_close; pub mod encryption; pub mod manager; pub mod password; +pub mod portable; +pub mod trash; pub mod types; pub use manager::ProfileManager; diff --git a/src-tauri/src/profile/password.rs b/src-tauri/src/profile/password.rs index cdf2686..4d39b56 100644 --- a/src-tauri/src/profile/password.rs +++ b/src-tauri/src/profile/password.rs @@ -17,7 +17,7 @@ use crate::sync::manifest::DEFAULT_EXCLUDE_PATTERNS; use serde_json::json; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use std::time::SystemTime; /// Build a JSON error payload with just a code. @@ -53,6 +53,33 @@ lazy_static::lazy_static! { /// Per-profile failed unlock attempt tracking for rate-limiting. static ref FAILED_ATTEMPTS: Mutex> = Mutex::new(HashMap::new()); + + /// Per-profile lock serializing the whole check-lockout -> verify -> record + /// window. `check_lockout` and `record_failed_attempt` each take and release + /// `FAILED_ATTEMPTS` independently, with an Argon2 verification between them, + /// so without this a burst of concurrent attempts all read the same stale + /// count before any of them increments it and one lockout window admits as + /// many guesses as there are worker threads. + static ref ATTEMPT_LOCKS: Mutex>>> = + Mutex::new(HashMap::new()); +} + +/// The attempt lock for one profile. The std map lock is released before the +/// caller awaits the returned lock, so it is never held across an await. +/// +/// A poisoned map degrades to serialized rather than silently unserialized. +fn attempt_lock(profile_id: &uuid::Uuid) -> Arc> { + let mut guard = ATTEMPT_LOCKS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // An entry only the map itself still references has no attempt in progress, + // so dropping it here keeps a long-lived process from accumulating one lock + // per profile ever touched. A live holder always keeps the count above 1. + guard.retain(|_, lock| Arc::strong_count(lock) > 1); + guard + .entry(*profile_id) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() } #[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)] @@ -309,6 +336,10 @@ pub async fn verify_profile_password(profile_id: String, password: String) -> Re if !profile.password_protected { return Err(err_code("PROFILE_NOT_PROTECTED")); } + // Bound, never dropped early: it must cover check_lockout through the + // record/clear branches below. See `attempt_lock`. + let attempt = attempt_lock(&id); + let _attempt_guard = attempt.lock().await; if let Err(secs) = check_lockout(&id) { return Err(err_with("LOCKED_OUT", &[("seconds", secs.to_string())])); } @@ -338,6 +369,10 @@ pub async fn unlock_profile(profile_id: String, password: String) -> Result<(), if !profile.password_protected { return Err(err_code("PROFILE_NOT_PROTECTED")); } + // Bound, never dropped early: it must cover check_lockout through the + // record/clear branches below. See `attempt_lock`. + let attempt = attempt_lock(&id); + let _attempt_guard = attempt.lock().await; if let Err(secs) = check_lockout(&id) { return Err(err_with("LOCKED_OUT", &[("seconds", secs.to_string())])); } @@ -399,6 +434,10 @@ pub async fn change_profile_password( return Err(err_code("PROFILE_RUNNING")); } + // Bound, never dropped early: it must cover check_lockout through the + // record/clear branches below. See `attempt_lock`. + let attempt = attempt_lock(&id); + let _attempt_guard = attempt.lock().await; if let Err(secs) = check_lockout(&id) { return Err(err_with("LOCKED_OUT", &[("seconds", secs.to_string())])); } @@ -450,6 +489,10 @@ pub async fn remove_profile_password(profile_id: String, password: String) -> Re return Err(err_code("PROFILE_RUNNING")); } + // Bound, never dropped early: it must cover check_lockout through the + // record/clear branches below. See `attempt_lock`. + let attempt = attempt_lock(&id); + let _attempt_guard = attempt.lock().await; if let Err(secs) = check_lockout(&id) { return Err(err_with("LOCKED_OUT", &[("seconds", secs.to_string())])); } @@ -1255,6 +1298,30 @@ mod tests { clear_failed_attempts(&profile.id); } + #[tokio::test] + async fn attempt_lock_serializes_one_profile_without_blocking_others() { + let a = uuid::Uuid::new_v4(); + let b = uuid::Uuid::new_v4(); + + // One lock per profile is what turns check-lockout -> verify -> record + // into a critical section instead of a check-then-act race. + assert!(Arc::ptr_eq(&attempt_lock(&a), &attempt_lock(&a))); + assert!(!Arc::ptr_eq(&attempt_lock(&a), &attempt_lock(&b))); + + let held = attempt_lock(&a); + let guard = held.lock().await; + assert!( + attempt_lock(&a).try_lock().is_err(), + "a concurrent attempt on the same profile must wait for the window" + ); + assert!( + attempt_lock(&b).try_lock().is_ok(), + "a different profile must not be serialized behind it" + ); + drop(guard); + assert!(attempt_lock(&a).try_lock().is_ok()); + } + #[test] fn lockout_schedule_progression() { use std::time::Duration; diff --git a/src-tauri/src/profile/portable.rs b/src-tauri/src/profile/portable.rs new file mode 100644 index 0000000..61e64be --- /dev/null +++ b/src-tauri/src/profile/portable.rs @@ -0,0 +1,765 @@ +//! Moving a profile between machines. +//! +//! An export is one zip: a manifest, the profile's configuration, and +//! optionally its browser data directory. An import creates a NEW profile from +//! it, with a fresh id, so importing an export twice gives two profiles rather +//! than a conflict or a silent overwrite. +//! +//! What deliberately does NOT travel: +//! - the process id, the last-launch time and the cloud-sync bookkeeping, +//! which describe the machine that exported, not the profile; +//! - the caches, which Chromium rebuilds and which are most of the bytes; +//! - the browser binary, which the importing machine downloads for itself; +//! - a password-protected profile's data, because its at-rest key belongs to +//! the exporting machine's keychain and the bytes would be unreadable +//! anywhere else. Its configuration exports, its data does not, and the +//! export says so rather than shipping an archive nobody can open. + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::{Read, Seek, Write}; +use std::path::{Path, PathBuf}; + +use crate::profile::types::BrowserProfile; + +/// Bumped when the archive layout changes in a way an older build cannot read. +const FORMAT_VERSION: u32 = 1; +const MANIFEST_ENTRY: &str = "manifest.json"; +const PROFILE_ENTRY: &str = "profile.json"; +const DATA_PREFIX: &str = "data/"; +/// A profile directory is browsing history, cookies and extension state. Past +/// this size an export is almost certainly a mistake (a cache directory that +/// escaped the prune, say), and writing gigabytes to a user's Downloads folder +/// without saying why is worse than refusing. +const MAX_DATA_BYTES: u64 = 4 * 1024 * 1024 * 1024; + +/// What one archive says about itself. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PortableManifest { + pub format_version: u32, + /// The app that wrote it, for a bug report. + pub exported_by: String, + pub exported_at: u64, + /// The profile's name at export time. The id is deliberately absent: an + /// import mints a new one, and carrying the old id invites a caller to + /// "restore" over a live profile. + pub profile_name: String, + pub browser: String, + pub version: String, + /// Whether `data/` is present. False for a configuration-only export and for + /// a password-protected profile, whose bytes cannot travel. + pub includes_data: bool, + /// Why the data is absent, when it is. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data_omitted_reason: Option, +} + +/// What an import found in an archive, before it creates anything. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PortablePreview { + pub manifest: PortableManifest, + /// The proxy the exporting machine had assigned, by name, when it had one. + /// An import never links a proxy by id: ids are local to a machine. + pub proxy_name: Option, + pub group_name: Option, + pub tags: Vec, +} + +fn err(context: &str, detail: impl std::fmt::Display) -> String { + crate::backend_error_with_detail("PROFILE_EXPORT_FAILED", format!("{context}: {detail}")) +} + +fn import_err(detail: impl std::fmt::Display) -> String { + crate::backend_error_with_detail("PROFILE_IMPORT_FAILED", detail.to_string()) +} + +/// The configuration an export carries: the profile as stored, minus +/// everything that describes this machine or this moment. +pub fn exportable_config(profile: &BrowserProfile) -> serde_json::Value { + let mut value = serde_json::to_value(profile).unwrap_or(serde_json::Value::Null); + if let Some(object) = value.as_object_mut() { + for machine_local in [ + "id", + "process_id", + "last_launch", + "last_sync", + "encryption_salt", + "created_by_id", + "created_by_email", + "proxy_id", + "vpn_id", + "group_id", + "extension_group_id", + "temporary", + ] { + object.remove(machine_local); + } + // A password-protected profile's data cannot travel, so the flag must not + // either: an imported profile with the flag set and no key is unopenable. + object.insert("password_protected".to_string(), serde_json::json!(false)); + object.insert("sync_mode".to_string(), serde_json::json!("Disabled")); + } + value +} + +/// Every file under `dir`, relative to it, skipping the cache directories and +/// the launcher's own per-launch documents. +fn collect_files(dir: &Path) -> Result, String> { + fn walk(root: &Path, dir: &Path, out: &mut Vec<(String, PathBuf)>) -> Result<(), String> { + let entries = fs::read_dir(dir).map_err(|e| err("could not read the profile directory", e))?; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(relative) = path.strip_prefix(root) else { + continue; + }; + let relative = relative.to_string_lossy().replace('\\', "/"); + if is_excluded(&relative) { + continue; + } + let file_type = entry + .file_type() + .map_err(|e| err("could not stat a file", e))?; + if file_type.is_symlink() { + // A symlink in an archive is either useless on the other machine or a + // way out of the extraction directory. Neither travels. + continue; + } + if file_type.is_dir() { + walk(root, &path, out)?; + } else if file_type.is_file() { + out.push((relative, path)); + } + } + Ok(()) + } + + let mut files = Vec::new(); + walk(dir, dir, &mut files)?; + files.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(files) +} + +/// Whether a path inside the profile directory is left out of an export. +pub fn is_excluded(relative: &str) -> bool { + const CACHE_SEGMENTS: [&str; 10] = [ + "Cache", + "Code Cache", + "GPUCache", + "GrShaderCache", + "ShaderCache", + "DawnCache", + "DawnGraphiteCache", + "GraphiteDawnCache", + "CacheStorage", + "ScriptCache", + ]; + const LAUNCH_FILES: [&str; 3] = [ + "wayfern-identity.json", + "wayfern-persona.json", + "window-icon.png", + ]; + const SINGLETONS: [&str; 3] = ["SingletonLock", "SingletonSocket", "SingletonCookie"]; + + let segments: Vec<&str> = relative.split('/').collect(); + if segments + .iter() + .any(|segment| CACHE_SEGMENTS.contains(segment)) + { + return true; + } + let Some(name) = segments.last() else { + return true; + }; + LAUNCH_FILES.contains(name) || SINGLETONS.contains(name) || name.ends_with(".tmp") +} + +/// Write an export archive for `profile` to `destination`. +/// +/// `data_dir` is the profile's browser directory; `include_data` false writes +/// a configuration-only archive, which is the small one worth emailing. +pub fn export_to( + profile: &BrowserProfile, + data_dir: &Path, + destination: &Path, + include_data: bool, + proxy_name: Option, + group_name: Option, +) -> Result { + let data_omitted_reason = if !include_data { + Some("the export was asked for without the browser data".to_string()) + } else if profile.password_protected { + Some( + "the profile is password protected, and its data is encrypted with a key held by the exporting machine".to_string(), + ) + } else if !data_dir.is_dir() { + Some("the profile has no browser data yet".to_string()) + } else { + None + }; + let carries_data = data_omitted_reason.is_none(); + + let manifest = PortableManifest { + format_version: FORMAT_VERSION, + exported_by: format!("Donut Browser {}", env!("CARGO_PKG_VERSION")), + exported_at: crate::proxy_manager::now_secs(), + profile_name: profile.name.clone(), + browser: profile.browser.clone(), + version: profile.version.clone(), + includes_data: carries_data, + data_omitted_reason, + }; + + let mut preview = serde_json::to_value(&manifest).map_err(|e| err("manifest", e))?; + if let Some(object) = preview.as_object_mut() { + object.insert("proxy_name".to_string(), serde_json::json!(proxy_name)); + object.insert("group_name".to_string(), serde_json::json!(group_name)); + } + + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent).map_err(|e| err("could not create the destination folder", e))?; + } + let file = fs::File::create(destination).map_err(|e| err("could not create the archive", e))?; + let mut writer = zip::ZipWriter::new(file); + let options: zip::write::FileOptions<'_, ()> = + zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Deflated); + + writer + .start_file(MANIFEST_ENTRY, options) + .map_err(|e| err("manifest", e))?; + writer + .write_all( + serde_json::to_string_pretty(&preview) + .map_err(|e| err("manifest", e))? + .as_bytes(), + ) + .map_err(|e| err("manifest", e))?; + + writer + .start_file(PROFILE_ENTRY, options) + .map_err(|e| err("profile", e))?; + writer + .write_all( + serde_json::to_string_pretty(&exportable_config(profile)) + .map_err(|e| err("profile", e))? + .as_bytes(), + ) + .map_err(|e| err("profile", e))?; + + if carries_data { + let mut written = 0u64; + for (relative, absolute) in collect_files(data_dir)? { + let data = match fs::read(&absolute) { + Ok(data) => data, + // A browser file can vanish between the walk and the read; that is not + // a reason to fail an export of everything else. + Err(e) => { + log::warn!("Skipping {} in the export: {e}", absolute.display()); + continue; + } + }; + written = written.saturating_add(data.len() as u64); + if written > MAX_DATA_BYTES { + return Err(crate::backend_error("PROFILE_EXPORT_TOO_LARGE")); + } + writer + .start_file(format!("{DATA_PREFIX}{relative}"), options) + .map_err(|e| err(&relative, e))?; + writer.write_all(&data).map_err(|e| err(&relative, e))?; + } + } + + writer + .finish() + .map_err(|e| err("could not finish the archive", e))?; + Ok(manifest) +} + +fn open_archive(path: &Path) -> Result, String> { + let file = fs::File::open(path).map_err(|e| import_err(format!("could not open it: {e}")))?; + zip::ZipArchive::new(file).map_err(|e| import_err(format!("it is not a readable archive: {e}"))) +} + +fn read_entry( + archive: &mut zip::ZipArchive, + name: &str, +) -> Result { + let mut entry = archive + .by_name(name) + .map_err(|_| import_err(format!("the archive has no {name}")))?; + let mut body = String::new(); + entry + .read_to_string(&mut body) + .map_err(|e| import_err(format!("{name} could not be read: {e}")))?; + Ok(body) +} + +/// What an archive holds, without creating anything. +pub fn preview(path: &Path) -> Result { + let mut archive = open_archive(path)?; + let manifest_json = read_entry(&mut archive, MANIFEST_ENTRY)?; + let manifest: PortableManifest = serde_json::from_str(&manifest_json) + .map_err(|e| import_err(format!("its manifest is malformed: {e}")))?; + if manifest.format_version > FORMAT_VERSION { + return Err(crate::backend_error_with_detail( + "PROFILE_IMPORT_TOO_NEW", + manifest.format_version.to_string(), + )); + } + let extra: serde_json::Value = serde_json::from_str(&manifest_json).unwrap_or_default(); + let profile_json = read_entry(&mut archive, PROFILE_ENTRY)?; + let stored: serde_json::Value = serde_json::from_str(&profile_json) + .map_err(|e| import_err(format!("its profile is malformed: {e}")))?; + + Ok(PortablePreview { + manifest, + proxy_name: extra["proxy_name"].as_str().map(str::to_string), + group_name: extra["group_name"].as_str().map(str::to_string), + tags: stored["tags"] + .as_array() + .map(|tags| { + tags + .iter() + .filter_map(|tag| tag.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(), + }) +} + +/// The profile an import should create: the archive's configuration under a +/// fresh id and the given name, with nothing carried over from the exporting +/// machine. +pub fn imported_profile( + archive_profile: &serde_json::Value, + name: &str, +) -> Result { + let mut value = archive_profile.clone(); + let object = value + .as_object_mut() + .ok_or_else(|| import_err("its profile is not an object"))?; + object.insert( + "id".to_string(), + serde_json::json!(uuid::Uuid::new_v4().to_string()), + ); + object.insert("name".to_string(), serde_json::json!(name)); + object.insert("process_id".to_string(), serde_json::Value::Null); + object.insert("last_launch".to_string(), serde_json::Value::Null); + object.insert("last_sync".to_string(), serde_json::Value::Null); + object.insert("encryption_salt".to_string(), serde_json::Value::Null); + object.insert("password_protected".to_string(), serde_json::json!(false)); + object.insert("temporary".to_string(), serde_json::json!(false)); + object.insert("sync_mode".to_string(), serde_json::json!("Disabled")); + object.insert( + "host_os".to_string(), + serde_json::json!(crate::profile::types::get_host_os()), + ); + object.insert( + "created_at".to_string(), + serde_json::json!(crate::proxy_manager::now_secs()), + ); + object.insert( + "updated_at".to_string(), + serde_json::json!(crate::proxy_manager::now_secs()), + ); + serde_json::from_value(value).map_err(|e| import_err(format!("its profile is unusable: {e}"))) +} + +/// Extract the archive's `data/` into `data_dir`. +/// +/// Every entry is checked to land inside `data_dir`: an archive is untrusted +/// input, and `../` in a name is how an extraction writes over a user's files. +pub fn extract_data(path: &Path, data_dir: &Path) -> Result { + let mut archive = open_archive(path)?; + fs::create_dir_all(data_dir) + .map_err(|e| import_err(format!("could not create the profile directory: {e}")))?; + let root = data_dir + .canonicalize() + .map_err(|e| import_err(format!("could not resolve the profile directory: {e}")))?; + + let mut restored = 0; + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .map_err(|e| import_err(format!("could not read entry {index}: {e}")))?; + if entry.is_dir() { + continue; + } + let Some(name) = entry.enclosed_name() else { + return Err(crate::backend_error("PROFILE_IMPORT_UNSAFE_ARCHIVE")); + }; + let name = name.to_string_lossy().replace('\\', "/"); + let Some(relative) = name.strip_prefix(DATA_PREFIX) else { + continue; + }; + if relative.is_empty() || is_excluded(relative) { + continue; + } + let destination = root.join(relative); + if !destination.starts_with(&root) { + return Err(crate::backend_error("PROFILE_IMPORT_UNSAFE_ARCHIVE")); + } + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent) + .map_err(|e| import_err(format!("could not create {}: {e}", parent.display())))?; + } + let mut file = fs::File::create(&destination) + .map_err(|e| import_err(format!("could not write {}: {e}", destination.display())))?; + std::io::copy(&mut entry, &mut file) + .map_err(|e| import_err(format!("could not write {}: {e}", destination.display())))?; + restored += 1; + } + Ok(restored) +} + +/// Pick a name no live profile carries: the archive's own when it is free, +/// otherwise `name (imported)`, `name (imported 2)`, and so on. +pub fn unique_imported_name(name: &str, taken: &[String]) -> String { + let normalized: Vec = taken.iter().map(|n| n.trim().to_lowercase()).collect(); + let is_taken = |candidate: &str| normalized.contains(&candidate.trim().to_lowercase()); + if !is_taken(name) { + return name.to_string(); + } + let mut attempt = 1u32; + loop { + let candidate = if attempt == 1 { + format!("{name} (imported)") + } else { + format!("{name} (imported {attempt})") + }; + if !is_taken(&candidate) { + return candidate; + } + attempt += 1; + } +} + +/// Write an export of `profile_id` to `destination`. +#[tauri::command] +pub async fn export_profile( + profile_id: String, + destination: String, + include_data: Option, +) -> Result { + let manager = crate::profile::ProfileManager::instance(); + let profile = manager + .list_profiles() + .map_err(|e| err("could not read the profiles", e))? + .into_iter() + .find(|p| p.id.to_string() == profile_id) + .ok_or_else(|| crate::backend_error("PROFILE_NOT_FOUND"))?; + // An export reads the whole profile directory; a browser writing to it at + // the same time produces an archive of half-written databases. + if profile + .process_id + .is_some_and(crate::proxy_storage::is_process_running) + { + return Err(crate::backend_error("PROFILE_RUNNING")); + } + + let data_dir = manager + .get_profiles_dir() + .join(profile.id.to_string()) + .join("profile"); + let proxy_name = profile.proxy_id.as_deref().and_then(|id| { + crate::proxy_manager::PROXY_MANAGER + .get_stored_proxies() + .into_iter() + .find(|proxy| proxy.id == id) + .map(|proxy| proxy.name) + }); + let group_name = profile.group_id.as_deref().and_then(|id| { + let manager = crate::group_manager::GROUP_MANAGER + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + manager + .get_all_groups() + .ok()? + .into_iter() + .find(|group| group.id == id) + .map(|group| group.name) + }); + + export_to( + &profile, + &data_dir, + Path::new(&destination), + include_data.unwrap_or(true), + proxy_name, + group_name, + ) +} + +/// What an archive holds, so the user can decide before anything is created. +#[tauri::command] +pub fn preview_profile_archive(path: String) -> Result { + preview(Path::new(&path)) +} + +/// Create a profile from an archive. +#[tauri::command] +pub async fn import_profile_archive( + path: String, + name: Option, +) -> Result { + let archive_path = Path::new(&path); + let details = preview(archive_path)?; + let manager = crate::profile::ProfileManager::instance(); + let existing = manager + .list_profiles() + .map_err(|e| import_err(format!("could not read the profiles: {e}")))?; + let taken: Vec = existing.iter().map(|p| p.name.clone()).collect(); + let wanted = name + .as_deref() + .map(str::trim) + .filter(|n| !n.is_empty()) + .unwrap_or(&details.manifest.profile_name); + if wanted.is_empty() { + return Err(crate::backend_error("NAME_CANNOT_BE_EMPTY")); + } + + let mut archive = open_archive(archive_path)?; + let stored: serde_json::Value = + serde_json::from_str(&read_entry(&mut archive, PROFILE_ENTRY)?) + .map_err(|e| import_err(format!("its profile is malformed: {e}")))?; + drop(archive); + + let profile = imported_profile(&stored, &unique_imported_name(wanted, &taken))?; + let data_dir = manager + .get_profiles_dir() + .join(profile.id.to_string()) + .join("profile"); + fs::create_dir_all(&data_dir) + .map_err(|e| import_err(format!("could not create the profile directory: {e}")))?; + + if details.manifest.includes_data { + if let Err(e) = extract_data(archive_path, &data_dir) { + // Nothing half-imported is left behind: the profile was never saved, so + // removing its directory removes every trace of the attempt. + let _ = fs::remove_dir_all(data_dir.parent().unwrap_or(&data_dir)); + return Err(e); + } + } + + manager.save_profile(&profile).map_err(|e| { + let _ = fs::remove_dir_all(data_dir.parent().unwrap_or(&data_dir)); + import_err(format!("could not save it: {e}")) + })?; + let _ = crate::events::emit("profiles-changed", serde_json::json!({})); + Ok(profile) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn sample() -> BrowserProfile { + BrowserProfile { + id: uuid::Uuid::new_v4(), + name: "Shop".to_string(), + browser: "wayfern".to_string(), + version: "152.0.7977.64".to_string(), + proxy_id: Some("proxy-1".to_string()), + group_id: Some("group-1".to_string()), + tags: vec!["eu".to_string()], + process_id: Some(4242), + last_launch: Some(1000), + created_by_email: Some("someone@example.com".to_string()), + ..BrowserProfile::default() + } + } + + fn seed_data(dir: &Path) { + fs::create_dir_all(dir.join("Default/Network")).unwrap(); + fs::write(dir.join("Default/Network/Cookies"), b"cookie-db").unwrap(); + fs::write(dir.join("Local State"), b"{}").unwrap(); + fs::create_dir_all(dir.join("Default/Cache")).unwrap(); + fs::write(dir.join("Default/Cache/data_0"), vec![0u8; 4096]).unwrap(); + fs::write(dir.join("wayfern-identity.json"), b"{}").unwrap(); + fs::write(dir.join("SingletonLock"), b"lock").unwrap(); + } + + fn entries(path: &Path) -> Vec { + let mut archive = open_archive(path).unwrap(); + (0..archive.len()) + .map(|i| archive.by_index(i).unwrap().name().to_string()) + .collect() + } + + #[test] + fn an_export_carries_the_profile_and_its_data_but_not_the_machine() { + let root = TempDir::new().unwrap(); + let data = root.path().join("profile"); + seed_data(&data); + let archive = root.path().join("shop.donutprofile"); + let profile = sample(); + + let manifest = export_to( + &profile, + &data, + &archive, + true, + Some("Residential EU".to_string()), + Some("Clients".to_string()), + ) + .unwrap(); + assert!(manifest.includes_data); + assert_eq!(manifest.profile_name, "Shop"); + + let names = entries(&archive); + assert!(names.contains(&"manifest.json".to_string())); + assert!(names.contains(&"profile.json".to_string())); + assert!(names.contains(&"data/Default/Network/Cookies".to_string())); + assert!( + !names.iter().any(|n| n.contains("Cache")), + "caches are rebuilt by the browser and must not travel: {names:?}" + ); + assert!( + !names.iter().any(|n| n.contains("wayfern-identity.json")), + "the launcher rewrites its own documents every launch: {names:?}" + ); + assert!(!names.iter().any(|n| n.contains("SingletonLock"))); + + let preview = preview(&archive).unwrap(); + assert_eq!(preview.proxy_name.as_deref(), Some("Residential EU")); + assert_eq!(preview.group_name.as_deref(), Some("Clients")); + assert_eq!(preview.tags, vec!["eu".to_string()]); + } + + #[test] + fn the_exported_configuration_drops_what_belongs_to_this_machine() { + let config = exportable_config(&sample()); + for gone in [ + "id", + "process_id", + "last_launch", + "proxy_id", + "group_id", + "created_by_email", + "encryption_salt", + ] { + assert!(config.get(gone).is_none(), "{gone} must not travel"); + } + assert_eq!(config["password_protected"], serde_json::json!(false)); + assert_eq!(config["sync_mode"], serde_json::json!("Disabled")); + assert_eq!(config["version"], serde_json::json!("152.0.7977.64")); + } + + #[test] + fn a_password_protected_profile_exports_its_configuration_and_says_why_not_its_data() { + let root = TempDir::new().unwrap(); + let data = root.path().join("profile"); + seed_data(&data); + let archive = root.path().join("locked.donutprofile"); + let mut profile = sample(); + profile.password_protected = true; + + let manifest = export_to(&profile, &data, &archive, true, None, None).unwrap(); + assert!(!manifest.includes_data); + assert!(manifest + .data_omitted_reason + .as_deref() + .unwrap() + .contains("password protected")); + assert!(!entries(&archive).iter().any(|n| n.starts_with("data/"))); + } + + #[test] + fn an_import_is_a_new_profile_that_owes_nothing_to_the_exporter() { + let source = sample(); + let config = exportable_config(&source); + let imported = imported_profile(&config, "Shop (imported)").unwrap(); + + assert_ne!(imported.id, source.id); + assert_eq!(imported.name, "Shop (imported)"); + assert_eq!(imported.version, source.version); + assert_eq!(imported.tags, source.tags); + assert_eq!(imported.process_id, None); + assert_eq!(imported.proxy_id, None, "a proxy id is local to a machine"); + assert_eq!(imported.group_id, None); + assert!(!imported.password_protected); + assert!(!imported.temporary); + assert!(imported.created_at.is_some()); + + // Twice from one archive gives two profiles, not a conflict. + let again = imported_profile(&config, "Shop (imported)").unwrap(); + assert_ne!(again.id, imported.id); + } + + #[test] + fn extraction_restores_the_data_and_refuses_to_escape_the_profile_directory() { + let root = TempDir::new().unwrap(); + let data = root.path().join("profile"); + seed_data(&data); + let archive = root.path().join("shop.donutprofile"); + export_to(&sample(), &data, &archive, true, None, None).unwrap(); + + let restored_dir = root.path().join("restored"); + let restored = extract_data(&archive, &restored_dir).unwrap(); + assert!(restored >= 2); + assert_eq!( + fs::read(restored_dir.join("Default/Network/Cookies")).unwrap(), + b"cookie-db" + ); + assert!(!restored_dir.join("Default/Cache/data_0").exists()); + + // A hand-made archive with a traversing entry is refused outright. + let hostile = root.path().join("hostile.donutprofile"); + { + let file = fs::File::create(&hostile).unwrap(); + let mut writer = zip::ZipWriter::new(file); + let options: zip::write::FileOptions<'_, ()> = zip::write::FileOptions::default(); + writer.start_file("manifest.json", options).unwrap(); + writer.write_all(b"{}").unwrap(); + writer.start_file("data/../../escaped", options).unwrap(); + writer.write_all(b"nope").unwrap(); + writer.finish().unwrap(); + } + let target = root.path().join("target"); + assert!(extract_data(&hostile, &target) + .unwrap_err() + .contains("PROFILE_IMPORT_UNSAFE_ARCHIVE")); + assert!(!root.path().join("escaped").exists()); + } + + #[test] + fn an_archive_from_a_newer_build_is_refused_by_name() { + let root = TempDir::new().unwrap(); + let archive = root.path().join("future.donutprofile"); + { + let file = fs::File::create(&archive).unwrap(); + let mut writer = zip::ZipWriter::new(file); + let options: zip::write::FileOptions<'_, ()> = zip::write::FileOptions::default(); + writer.start_file("manifest.json", options).unwrap(); + writer + .write_all( + serde_json::json!({ + "format_version": FORMAT_VERSION + 1, + "exported_by": "Donut Browser 99.0.0", + "exported_at": 1, + "profile_name": "Future", + "browser": "wayfern", + "version": "999", + "includes_data": false, + }) + .to_string() + .as_bytes(), + ) + .unwrap(); + writer.finish().unwrap(); + } + assert!(preview(&archive) + .unwrap_err() + .contains("PROFILE_IMPORT_TOO_NEW")); + } + + #[test] + fn a_file_that_is_not_an_archive_is_a_coded_error_not_a_panic() { + let root = TempDir::new().unwrap(); + let bogus = root.path().join("notes.txt"); + fs::write(&bogus, b"just some text").unwrap(); + assert!(preview(&bogus) + .unwrap_err() + .contains("PROFILE_IMPORT_FAILED")); + } +} diff --git a/src-tauri/src/profile/trash.rs b/src-tauri/src/profile/trash.rs new file mode 100644 index 0000000..107fb77 --- /dev/null +++ b/src-tauri/src/profile/trash.rs @@ -0,0 +1,917 @@ +//! Recoverable delete for profiles. +//! +//! A deleted profile is moved to `/trash//` instead of +//! being destroyed, so an accidental delete of a profile that carries logins +//! can be undone. Each entry holds: +//! +//! - `profile.json`: the full `BrowserProfile` at the moment of deletion. +//! - `manifest.json`: when it was trashed, when it expires, how big it is. +//! - `profile/`: the profile's own data directory, moved as is. Chromium's +//! cache-only directories are pruned first; the browser rebuilds them on +//! the next launch, so keeping them would only make the trash heavy. +//! +//! A password-protected profile is moved in its encrypted at-rest form and +//! stays protected while it sits here. Ephemeral profiles never land here; +//! their data lives in RAM and is gone the moment the browser exits. +//! +//! From the cloud's point of view a trashed profile is deleted: the sync +//! tombstone is written by the same path a permanent delete uses. Restoring +//! re-registers the profile under its original id and routes it through the +//! normal sync-enable path so it wins the stale tombstone. + +use crate::profile::types::BrowserProfile; +use crate::profile::ProfileManager; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard}; + +pub const DEFAULT_RETENTION_DAYS: u32 = 30; +pub const MIN_RETENTION_DAYS: u32 = 1; +pub const MAX_RETENTION_DAYS: u32 = 365; +/// How often expired entries are swept while the app runs. +pub const PURGE_INTERVAL_SECS: u64 = 6 * 60 * 60; + +const SECS_PER_DAY: u64 = 24 * 60 * 60; +const PROFILE_FILE: &str = "profile.json"; +const MANIFEST_FILE: &str = "manifest.json"; +const DATA_DIR: &str = "profile"; +const METADATA_FILE: &str = "metadata.json"; +const RESTORED_SUFFIX: &str = "(restored)"; + +/// Chromium directories that only ever hold caches, relative to the profile +/// data directory. Every one of them is recreated by the browser on demand. +const CACHE_DIRS: [&str; 8] = [ + "Cache", + "Code Cache", + "GPUCache", + "GrShaderCache", + "ShaderCache", + "DawnCache", + "Service Worker/CacheStorage", + "Service Worker/ScriptCache", +]; + +/// Serialises every trash mutation so a restore cannot interleave with a +/// purge of the same entry. +static TRASH_MUTATION: Mutex<()> = Mutex::new(()); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrashManifest { + pub deleted_at: u64, + pub expires_at: u64, + pub size_bytes: u64, + pub original_name: String, +} + +/// What the Trash page shows for one entry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrashedProfileSummary { + pub id: String, + pub name: String, + pub browser: String, + pub version: String, + pub deleted_at: u64, + pub expires_at: u64, + pub size_bytes: u64, + #[serde(default)] + pub group_id: Option, + pub password_protected: bool, +} + +pub fn trash_dir() -> PathBuf { + crate::app_dirs::data_dir().join("trash") +} + +pub fn mutation_lock() -> MutexGuard<'static, ()> { + TRASH_MUTATION + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +pub fn clamp_retention_days(days: u32) -> u32 { + days.clamp(MIN_RETENTION_DAYS, MAX_RETENTION_DAYS) +} + +/// The retention the user configured, already clamped to the allowed range. +pub fn configured_retention_days() -> u32 { + crate::settings_manager::SettingsManager::instance() + .load_settings() + .map(|settings| clamp_retention_days(settings.trash_retention_days)) + .unwrap_or(DEFAULT_RETENTION_DAYS) +} + +fn err_internal(e: impl std::fmt::Display) -> String { + crate::backend_error_with_detail("INTERNAL_ERROR", e) +} + +fn write_json(path: &Path, value: &T) -> Result<(), String> { + let json = serde_json::to_string_pretty(value).map_err(err_internal)?; + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, json).map_err(err_internal)?; + fs::rename(&tmp, path).map_err(err_internal) +} + +fn read_json Deserialize<'de>>(path: &Path) -> Result { + let content = fs::read_to_string(path).map_err(err_internal)?; + serde_json::from_str(&content).map_err(err_internal) +} + +/// Remove the cache-only directories from a profile data directory. Returns +/// the directories that were actually removed. +pub fn prune_cache_dirs(data_dir: &Path) -> Vec { + let mut removed = Vec::new(); + for relative in CACHE_DIRS { + let dir = data_dir.join(relative); + if !dir.is_dir() { + continue; + } + match fs::remove_dir_all(&dir) { + Ok(()) => removed.push(dir), + Err(e) => log::warn!("Could not prune cache dir {}: {e}", dir.display()), + } + } + removed +} + +/// Total size of every regular file under `path`. Symlinks are not followed. +pub fn dir_size(path: &Path) -> u64 { + let Ok(entries) = fs::read_dir(path) else { + return 0; + }; + entries + .flatten() + .map(|entry| { + let path = entry.path(); + match fs::symlink_metadata(&path) { + Ok(meta) if meta.is_dir() => dir_size(&path), + Ok(meta) if meta.is_file() => meta.len(), + _ => 0, + } + }) + .sum() +} + +fn copy_dir_recursive(from: &Path, to: &Path) -> std::io::Result<()> { + fs::create_dir_all(to)?; + for entry in fs::read_dir(from)? { + let entry = entry?; + let source = entry.path(); + let target = to.join(entry.file_name()); + let meta = fs::symlink_metadata(&source)?; + if meta.is_dir() { + copy_dir_recursive(&source, &target)?; + } else if meta.is_file() { + fs::copy(&source, &target)?; + } + } + Ok(()) +} + +/// Move a directory: a rename when both sides share a volume, otherwise a +/// copy followed by removal of the source. A failed copy leaves the source +/// untouched and no half-written target behind. +pub fn move_dir(from: &Path, to: &Path) -> std::io::Result<()> { + if let Some(parent) = to.parent() { + fs::create_dir_all(parent)?; + } + match fs::rename(from, to) { + Ok(()) => Ok(()), + Err(rename_error) => { + log::info!( + "Rename of {} failed ({rename_error}); copying instead", + from.display() + ); + if let Err(copy_error) = copy_dir_recursive(from, to) { + let _ = fs::remove_dir_all(to); + return Err(copy_error); + } + fs::remove_dir_all(from) + } + } +} + +/// Move a profile's directory into the trash and record when it expires. +/// +/// `profiles_dir//` becomes `trash_root//`; `metadata.json` is +/// replaced by `profile.json` (the struct handed in, with any process id +/// cleared) and `manifest.json` is added. An older trash entry under the same +/// id is dropped: ids survive a restore, so the profile being trashed now is +/// the newer copy. +pub fn trash_profile( + profiles_dir: &Path, + trash_root: &Path, + profile: &BrowserProfile, + retention_days: u32, + now: u64, +) -> Result { + let id = profile.id.to_string(); + let source_dir = profiles_dir.join(&id); + let target_dir = trash_root.join(&id); + + fs::create_dir_all(trash_root).map_err(err_internal)?; + if target_dir.exists() { + fs::remove_dir_all(&target_dir).map_err(err_internal)?; + } + + if !profile.password_protected { + let removed = prune_cache_dirs(&source_dir.join(DATA_DIR)); + if !removed.is_empty() { + log::info!( + "Pruned {} cache director{} from profile {id} before trashing", + removed.len(), + if removed.len() == 1 { "y" } else { "ies" } + ); + } + } + + if source_dir.exists() { + move_dir(&source_dir, &target_dir).map_err(err_internal)?; + } else { + fs::create_dir_all(&target_dir).map_err(err_internal)?; + } + let _ = fs::remove_file(target_dir.join(METADATA_FILE)); + + let mut stored = profile.clone(); + stored.process_id = None; + write_json(&target_dir.join(PROFILE_FILE), &stored)?; + + let manifest = TrashManifest { + deleted_at: now, + expires_at: now.saturating_add(u64::from(clamp_retention_days(retention_days)) * SECS_PER_DAY), + size_bytes: dir_size(&target_dir.join(DATA_DIR)), + original_name: profile.name.clone(), + }; + write_json(&target_dir.join(MANIFEST_FILE), &manifest)?; + Ok(manifest) +} + +/// Read one entry. `TRASH_ENTRY_NOT_FOUND` when there is no such entry. +pub fn read_entry( + trash_root: &Path, + profile_id: &str, +) -> Result<(BrowserProfile, TrashManifest), String> { + let entry_dir = trash_root.join(profile_id); + let profile_file = entry_dir.join(PROFILE_FILE); + let manifest_file = entry_dir.join(MANIFEST_FILE); + if !profile_file.is_file() || !manifest_file.is_file() { + return Err(crate::backend_error("TRASH_ENTRY_NOT_FOUND")); + } + Ok((read_json(&profile_file)?, read_json(&manifest_file)?)) +} + +/// Every readable entry, newest deletion first. Unreadable entries are +/// skipped with a warning rather than hiding the whole trash. +pub fn list_entries(trash_root: &Path) -> Vec<(BrowserProfile, TrashManifest)> { + let Ok(entries) = fs::read_dir(trash_root) else { + return Vec::new(); + }; + let mut listed: Vec<(BrowserProfile, TrashManifest)> = entries + .flatten() + .filter(|entry| entry.path().is_dir()) + .filter_map(|entry| { + let name = entry.file_name(); + let id = name.to_string_lossy(); + match read_entry(trash_root, &id) { + Ok(found) => Some(found), + Err(e) => { + log::warn!("Skipping unreadable trash entry {id}: {e}"); + None + } + } + }) + .collect(); + listed.sort_by_key(|(_, manifest)| std::cmp::Reverse(manifest.deleted_at)); + listed +} + +pub fn summaries(trash_root: &Path) -> Vec { + list_entries(trash_root) + .into_iter() + .map(|(profile, manifest)| TrashedProfileSummary { + id: profile.id.to_string(), + name: profile.name, + browser: profile.browser, + version: profile.version, + deleted_at: manifest.deleted_at, + expires_at: manifest.expires_at, + size_bytes: manifest.size_bytes, + group_id: profile.group_id, + password_protected: profile.password_protected, + }) + .collect() +} + +fn normalized_name(name: &str) -> String { + name.trim().to_lowercase() +} + +/// Pick a name that no live profile carries: the original when it is free, +/// otherwise `name (restored)`, `name (restored 2)`, and so on. +pub fn unique_restored_name(name: &str, taken: &HashSet) -> String { + if !taken.contains(&normalized_name(name)) { + return name.to_string(); + } + let mut attempt = 1u32; + loop { + let candidate = if attempt == 1 { + format!("{name} {RESTORED_SUFFIX}") + } else { + format!( + "{name} {} {attempt})", + RESTORED_SUFFIX.trim_end_matches(')') + ) + }; + if !taken.contains(&normalized_name(&candidate)) { + return candidate; + } + attempt += 1; + } +} + +/// Move a trashed profile back under `profiles_dir` and return the profile as +/// it must be saved: same id, identity, proxy and tags; the group only when it +/// still exists; a fresh `updated_at` so it wins any stale sync tombstone. +/// +/// `TRASH_RESTORE_CONFLICT` when a live profile already carries the id. +pub fn restore_profile( + profiles_dir: &Path, + trash_root: &Path, + profile_id: &str, + live_profiles: &[BrowserProfile], + group_exists: &dyn Fn(&str) -> bool, + now: u64, +) -> Result { + let (mut profile, _manifest) = read_entry(trash_root, profile_id)?; + + if live_profiles.iter().any(|live| live.id == profile.id) { + return Err(crate::backend_error("TRASH_RESTORE_CONFLICT")); + } + + let target_dir = profiles_dir.join(profile_id); + if target_dir.exists() { + // Nothing registered lives here (a registered profile has metadata.json + // and would have been caught above), so this is leftover garbage. + log::warn!( + "Removing stale directory {} before restoring profile {profile_id}", + target_dir.display() + ); + fs::remove_dir_all(&target_dir).map_err(err_internal)?; + } + + let taken: HashSet = live_profiles + .iter() + .map(|live| normalized_name(&live.name)) + .collect(); + profile.name = unique_restored_name(&profile.name, &taken); + if let Some(group_id) = profile.group_id.clone() { + if !group_exists(&group_id) { + profile.group_id = None; + } + } + profile.process_id = None; + profile.updated_at = Some(now); + + let entry_dir = trash_root.join(profile_id); + move_dir(&entry_dir, &target_dir).map_err(err_internal)?; + let _ = fs::remove_file(target_dir.join(PROFILE_FILE)); + let _ = fs::remove_file(target_dir.join(MANIFEST_FILE)); + write_json(&target_dir.join(METADATA_FILE), &profile)?; + Ok(profile) +} + +/// Destroy one entry for good. `TRASH_ENTRY_NOT_FOUND` when absent. +pub fn purge_entry(trash_root: &Path, profile_id: &str) -> Result<(), String> { + let entry_dir = trash_root.join(profile_id); + if !entry_dir.is_dir() { + return Err(crate::backend_error("TRASH_ENTRY_NOT_FOUND")); + } + fs::remove_dir_all(&entry_dir).map_err(err_internal) +} + +/// Destroy every entry. Returns the ids that were removed. +pub fn purge_all(trash_root: &Path) -> Result, String> { + let ids: Vec = list_entries(trash_root) + .into_iter() + .map(|(profile, _)| profile.id.to_string()) + .collect(); + for id in &ids { + purge_entry(trash_root, id)?; + } + Ok(ids) +} + +/// Destroy every entry whose expiry has passed. Returns the ids removed. +pub fn purge_expired(trash_root: &Path, now: u64) -> Vec { + list_entries(trash_root) + .into_iter() + .filter(|(_, manifest)| manifest.expires_at <= now) + .filter_map(|(profile, _)| { + let id = profile.id.to_string(); + match purge_entry(trash_root, &id) { + Ok(()) => Some(id), + Err(e) => { + log::warn!("Could not purge expired trash entry {id}: {e}"); + None + } + } + }) + .collect() +} + +/// A profile whose browser process is alive on this machine cannot be +/// trashed: its data directory is in use. A stale process id (the browser +/// crashed) does not count, and a cross-OS profile can never be running here. +pub fn is_running_locally(profile: &BrowserProfile) -> bool { + profile + .process_id + .is_some_and(crate::proxy_storage::is_process_running) + && !profile.is_cross_os() +} + +fn command_error(e: Box, context: &str) -> String { + let msg = e.to_string(); + if msg.starts_with('{') { + msg + } else { + format!("{context}: {msg}") + } +} + +/// Sweep expired entries now and again every `PURGE_INTERVAL_SECS`. +pub fn start_expiry_sweeper() { + tauri::async_runtime::spawn(async move { + let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(PURGE_INTERVAL_SECS)); + loop { + interval.tick().await; + let purged = ProfileManager::instance().purge_expired_trash(); + if purged > 0 { + log::info!( + "Purged {purged} expired trash entr{}", + if purged == 1 { "y" } else { "ies" } + ); + } + } + }); +} + +#[tauri::command] +pub fn list_trashed_profiles() -> Result, String> { + Ok(summaries(&trash_dir())) +} + +#[tauri::command] +pub async fn restore_trashed_profile( + app_handle: tauri::AppHandle, + profile_id: String, +) -> Result { + let manager = ProfileManager::instance(); + let mut profile = manager + .restore_trashed_profile(&profile_id) + .map_err(|e| command_error(e, "Failed to restore profile"))?; + + if profile.is_sync_enabled() { + // The cloud saw a delete (a tombstone was written when the profile was + // trashed). Re-enabling through the normal path clears that tombstone + // and queues the re-upload. When that path refuses (sync no longer + // configured, a cross-OS copy), sync is switched off on the restored + // profile so the next reconcile keeps the local copy instead of + // honouring the tombstone. + let mode = if profile.is_encrypted_sync() { + "Encrypted" + } else { + "Regular" + }; + if let Err(e) = + crate::sync::set_profile_sync_mode(app_handle.clone(), profile_id.clone(), mode.to_string()) + .await + { + log::warn!("Restored profile {profile_id} could not re-enable sync ({e}); leaving sync off"); + profile.sync_mode = crate::profile::types::SyncMode::Disabled; + manager + .save_profile(&profile) + .map_err(|e| command_error(e, "Failed to save restored profile"))?; + let _ = crate::events::emit_empty("profiles-changed"); + } + } + + Ok(profile) +} + +#[tauri::command] +pub fn purge_trashed_profile(profile_id: String) -> Result<(), String> { + ProfileManager::instance() + .purge_trashed_profile(&profile_id) + .map_err(|e| command_error(e, "Failed to delete trashed profile")) +} + +#[tauri::command] +pub fn empty_trash() -> Result { + ProfileManager::instance() + .empty_trash() + .map_err(|e| command_error(e, "Failed to empty trash")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wayfern_manager::WayfernConfig; + use tempfile::TempDir; + + const NOW: u64 = 1_700_000_000; + + fn sample_profile(name: &str) -> BrowserProfile { + BrowserProfile { + id: uuid::Uuid::new_v4(), + name: name.to_string(), + browser: "wayfern".to_string(), + version: "150.0.7871.100".to_string(), + proxy_id: Some("proxy-1".to_string()), + group_id: Some("group-1".to_string()), + tags: vec!["shop".to_string(), "eu".to_string()], + release_type: "stable".to_string(), + wayfern_config: Some(WayfernConfig { + identity_id: Some("identity-42".to_string()), + identity_overrides: Some(r#"{"userAgent":"custom"}"#.to_string()), + location: Some(r#"{"timezone":"Europe/Berlin"}"#.to_string()), + ..WayfernConfig::default() + }), + updated_at: Some(NOW - 1000), + ..BrowserProfile::default() + } + } + + /// Lay out `profiles//{metadata.json, profile/...}` the way the app does. + fn seed_profile(root: &Path, profile: &BrowserProfile, with_caches: bool) -> PathBuf { + let profiles_dir = root.join("profiles"); + let uuid_dir = profiles_dir.join(profile.id.to_string()); + let data_dir = uuid_dir.join("profile"); + fs::create_dir_all(data_dir.join("Default")).unwrap(); + fs::write(data_dir.join("Default").join("Cookies"), b"cookie-db").unwrap(); + fs::write(data_dir.join("Local State"), b"{}").unwrap(); + if with_caches { + for relative in CACHE_DIRS { + let dir = data_dir.join(relative); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("blob"), vec![0u8; 512]).unwrap(); + } + } + fs::write( + uuid_dir.join("metadata.json"), + serde_json::to_string_pretty(profile).unwrap(), + ) + .unwrap(); + profiles_dir + } + + fn group_exists(_: &str) -> bool { + true + } + + #[test] + fn trash_and_restore_round_trip_keeps_identity_and_data() { + let root = TempDir::new().unwrap(); + let profile = sample_profile("Shop Account"); + let profiles_dir = seed_profile(root.path(), &profile, true); + let trash_root = root.path().join("trash"); + + let manifest = trash_profile(&profiles_dir, &trash_root, &profile, 30, NOW).unwrap(); + assert_eq!(manifest.deleted_at, NOW); + assert_eq!(manifest.expires_at, NOW + 30 * SECS_PER_DAY); + assert_eq!(manifest.original_name, "Shop Account"); + assert!(manifest.size_bytes > 0); + + let uuid_dir = profiles_dir.join(profile.id.to_string()); + assert!(!uuid_dir.exists(), "the live directory must be gone"); + let entry_dir = trash_root.join(profile.id.to_string()); + assert!(entry_dir.join("profile.json").is_file()); + assert!(entry_dir.join("manifest.json").is_file()); + assert!(!entry_dir.join("metadata.json").exists()); + assert_eq!( + fs::read(entry_dir.join("profile").join("Default").join("Cookies")).unwrap(), + b"cookie-db" + ); + for relative in CACHE_DIRS { + assert!( + !entry_dir.join("profile").join(relative).exists(), + "{relative} must be pruned before the move" + ); + } + + let listed = summaries(&trash_root); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, profile.id.to_string()); + assert_eq!(listed[0].name, "Shop Account"); + assert_eq!(listed[0].group_id.as_deref(), Some("group-1")); + assert!(!listed[0].password_protected); + + let restored = restore_profile( + &profiles_dir, + &trash_root, + &profile.id.to_string(), + &[], + &group_exists, + NOW + 60, + ) + .unwrap(); + assert_eq!(restored.id, profile.id); + assert_eq!(restored.name, "Shop Account"); + assert_eq!(restored.proxy_id.as_deref(), Some("proxy-1")); + assert_eq!(restored.group_id.as_deref(), Some("group-1")); + assert_eq!(restored.tags, vec!["shop", "eu"]); + assert_eq!(restored.updated_at, Some(NOW + 60)); + let config = restored.wayfern_config.as_ref().unwrap(); + assert_eq!(config.identity_id.as_deref(), Some("identity-42")); + assert_eq!( + config.identity_overrides.as_deref(), + Some(r#"{"userAgent":"custom"}"#) + ); + assert_eq!( + config.location.as_deref(), + Some(r#"{"timezone":"Europe/Berlin"}"#) + ); + + assert!(!entry_dir.exists(), "the trash entry must be gone"); + assert!(uuid_dir.join("metadata.json").is_file()); + assert!(!uuid_dir.join("profile.json").exists()); + assert!(!uuid_dir.join("manifest.json").exists()); + assert_eq!( + fs::read(uuid_dir.join("profile").join("Default").join("Cookies")).unwrap(), + b"cookie-db" + ); + let on_disk: BrowserProfile = + serde_json::from_str(&fs::read_to_string(uuid_dir.join("metadata.json")).unwrap()).unwrap(); + assert_eq!(on_disk.id, profile.id); + assert_eq!(on_disk.updated_at, Some(NOW + 60)); + assert!(summaries(&trash_root).is_empty()); + } + + #[test] + fn restore_appends_suffix_when_a_live_profile_has_the_name() { + let root = TempDir::new().unwrap(); + let profile = sample_profile("Shop Account"); + let profiles_dir = seed_profile(root.path(), &profile, false); + let trash_root = root.path().join("trash"); + trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW).unwrap(); + + let mut twin = sample_profile("shop account"); + twin.id = uuid::Uuid::new_v4(); + let mut second_twin = sample_profile("Shop Account (restored)"); + second_twin.id = uuid::Uuid::new_v4(); + + let restored = restore_profile( + &profiles_dir, + &trash_root, + &profile.id.to_string(), + &[twin, second_twin], + &group_exists, + NOW, + ) + .unwrap(); + assert_eq!(restored.name, "Shop Account (restored 2)"); + assert_eq!(restored.id, profile.id); + } + + #[test] + fn unique_restored_name_prefers_the_original() { + let taken: HashSet = ["other".to_string()].into_iter().collect(); + assert_eq!(unique_restored_name("Mine", &taken), "Mine"); + let taken: HashSet = ["mine".to_string()].into_iter().collect(); + assert_eq!(unique_restored_name("Mine", &taken), "Mine (restored)"); + } + + #[test] + fn restore_refuses_when_a_live_profile_has_the_same_id() { + let root = TempDir::new().unwrap(); + let profile = sample_profile("Shop Account"); + let profiles_dir = seed_profile(root.path(), &profile, false); + let trash_root = root.path().join("trash"); + trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW).unwrap(); + + let err = restore_profile( + &profiles_dir, + &trash_root, + &profile.id.to_string(), + std::slice::from_ref(&profile), + &group_exists, + NOW, + ) + .unwrap_err(); + assert!(err.contains("TRASH_RESTORE_CONFLICT"), "{err}"); + assert_eq!(summaries(&trash_root).len(), 1, "the entry must survive"); + } + + #[test] + fn restore_and_purge_of_a_missing_entry_report_not_found() { + let root = TempDir::new().unwrap(); + let trash_root = root.path().join("trash"); + let err = restore_profile( + &root.path().join("profiles"), + &trash_root, + "does-not-exist", + &[], + &group_exists, + NOW, + ) + .unwrap_err(); + assert!(err.contains("TRASH_ENTRY_NOT_FOUND"), "{err}"); + let err = purge_entry(&trash_root, "does-not-exist").unwrap_err(); + assert!(err.contains("TRASH_ENTRY_NOT_FOUND"), "{err}"); + } + + #[test] + fn restore_clears_the_group_when_it_no_longer_exists() { + let root = TempDir::new().unwrap(); + let profile = sample_profile("Grouped"); + let profiles_dir = seed_profile(root.path(), &profile, false); + let trash_root = root.path().join("trash"); + trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW).unwrap(); + + let restored = restore_profile( + &profiles_dir, + &trash_root, + &profile.id.to_string(), + &[], + &|_| false, + NOW, + ) + .unwrap(); + assert_eq!(restored.group_id, None); + assert_eq!(restored.proxy_id.as_deref(), Some("proxy-1")); + } + + #[test] + fn password_protected_entry_is_moved_as_is() { + let root = TempDir::new().unwrap(); + let mut profile = sample_profile("Vault"); + profile.password_protected = true; + profile.encryption_salt = Some("salt".to_string()); + let profiles_dir = seed_profile(root.path(), &profile, true); + let trash_root = root.path().join("trash"); + trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW).unwrap(); + + let entry_dir = trash_root.join(profile.id.to_string()); + for relative in CACHE_DIRS { + assert!( + entry_dir.join("profile").join(relative).exists(), + "an encrypted tree is never pruned ({relative})" + ); + } + assert!(summaries(&trash_root)[0].password_protected); + + let restored = restore_profile( + &profiles_dir, + &trash_root, + &profile.id.to_string(), + &[], + &group_exists, + NOW, + ) + .unwrap(); + assert!(restored.password_protected); + assert_eq!(restored.encryption_salt.as_deref(), Some("salt")); + } + + #[test] + fn expiry_purge_removes_only_expired_entries() { + let root = TempDir::new().unwrap(); + let old = sample_profile("Old"); + let fresh = sample_profile("Fresh"); + let profiles_dir = seed_profile(root.path(), &old, false); + seed_profile(root.path(), &fresh, false); + let trash_root = root.path().join("trash"); + trash_profile(&profiles_dir, &trash_root, &old, 1, NOW).unwrap(); + trash_profile(&profiles_dir, &trash_root, &fresh, 30, NOW).unwrap(); + assert_eq!(summaries(&trash_root).len(), 2); + + assert!(purge_expired(&trash_root, NOW + SECS_PER_DAY - 1).is_empty()); + let purged = purge_expired(&trash_root, NOW + SECS_PER_DAY); + assert_eq!(purged, vec![old.id.to_string()]); + let remaining = summaries(&trash_root); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].id, fresh.id.to_string()); + assert!(!trash_root.join(old.id.to_string()).exists()); + } + + #[test] + fn retention_is_clamped_to_the_allowed_range() { + assert_eq!(clamp_retention_days(0), MIN_RETENTION_DAYS); + assert_eq!(clamp_retention_days(30), 30); + assert_eq!(clamp_retention_days(10_000), MAX_RETENTION_DAYS); + let root = TempDir::new().unwrap(); + let profile = sample_profile("Clamped"); + let profiles_dir = seed_profile(root.path(), &profile, false); + let manifest = + trash_profile(&profiles_dir, &root.path().join("trash"), &profile, 0, NOW).unwrap(); + assert_eq!(manifest.expires_at, NOW + SECS_PER_DAY); + } + + #[test] + fn empty_trash_removes_every_entry() { + let root = TempDir::new().unwrap(); + let first = sample_profile("First"); + let second = sample_profile("Second"); + let profiles_dir = seed_profile(root.path(), &first, false); + seed_profile(root.path(), &second, false); + let trash_root = root.path().join("trash"); + trash_profile(&profiles_dir, &trash_root, &first, 7, NOW).unwrap(); + trash_profile(&profiles_dir, &trash_root, &second, 7, NOW + 1).unwrap(); + + let listed = summaries(&trash_root); + assert_eq!(listed[0].name, "Second", "newest deletion is listed first"); + let mut purged = purge_all(&trash_root).unwrap(); + purged.sort(); + let mut expected = vec![first.id.to_string(), second.id.to_string()]; + expected.sort(); + assert_eq!(purged, expected); + assert!(summaries(&trash_root).is_empty()); + } + + #[test] + fn trashing_a_profile_again_replaces_the_older_entry() { + let root = TempDir::new().unwrap(); + let profile = sample_profile("Twice"); + let profiles_dir = seed_profile(root.path(), &profile, false); + let trash_root = root.path().join("trash"); + trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW).unwrap(); + restore_profile( + &profiles_dir, + &trash_root, + &profile.id.to_string(), + &[], + &group_exists, + NOW, + ) + .unwrap(); + let uuid_dir = profiles_dir.join(profile.id.to_string()); + fs::write(uuid_dir.join("profile").join("Local State"), b"newer").unwrap(); + // Simulate a leftover entry that a crash left behind under the same id. + fs::create_dir_all(trash_root.join(profile.id.to_string())).unwrap(); + fs::write(trash_root.join(profile.id.to_string()).join("stale"), b"x").unwrap(); + + trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW + 5).unwrap(); + let entry_dir = trash_root.join(profile.id.to_string()); + assert!(!entry_dir.join("stale").exists()); + assert_eq!( + fs::read(entry_dir.join("profile").join("Local State")).unwrap(), + b"newer" + ); + assert_eq!(summaries(&trash_root).len(), 1); + } + + #[test] + fn unreadable_entries_are_skipped_not_fatal() { + let root = TempDir::new().unwrap(); + let profile = sample_profile("Good"); + let profiles_dir = seed_profile(root.path(), &profile, false); + let trash_root = root.path().join("trash"); + trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW).unwrap(); + let broken = trash_root.join("broken-entry"); + fs::create_dir_all(&broken).unwrap(); + fs::write(broken.join("profile.json"), b"not json").unwrap(); + fs::write(broken.join("manifest.json"), b"{}").unwrap(); + + let listed = summaries(&trash_root); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].name, "Good"); + } + + #[test] + fn move_dir_copies_when_a_rename_is_impossible() { + let root = TempDir::new().unwrap(); + let from = root.path().join("from"); + fs::create_dir_all(from.join("nested")).unwrap(); + fs::write(from.join("nested").join("file"), b"payload").unwrap(); + let to = root.path().join("to"); + copy_dir_recursive(&from, &to).unwrap(); + assert_eq!( + fs::read(to.join("nested").join("file")).unwrap(), + b"payload" + ); + assert_eq!(dir_size(&to), 7); + move_dir(&from, &root.path().join("moved")).unwrap(); + assert!(!from.exists()); + assert_eq!( + fs::read(root.path().join("moved").join("nested").join("file")).unwrap(), + b"payload" + ); + } + + #[test] + fn running_check_uses_a_live_process() { + let mut profile = sample_profile("Running"); + profile.process_id = Some(std::process::id()); + assert!(is_running_locally(&profile)); + // A cross-OS profile can never be running on this machine. + profile.host_os = Some(if cfg!(target_os = "macos") { + "linux".to_string() + } else { + "macos".to_string() + }); + assert!(!is_running_locally(&profile)); + let mut idle = sample_profile("Idle"); + idle.process_id = None; + assert!(!is_running_locally(&idle)); + } +} diff --git a/src-tauri/src/profile/types.rs b/src-tauri/src/profile/types.rs index 44f4118..e017284 100644 --- a/src-tauri/src/profile/types.rs +++ b/src-tauri/src/profile/types.rs @@ -58,6 +58,13 @@ pub struct BrowserProfile { pub host_os: Option, // OS where profile was created ("macos", "windows", "linux") #[serde(default)] pub ephemeral: bool, + /// A profile that exists for one automation run. REST and MCP create it, + /// the browser stopping destroys it, and a startup sweep destroys any that + /// outlived a crash. Always ephemeral as well, so nothing it browses ever + /// reaches real disk. Never trashed: a disposable profile has nothing to + /// restore, and keeping one would defeat the point of asking for it. + #[serde(default)] + pub temporary: bool, #[serde(default)] pub extension_group_id: Option, #[serde(default)] diff --git a/src-tauri/src/profile_import/os_crypt.rs b/src-tauri/src/profile_import/os_crypt.rs index efb3588..a137cd0 100644 --- a/src-tauri/src/profile_import/os_crypt.rs +++ b/src-tauri/src/profile_import/os_crypt.rs @@ -1,17 +1,16 @@ //! Key material for profile import. //! -//! Wayfern deliberately does not use the OS keyring. Every `os_crypt_async` -//! key provider is patched to read (or mint) `/os_crypt_key` -//! instead, so a profile directory is self-contained and portable. See -//! `wayfern/patches/extra/fingerprint/components-os_crypt-async-browser-*`. +//! Wayfern keeps os_crypt key material in `/os_crypt_key` +//! rather than the OS keyring, so a profile directory is self-contained and +//! portable. //! //! That portability is exactly why an imported Chrome profile carries nothing: //! its secrets are sealed with a key held in the macOS Keychain / Windows DPAPI //! / the Freedesktop secret service, and Wayfern never looks there. Import has //! to open the source's lock and re-seal everything with Wayfern's. //! -//! The on-disk format is per-platform and NOT interchangeable, matching the -//! provider that owns each tag in the patched Chromium 151 tree: +//! The on-disk format is per-platform and NOT interchangeable. The tag in each +//! record selects the derivation: //! //! | Host | `os_crypt_key` | Derivation | Cipher | Tag | //! |---------|---------------------|-------------------------------------|--------------|-------| @@ -248,10 +247,10 @@ impl TargetKey { /// Read the existing `os_crypt_key`, or mint and persist one. /// /// Writing eagerly at import time — rather than letting the first launch do - /// it — is deliberate. The mac and Linux patches have no `else` branch when - /// the write fails, so the browser would run on an in-memory key that dies - /// with the process and orphans everything it wrote. Failing here instead - /// turns that silent data loss into a visible import error. + /// it — is deliberate: a key the browser cannot persist would live only in + /// memory, die with the process, and orphan everything written with it. + /// Failing here instead turns that silent data loss into a visible import + /// error. pub fn ensure(user_data_dir: &Path) -> Result { let key_file = user_data_dir.join(KEY_FILE_NAME); @@ -476,7 +475,7 @@ mod tests { #[cfg(not(target_os = "windows"))] { - // Wayfern writes base64(16 random bytes) = 24 ASCII chars. + // The non-Windows key file is base64(16 random bytes) = 24 ASCII chars. assert_eq!(contents.len(), 24); let text = String::from_utf8(contents).expect("ascii"); assert!( diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs index e29ac7b..842c624 100644 --- a/src-tauri/src/profile_importer.rs +++ b/src-tauri/src/profile_importer.rs @@ -983,6 +983,7 @@ impl ProfileImporter { last_sync: None, host_os: None, ephemeral: false, + temporary: false, extension_group_id: None, proxy_bypass_rules: Vec::new(), created_by_id: None, @@ -1063,6 +1064,7 @@ impl ProfileImporter { last_sync: None, host_os: Some(get_host_os()), ephemeral: false, + temporary: false, extension_group_id: None, proxy_bypass_rules: Vec::new(), created_by_id: None, diff --git a/src-tauri/src/proxy_distribution.rs b/src-tauri/src/proxy_distribution.rs new file mode 100644 index 0000000..9e61539 --- /dev/null +++ b/src-tauri/src/proxy_distribution.rs @@ -0,0 +1,442 @@ +//! Handing a fleet of profiles one proxy each. +//! +//! Fifty profiles and fifty residential proxies is fifty dialogs by hand. This +//! pairs them positionally instead — profile 1 to proxy 1, profile 2 to proxy +//! 2 — and it never wraps around: when the two lists differ in length the +//! remainder is reported rather than reused, because silently giving two +//! profiles the same exit is the one outcome a fleet owner is buying separate +//! proxies to avoid. +//! +//! The pairing rule lives here as one pure function so the dialog's preview, +//! the counts it shows, and the assignment that is finally applied all come +//! from the same code. The apply step takes explicit pairs, so a caller that +//! wants a different arrangement — REST, MCP, or a user who ticked boxes by +//! hand — is not forced through the default. + +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use utoipa::ToSchema; + +/// One profile and the proxy it should end up on. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct ProxyPair { + pub profile_id: String, + pub proxy_id: String, +} + +/// A profile as the pairing rule sees it. +#[derive(Debug, Clone)] +pub struct ProfileCandidate { + pub id: String, + /// Its browser is alive on this machine, so its proxy cannot be changed. + pub running: bool, +} + +/// What a distribution would do, before anything is written. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct DistributionPlan { + /// The assignments, in the order the profiles were given. + pub pairs: Vec, + /// Chosen profiles that no proxy was left for. + pub unpaired_profile_ids: Vec, + /// Chosen proxies that no profile was left for. + pub unused_proxy_ids: Vec, + /// Chosen profiles refused because their browser is running. + pub running_profile_ids: Vec, + /// Chosen proxies withheld because a profile outside this distribution + /// already uses them and sharing was not allowed. + pub shared_proxy_ids: Vec, +} + +/// Pair profiles to proxies one to one. +/// +/// `assigned_elsewhere` is the set of proxies held by profiles that are not +/// part of this distribution. With `allow_sharing` off those proxies are taken +/// out of the pool, so a run cannot quietly put a second profile behind an exit +/// that is already in use. A proxy listed twice by the caller is the same +/// hazard and is deduplicated the same way. +pub fn plan( + profiles: &[ProfileCandidate], + proxy_ids: &[String], + allow_sharing: bool, + assigned_elsewhere: &HashSet, +) -> DistributionPlan { + let mut plan = DistributionPlan::default(); + + let mut eligible = Vec::with_capacity(profiles.len()); + for profile in profiles { + if profile.running { + plan.running_profile_ids.push(profile.id.clone()); + } else { + eligible.push(profile.id.clone()); + } + } + + let mut pool: Vec = Vec::with_capacity(proxy_ids.len()); + let mut seen: HashSet<&str> = HashSet::new(); + for proxy_id in proxy_ids { + if !seen.insert(proxy_id.as_str()) { + // The same proxy twice in one list is sharing spelled differently. + if !allow_sharing { + plan.shared_proxy_ids.push(proxy_id.clone()); + continue; + } + } + if !allow_sharing && assigned_elsewhere.contains(proxy_id) { + plan.shared_proxy_ids.push(proxy_id.clone()); + continue; + } + pool.push(proxy_id.clone()); + } + + let paired = eligible.len().min(pool.len()); + for index in 0..paired { + plan.pairs.push(ProxyPair { + profile_id: eligible[index].clone(), + proxy_id: pool[index].clone(), + }); + } + plan.unpaired_profile_ids = eligible[paired..].to_vec(); + plan.unused_proxy_ids = pool[paired..].to_vec(); + plan +} + +/// What happened to one profile in an apply. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct ProxyAssignmentResult { + pub profile_id: String, + pub proxy_id: String, + pub ok: bool, + /// A `{"code": ...}` payload when `ok` is false, otherwise null. + pub error: Option, +} + +fn failed(pair: &ProxyPair, code: &str) -> ProxyAssignmentResult { + ProxyAssignmentResult { + profile_id: pair.profile_id.clone(), + proxy_id: pair.proxy_id.clone(), + ok: false, + error: Some(serde_json::json!({ "code": code }).to_string()), + } +} + +/// Apply explicit pairs, one profile at a time, and report each outcome. +/// +/// A profile that cannot be moved never stops the rest: fifty assignments in +/// which the one running profile fails is a useful answer, and an all-or- +/// nothing abort halfway through a fleet is not. +pub async fn apply_pairs( + app_handle: tauri::AppHandle, + pairs: &[ProxyPair], +) -> Vec { + let manager = crate::profile::ProfileManager::instance(); + let known_proxies: HashSet = crate::proxy_manager::PROXY_MANAGER + .get_stored_proxies() + .into_iter() + .map(|proxy| proxy.id) + .collect(); + + let mut results = Vec::with_capacity(pairs.len()); + let mut already_paired: HashSet<&str> = HashSet::new(); + + for pair in pairs { + if !already_paired.insert(pair.profile_id.as_str()) { + // Two proxies for one profile is not an assignment, it is a mistake in + // the request, and quietly applying the last one hides it. + results.push(failed(pair, "PROFILE_PAIRED_TWICE")); + continue; + } + if !known_proxies.contains(&pair.proxy_id) { + results.push(failed(pair, "PROXY_NOT_FOUND")); + continue; + } + + // Re-read on every pair: an earlier assignment in this same batch, or a + // browser someone started while the dialog was open, has to be visible. + let profile = match manager.list_profiles() { + Ok(profiles) => profiles + .into_iter() + .find(|profile| profile.id.to_string() == pair.profile_id), + Err(e) => { + log::warn!("Could not list profiles while distributing proxies: {e}"); + None + } + }; + let Some(profile) = profile else { + results.push(failed(pair, "PROFILE_NOT_FOUND")); + continue; + }; + if crate::profile::trash::is_running_locally(&profile) { + results.push(failed(pair, "PROFILE_RUNNING")); + continue; + } + + match manager + .update_profile_proxy( + app_handle.clone(), + &pair.profile_id, + Some(pair.proxy_id.clone()), + ) + .await + { + Ok(_) => results.push(ProxyAssignmentResult { + profile_id: pair.profile_id.clone(), + proxy_id: pair.proxy_id.clone(), + ok: true, + error: None, + }), + Err(e) => results.push(ProxyAssignmentResult { + profile_id: pair.profile_id.clone(), + proxy_id: pair.proxy_id.clone(), + ok: false, + error: Some(e.to_string()), + }), + } + } + + results +} + +/// Build the pairing rule's view of the world from what is on disk. +fn candidates( + profile_ids: &[String], +) -> Result<(Vec, HashSet), Box> { + let profiles = crate::profile::ProfileManager::instance().list_profiles()?; + let chosen: HashSet<&str> = profile_ids.iter().map(String::as_str).collect(); + + let assigned_elsewhere = profiles + .iter() + .filter(|profile| !chosen.contains(profile.id.to_string().as_str())) + .filter_map(|profile| profile.proxy_id.clone()) + .collect(); + + let ordered = profile_ids + .iter() + .map(|id| { + let found = profiles.iter().find(|p| p.id.to_string() == *id); + ProfileCandidate { + id: id.clone(), + // A profile that is not on disk cannot be launched either, so it is + // simply never paired; the plan reports it as unpaired. + running: found.is_none_or(crate::profile::trash::is_running_locally), + } + }) + .collect(); + + Ok((ordered, assigned_elsewhere)) +} + +/// Tauri command: what would happen, without touching anything. +#[tauri::command] +pub async fn plan_proxy_distribution( + profile_ids: Vec, + proxy_ids: Vec, + allow_sharing: bool, +) -> Result { + let (profiles, assigned_elsewhere) = candidates(&profile_ids).map_err(|e| e.to_string())?; + Ok(plan( + &profiles, + &proxy_ids, + allow_sharing, + &assigned_elsewhere, + )) +} + +/// Tauri command: apply the pairs and report every profile's outcome. +#[tauri::command] +pub async fn distribute_proxies_to_profiles( + app_handle: tauri::AppHandle, + pairs: Vec, +) -> Result, String> { + Ok(apply_pairs(app_handle, &pairs).await) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn free(id: &str) -> ProfileCandidate { + ProfileCandidate { + id: id.to_string(), + running: false, + } + } + + fn running(id: &str) -> ProfileCandidate { + ProfileCandidate { + id: id.to_string(), + running: true, + } + } + + fn ids(values: &[&str]) -> Vec { + values.iter().map(|v| v.to_string()).collect() + } + + fn assigned(values: &[&str]) -> HashSet { + values.iter().map(|v| v.to_string()).collect() + } + + #[test] + fn equal_lists_pair_one_to_one_in_order() { + let plan = plan( + &[free("p1"), free("p2"), free("p3")], + &ids(&["x1", "x2", "x3"]), + false, + &HashSet::new(), + ); + assert_eq!( + plan.pairs, + vec![ + ProxyPair { + profile_id: "p1".into(), + proxy_id: "x1".into() + }, + ProxyPair { + profile_id: "p2".into(), + proxy_id: "x2".into() + }, + ProxyPair { + profile_id: "p3".into(), + proxy_id: "x3".into() + }, + ] + ); + assert!(plan.unpaired_profile_ids.is_empty()); + assert!(plan.unused_proxy_ids.is_empty()); + } + + #[test] + fn more_profiles_than_proxies_leaves_the_remainder_alone() { + // The failure this guards: wrapping around, which would put p3 and p4 on + // the same exits as p1 and p2 without anyone asking for it. + let plan = plan( + &[free("p1"), free("p2"), free("p3"), free("p4")], + &ids(&["x1", "x2"]), + false, + &HashSet::new(), + ); + assert_eq!(plan.pairs.len(), 2); + assert_eq!(plan.unpaired_profile_ids, ids(&["p3", "p4"])); + assert!(plan.unused_proxy_ids.is_empty()); + } + + #[test] + fn more_proxies_than_profiles_reports_the_leftovers() { + let plan = plan( + &[free("p1")], + &ids(&["x1", "x2", "x3"]), + false, + &HashSet::new(), + ); + assert_eq!(plan.pairs.len(), 1); + assert_eq!(plan.unused_proxy_ids, ids(&["x2", "x3"])); + assert!(plan.unpaired_profile_ids.is_empty()); + } + + #[test] + fn a_proxy_another_profile_holds_is_withheld_until_sharing_is_allowed() { + let profiles = [free("p1"), free("p2")]; + let proxies = ids(&["x1", "x2"]); + let elsewhere = assigned(&["x1"]); + + let strict = plan(&profiles, &proxies, false, &elsewhere); + assert_eq!(strict.shared_proxy_ids, ids(&["x1"])); + assert_eq!( + strict.pairs, + vec![ProxyPair { + profile_id: "p1".into(), + proxy_id: "x2".into() + }] + ); + assert_eq!(strict.unpaired_profile_ids, ids(&["p2"])); + + let permissive = plan(&profiles, &proxies, true, &elsewhere); + assert!(permissive.shared_proxy_ids.is_empty()); + assert_eq!(permissive.pairs.len(), 2); + assert_eq!(permissive.pairs[0].proxy_id, "x1"); + } + + #[test] + fn the_same_proxy_listed_twice_is_sharing_too() { + let strict = plan( + &[free("p1"), free("p2")], + &ids(&["x1", "x1"]), + false, + &HashSet::new(), + ); + assert_eq!(strict.pairs.len(), 1); + assert_eq!(strict.shared_proxy_ids, ids(&["x1"])); + assert_eq!(strict.unpaired_profile_ids, ids(&["p2"])); + + let permissive = plan( + &[free("p1"), free("p2")], + &ids(&["x1", "x1"]), + true, + &HashSet::new(), + ); + assert_eq!(permissive.pairs.len(), 2); + assert_eq!(permissive.pairs[1].proxy_id, "x1"); + } + + #[test] + fn a_running_profile_is_named_and_never_paired() { + let plan = plan( + &[free("p1"), running("p2"), free("p3")], + &ids(&["x1", "x2"]), + false, + &HashSet::new(), + ); + assert_eq!(plan.running_profile_ids, ids(&["p2"])); + assert_eq!( + plan.pairs, + vec![ + ProxyPair { + profile_id: "p1".into(), + proxy_id: "x1".into() + }, + // p3 takes the second proxy: the running profile is skipped, it does + // not consume a proxy and leave a hole behind it. + ProxyPair { + profile_id: "p3".into(), + proxy_id: "x2".into() + }, + ] + ); + assert!(plan.unpaired_profile_ids.is_empty()); + } + + #[test] + fn every_profile_running_pairs_nothing_and_frees_every_proxy() { + let plan = plan( + &[running("p1"), running("p2")], + &ids(&["x1", "x2"]), + false, + &HashSet::new(), + ); + assert!(plan.pairs.is_empty()); + assert_eq!(plan.running_profile_ids, ids(&["p1", "p2"])); + assert_eq!(plan.unused_proxy_ids, ids(&["x1", "x2"])); + } + + #[test] + fn a_profiles_own_proxy_is_not_treated_as_someone_elses() { + // p1 already sits on x1. Because p1 is part of this distribution, x1 is + // not "assigned elsewhere", so it stays in the pool and the run can + // reshuffle it rather than refusing to touch it. + let plan = plan( + &[free("p1"), free("p2")], + &ids(&["x1", "x2"]), + false, + &HashSet::new(), + ); + assert!(plan.shared_proxy_ids.is_empty()); + assert_eq!(plan.pairs.len(), 2); + } + + #[test] + fn nothing_chosen_produces_an_empty_plan() { + let plan = plan(&[], &[], false, &HashSet::new()); + assert_eq!(plan, DistributionPlan::default()); + } +} diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index c564ea5..0f108a7 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -1,6 +1,7 @@ use chrono::Utc; use serde::{Deserialize, Serialize}; use serde_json::Value; +use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::fs; use std::path::PathBuf; @@ -93,6 +94,76 @@ pub struct ProxyCheckResult { pub country_code: Option, pub timestamp: u64, pub is_valid: bool, + /// The exit's ISP or registered organisation, read from the local MaxMind + /// databases. `None` means the databases carry none, never "no ISP". + #[serde(default)] + pub isp: Option, + /// The exit's own timezone, the value a fingerprint is matched against. + #[serde(default)] + pub timezone: Option, + /// Whether the proxy carries UDP, which decides whether WebRTC can be + /// routed through it at all. Receipts written before this existed + /// deserialize as `Unknown`, which is the truth about them. + #[serde(default)] + pub udp: crate::proxy_udp::UdpSupport, + /// How long the whole check took, end to end. + #[serde(default)] + pub latency_ms: Option, +} + +/// One line of a proxy's check log. Deliberately smaller than +/// `ProxyCheckResult`: this is a trail, not a cache, so it keeps what a user +/// reads down a list and nothing that would make the file grow without bound. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ProxyCheckHistoryEntry { + pub timestamp: u64, + pub ok: bool, + #[serde(default)] + pub ip: Option, + #[serde(default)] + pub country: Option, + #[serde(default)] + pub country_code: Option, + #[serde(default)] + pub isp: Option, + #[serde(default)] + pub udp: crate::proxy_udp::UdpSupport, + #[serde(default)] + pub latency_ms: Option, +} + +/// How many checks a proxy remembers. Old enough entries stop being evidence +/// and the file has to stay small enough to read on every popover open. +pub const PROXY_CHECK_HISTORY_LIMIT: usize = 50; + +#[derive(Debug, Default, Serialize, Deserialize)] +struct ProxyCheckHistory { + #[serde(default)] + entries: Vec, +} + +/// Prepend `entry` and drop anything past the limit. Newest first is both the +/// order the list is read in and the order that makes the cap mean "the last +/// 50 checks" rather than "the first 50". +fn push_history_entry(entries: &mut Vec, entry: ProxyCheckHistoryEntry) { + entries.insert(0, entry); + entries.truncate(PROXY_CHECK_HISTORY_LIMIT); +} + +#[derive(Serialize, Deserialize)] +struct CachedProxyCheck { + settings_hash: [u8; 32], + result: ProxyCheckResult, +} + +impl CachedProxyCheck { + fn settings_hash(settings: &ProxySettings) -> Result<[u8; 32], serde_json::Error> { + Ok(Sha256::digest(serde_json::to_vec(settings)?).into()) + } + + fn for_settings(self, settings: &ProxySettings) -> Option { + (self.settings_hash == Self::settings_hash(settings).ok()?).then_some(self.result) + } } pub const CLOUD_PROXY_ID: &str = "cloud-included-proxy"; @@ -303,17 +374,25 @@ impl ProxyManager { Err(_) => return None, }; - serde_json::from_str::(&content).ok() + let settings = self.get_proxy_settings_by_id(proxy_id)?; + serde_json::from_str::(&content) + .ok()? + .for_settings(&settings) } // Save proxy check result to cache fn save_proxy_check_cache( &self, proxy_id: &str, + settings: &ProxySettings, result: &ProxyCheckResult, ) -> Result<(), Box> { let cache_file = self.get_proxy_check_cache_file(proxy_id)?; - let content = serde_json::to_string_pretty(result)?; + let cache = CachedProxyCheck { + settings_hash: CachedProxyCheck::settings_hash(settings)?, + result: result.clone(), + }; + let content = serde_json::to_string_pretty(&cache)?; crate::app_dirs::write_owner_only(&cache_file, content.as_bytes())?; Ok(()) } @@ -450,9 +529,72 @@ impl ProxyManager { if proxy_file.exists() { fs::remove_file(proxy_file)?; } + let history_file = self.get_proxy_history_file_path(proxy_id); + if history_file.exists() { + // The trail is about a proxy that no longer exists, and it names the + // addresses that proxy exited from. It goes with the config. + fs::remove_file(history_file)?; + } Ok(()) } + /// The check trail sits in a `history` folder beside the proxy configs + /// rather than next to them: `load_stored_proxies` reads every `*.json` in + /// the proxies directory and warns about anything that is not a proxy, so a + /// sibling file would log a parse failure per proxy on every start. + fn get_proxy_history_dir(&self) -> PathBuf { + self.get_proxies_dir().join("history") + } + + fn get_proxy_history_file_path(&self, proxy_id: &str) -> PathBuf { + self + .get_proxy_history_dir() + .join(format!("{proxy_id}.json")) + } + + /// Every remembered check for a proxy, newest first. + pub fn get_proxy_check_history(&self, proxy_id: &str) -> Vec { + let path = self.get_proxy_history_file_path(proxy_id); + let Ok(content) = fs::read_to_string(&path) else { + return Vec::new(); + }; + match serde_json::from_str::(&content) { + Ok(mut history) => { + history.entries.truncate(PROXY_CHECK_HISTORY_LIMIT); + history.entries + } + Err(e) => { + log::warn!("Failed to parse proxy check history {path:?}: {e}"); + Vec::new() + } + } + } + + /// Append one check to a proxy's trail. + fn record_proxy_check(&self, proxy_id: &str, entry: ProxyCheckHistoryEntry) { + let mut entries = self.get_proxy_check_history(proxy_id); + push_history_entry(&mut entries, entry); + + let dir = self.get_proxy_history_dir(); + if let Err(e) = fs::create_dir_all(&dir) { + log::warn!("Failed to create the proxy check history directory: {e}"); + return; + } + match serde_json::to_string_pretty(&ProxyCheckHistory { entries }) { + Ok(content) => { + // Owner-only: the trail records which addresses this machine exits + // from, which is exactly what the proxy exists to keep private. + if let Err(e) = crate::app_dirs::write_owner_only( + &self.get_proxy_history_file_path(proxy_id), + content.as_bytes(), + ) { + log::warn!("Failed to write the proxy check history: {e}"); + } + } + Err(e) => log::warn!("Failed to serialize the proxy check history: {e}"), + } + } + fn normalize_proxy_settings(mut proxy_settings: ProxySettings) -> Result { if !proxy_settings.proxy_type.eq_ignore_ascii_case("vless") { proxy_settings.vless_uri = None; @@ -1159,12 +1301,58 @@ impl ProxyManager { /// the exit rather than on this machine. Resolving locally would leak the /// real DNS and, behind a split-horizon resolver, can reach a different host /// than the browser would. + /// + /// `httpstls` becomes `https://` because reqwest has no such scheme; both + /// spellings mean TLS to the proxy followed by CONNECT, so the probe still + /// crosses the same encrypted hop the browser will. pub fn build_probe_proxy_url(proxy_settings: &ProxySettings) -> String { let url = Self::build_proxy_url(proxy_settings); if proxy_settings.proxy_type.eq_ignore_ascii_case("socks5") { return url.replacen("socks5://", "socks5h://", 1); } - url + crate::proxy_storage::reqwest_upstream_url(&url) + } + + /// Prove the TLS hop to an `httpstls` proxy can actually be established, so + /// a certificate that does not verify is reported as exactly that. + /// + /// Returns the coded error the frontend translates. There is intentionally no + /// "connect anyway" path: verification is the property that makes this proxy + /// type resistant to an active man-in-the-middle rather than only to a + /// passive one, so a failure here is fatal by design. + async fn verify_upstream_tls(proxy_settings: &ProxySettings) -> Result<(), String> { + let host = proxy_settings.host.clone(); + let port = proxy_settings.port; + let addr = format!("{host}:{port}"); + + let attempt = async { + let tcp = tokio::net::TcpStream::connect((host.as_str(), port)) + .await + .map_err(|e| e.to_string())?; + let connector = tokio_native_tls::TlsConnector::from( + native_tls::TlsConnector::new().map_err(|e| e.to_string())?, + ); + connector + .connect(host.as_str(), tcp) + .await + .map(|_| ()) + .map_err(|e| e.to_string()) + }; + + let detail = match tokio::time::timeout(std::time::Duration::from_secs(15), attempt).await { + Ok(Ok(())) => return Ok(()), + Ok(Err(detail)) => detail, + Err(_) => "timed out".to_string(), + }; + + log::warn!("TLS handshake with upstream proxy {addr} failed: {detail}"); + Err( + serde_json::json!({ + "code": "PROXY_TLS_HANDSHAKE_FAILED", + "params": { "proxy": addr } + }) + .to_string(), + ) } // Check if a proxy is valid by routing through a temporary donut-proxy process. @@ -1190,6 +1378,29 @@ impl ProxyManager { proxy_settings.clone() }; let upstream_url = Self::build_proxy_url(&effective_proxy_settings); + let started = std::time::Instant::now(); + + // Whether the proxy carries UDP is asked of the same endpoint the browser + // dials. Spawned rather than awaited here so it runs alongside the exit + // lookup: a check should not take twice as long to answer twice as much, + // and for a VLESS proxy the probe has to reach the local worker while it + // is still up. + let probe_settings = effective_proxy_settings.clone(); + let udp_probe = tauri::async_runtime::spawn(async move { + crate::proxy_udp::probe_udp_support(&probe_settings).await + }); + + // The dominant failure for a TLS-wrapped hop is a certificate that will not + // verify: the provider publishes a bare IP, or serves a self-signed cert. + // Through the worker that surfaces as a generic "could not connect", which + // sends users hunting for the wrong problem. One handshake, on this type + // only, so no existing proxy type can regress. + if effective_proxy_settings + .proxy_type + .eq_ignore_ascii_case("httpstls") + { + Self::verify_upstream_tls(&effective_proxy_settings).await?; + } // Try process-based check first (identical to browser launch path). // If the proxy worker fails to start (e.g. Gatekeeper, antivirus, signing @@ -1235,7 +1446,24 @@ impl ProxyManager { "Proxy worker failed to start ({}), falling back to direct check", err_msg ); - ip_utils::fetch_public_ip(Some(&upstream_url)).await + // reqwest cannot parse Donut's own `httpstls` scheme; without the + // rewrite every fallback check on that type dies as "Invalid proxy" + // rather than telling the user anything true. Deliberately not + // `build_probe_proxy_url` here: that would also flip existing SOCKS5 + // fallbacks to `socks5h`, an unrelated behaviour change. + let fallback_url = crate::proxy_storage::reqwest_upstream_url(&upstream_url); + // Only when reqwest can genuinely route through it. For `ss`, + // `vless`, or any scheme it does not know, `Proxy::all` succeeds and + // then matches nothing, so this "fallback check" fetched the + // MACHINE'S OWN address, reported it as the proxy's exit, and marked + // the proxy valid. Answering "could not check" is the honest result. + if crate::proxy_storage::reqwest_can_proxy(&fallback_url) { + ip_utils::fetch_public_ip(Some(&fallback_url)).await + } else { + Err(ip_utils::IpError::Network(format!( + "Could not start a proxy worker ({err_msg}), and this proxy type cannot be checked directly" + ))) + } } } }; @@ -1246,6 +1474,9 @@ impl ProxyManager { let ip = match ip_result { Ok(ip) => ip, Err(e) => { + let udp = udp_probe + .await + .unwrap_or(crate::proxy_udp::UdpSupport::Unknown); let failed_result = ProxyCheckResult { ip: String::new(), city: None, @@ -1253,8 +1484,13 @@ impl ProxyManager { country_code: None, timestamp: Self::get_current_timestamp(), is_valid: false, + isp: None, + timezone: None, + udp, + latency_ms: Some(started.elapsed().as_millis() as u64), }; - let _ = self.save_proxy_check_cache(proxy_id, &failed_result); + let _ = self.save_proxy_check_cache(proxy_id, proxy_settings, &failed_result); + self.record_proxy_check(proxy_id, Self::history_entry(&failed_result)); let err_str = e.to_string(); let user_message = Self::classify_proxy_error(&err_str, proxy_settings); @@ -1266,6 +1502,14 @@ impl ProxyManager { let (city, country, country_code): (Option, Option, Option) = Self::get_ip_geolocation(&ip).await.unwrap_or_default(); + // The ISP and the timezone come off the databases already on disk. Handing + // an exit address to an outside lookup service to learn them would tell + // that service which addresses this machine is testing. + let insight = crate::geolocation::lookup_exit_insight(&ip); + let udp = udp_probe + .await + .unwrap_or(crate::proxy_udp::UdpSupport::Unknown); + // Create successful result let result = ProxyCheckResult { ip: ip.clone(), @@ -1274,14 +1518,34 @@ impl ProxyManager { country_code, timestamp: Self::get_current_timestamp(), is_valid: true, + isp: insight.organization, + timezone: insight.timezone, + udp, + latency_ms: Some(started.elapsed().as_millis() as u64), }; // Save to cache - let _ = self.save_proxy_check_cache(proxy_id, &result); + let _ = self.save_proxy_check_cache(proxy_id, proxy_settings, &result); + self.record_proxy_check(proxy_id, Self::history_entry(&result)); Ok(result) } + /// The trail line for a finished check. Built from the receipt rather than + /// assembled twice, so the list can never disagree with the last result. + fn history_entry(result: &ProxyCheckResult) -> ProxyCheckHistoryEntry { + ProxyCheckHistoryEntry { + timestamp: result.timestamp, + ok: result.is_valid, + ip: (!result.ip.is_empty()).then(|| result.ip.clone()), + country: result.country.clone(), + country_code: result.country_code.clone(), + isp: result.isp.clone(), + udp: result.udp, + latency_ms: result.latency_ms, + } + } + // Get cached proxy check result pub fn get_cached_proxy_check(&self, proxy_id: &str) -> Option { self.load_proxy_check_cache(proxy_id) @@ -1449,7 +1713,14 @@ impl ProxyManager { } // Check for protocol prefix using strip_prefix - let (protocol, rest) = if let Some(rest) = line.strip_prefix("http://") { + let (protocol, rest) = if let Some(rest) = line.strip_prefix("httpstls://") { + // Must be tested before `http://`, which is not a prefix of it but reads + // as though it could be at a glance. Deliberately NOT folded into + // `https://`: provider lists routinely paste `https://user:pass@host:port` + // for a plaintext CONNECT endpoint, so mapping that to the TLS type would + // break real imports. + ("httpstls", rest) + } else if let Some(rest) = line.strip_prefix("http://") { ("http", rest) } else if let Some(rest) = line.strip_prefix("https://") { ("https", rest) @@ -2526,6 +2797,63 @@ mod tests { Ok(proxy_binary) } + #[test] + fn cached_checks_do_not_survive_route_or_credential_edits() { + let settings = ProxySettings { + proxy_type: "http".into(), + host: "127.0.0.1".into(), + port: 8080, + username: Some("user".into()), + password: Some("secret".into()), + vless_uri: None, + }; + let result = ProxyCheckResult { + ip: "203.0.113.1".into(), + city: None, + country: None, + country_code: None, + timestamp: 123, + is_valid: true, + isp: None, + timezone: None, + udp: crate::proxy_udp::UdpSupport::Unknown, + latency_ms: None, + }; + let encoded = serde_json::to_string(&CachedProxyCheck { + settings_hash: CachedProxyCheck::settings_hash(&settings).unwrap(), + result: result.clone(), + }) + .unwrap(); + assert!(!encoded.contains("secret")); + let decode = |current: &ProxySettings| { + serde_json::from_str::(&encoded) + .unwrap() + .for_settings(current) + }; + assert_eq!(decode(&settings).unwrap().ip, result.ip); + let mut changed = settings.clone(); + changed.password = Some("rotated".into()); + assert!(decode(&changed).is_none()); + changed = settings.clone(); + changed.host = "other.example".into(); + assert!(decode(&changed).is_none()); + changed = settings.clone(); + changed.port = 1080; + assert!(decode(&changed).is_none()); + changed = settings.clone(); + changed.proxy_type = "socks5".into(); + assert!(decode(&changed).is_none()); + changed = settings.clone(); + changed.username = Some("another-user".into()); + assert!(decode(&changed).is_none()); + changed = settings.clone(); + changed.vless_uri = Some("changed-route".into()); + assert!(decode(&changed).is_none()); + assert!( + serde_json::from_str::(&serde_json::to_string(&result).unwrap()).is_err() + ); + } + #[test] fn test_proxy_settings_validation() { // Test valid proxy settings @@ -3525,6 +3853,82 @@ mod tests { assert_eq!(url, "http://justuser@host.io:3128"); } + #[test] + fn probe_proxy_url_maps_httpstls_to_the_scheme_reqwest_understands() { + // The browser tunnel dials `httpstls` itself, but the check button and the + // fingerprint probe go through reqwest, which has never heard of it. Both + // spellings mean TLS-to-the-proxy, so the probe still crosses the encrypted + // hop rather than silently falling back to a plaintext one. + let url = ProxyManager::build_probe_proxy_url(&ProxySettings { + proxy_type: "httpstls".to_string(), + host: "proxy.example.com".to_string(), + port: 443, + username: Some("user".to_string()), + password: Some("p@ss".to_string()), + vless_uri: None, + }); + assert_eq!(url, "https://user:p%40ss@proxy.example.com:443"); + } + + #[test] + fn probe_proxy_url_still_forces_remote_dns_for_socks5() { + // Pinning the pre-existing behaviour: adding the httpstls rewrite must not + // disturb the socks5h rewrite that keeps DNS off this machine. + let url = ProxyManager::build_probe_proxy_url(&ProxySettings { + proxy_type: "socks5".to_string(), + host: "proxy.example.com".to_string(), + port: 1080, + username: None, + password: None, + vless_uri: None, + }); + assert_eq!(url, "socks5h://proxy.example.com:1080"); + } + + #[test] + fn probe_proxy_url_leaves_the_plaintext_types_alone() { + for proxy_type in ["http", "https", "socks4"] { + let url = ProxyManager::build_probe_proxy_url(&ProxySettings { + proxy_type: proxy_type.to_string(), + host: "proxy.example.com".to_string(), + port: 8080, + username: None, + password: None, + vless_uri: None, + }); + assert_eq!(url, format!("{proxy_type}://proxy.example.com:8080")); + } + } + + #[test] + fn parse_txt_proxies_round_trips_the_tls_scheme_without_stealing_https() { + // `httpstls://` must parse as its own type... + let results = + ProxyManager::parse_txt_proxies("httpstls://admin:secret@proxy.example.com:443\n"); + match &results[0] { + ProxyParseResult::Parsed(p) => { + assert_eq!(p.proxy_type, "httpstls"); + assert_eq!(p.host, "proxy.example.com"); + assert_eq!(p.port, 443); + assert_eq!(p.username.as_deref(), Some("admin")); + assert_eq!(p.password.as_deref(), Some("secret")); + } + other => panic!("Expected Parsed, got {other:?}"), + } + + // ...and `https://` must keep meaning the plaintext CONNECT type. Provider + // lists paste it for endpoints that do no TLS at all, so promoting it here + // would break real imports and claim an encrypted hop that is not there. + let results = ProxyManager::parse_txt_proxies("https://admin:secret@proxy.example.com:8443\n"); + match &results[0] { + ProxyParseResult::Parsed(p) => { + assert_eq!(p.proxy_type, "https"); + assert_eq!(p.port, 8443); + } + other => panic!("Expected Parsed, got {other:?}"), + } + } + fn valid_vless_uri() -> String { use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; @@ -3571,6 +3975,109 @@ mod tests { assert!(!error.contains(&invalid)); } + fn history_entry(timestamp: u64, ok: bool) -> ProxyCheckHistoryEntry { + ProxyCheckHistoryEntry { + timestamp, + ok, + ip: ok.then(|| format!("203.0.113.{}", timestamp % 250)), + country: ok.then(|| "Netherlands".to_string()), + country_code: ok.then(|| "NL".to_string()), + isp: ok.then(|| "Example Telecom B.V.".to_string()), + udp: crate::proxy_udp::UdpSupport::Yes, + latency_ms: Some(timestamp), + } + } + + #[test] + fn the_check_trail_keeps_the_last_fifty_newest_first_and_survives_a_restart() { + let temp = tempfile::tempdir().unwrap(); + let _data_guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); + let manager = ProxyManager::new(); + let proxy_id = "trail-proxy"; + + assert!(manager.get_proxy_check_history(proxy_id).is_empty()); + + for timestamp in 1..=(PROXY_CHECK_HISTORY_LIMIT as u64 + 12) { + manager.record_proxy_check(proxy_id, history_entry(timestamp, timestamp % 4 != 0)); + } + + let stored = manager.get_proxy_check_history(proxy_id); + assert_eq!(stored.len(), PROXY_CHECK_HISTORY_LIMIT); + // Newest first, and the cap drops the OLDEST checks rather than refusing + // to record new ones. + assert_eq!(stored[0].timestamp, PROXY_CHECK_HISTORY_LIMIT as u64 + 12); + assert_eq!(stored[PROXY_CHECK_HISTORY_LIMIT - 1].timestamp, 13); + assert!(stored + .windows(2) + .all(|pair| pair[0].timestamp > pair[1].timestamp)); + + // A fresh manager reads the same trail off disk, with every field intact. + let reopened = ProxyManager::new().get_proxy_check_history(proxy_id); + assert_eq!(reopened, stored); + let newest = &reopened[0]; + assert_eq!(newest.isp.as_deref(), Some("Example Telecom B.V.")); + assert_eq!(newest.country_code.as_deref(), Some("NL")); + assert_eq!(newest.udp, crate::proxy_udp::UdpSupport::Yes); + assert_eq!( + newest.latency_ms, + Some(PROXY_CHECK_HISTORY_LIMIT as u64 + 12) + ); + assert!(reopened.iter().any(|entry| !entry.ok)); + + // The trail lives beside the configs without being mistaken for one: the + // loader reads every `*.json` in the proxies directory. + let history_file = manager.get_proxy_history_file_path(proxy_id); + assert!(history_file.starts_with(manager.get_proxies_dir())); + assert_ne!(history_file, manager.get_proxy_file_path(proxy_id)); + assert!(ProxyManager::new().get_stored_proxies().is_empty()); + } + + #[test] + fn a_check_that_failed_records_that_it_failed_rather_than_an_empty_exit() { + let failure = ProxyCheckResult { + ip: String::new(), + city: None, + country: None, + country_code: None, + timestamp: 1700, + is_valid: false, + isp: None, + timezone: None, + udp: crate::proxy_udp::UdpSupport::No, + latency_ms: Some(42), + }; + let entry = ProxyManager::history_entry(&failure); + assert!(!entry.ok); + assert_eq!(entry.ip, None); + assert_eq!(entry.udp, crate::proxy_udp::UdpSupport::No); + assert_eq!(entry.latency_ms, Some(42)); + } + + #[test] + fn deleting_a_proxy_takes_its_check_trail_with_it() { + let temp = tempfile::tempdir().unwrap(); + let _data_guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); + let manager = ProxyManager::new(); + let stored = StoredProxy::new( + "Doomed".to_string(), + ProxySettings { + proxy_type: "socks5".to_string(), + host: "127.0.0.1".to_string(), + port: 1080, + username: None, + password: None, + vless_uri: None, + }, + ); + manager.save_proxy(&stored).unwrap(); + manager.record_proxy_check(&stored.id, history_entry(9, true)); + assert!(manager.get_proxy_history_file_path(&stored.id).exists()); + + manager.delete_proxy_file(&stored.id).unwrap(); + assert!(!manager.get_proxy_history_file_path(&stored.id).exists()); + assert!(manager.get_proxy_check_history(&stored.id).is_empty()); + } + #[test] fn vless_stored_proxy_persistence_and_exports_preserve_the_canonical_uri() { let temp = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/proxy_server.rs b/src-tauri/src/proxy_server.rs index af2f6ea..d005dfb 100644 --- a/src-tauri/src/proxy_server.rs +++ b/src-tauri/src/proxy_server.rs @@ -2,6 +2,7 @@ use crate::proxy_storage::ProxyConfig; use crate::traffic_stats::{get_traffic_tracker, init_traffic_tracker, LiveTrafficTracker}; use http_body_util::{BodyExt, Full}; use hyper::body::Bytes; +use hyper::header::{HeaderName, HeaderValue}; use hyper::server::conn::http1; use hyper::service::service_fn; use hyper::{Method, Request, Response, StatusCode}; @@ -204,14 +205,18 @@ impl AsyncWrite for CountingStream { } } -// Wrapper to prepend consumed bytes to a stream -struct PrependReader { +// Wrapper to prepend consumed bytes to a stream. +// +// Generic over the inner stream rather than fixed to `TcpStream`: the upstream +// hop is a bare socket for `http`/`https` but a `TlsStream` for +// `httpstls`, and both need the same coalesced-payload replay. +struct PrependReader { prepended: Vec, prepended_pos: usize, - inner: TcpStream, + inner: S, } -impl AsyncRead for PrependReader { +impl AsyncRead for PrependReader { fn poll_read( mut self: Pin<&mut Self>, cx: &mut Context<'_>, @@ -231,7 +236,7 @@ impl AsyncRead for PrependReader { } } -impl AsyncWrite for PrependReader { +impl AsyncWrite for PrependReader { fn poll_write( mut self: Pin<&mut Self>, cx: &mut Context<'_>, @@ -466,9 +471,23 @@ async fn connect_via_socks( } } +/// How the body of a buffered response is framed on the wire. +enum BufferedBody { + /// The body follows the header block in `bytes` exactly as the upstream sent + /// it. + AsSent, + /// The upstream used `Transfer-Encoding: chunked`; this is the de-framed body. + Dechunked(Vec), + /// The upstream declared chunked but the framing never completed. Nothing can + /// be forwarded: the chunk-size lines are not body bytes, and a half-decoded + /// body reaches the browser as a complete-looking short one. + BrokenChunks, +} + /// A buffered HTTP response read off a raw upstream stream. struct BufferedHttpResponse { bytes: Vec, + body: BufferedBody, /// True when the read stopped at `MAX_HTTP_HEADER_BUFFER` / /// `MAX_HTTP_RESPONSE_BUFFER` rather than at the end of the response, so /// `bytes` holds only a prefix. Callers must fail the request instead of @@ -478,6 +497,117 @@ struct BufferedHttpResponse { truncated: bool, } +/// Progress of a chunked body walk. +enum ChunkedState { + /// The terminating zero-length chunk was reached. + Complete, + /// Well-formed so far, but the terminating chunk has not arrived yet. + Incomplete, + /// The framing itself is broken, so no further byte of it can be trusted. + Malformed, +} + +/// Decode as much of a `Transfer-Encoding: chunked` body as `body` holds, +/// appending the payload to `out` and advancing `cursor` past every chunk +/// consumed in full. Carrying the cursor across reads keeps a body that arrives +/// in many pieces a single linear walk instead of one per read. +/// +/// Any trailer section after the zero-length chunk is dropped; hyper re-derives +/// the framing of the response it sends. +fn decode_chunked(body: &[u8], cursor: &mut usize, out: &mut Vec) -> ChunkedState { + loop { + let rest = &body[*cursor..]; + let Some(line_end) = rest.windows(2).position(|w| w == b"\r\n") else { + return ChunkedState::Incomplete; + }; + let Ok(header) = std::str::from_utf8(&rest[..line_end]) else { + return ChunkedState::Malformed; + }; + // A chunk extension (`;name=value`) may follow the size and carries nothing + // this proxy acts on. + let size_text = header.split(';').next().unwrap_or("").trim(); + let Ok(size) = usize::from_str_radix(size_text, 16) else { + return ChunkedState::Malformed; + }; + // A chunk larger than the whole buffer cap can never be satisfied, and + // rejecting it here keeps the offset arithmetic below overflow-free. + if size > MAX_HTTP_RESPONSE_BUFFER { + return ChunkedState::Malformed; + } + if size == 0 { + return ChunkedState::Complete; + } + let data_start = line_end + 2; + let data_end = data_start + size; + let Some(trailing) = rest.get(data_end..) else { + return ChunkedState::Incomplete; + }; + if trailing.len() < 2 { + return ChunkedState::Incomplete; + } + if !trailing.starts_with(b"\r\n") { + return ChunkedState::Malformed; + } + out.extend_from_slice(&rest[data_start..data_end]); + *cursor += data_end + 2; + } +} + +/// True when this raw header block declares `Transfer-Encoding: chunked`. +fn declares_chunked(header_block: &[u8]) -> bool { + String::from_utf8_lossy(header_block).lines().any(|line| { + let line = line.to_lowercase(); + line.starts_with("transfer-encoding:") && line.contains("chunked") + }) +} + +/// Headers hyper re-derives for the `Full` body this proxy builds, plus +/// the hop-by-hop set. Forwarding the upstream's own framing would fight +/// hyper's and corrupt every response through these paths. +const NON_FORWARDED_RESPONSE_HEADERS: &[&str] = &[ + "content-length", + "transfer-encoding", + "connection", + "keep-alive", + "proxy-connection", + "upgrade", + "trailer", + "te", +]; + +/// Copy an upstream's response headers onto a response assembled from raw +/// bytes. The SOCKS4 and Shadowsocks paths speak HTTP by hand, and without this +/// a redirect loses its `Location`, a sign-in loses its `Set-Cookie` and a +/// compressed body arrives with no `Content-Encoding` to undo it. +/// +/// `header_block` is the raw header bytes including the status line; a trailing +/// blank line is tolerated. A line that does not parse is dropped rather than +/// failing the whole response, and `HeaderName`/`HeaderValue` do the rejecting, +/// so a hostile upstream cannot smuggle a header past this. +fn forward_upstream_headers(response: &mut Response>, header_block: &[u8]) { + let block = String::from_utf8_lossy(header_block); + for line in block.split("\r\n").skip(1) { + let Some((name, value)) = line.split_once(':') else { + continue; + }; + let name = name.trim(); + if NON_FORWARDED_RESPONSE_HEADERS + .iter() + .any(|skipped| name.eq_ignore_ascii_case(skipped)) + { + continue; + } + let (Ok(name), Ok(value)) = ( + HeaderName::from_bytes(name.as_bytes()), + HeaderValue::from_str(value.trim()), + ) else { + continue; + }; + // `append`, not `insert`: every `Set-Cookie` has to survive. + response.headers_mut().append(name, value); + } +} + /// Read a full HTTP response from `stream` into a buffer: headers first /// (capped at `MAX_HTTP_HEADER_BUFFER` — a peer streaming data that never /// contains CRLFCRLF must not grow memory unboundedly), then the body per @@ -489,6 +619,7 @@ async fn read_http_response_buffer(stream: &mut S) -> Buff let mut content_length: Option = None; let mut is_chunked = false; let mut truncated = false; + let mut body = BufferedBody::AsSent; // Read until we have complete headers loop { @@ -553,7 +684,38 @@ async fn read_http_response_buffer(stream: &mut S) -> Buff } } } - } else if !is_chunked { + } else if is_chunked { + // A chunked body has no Content-Length, so the framing itself says + // where it ends. Walk it as the bytes arrive, and de-frame it here: + // the chunk-size lines are not body bytes, and forwarding them left + // the browser rendering the framing. + let body_start = pos + 4; + let mut cursor = 0; + let mut decoded = Vec::new(); + let state = loop { + match decode_chunked(&response_buffer[body_start..], &mut cursor, &mut decoded) { + ChunkedState::Incomplete => {} + terminal => break terminal, + } + if response_buffer.len() >= MAX_HTTP_RESPONSE_BUFFER { + log::warn!( + "Chunked HTTP response exceeded {} bytes; refusing to forward a truncated response", + MAX_HTTP_RESPONSE_BUFFER + ); + truncated = true; + break ChunkedState::Incomplete; + } + match stream.read(&mut temp_buf).await { + Ok(0) => break ChunkedState::Incomplete, + Ok(n) => response_buffer.extend_from_slice(&temp_buf[..n]), + Err(_) => break ChunkedState::Incomplete, + } + }; + body = match state { + ChunkedState::Complete => BufferedBody::Dechunked(decoded), + _ => BufferedBody::BrokenChunks, + }; + } else { // No Content-Length and not chunked - read until connection closes // But limit to reasonable size to avoid memory issues loop { @@ -574,8 +736,6 @@ async fn read_http_response_buffer(stream: &mut S) -> Buff } } } - // Note: Chunked encoding is complex to parse manually, so we'll read what we can - // For full chunked support, we'd need a proper HTTP parser break; } } @@ -588,6 +748,7 @@ async fn read_http_response_buffer(stream: &mut S) -> Buff BufferedHttpResponse { bytes: response_buffer, + body, truncated, } } @@ -821,7 +982,11 @@ async fn handle_http_via_socks4( *response.status_mut() = StatusCode::BAD_GATEWAY; return Ok(response); } - let response_buffer = buffered.bytes; + let BufferedHttpResponse { + bytes: response_buffer, + body: buffered_body, + .. + } = buffered; // Parse HTTP response let response_str = String::from_utf8_lossy(&response_buffer); @@ -840,7 +1005,16 @@ async fn handle_http_via_socks4( .map(|p| p + 4) .unwrap_or(response_buffer.len()); - let body = response_buffer[header_end..].to_vec(); + let body = match buffered_body { + BufferedBody::AsSent => response_buffer[header_end..].to_vec(), + BufferedBody::Dechunked(body) => body, + BufferedBody::BrokenChunks => { + log::error!("Chunked HTTP response via SOCKS4 for {domain} did not decode"); + let mut response = Response::new(Full::new(Bytes::from("Malformed upstream response"))); + *response.status_mut() = StatusCode::BAD_GATEWAY; + return Ok(response); + } + }; // Record request in traffic tracker let response_size = body.len() as u64; @@ -849,7 +1023,11 @@ async fn handle_http_via_socks4( } let mut hyper_response = Response::new(Full::new(Bytes::from(body))); - *hyper_response.status_mut() = StatusCode::from_u16(status_code).unwrap(); + // A status line carrying something outside 100..=999 must not panic the + // connection task. + *hyper_response.status_mut() = + StatusCode::from_u16(status_code).unwrap_or(StatusCode::BAD_GATEWAY); + forward_upstream_headers(&mut hyper_response, &response_buffer[..header_end]); Ok(hyper_response) } @@ -950,10 +1128,17 @@ async fn handle_http_via_shadowsocks( tracker.record_request(&domain, raw_req.len() as u64, response_buf.len() as u64); } - // Parse the raw HTTP response - let response_str = String::from_utf8_lossy(&response_buf); - let header_end = response_str.find("\r\n\r\n").unwrap_or(response_str.len()); - let status_line = response_str + // Parse the raw HTTP response. The boundary is found in the raw bytes, not in + // a lossy UTF-8 copy of them, so a body byte that is not valid UTF-8 cannot + // shift the offset the body is sliced at. + let header_end = response_buf + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map(|p| p + 4) + .unwrap_or(response_buf.len()); + let header_block = &response_buf[..header_end]; + let header_text = String::from_utf8_lossy(header_block); + let status_line = header_text .lines() .next() .unwrap_or("HTTP/1.1 502 Bad Gateway"); @@ -962,15 +1147,28 @@ async fn handle_http_via_shadowsocks( .nth(1) .and_then(|s| s.parse().ok()) .unwrap_or(502); - let body = if header_end + 4 < response_buf.len() { - &response_buf[header_end + 4..] + + let raw_body = &response_buf[header_end..]; + let body = if declares_chunked(header_block) { + let mut cursor = 0; + let mut decoded = Vec::new(); + match decode_chunked(raw_body, &mut cursor, &mut decoded) { + ChunkedState::Complete => decoded, + _ => { + log::error!("Chunked HTTP response via Shadowsocks for {domain} did not decode"); + let mut resp = Response::new(Full::new(Bytes::from("Malformed upstream response"))); + *resp.status_mut() = StatusCode::BAD_GATEWAY; + return Ok(resp); + } + } } else { - b"" + raw_body.to_vec() }; - let mut hyper_response = Response::new(Full::new(Bytes::from(body.to_vec()))); + let mut hyper_response = Response::new(Full::new(Bytes::from(body))); *hyper_response.status_mut() = StatusCode::from_u16(status_code).unwrap_or(StatusCode::BAD_GATEWAY); + forward_upstream_headers(&mut hyper_response, header_block); Ok(hyper_response) } @@ -1186,10 +1384,16 @@ fn build_reqwest_client_with_proxy( let proxy = match scheme { "http" | "https" => { - // For HTTP/HTTPS proxies, reqwest handles them directly - // Note: HTTPS proxy URLs still use HTTP CONNECT method, reqwest handles TLS automatically + // Both are a plaintext hop to the proxy. `https` is only a provider + // label here; the tunnel path treats it identically to `http`. Proxy::http(upstream_url)? } + "httpstls" => { + // TLS to the proxy. reqwest spells that `https://`, which is what + // `reqwest_upstream_url` produces; the scheme rewrite is the whole + // difference, the endpoint and credentials are unchanged. + Proxy::http(crate::proxy_storage::reqwest_upstream_url(upstream_url))? + } "socks5" => { // Force REMOTE (proxy-side) DNS for plaintext HTTP over a SOCKS5 // upstream. reqwest maps the bare `socks5` scheme to DnsResolve::Local, @@ -1944,8 +2148,8 @@ pub(crate) fn log_throttle(key: &str) -> Option { /// and the terminating CRLFCRLF can arrive with destination payload appended /// (those bytes belong to the tunnel). Reads until the header terminator and /// returns `(headers, bytes_after_headers)`. -async fn read_upstream_connect_response( - stream: &mut TcpStream, +async fn read_upstream_connect_response( + stream: &mut S, ) -> Result<(String, Vec), Box> { let mut buffer = Vec::with_capacity(1024); let mut chunk = [0u8; 4096]; @@ -1978,11 +2182,108 @@ async fn read_upstream_connect_response( } } +/// Perform the HTTP CONNECT handshake over an already-established hop to the +/// proxy and return the tunnelled stream. +/// +/// Generic over the hop so the identical handshake runs on a bare `TcpStream` +/// (`http`/`https`) and on a `TlsStream` (`httpstls`). This is the +/// only place `Proxy-Authorization` is written, so whether those credentials +/// cross the network in the clear is decided entirely by which stream the +/// caller hands in, nothing here can weaken it. +async fn connect_via_http_proxy( + mut proxy_stream: S, + proxy_host: &str, + proxy_port: u16, + target_host: &str, + target_port: u16, + upstream: &Url, +) -> Result> { + let mut connect_req = format!( + "CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n", + target_host, target_port, target_host, target_port + ); + + let (username, password) = upstream_userpass(upstream); + if !username.is_empty() { + use base64::{engine::general_purpose, Engine as _}; + let auth = general_purpose::STANDARD.encode(format!("{}:{}", username, password)); + connect_req.push_str(&format!("Proxy-Authorization: Basic {}\r\n", auth)); + } + + connect_req.push_str("\r\n"); + + proxy_stream.write_all(connect_req.as_bytes()).await?; + + let (response_headers, coalesced) = read_upstream_connect_response(&mut proxy_stream).await?; + let status_line = response_headers.lines().next().unwrap_or("").to_string(); + + if !response_headers.starts_with("HTTP/1.1 200") && !response_headers.starts_with("HTTP/1.0 200") + { + log::warn!( + "Upstream CONNECT to {}:{} via {}:{} rejected: {}", + target_host, + target_port, + proxy_host, + proxy_port, + status_line + ); + return Err(format!("Upstream proxy CONNECT failed: {status_line}").into()); + } + + log::info!( + "Upstream CONNECT to {}:{} via {}:{} accepted ({})", + target_host, + target_port, + proxy_host, + proxy_port, + status_line + ); + + if coalesced.is_empty() { + Ok(Box::new(proxy_stream)) + } else { + // The upstream packed the destination's first bytes into the same + // segment as its 200. They are tunnel payload, not proxy protocol: + // replay them ahead of the socket so the client sees an unbroken + // stream. Server-speaks-first protocols (SMTP/IMAP/SSH banners) + // reach this reliably. + log::debug!( + "Upstream CONNECT response coalesced {} byte(s) of payload; forwarding", + coalesced.len() + ); + Ok(Box::new(PrependReader { + prepended: coalesced, + prepended_pos: 0, + inner: proxy_stream, + })) + } +} + +/// Wrap an established TCP hop to the proxy in TLS, verifying the proxy's +/// certificate against `proxy_host`. +/// +/// There is deliberately no opportunistic downgrade and no +/// `danger_accept_invalid_certs` escape hatch: a failed handshake is a failed +/// connection. Certificate verification is what makes this hop resistant to an +/// active man-in-the-middle and not merely to a passive sniffer, and a bypass +/// switch would be clicked the first time a provider hands out a bare IP. +async fn tls_wrap_upstream_hop( + tcp: TcpStream, + proxy_host: &str, +) -> Result, Box> { + let connector = tokio_native_tls::TlsConnector::from(native_tls::TlsConnector::new()?); + match tokio::time::timeout(UPSTREAM_DIAL_TIMEOUT, connector.connect(proxy_host, tcp)).await { + Ok(result) => Ok(result?), + Err(_) => Err(format!("TLS handshake with upstream proxy {proxy_host} timed out").into()), + } +} + /// Establish a stream to `target_host:target_port`, either directly or through /// the configured upstream proxy. Shared by the HTTP CONNECT path and the /// local SOCKS5 server so every upstream type (direct, HTTP/HTTPS CONNECT, -/// SOCKS4/5, Shadowsocks) is dialed in exactly one place. Returns a -/// `BoxedAsyncStream` so the caller can tunnel over any upstream uniformly. +/// TLS-wrapped CONNECT, SOCKS4/5, Shadowsocks) is dialed in exactly one place. +/// Returns a `BoxedAsyncStream` so the caller can tunnel over any upstream +/// uniformly. pub(crate) async fn connect_to_target_via_upstream( target_host: &str, target_port: u16, @@ -2002,10 +2303,15 @@ pub(crate) async fn connect_to_target_via_upstream( let scheme = upstream.scheme(); match scheme { + // `https` here is NOT TLS to the proxy: it is a label many providers + // put on a plaintext CONNECT endpoint, and Donut has always treated it + // byte-for-byte like `http`. Changing that would silently break every + // stored `https` proxy, so the encrypted hop is the separate + // `httpstls` scheme below. "http" | "https" => { let proxy_host = upstream.host_str().unwrap_or("127.0.0.1"); let proxy_port = upstream.port().unwrap_or(8080); - let mut proxy_stream = tokio::time::timeout( + let proxy_stream = tokio::time::timeout( UPSTREAM_DIAL_TIMEOUT, TcpStream::connect((proxy_host, proxy_port)), ) @@ -2015,67 +2321,43 @@ pub(crate) async fn connect_to_target_via_upstream( })??; configure_tcp(&proxy_stream); - let mut connect_req = format!( - "CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n", - target_host, target_port, target_host, target_port - ); - - let (username, password) = upstream_userpass(&upstream); - if !username.is_empty() { - use base64::{engine::general_purpose, Engine as _}; - let auth = general_purpose::STANDARD.encode(format!("{}:{}", username, password)); - connect_req.push_str(&format!("Proxy-Authorization: Basic {}\r\n", auth)); - } - - connect_req.push_str("\r\n"); - - proxy_stream.write_all(connect_req.as_bytes()).await?; - - let (response_headers, coalesced) = - read_upstream_connect_response(&mut proxy_stream).await?; - let status_line = response_headers.lines().next().unwrap_or("").to_string(); - - if !response_headers.starts_with("HTTP/1.1 200") - && !response_headers.starts_with("HTTP/1.0 200") - { - log::warn!( - "Upstream CONNECT to {}:{} via {}:{} rejected: {}", - target_host, - target_port, - proxy_host, - proxy_port, - status_line - ); - return Err(format!("Upstream proxy CONNECT failed: {status_line}").into()); - } - - log::info!( - "Upstream CONNECT to {}:{} via {}:{} accepted ({})", - target_host, - target_port, + connect_via_http_proxy( + proxy_stream, proxy_host, proxy_port, - status_line - ); + target_host, + target_port, + &upstream, + ) + .await? + } + // TLS to the proxy first, CONNECT second. The target hostname and the + // `Proxy-Authorization` credentials are written only after the + // handshake, so neither reaches the wire in the clear. + "httpstls" => { + let proxy_host = upstream.host_str().unwrap_or("127.0.0.1"); + let proxy_port = upstream.port().unwrap_or(443); + let tcp = tokio::time::timeout( + UPSTREAM_DIAL_TIMEOUT, + TcpStream::connect((proxy_host, proxy_port)), + ) + .await + .map_err(|_| { + format!("upstream proxy connect to {proxy_host}:{proxy_port} timed out") + })??; + configure_tcp(&tcp); - if coalesced.is_empty() { - Box::new(proxy_stream) - } else { - // The upstream packed the destination's first bytes into the same - // segment as its 200. They are tunnel payload, not proxy protocol: - // replay them ahead of the socket so the client sees an unbroken - // stream. Server-speaks-first protocols (SMTP/IMAP/SSH banners) - // reach this reliably. - log::debug!( - "Upstream CONNECT response coalesced {} byte(s) of payload; forwarding", - coalesced.len() - ); - Box::new(PrependReader { - prepended: coalesced, - prepended_pos: 0, - inner: proxy_stream, - }) - } + let tls = tls_wrap_upstream_hop(tcp, proxy_host).await?; + + connect_via_http_proxy( + tls, + proxy_host, + proxy_port, + target_host, + target_port, + &upstream, + ) + .await? } "socks4" | "socks5" => { let socks_host = upstream.host_str().unwrap_or("127.0.0.1"); @@ -2502,6 +2784,133 @@ mod tests { assert!(!buf.truncated); } + /// Frame `pieces` as a chunked body, terminator included. + fn chunked_wire(pieces: &[&str]) -> Vec { + let mut out = Vec::new(); + for piece in pieces { + out.extend_from_slice(format!("{:x}\r\n", piece.len()).as_bytes()); + out.extend_from_slice(piece.as_bytes()); + out.extend_from_slice(b"\r\n"); + } + out.extend_from_slice(b"0\r\n\r\n"); + out + } + + #[tokio::test] + async fn read_http_response_buffer_dechunks_a_chunked_body() { + let (mut writer, mut reader) = tokio::io::duplex(1024); + let mut resp = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n".to_vec(); + resp.extend_from_slice(&chunked_wire(&["hello ", "world"])); + writer.write_all(&resp).await.unwrap(); + drop(writer); + + let buf = read_http_response_buffer(&mut reader).await; + assert!(!buf.truncated); + match buf.body { + BufferedBody::Dechunked(body) => assert_eq!(body, b"hello world".to_vec()), + _ => panic!("a chunked body must be de-framed before it reaches the browser"), + } + } + + #[test] + fn chunked_body_decodes_to_the_payload_alone() { + let wire = chunked_wire(&["hello ", "world"]); + let mut cursor = 0; + let mut out = Vec::new(); + assert!(matches!( + decode_chunked(&wire, &mut cursor, &mut out), + ChunkedState::Complete + )); + assert_eq!(out, b"hello world".to_vec()); + } + + #[test] + fn a_chunked_body_split_across_reads_is_walked_once() { + let wire = chunked_wire(&["one", "two"]); + let mut cursor = 0; + let mut out = Vec::new(); + // Half the buffer holds the first chunk and part of the second header. + assert!(matches!( + decode_chunked(&wire[..wire.len() / 2], &mut cursor, &mut out), + ChunkedState::Incomplete + )); + assert!(matches!( + decode_chunked(&wire, &mut cursor, &mut out), + ChunkedState::Complete + )); + assert_eq!(out, b"onetwo".to_vec()); + } + + #[test] + fn chunk_extensions_are_ignored() { + let mut cursor = 0; + let mut out = Vec::new(); + assert!(matches!( + decode_chunked(b"5;name=value\r\nhello\r\n0\r\n\r\n", &mut cursor, &mut out), + ChunkedState::Complete + )); + assert_eq!(out, b"hello".to_vec()); + } + + #[test] + fn a_malformed_chunk_stream_is_rejected_not_half_decoded() { + for wire in [ + // A size that is not hexadecimal. + b"zz\r\nnope\r\n0\r\n\r\n".to_vec(), + // Chunk data not followed by its CRLF. + b"5\r\nhelloXX\r\n0\r\n\r\n".to_vec(), + ] { + let mut cursor = 0; + let mut out = Vec::new(); + assert!( + matches!( + decode_chunked(&wire, &mut cursor, &mut out), + ChunkedState::Malformed + ), + "{}", + String::from_utf8_lossy(&wire) + ); + } + } + + #[test] + fn upstream_response_headers_reach_the_browser() { + let block = b"HTTP/1.1 302 Found\r\n\ +Location: https://example.com/next\r\n\ +Set-Cookie: a=1; Path=/\r\n\ +Set-Cookie: b=2; Path=/\r\n\ +Content-Type: text/html; charset=utf-8\r\n\ +Content-Length: 17\r\n\ +Transfer-Encoding: chunked\r\n\ +Connection: keep-alive\r\n\ +this line has no colon\r\n\ +\r\n"; + let mut response = Response::new(Full::new(Bytes::new())); + forward_upstream_headers(&mut response, block); + let headers = response.headers(); + + assert_eq!(headers.get("location").unwrap(), "https://example.com/next"); + assert_eq!( + headers.get("content-type").unwrap(), + "text/html; charset=utf-8" + ); + // Every Set-Cookie survives, so a sign-in actually establishes a session. + let cookies: Vec<&str> = headers + .get_all("set-cookie") + .iter() + .map(|value| value.to_str().unwrap()) + .collect(); + assert_eq!(cookies, ["a=1; Path=/", "b=2; Path=/"]); + // hyper re-derives the framing for the body it is handed; the upstream's + // own framing headers would contradict it. + for framing in ["content-length", "transfer-encoding", "connection"] { + assert!( + headers.get(framing).is_none(), + "{framing} must not be forwarded" + ); + } + } + #[tokio::test] async fn read_http_response_buffer_caps_oversized_content_length_body() { let (mut writer, mut reader) = tokio::io::duplex(64 * 1024); @@ -2652,6 +3061,187 @@ mod tests { assert_eq!(domain_stats.bytes_received, download_len as u64); } + /// Dial `connect_to_target_via_upstream` at a listener that never answers and + /// return the first bytes it puts on the wire. + /// + /// The dial cannot complete (nothing on the far end speaks proxy or TLS), and + /// that is the point: what matters is what leaves this machine BEFORE the + /// other side has proved anything. + async fn first_bytes_sent_to_upstream(scheme: &str) -> Vec { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let upstream = format!("{scheme}://donutuser:hunter2secret@127.0.0.1:{port}"); + + let dial = tokio::spawn(async move { + let matcher = BypassMatcher::new(&[]); + let _ = connect_to_target_via_upstream( + "private-target.example.com", + 443, + Some(&upstream), + &matcher, + ) + .await; + }); + + let (mut server, _) = listener.accept().await.unwrap(); + let mut buf = vec![0u8; 4096]; + let n = tokio::time::timeout(std::time::Duration::from_secs(10), server.read(&mut buf)) + .await + .expect("upstream saw no bytes at all before the timeout") + .expect("reading from the mock upstream failed"); + buf.truncate(n); + + dial.abort(); + buf + } + + #[tokio::test] + async fn httpstls_upstream_negotiates_tls_before_writing_anything_readable() { + // This is the whole point of the type. Nothing readable may reach the wire + // ahead of the TLS handshake: not the CONNECT verb, not the target host, + // and above all not the Proxy-Authorization credentials. The handshake here + // never completes, which proves the credentials never left the machine. + let first = first_bytes_sent_to_upstream("httpstls").await; + + assert_eq!( + first.first().copied(), + Some(0x16), + "the first byte must be a TLS handshake record (0x16), got {:02x?}", + &first[..first.len().min(16)] + ); + + let as_text = String::from_utf8_lossy(&first); + for secret in [ + "CONNECT ", + "Proxy-Authorization", + "private-target.example.com", + "donutuser", + "hunter2secret", + ] { + assert!( + !as_text.contains(secret), + "{secret:?} reached the wire in the clear on an httpstls upstream" + ); + } + } + + #[tokio::test] + async fn http_upstream_still_writes_a_plaintext_connect() { + // The counterpart, pinning today's behaviour rather than wishing it away. + // If this ever stops holding, the `http` path changed and every stored + // plaintext proxy changed with it. + let first = first_bytes_sent_to_upstream("http").await; + let as_text = String::from_utf8_lossy(&first); + + assert!( + as_text.starts_with("CONNECT private-target.example.com:443 "), + "expected a plaintext CONNECT, got {as_text:?}" + ); + assert!( + as_text.contains("Proxy-Authorization: Basic "), + "expected plaintext proxy credentials, got {as_text:?}" + ); + } + + #[tokio::test] + async fn https_upstream_is_a_plaintext_hop_despite_the_name() { + // `https` is a provider label, not TLS to the proxy. The UI now says so; + // this is the assertion that keeps the code and the copy agreeing. + let first = first_bytes_sent_to_upstream("https").await; + assert!( + String::from_utf8_lossy(&first).starts_with("CONNECT "), + "the `https` type must keep behaving exactly like `http`" + ); + assert_ne!( + first.first().copied(), + Some(0x16), + "`https` must not have silently become a TLS hop" + ); + } + + #[tokio::test] + async fn httpstls_refuses_a_hop_that_answers_in_plaintext() { + // The downgrade case: a proxy (or something sitting in front of it) + // answering the way a plaintext CONNECT endpoint would. There is no + // opportunistic fallback, a failed handshake is a failed connection, or + // the whole type is worth nothing against an active attacker. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut scratch = [0u8; 1024]; + let _ = socket.read(&mut scratch).await; + let _ = socket + .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") + .await; + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + }); + + let upstream = format!("httpstls://user:pass@127.0.0.1:{port}"); + let matcher = BypassMatcher::new(&[]); + let result = tokio::time::timeout( + std::time::Duration::from_secs(20), + connect_to_target_via_upstream("example.com", 443, Some(&upstream), &matcher), + ) + .await + .expect("the dial must not hang"); + + assert!( + result.is_err(), + "a plaintext answer must not yield a usable tunnel on an httpstls upstream" + ); + server.abort(); + } + + #[tokio::test] + async fn build_reqwest_client_with_proxy_accepts_the_tls_scheme() { + // reqwest rejects the `httpstls` scheme outright, so without the rewrite + // every plain-HTTP request through such a proxy fails to build a client. + build_reqwest_client_with_proxy("httpstls://user:pass@proxy.example.com:443") + .expect("httpstls must build a reqwest client"); + build_reqwest_client_with_proxy("http://proxy.example.com:8080") + .expect("http must keep building a reqwest client"); + } + + #[tokio::test] + async fn prepend_reader_replays_payload_over_a_non_tcp_stream() { + // The coalesced-payload replay has to survive the TLS stream type, not just + // TcpStream. `duplex` stands in for any non-TCP AsyncRead+AsyncWrite. + let (mut peer, inner) = tokio::io::duplex(1024); + peer.write_all(b"-rest-of-stream").await.unwrap(); + + let mut reader = PrependReader { + prepended: b"replayed-".to_vec(), + prepended_pos: 0, + inner, + }; + + let mut got = [0u8; 24]; + let mut filled = 0; + while filled < got.len() { + let n = reader.read(&mut got[filled..]).await.unwrap(); + assert_ne!(n, 0, "stream ended before the expected bytes arrived"); + filled += n; + } + assert_eq!(&got[..filled], b"replayed--rest-of-stream"); + } + + #[tokio::test] + async fn read_upstream_connect_response_works_off_a_non_tcp_stream() { + // Guards the generic bound: a `&mut TcpStream` signature would not compile + // against the TLS stream the httpstls path hands it. + let (mut peer, mut inner) = tokio::io::duplex(1024); + peer + .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\nBANNER") + .await + .unwrap(); + + let (headers, leftover) = read_upstream_connect_response(&mut inner).await.unwrap(); + assert!(headers.starts_with("HTTP/1.1 200")); + assert_eq!(leftover, b"BANNER"); + } + #[test] fn test_blocklist_comments_skipped() { let mut tmpfile = tempfile::NamedTempFile::new().unwrap(); diff --git a/src-tauri/src/proxy_storage.rs b/src-tauri/src/proxy_storage.rs index 363fb38..df734f1 100644 --- a/src-tauri/src/proxy_storage.rs +++ b/src-tauri/src/proxy_storage.rs @@ -118,6 +118,42 @@ pub fn build_proxy_url( url } +/// Rewrite a stored upstream URL into something `reqwest::Proxy` accepts. +/// +/// `donut-proxy` dials `httpstls://` itself, so the scheme is Donut's own and +/// reqwest has never heard of it, `Proxy::all` would reject it outright and +/// every probe through such a proxy would die as "Invalid proxy". reqwest's +/// `https://` proxy scheme means exactly what `httpstls` means here (TLS to the +/// proxy, then CONNECT), so the two agree on the wire; only the spelling +/// differs. Every other scheme is passed through untouched. +pub fn reqwest_upstream_url(url: &str) -> String { + match url.strip_prefix("httpstls://") { + Some(rest) => format!("https://{rest}"), + None => url.to_string(), + } +} + +/// Whether `reqwest` can actually route a request through this upstream URL. +/// +/// An ALLOW-list of the schemes hyper-util's matcher accepts. Anything else +/// makes `reqwest::Proxy::all` SUCCEED and then match nothing, so the request is +/// sent DIRECT with no error and no log line, which is how a geolocation probe +/// and a proxy-check both came to report the machine's own address. +/// +/// Callers should pass the url through [`reqwest_upstream_url`] first, so +/// `httpstls` is judged as the `https` it becomes. +pub fn reqwest_can_proxy(url: &str) -> bool { + let scheme = url + .split("://") + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + matches!( + scheme.as_str(), + "http" | "https" | "socks4" | "socks4a" | "socks5" | "socks5h" + ) +} + pub fn get_storage_dir() -> PathBuf { crate::app_dirs::proxy_workers_dir() } @@ -612,6 +648,40 @@ mod tests { assert_eq!(config.browser_pid_start_time, None); } + #[test] + fn reqwest_upstream_url_rewrites_only_the_donut_specific_scheme() { + // reqwest cannot parse `httpstls`, so without this rewrite every probe and + // every fallback check through such a proxy dies as "Invalid proxy". The + // credentials, host and port must survive untouched. + assert_eq!( + reqwest_upstream_url("httpstls://user:p%40ss@proxy.example:443"), + "https://user:p%40ss@proxy.example:443" + ); + + // Everything else is reqwest-native and must pass through byte-for-byte. + // `https` in particular: rewriting it would be a no-op today but pinning it + // here says the plaintext type is deliberately left alone. + for untouched in [ + "http://proxy.example:8080", + "https://proxy.example:8080", + "socks5://proxy.example:1080", + "socks5h://proxy.example:1080", + "ss://proxy.example:8388", + "DIRECT", + ] { + assert_eq!(reqwest_upstream_url(untouched), untouched); + } + } + + #[test] + fn reqwest_upstream_url_only_matches_the_scheme_prefix() { + // A host that merely starts with the scheme text must not be rewritten. + assert_eq!( + reqwest_upstream_url("http://httpstls://weird"), + "http://httpstls://weird" + ); + } + #[test] fn test_is_process_running_returns_false_for_nonexistent_pid() { // PID 0 is the "System Idle Process" on Windows and sysinfo reports it as running, diff --git a/src-tauri/src/proxy_udp.rs b/src-tauri/src/proxy_udp.rs new file mode 100644 index 0000000..78a29b9 --- /dev/null +++ b/src-tauri/src/proxy_udp.rs @@ -0,0 +1,431 @@ +//! Whether a proxy can carry UDP, asked the way the browser would ask. +//! +//! This decides more than it looks like it does: WebRTC is UDP, so a profile +//! on a proxy without `UDP ASSOCIATE` either leaks WebRTC around the proxy or +//! loses it entirely. The answer therefore has to be a fact, not a guess — +//! hence three verdicts, with "unknown" reserved for everything the probe +//! could not establish. + +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use crate::browser::ProxySettings; + +/// How long the whole handshake gets. A proxy that cannot answer a three-byte +/// greeting and one request inside this is not going to carry a media stream. +const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +const SOCKS5: u8 = 0x05; +const AUTH_NONE: u8 = 0x00; +const AUTH_USERPASS: u8 = 0x02; +const AUTH_UNACCEPTABLE: u8 = 0xFF; +const CMD_UDP_ASSOCIATE: u8 = 0x03; +const ATYP_IPV4: u8 = 0x01; +const ATYP_DOMAIN: u8 = 0x03; +const ATYP_IPV6: u8 = 0x04; +const REP_SUCCEEDED: u8 = 0x00; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum UdpSupport { + /// The proxy accepted `UDP ASSOCIATE`. + Yes, + /// The proxy cannot carry UDP: it refused the command, or its protocol has + /// no way to carry a datagram at all. + No, + /// Not established. Never reported as `Yes`, and never as `No` either: an + /// unreachable proxy has not proved anything. + #[default] + Unknown, +} + +/// The verdict that follows from the protocol alone, before anything is +/// dialled. `None` means the protocol can carry UDP in principle and the +/// proxy itself has to be asked. +/// +/// HTTP proxies answer `No` here rather than being probed: CONNECT builds a +/// TCP tunnel and the protocol has no datagram command to send. +pub fn udp_verdict_for_type(proxy_type: &str) -> Option { + match proxy_type.trim().to_ascii_lowercase().as_str() { + "socks5" | "socks5h" => None, + // SOCKS4 and SOCKS4a define CONNECT and BIND only. + "http" | "https" | "httpstls" | "socks4" | "socks4a" => Some(UdpSupport::No), + // Shadowsocks and VLESS can carry UDP, but whether this endpoint does is + // not something a SOCKS handshake can answer. + _ => Some(UdpSupport::Unknown), + } +} + +/// Map a SOCKS5 reply code onto a verdict. +/// +/// Only an accepted association counts as `Yes`. Every other reply is a +/// refusal the proxy stated in answer to the exact request, which is a real +/// `No`. That deliberately includes codes RFC 1928 never defined: a residential +/// gateway tested here answers `UDP ASSOCIATE` with `0xFF`, and calling that +/// "unknown" would leave the column blank for the proxies it matters most for. +/// +/// `Unknown` belongs to the cases where the proxy was never actually asked — +/// unreachable, timed out, refused the authentication, or answered something +/// that is not a SOCKS5 reply — and those are decided by the caller before a +/// reply code ever gets here. +pub fn udp_verdict_from_socks_reply(reply: u8) -> UdpSupport { + if reply == REP_SUCCEEDED { + UdpSupport::Yes + } else { + UdpSupport::No + } +} + +/// Ask a proxy whether it carries UDP. +/// +/// The probe dials the proxy exactly as a launch would — the same host, the +/// same port, the same credentials — and asks for an association it never +/// uses. No datagram is sent and no third-party host is named, so nothing +/// about this check reaches anywhere the browser would not already go. +pub async fn probe_udp_support(settings: &ProxySettings) -> UdpSupport { + if let Some(verdict) = udp_verdict_for_type(&settings.proxy_type) { + return verdict; + } + + match tokio::time::timeout(PROBE_TIMEOUT, socks5_udp_associate(settings)).await { + Ok(Ok(verdict)) => verdict, + Ok(Err(e)) => { + log::debug!( + "UDP probe of {}:{} could not complete: {e}", + settings.host, + settings.port + ); + UdpSupport::Unknown + } + Err(_) => { + log::debug!("UDP probe of {}:{} timed out", settings.host, settings.port); + UdpSupport::Unknown + } + } +} + +async fn socks5_udp_associate(settings: &ProxySettings) -> std::io::Result { + let mut stream = tokio::net::TcpStream::connect((settings.host.as_str(), settings.port)).await?; + + let credentials = settings + .username + .as_deref() + .filter(|user| !user.is_empty()) + .map(|user| (user, settings.password.as_deref().unwrap_or(""))); + + let greeting: Vec = match credentials { + Some(_) => vec![SOCKS5, 2, AUTH_NONE, AUTH_USERPASS], + None => vec![SOCKS5, 1, AUTH_NONE], + }; + stream.write_all(&greeting).await?; + + let mut selection = [0u8; 2]; + stream.read_exact(&mut selection).await?; + if selection[0] != SOCKS5 { + return Ok(UdpSupport::Unknown); + } + match selection[1] { + AUTH_NONE => {} + AUTH_USERPASS => { + let Some((user, password)) = credentials else { + return Ok(UdpSupport::Unknown); + }; + if !authenticate(&mut stream, user, password).await? { + return Ok(UdpSupport::Unknown); + } + } + AUTH_UNACCEPTABLE => return Ok(UdpSupport::Unknown), + _ => return Ok(UdpSupport::Unknown), + } + + // An all-zero address is what a client sends when it does not yet know the + // address it will send datagrams from, which is exactly this case: the + // association is requested and then dropped. + stream + .write_all(&[SOCKS5, CMD_UDP_ASSOCIATE, 0x00, ATYP_IPV4, 0, 0, 0, 0, 0, 0]) + .await?; + + // Only the version and the reply code are read up front. RFC 1928 says a + // reply carries a bound address as well, but a refusing server does not + // always send one: the residential gateway this was tested against answers + // `05 FF` and closes. Demanding the full four-byte header there turns a + // stated refusal into a read error, and the verdict into "unknown". + let mut head = [0u8; 2]; + stream.read_exact(&mut head).await?; + if head[0] != SOCKS5 { + return Ok(UdpSupport::Unknown); + } + let verdict = udp_verdict_from_socks_reply(head[1]); + + if verdict == UdpSupport::Yes { + // An accepted association does carry the address to send datagrams to. + // Nothing here uses it, but reading it leaves the socket drained rather + // than closing under a server that is still writing. + let mut tail = [0u8; 2]; + if stream.read_exact(&mut tail).await.is_ok() { + let _ = drain_bound_address(&mut stream, tail[1]).await; + } + } + Ok(verdict) +} + +async fn authenticate( + stream: &mut tokio::net::TcpStream, + user: &str, + password: &str, +) -> std::io::Result { + if user.len() > 255 || password.len() > 255 { + return Ok(false); + } + let mut request = Vec::with_capacity(3 + user.len() + password.len()); + request.push(0x01); + request.push(user.len() as u8); + request.extend_from_slice(user.as_bytes()); + request.push(password.len() as u8); + request.extend_from_slice(password.as_bytes()); + stream.write_all(&request).await?; + + let mut reply = [0u8; 2]; + stream.read_exact(&mut reply).await?; + Ok(reply[1] == 0x00) +} + +async fn drain_bound_address( + stream: &mut tokio::net::TcpStream, + address_type: u8, +) -> std::io::Result<()> { + let length = match address_type { + ATYP_IPV4 => 4, + ATYP_IPV6 => 16, + ATYP_DOMAIN => { + let mut len = [0u8; 1]; + stream.read_exact(&mut len).await?; + len[0] as usize + } + _ => return Ok(()), + }; + let mut scratch = vec![0u8; length + 2]; + stream.read_exact(&mut scratch).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// RFC 1928's "command not supported". Production no longer needs to name + /// it — every non-zero reply is a refusal — but a test server has to send + /// something a real proxy would send. + const REP_CMD_NOT_SUPPORTED: u8 = 0x07; + + #[test] + fn an_http_proxy_is_answered_without_ever_being_dialled() { + for proxy_type in ["http", "HTTP", "https", "httpstls", "socks4", "socks4a"] { + assert_eq!( + udp_verdict_for_type(proxy_type), + Some(UdpSupport::No), + "{proxy_type}" + ); + } + } + + #[test] + fn socks5_is_the_only_type_that_gets_probed() { + assert_eq!(udp_verdict_for_type("socks5"), None); + assert_eq!(udp_verdict_for_type("SOCKS5"), None); + assert_eq!(udp_verdict_for_type("socks5h"), None); + } + + #[test] + fn a_protocol_no_socks_handshake_can_answer_stays_unknown() { + for proxy_type in ["ss", "vless", "", "something-new"] { + assert_eq!( + udp_verdict_for_type(proxy_type), + Some(UdpSupport::Unknown), + "{proxy_type}" + ); + } + } + + #[test] + fn only_an_accepted_association_counts_as_yes() { + assert_eq!(udp_verdict_from_socks_reply(0x00), UdpSupport::Yes); + // Every stated refusal is a refusal, including 0xFF, which is not in + // RFC 1928 but is what a real residential gateway answers. + for refused in [0x01u8, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0xFF] { + assert_eq!( + udp_verdict_from_socks_reply(refused), + UdpSupport::No, + "reply {refused:#04x}" + ); + } + } + + #[test] + fn the_default_verdict_is_unknown_so_an_old_receipt_never_claims_support() { + assert_eq!(UdpSupport::default(), UdpSupport::Unknown); + assert_eq!(serde_json::to_string(&UdpSupport::Yes).unwrap(), "\"yes\""); + assert_eq!( + serde_json::from_str::("\"unknown\"").unwrap(), + UdpSupport::Unknown + ); + } + + #[tokio::test] + async fn a_proxy_that_cannot_be_reached_reports_unknown_not_no() { + let settings = ProxySettings { + proxy_type: "socks5".to_string(), + // Discard port on loopback: nothing is listening, so the dial fails. + host: "127.0.0.1".to_string(), + port: 9, + username: None, + password: None, + vless_uri: None, + }; + assert_eq!(probe_udp_support(&settings).await, UdpSupport::Unknown); + } + + #[tokio::test] + async fn a_socks5_server_that_accepts_the_association_reports_yes() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut greeting = [0u8; 3]; + stream.read_exact(&mut greeting).await.unwrap(); + stream.write_all(&[SOCKS5, AUTH_NONE]).await.unwrap(); + let mut request = [0u8; 10]; + stream.read_exact(&mut request).await.unwrap(); + assert_eq!(request[1], CMD_UDP_ASSOCIATE); + stream + .write_all(&[ + SOCKS5, + REP_SUCCEEDED, + 0x00, + ATYP_IPV4, + 127, + 0, + 0, + 1, + 0x11, + 0x11, + ]) + .await + .unwrap(); + }); + + let settings = ProxySettings { + proxy_type: "socks5".to_string(), + host: "127.0.0.1".to_string(), + port, + username: None, + password: None, + vless_uri: None, + }; + assert_eq!(probe_udp_support(&settings).await, UdpSupport::Yes); + } + + /// A refusal that arrives as two bytes and a closed socket, which is what a + /// real residential gateway sends. Reading a full reply header here would + /// hit end-of-file and report "unknown" for a proxy that plainly said no. + #[tokio::test] + async fn a_truncated_refusal_is_still_a_refusal() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut greeting = [0u8; 3]; + stream.read_exact(&mut greeting).await.unwrap(); + stream.write_all(&[SOCKS5, AUTH_NONE]).await.unwrap(); + let mut request = [0u8; 10]; + stream.read_exact(&mut request).await.unwrap(); + stream.write_all(&[SOCKS5, 0xFF]).await.unwrap(); + }); + + let settings = ProxySettings { + proxy_type: "socks5".to_string(), + host: "127.0.0.1".to_string(), + port, + username: None, + password: None, + vless_uri: None, + }; + assert_eq!(probe_udp_support(&settings).await, UdpSupport::No); + } + + /// A server that will not accept the offered authentication never got asked + /// about UDP, so the answer is "unknown", not "no". + #[tokio::test] + async fn a_refused_handshake_reports_unknown() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut greeting = [0u8; 3]; + stream.read_exact(&mut greeting).await.unwrap(); + stream + .write_all(&[SOCKS5, AUTH_UNACCEPTABLE]) + .await + .unwrap(); + }); + + let settings = ProxySettings { + proxy_type: "socks5".to_string(), + host: "127.0.0.1".to_string(), + port, + username: None, + password: None, + vless_uri: None, + }; + assert_eq!(probe_udp_support(&settings).await, UdpSupport::Unknown); + } + + #[tokio::test] + async fn a_socks5_server_that_refuses_the_command_reports_no() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut greeting = [0u8; 4]; + stream.read_exact(&mut greeting).await.unwrap(); + stream.write_all(&[SOCKS5, AUTH_USERPASS]).await.unwrap(); + let mut header = [0u8; 2]; + stream.read_exact(&mut header).await.unwrap(); + let mut user = vec![0u8; header[1] as usize]; + stream.read_exact(&mut user).await.unwrap(); + let mut password_len = [0u8; 1]; + stream.read_exact(&mut password_len).await.unwrap(); + let mut password = vec![0u8; password_len[0] as usize]; + stream.read_exact(&mut password).await.unwrap(); + assert_eq!(user, b"probe-user"); + stream.write_all(&[0x01, 0x00]).await.unwrap(); + let mut request = [0u8; 10]; + stream.read_exact(&mut request).await.unwrap(); + stream + .write_all(&[ + SOCKS5, + REP_CMD_NOT_SUPPORTED, + 0x00, + ATYP_IPV4, + 0, + 0, + 0, + 0, + 0, + 0, + ]) + .await + .unwrap(); + }); + + let settings = ProxySettings { + proxy_type: "socks5".to_string(), + host: "127.0.0.1".to_string(), + port, + username: Some("probe-user".to_string()), + password: Some("probe-pass".to_string()), + vless_uri: None, + }; + assert_eq!(probe_udp_support(&settings).await, UdpSupport::No); + } +} diff --git a/src-tauri/src/recorder.rs b/src-tauri/src/recorder.rs new file mode 100644 index 0000000..9bc6340 --- /dev/null +++ b/src-tauri/src/recorder.rs @@ -0,0 +1,560 @@ +//! Recording what a person does, as a recipe. +//! +//! The browser reports real input at the browser-process level +//! (`Wayfern.enableInputCapture` / `Wayfern.inputCaptured`), so a recording +//! sees what the user actually did rather than what a page chose to expose. +//! Each event is turned into the same typed step the agent recipes API +//! validates, so a recording can be saved as a recipe and replayed unchanged. +//! +//! Two rules shape everything here: +//! +//! * **A click becomes a locator, not a coordinate.** Coordinates are useless +//! on the next window size; the element under the pointer is resolved to its +//! role and accessible name, and only falls back to a CSS selector when the +//! element has no name worth matching. +//! * **A password is never recorded.** Typing into a password field produces no +//! step at all and no characters are kept, not even redacted ones: a recipe +//! is stored in the cloud, and a "redacted" field is still a place a secret +//! can end up. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::sync::Arc; +use tokio::sync::Mutex as AsyncMutex; + +use crate::wayfern_cdp::WayfernSession; + +/// Emitted as each step is recognised, so the UI can show the recipe growing. +pub const EVENT_RECORDED_STEP: &str = "recipe-recording-step"; +/// Emitted when a recording stops, for any reason including the browser +/// closing under it. Payload: `{ "reason": "stopped" | "browser-gone" }`. +pub const EVENT_RECORDING_ENDED: &str = "recipe-recording-ended"; + +/// A recording holds at most this many steps. The API refuses a longer recipe, +/// and a recording that silently kept growing would be discarded at save time. +const MAX_STEPS: usize = 200; + +/// What one recording has produced so far. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecordingStatus { + /// The profile being recorded, when one is. + pub profile_id: Option, + pub steps: Vec, + /// True while the capture is live. + pub recording: bool, +} + +#[derive(Default)] +struct RecorderState { + profile_id: Option, + steps: Vec, + /// Set to stop the reader task; the task also stops when the socket closes. + cancel: Option>, + /// Text typed into the focused field since the last flush, and whether that + /// field is a password (in which case nothing is kept). + pending_text: String, + pending_target: Option, + pending_is_password: bool, +} + +lazy_static::lazy_static! { + static ref RECORDER: Arc> = Arc::new(AsyncMutex::new(RecorderState::default())); +} + +fn err(code: &str) -> String { + json!({ "code": code }).to_string() +} + +/// The locator a recorded step should carry for `node`, or a CSS selector when +/// the element has no name to match on. +/// +/// Role and name come from the accessibility tree, which is what the resolver +/// on the other side matches against, so a step recorded here resolves there. +pub fn target_from_description( + role: Option<&str>, + name: Option<&str>, + selector: Option<&str>, +) -> Option { + let role = role.map(str::trim).filter(|role| { + !role.is_empty() && *role != "none" && *role != "generic" && *role != "GenericContainer" + }); + let name = name + .map(|name| name.split_whitespace().collect::>().join(" ")) + .filter(|name| !name.is_empty() && name.chars().count() <= 500); + + if let Some(name) = name { + let mut locator = serde_json::Map::new(); + if let Some(role) = role { + locator.insert("role".to_string(), json!(role)); + } + locator.insert("name".to_string(), json!(name)); + return Some(json!({ "locator": Value::Object(locator) })); + } + // No accessible name: a locator on role alone would match half the page, so + // the selector is the honest handle. Without either there is nothing to + // record and the click is dropped rather than guessed at. + let selector = selector.map(str::trim).filter(|s| !s.is_empty())?; + Some(json!({ "selector": selector })) +} + +/// Merge a target (`{selector}` or `{locator}`) into a step object. +fn with_target(mut step: serde_json::Map, target: &Value) -> Value { + if let Some(object) = target.as_object() { + for (key, value) in object { + step.insert(key.clone(), value.clone()); + } + } + Value::Object(step) +} + +/// Whether this navigation is worth a step of its own. +/// +/// A click that follows a link navigates, and recording both would replay the +/// click and then jump to where it already went. Only a navigation the user +/// asked for directly — typed into the address bar, or the first page — is a +/// step, which is what `type: "typed"` and `"other"` mean in the transition. +pub fn navigation_is_user_intent(transition_type: Option<&str>) -> bool { + matches!( + transition_type, + Some("typed") | Some("auto_bookmark") | Some("generated") | Some("keyword") + ) +} + +/// The step a captured key sequence becomes, or `None` when nothing should be +/// recorded (a password, or no text at all). +pub fn typing_step(text: &str, target: Option<&Value>, is_password: bool) -> Option { + if is_password || text.is_empty() { + return None; + } + let target = target?; + let mut step = serde_json::Map::new(); + step.insert("type".to_string(), json!("type")); + step.insert("text".to_string(), json!(text)); + Some(with_target(step, target)) +} + +/// The step a captured click becomes. +pub fn click_step(target: &Value) -> Value { + let mut step = serde_json::Map::new(); + step.insert("type".to_string(), json!("click")); + with_target(step, target) +} + +/// The step a user-driven navigation becomes. +pub fn navigate_step(url: &str) -> Option { + let url = url.trim(); + if !(url.starts_with("http://") || url.starts_with("https://")) { + return None; + } + Some(json!({ "type": "navigate", "url": url })) +} + +/// Ask the page what sits at a viewport point, and describe it well enough to +/// build a target from. +async fn describe_point(session: &mut WayfernSession, x: f64, y: f64) -> Option { + let node = session + .call( + "DOM.getNodeForLocation", + json!({ "x": x, "y": y, "includeUserAgentShadowDOM": false }), + ) + .await + .ok()?; + let backend_node_id = node.get("backendNodeId").and_then(Value::as_i64)?; + + // The accessibility view is what the resolver on the other side matches, so + // the role and name are read from there rather than from the tag and text. + let ax = session + .call( + "Accessibility.getPartialAXTree", + json!({ "backendNodeId": backend_node_id, "fetchRelatives": false }), + ) + .await + .ok(); + let (role, name) = ax + .as_ref() + .and_then(|ax| ax.get("nodes")?.as_array()?.first().cloned()) + .map(|node| { + ( + node["role"]["value"].as_str().map(str::to_string), + node["name"]["value"].as_str().map(str::to_string), + ) + }) + .unwrap_or((None, None)); + + // A selector for the fallback, and the tag so a password field is known. + let described = session + .call( + "DOM.describeNode", + json!({ "backendNodeId": backend_node_id }), + ) + .await + .ok(); + let selector = described.as_ref().and_then(|described| { + let node = described.get("node")?; + let attributes = node.get("attributes")?.as_array()?; + let mut id = None; + let mut kind = None; + for pair in attributes.chunks(2) { + match (pair.first()?.as_str()?, pair.get(1)?.as_str()?) { + ("id", value) if !value.trim().is_empty() => id = Some(value.to_string()), + ("type", value) => kind = Some(value.to_string()), + _ => {} + } + } + let _ = kind; + id.map(|id| format!("#{id}")) + }); + + Some(json!({ + "role": role, + "name": name, + "selector": selector, + "isPassword": described + .as_ref() + .map(is_password_node) + .unwrap_or(false), + })) +} + +/// Whether a described node is a password field. +pub fn is_password_node(described: &Value) -> bool { + let Some(attributes) = described["node"]["attributes"].as_array() else { + return false; + }; + attributes.chunks(2).any(|pair| { + match ( + pair.first().and_then(Value::as_str), + pair.get(1).and_then(Value::as_str), + ) { + // The attribute name is the page's to spell: HTML is case-insensitive + // here and a field spelled `TYPE` is still a password field. + (Some(name), Some(value)) if name.eq_ignore_ascii_case("type") => { + value.eq_ignore_ascii_case("password") + } + _ => false, + } + }) +} + +/// Start recording the profile's browser. +#[tauri::command] +pub async fn start_recipe_recording( + app_handle: tauri::AppHandle, + profile_id: String, +) -> Result { + { + let state = RECORDER.lock().await; + if state.profile_id.is_some() { + return Err(err("RECORDING_ALREADY_RUNNING")); + } + } + + let profile = crate::profile::ProfileManager::instance() + .list_profiles() + .map_err(|e| format!("Failed to list profiles: {e}"))? + .into_iter() + .find(|p| p.id.to_string() == profile_id) + .ok_or_else(|| crate::backend_error("PROFILE_NOT_FOUND"))?; + if !crate::wayfern_manager::supports_wayfern_152(&profile.version) { + return Err(err("WAYFERN_152_REQUIRED")); + } + + let target = crate::cdp_target::resolve(&profile) + .await + .map_err(|_| crate::backend_error("PROFILE_NOT_RUNNING"))?; + let mut session = WayfernSession::open(&target) + .await + .map_err(|e| crate::backend_error_with_detail("RECORDING_FAILED", e.to_string()))?; + + session + .call("DOM.enable", json!({})) + .await + .map_err(|e| crate::backend_error_with_detail("RECORDING_FAILED", e.to_string()))?; + let _ = session.call("Accessibility.enable", json!({})).await; + let _ = session.call("Page.enable", json!({})).await; + session + .call( + "Wayfern.enableInputCapture", + json!({ "trackMouseMove": false }), + ) + .await + .map_err(|e| crate::backend_error_with_detail("RECORDING_FAILED", e.to_string()))?; + + let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel(); + { + let mut state = RECORDER.lock().await; + state.profile_id = Some(profile_id.clone()); + state.steps.clear(); + state.pending_text.clear(); + state.pending_target = None; + state.pending_is_password = false; + state.cancel = Some(cancel_tx); + } + + tauri::async_runtime::spawn(async move { + read_events(app_handle, session, cancel_rx).await; + }); + + Ok(RecordingStatus { + profile_id: Some(profile_id), + steps: Vec::new(), + recording: true, + }) +} + +/// Read captured input until the recording is stopped or the browser goes. +async fn read_events( + app_handle: tauri::AppHandle, + mut session: WayfernSession, + mut cancel: tokio::sync::oneshot::Receiver<()>, +) { + let reason = loop { + let event = tokio::select! { + _ = &mut cancel => break "stopped", + event = session.await_any_event( + &["Wayfern.inputCaptured", "Page.frameNavigated"], + std::time::Duration::from_secs(3600), + ) => event, + }; + match event { + Ok(Some((method, params))) => { + handle_event(&app_handle, &mut session, &method, ¶ms).await; + } + // A quiet hour is not a reason to stop; a closed socket is. + Ok(None) => continue, + Err(_) => break "browser-gone", + } + }; + + let _ = session.call("Wayfern.disableInputCapture", json!({})).await; + session.close().await; + { + let mut state = RECORDER.lock().await; + if reason == "browser-gone" { + state.profile_id = None; + } + state.cancel = None; + } + let _ = crate::events::emit(EVENT_RECORDING_ENDED, json!({ "reason": reason })); +} + +async fn handle_event( + app_handle: &tauri::AppHandle, + session: &mut WayfernSession, + method: &str, + params: &Value, +) { + let _ = app_handle; + match method { + "Page.frameNavigated" => { + // Only the main frame, and only when the user asked for it. + if params["frame"]["parentId"].is_string() { + return; + } + if !navigation_is_user_intent(params["frame"]["transitionType"].as_str()) { + return; + } + flush_typing().await; + if let Some(step) = params["frame"]["url"].as_str().and_then(navigate_step) { + push_step(step).await; + } + } + "Wayfern.inputCaptured" => match params["type"].as_str() { + Some("mousedown") => { + let (Some(x), Some(y)) = (params["x"].as_f64(), params["y"].as_f64()) else { + return; + }; + if params["button"] + .as_str() + .is_some_and(|button| button != "left") + { + return; + } + flush_typing().await; + let Some(described) = describe_point(session, x, y).await else { + return; + }; + let target = target_from_description( + described["role"].as_str(), + described["name"].as_str(), + described["selector"].as_str(), + ); + // Remember where the next keystrokes are going, and whether that field + // is one whose characters must never be kept. + { + let mut state = RECORDER.lock().await; + state.pending_target = target.clone(); + state.pending_is_password = described["isPassword"].as_bool().unwrap_or(false); + } + if let Some(target) = target { + push_step(click_step(&target)).await; + } + } + Some("char") => { + let Some(text) = params["text"].as_str() else { + return; + }; + let mut state = RECORDER.lock().await; + if state.pending_is_password { + return; + } + if state.pending_text.chars().count() < 4000 { + state.pending_text.push_str(text); + } + } + Some("keydown") => { + // A key that submits or moves focus ends the current field's text. + if matches!(params["key"].as_str(), Some("Enter") | Some("Tab")) { + flush_typing().await; + } + } + _ => {} + }, + _ => {} + } +} + +/// Turn the characters typed so far into a step, if they are worth keeping. +async fn flush_typing() { + let step = { + let mut state = RECORDER.lock().await; + let text = std::mem::take(&mut state.pending_text); + let step = typing_step( + &text, + state.pending_target.as_ref(), + state.pending_is_password, + ); + state.pending_is_password = false; + step + }; + if let Some(step) = step { + push_step(step).await; + } +} + +async fn push_step(step: Value) { + let mut state = RECORDER.lock().await; + if state.profile_id.is_none() || state.steps.len() >= MAX_STEPS { + return; + } + state.steps.push(step.clone()); + drop(state); + let _ = crate::events::emit(EVENT_RECORDED_STEP, step); +} + +/// What has been recorded so far. +#[tauri::command] +pub async fn get_recipe_recording() -> Result { + let state = RECORDER.lock().await; + Ok(RecordingStatus { + profile_id: state.profile_id.clone(), + steps: state.steps.clone(), + recording: state.profile_id.is_some() && state.cancel.is_some(), + }) +} + +/// Stop recording and hand back the steps. +#[tauri::command] +pub async fn stop_recipe_recording() -> Result { + flush_typing().await; + let (steps, profile_id) = { + let mut state = RECORDER.lock().await; + if let Some(cancel) = state.cancel.take() { + let _ = cancel.send(()); + } + let steps = std::mem::take(&mut state.steps); + let profile_id = state.profile_id.take(); + state.pending_target = None; + state.pending_text.clear(); + (steps, profile_id) + }; + Ok(RecordingStatus { + profile_id, + steps, + recording: false, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_click_records_what_the_resolver_can_find_again() { + // A named element travels as a locator, which survives a different window + // size; a coordinate would not. + let target = + target_from_description(Some("button"), Some(" Buy now "), Some("#buy")).unwrap(); + assert_eq!( + target, + json!({ "locator": { "role": "button", "name": "Buy now" } }), + "the accessible name is collapsed, and the selector is not needed" + ); + assert_eq!( + click_step(&target), + json!({ "type": "click", "locator": { "role": "button", "name": "Buy now" } }) + ); + + // No name: the selector is the only honest handle. + assert_eq!( + target_from_description(Some("generic"), None, Some("#cell")).unwrap(), + json!({ "selector": "#cell" }) + ); + // A role that matches half the page is not a locator on its own. + assert_eq!( + target_from_description(Some("generic"), Some(" "), Some(" ")), + None + ); + assert_eq!(target_from_description(None, None, None), None); + } + + #[test] + fn a_password_is_not_recorded_at_all() { + let target = json!({ "selector": "#pass" }); + assert_eq!( + typing_step("hunter2", Some(&target), true), + None, + "not even a redacted step: a recipe is stored in the cloud" + ); + assert_eq!( + typing_step("hello", Some(&target), false), + Some(json!({ "type": "type", "text": "hello", "selector": "#pass" })) + ); + assert_eq!(typing_step("", Some(&target), false), None); + assert_eq!(typing_step("hello", None, false), None); + } + + #[test] + fn a_password_field_is_recognised_from_what_the_browser_describes() { + let password = json!({ + "node": { "attributes": ["type", "password", "name", "pw"] } + }); + assert!(is_password_node(&password)); + let text = json!({ "node": { "attributes": ["type", "text"] } }); + assert!(!is_password_node(&text)); + assert!(!is_password_node(&json!({ "node": {} }))); + // Case is the page's choice, not a signal. + assert!(is_password_node(&json!({ + "node": { "attributes": ["TYPE", "PASSWORD"] } + }))); + } + + #[test] + fn only_a_navigation_the_user_asked_for_becomes_a_step() { + assert!(navigation_is_user_intent(Some("typed"))); + assert!(navigation_is_user_intent(Some("keyword"))); + // A link click is already recorded as the click; recording the landing too + // would replay the click and then jump past whatever it did. + assert!(!navigation_is_user_intent(Some("link"))); + assert!(!navigation_is_user_intent(Some("form_submit"))); + assert!(!navigation_is_user_intent(None)); + + assert_eq!( + navigate_step(" https://example.com/shop "), + Some(json!({ "type": "navigate", "url": "https://example.com/shop" })) + ); + // A recipe that opens a local file is not a recipe anyone should replay. + assert_eq!(navigate_step("file:///etc/passwd"), None); + assert_eq!(navigate_step("about:blank"), None); + } +} diff --git a/src-tauri/src/remote_exit.rs b/src-tauri/src/remote_exit.rs index fcc6c49..e7e0305 100644 --- a/src-tauri/src/remote_exit.rs +++ b/src-tauri/src/remote_exit.rs @@ -2,20 +2,20 @@ //! machine. //! //! Remote execution — an interactive remote session or a Cookie Bot night — runs -//! the browser on a leased fleet host, but the PROFILE (and its proxy, and its -//! VPN config) is pulled from the user's sync namespace. Nothing in that -//! handover rewrites addresses, so a proxy recorded as `127.0.0.1:8080` arrives -//! on the fleet host meaning *the fleet host's own loopback*. +//! the browser on a remote host, but the PROFILE (and its proxy, and its VPN +//! config) is pulled from the user's sync namespace. Addresses are not rewritten +//! in transit, so a proxy recorded as `127.0.0.1:8080` arrives meaning *that +//! machine's own loopback*. //! -//! That is the whole bug this module exists to prevent. The server already -//! refuses a profile with NO exit (`proxy_required`), because a night browsed -//! from the fleet's datacenter address damages an identity rather than building -//! it — but it was asking whether an exit was *configured*, never whether it was -//! *reachable*. A local proxy satisfied the first question and failed the -//! second, so the run was accepted, dispatched, and burned a leased host either -//! erroring out or (worse) egressing direct from the datacenter: exactly the -//! outcome `proxy_required` exists to stop, reached by the one route it did not -//! check. +//! That is the whole bug this module exists to prevent. A profile with NO exit +//! is already refused (`proxy_required`), because a night browsed without the +//! user's own exit damages an identity rather than building it — but "an exit is +//! configured" and "that exit is reachable from somewhere else" are different +//! questions, and only the first was ever asked. A local proxy satisfied it and +//! failed the second, so the run was accepted, dispatched, and burned an hour +//! either erroring out or (worse) egressing from the remote host's own address: +//! exactly the outcome `proxy_required` exists to stop, reached by the one route +//! it did not check. //! //! Local proxies are not an exotic case. A local MITM proxy, an SSH tunnel, a //! locally-run SOCKS client and Donut's own VLESS support all present to the @@ -24,11 +24,11 @@ //! This module is the single answer, shared by every caller, and it FAILS //! CLOSED: anything it cannot parse is reported as unreachable. Refusing a //! working setup costs the user one support question; accepting a broken one -//! costs a burned hour and a damaged profile identity. +//! costs an hour of quota and a damaged profile identity. use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -/// Whether a leased fleet host could dial this profile's exit. +/// Whether a remote host could dial this profile's exit. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ExitReachability { /// No proxy and no VPN. The caller's existing "no exit" refusal applies. @@ -42,6 +42,19 @@ pub enum ExitReachability { /// Which part of the config it came from: "proxy" or "VPN". source: &'static str, }, + /// A protocol a remote host has no way to speak, whatever address it names. + /// + /// Reachability is the wrong question for these: the server in a VLESS URI is + /// as publicly routable as any other, so the host check passes and the run is + /// accepted, dispatched, and then refused remotely, because dialling VLESS + /// needs a sidecar that is not available there. That is a permanent refusal + /// wearing a transient one's clothes, and every nightly retry pays for it. + UnsupportedKind { + /// The protocol, as the user would name it: "VLESS". + kind: String, + /// Which part of the config it came from: "proxy" or "VPN". + source: &'static str, + }, /// Configured, but this code could not determine the host. /// /// Treated as unreachable by [`ExitReachability::is_remote`] — see the @@ -66,6 +79,11 @@ impl ExitReachability { "The {source} for this profile points at {host}, which only exists on this computer. \ Remote runs happen on our hosts and cannot reach it." )), + ExitReachability::UnsupportedKind { kind, source } => Some(format!( + "The {source} for this profile is {kind}, which needs the Xray sidecar our hosts do not \ + run, so the fleet cannot dial it however reachable its server is. An HTTP, HTTPS, SOCKS \ + or WireGuard exit works." + )), ExitReachability::Unknown { reason, source } => Some(format!( "The {source} for this profile could not be read ({reason}), so we cannot confirm a \ remote host could use it." @@ -99,7 +117,7 @@ pub fn host_is_remote_reachable(host: &str) -> bool { } // Suffixes reserved for local/private name resolution (RFC 6762 mDNS, RFC - // 8375, and the names router vendors hand out on a LAN). A fleet host + // 8375, and the names router vendors hand out on a LAN). A remote host // resolving one of these gets its own network's answer, not the user's. const LOCAL_SUFFIXES: [&str; 7] = [ ".local", @@ -115,7 +133,7 @@ pub fn host_is_remote_reachable(host: &str) -> bool { } // A bare single-label name ("my-proxy", "router") is only resolvable through - // a local search domain, so it is no more use to a fleet host than `.local`. + // a local search domain, so it is no more use to a remote host than `.local`. if !lower.contains('.') { return false; } @@ -257,8 +275,39 @@ pub fn proxy_exit_host(settings: &crate::browser::ProxySettings) -> Result Option { + let has_uri = settings + .vless_uri + .as_deref() + .is_some_and(|uri| !uri.trim().is_empty()); + if settings.proxy_type.eq_ignore_ascii_case("vless") || has_uri { + return Some("VLESS".to_string()); + } + None +} + /// Classify a stored proxy. +/// +/// Protocol first, address second. A VLESS config names a perfectly routable +/// server, so asking the address question first answers `Remote` for an exit no +/// remote host can use; the kind has to disqualify it before the host is looked +/// at. (`proxy_exit_host` still resolves a VLESS server, because "which machine +/// does this dial" remains a real question for a log line.) pub fn classify_proxy(settings: &crate::browser::ProxySettings) -> ExitReachability { + if let Some(kind) = unsupported_remote_kind(settings) { + return ExitReachability::UnsupportedKind { + kind, + source: "proxy", + }; + } + match proxy_exit_host(settings) { Err(reason) => ExitReachability::Unknown { reason, @@ -380,7 +429,7 @@ mod tests { #[test] fn lan_only_names_are_local() { - // A fleet host resolving these gets ITS network's answer, not the user's — + // A remote host resolving these gets ITS network's answer, not the user's — // which is worse than failing, because it may well succeed against // something unrelated. for host in [ @@ -408,27 +457,62 @@ mod tests { } #[test] - fn a_vless_proxy_is_judged_by_its_server_not_its_local_port() { - // THE asymmetry. Donut points the browser at a local xray worker, so the - // browser-facing address of every VLESS proxy is 127.0.0.1 — but the stored - // config names a real server, and that is what a fleet host would dial. - // Classifying VLESS off `settings.host` would refuse every VLESS profile. + fn a_vless_proxy_is_refused_however_public_its_server_is() { + // This URI names a routable server, so the address check says Remote and + // the enrolment is accepted; every night after that the run is refused + // remotely, because dialling VLESS needs a sidecar that is not available + // there. The protocol has to disqualify the exit here, where no remote hour + // has been spent yet. let mut settings = proxy("vless", "127.0.0.1"); settings.vless_uri = Some("vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@vpn.example.com:443?type=tcp#node".into()); - assert_eq!(classify_proxy(&settings), ExitReachability::Remote); + assert_eq!( + classify_proxy(&settings), + ExitReachability::UnsupportedKind { + kind: "VLESS".to_string(), + source: "proxy", + } + ); + assert!(!classify_proxy(&settings).is_remote()); + // The refusal has to name the protocol and the exits that do work, or it + // reads as "your proxy is broken" for a proxy that is fine everywhere else. + let detail = classify_proxy(&settings).refusal_detail().unwrap(); + assert!(detail.contains("VLESS"), "{detail}"); + assert!(detail.contains("Xray"), "{detail}"); + assert!(detail.contains("SOCKS"), "{detail}"); } #[test] - fn a_vless_uri_pointing_at_loopback_is_still_local() { + fn a_vless_uri_is_refused_even_when_the_type_field_disagrees() { + // A config pasted as a bare URI can land with the type still unnormalised. + // Reading only `proxy_type` would classify this one on `settings.host` — + // which for VLESS is the local xray worker, so it would come back LocalOnly + // and tell the user to swap a proxy whose real problem is its protocol. + let mut settings = proxy("socks5", "1.2.3.4"); + settings.vless_uri = Some("vless://uuid@vpn.example.com:443?type=tcp".into()); + + assert_eq!( + classify_proxy(&settings), + ExitReachability::UnsupportedKind { + kind: "VLESS".to_string(), + source: "proxy", + } + ); + } + + #[test] + fn a_vless_uri_pointing_at_loopback_is_refused_on_its_kind() { + // Local AND unsupported. Either verdict blocks the run, but the kind is the + // one the user has to act on: fixing the address still leaves an exit no + // remote host can dial. let mut settings = proxy("vless", "127.0.0.1"); settings.vless_uri = Some("vless://uuid@127.0.0.1:443?type=tcp".into()); assert_eq!( classify_proxy(&settings), - ExitReachability::LocalOnly { - host: "127.0.0.1".to_string(), + ExitReachability::UnsupportedKind { + kind: "VLESS".to_string(), source: "proxy", } ); @@ -452,9 +536,7 @@ mod tests { // Unknown must never be treated as usable: the point of the check is that // we could not confirm reachability, and guessing "yes" reintroduces the // exact failure it prevents. - let mut settings = proxy("vless", ""); - settings.vless_uri = None; - let verdict = classify_proxy(&settings); + let verdict = classify_proxy(&proxy("socks5", " ")); assert!(matches!(verdict, ExitReachability::Unknown { .. })); assert!(!verdict.is_remote()); @@ -504,6 +586,11 @@ mod tests { source: "proxy" } .is_remote()); + assert!(!ExitReachability::UnsupportedKind { + kind: "VLESS".into(), + source: "proxy" + } + .is_remote()); // `None` has no detail: the caller's existing "no exit at all" refusal is // the better message, and two refusals for one condition read as a bug. assert!(ExitReachability::None.refusal_detail().is_none()); diff --git a/src-tauri/src/remote_handoff.rs b/src-tauri/src/remote_handoff.rs index b114cf3..b0d8411 100644 --- a/src-tauri/src/remote_handoff.rs +++ b/src-tauri/src/remote_handoff.rs @@ -1,6 +1,6 @@ //! What a remote session owes this machine, and the gate that collects it. //! -//! A profile that runs on the leased fleet is written by the host, not here. +//! A profile that runs remotely is written by that host, not here. //! The host pushes it back to cloud storage when the session ends, and until //! this machine has pulled that push, the local profile directory is a stale //! copy of something that has moved on. @@ -21,9 +21,9 @@ //! //! Two states, and the difference matters to the user: //! -//! - [`HandoffState::Running`]: a session is live on the fleet. The profile lock -//! is held server-side, so a launch would be refused anyway; this makes the -//! refusal instant and legible instead of a round trip and a raw string. +//! - [`HandoffState::Running`]: a session is live remotely. The profile lock is +//! held, so a launch would be refused anyway; this makes the refusal instant +//! and legible instead of a round trip and a raw string. //! - [`HandoffState::PendingSync`]: the session is over, the lock is released, //! and the work is sitting in cloud storage. This is the window that used to //! be wide open. @@ -40,10 +40,10 @@ pub const EVENT_REMOTE_HANDOFF: &str = "remote-handoff-changed"; /// /// The entry survives a failure, so "giving up" only means this burst stops; /// the next stream event, app start or manual sync tries again. What the retries -/// buy is the common case: the profile lock is released server-side a moment -/// before this machine's cached copy of it expires, and a single attempt would -/// hit `Skipped("profile is locked elsewhere")` and leave the user blocked for -/// no reason. +/// buy is the common case: the profile lock is released a moment before this +/// machine's cached copy of it expires, and a single attempt would hit +/// `Skipped("profile is locked elsewhere")` and leave the user blocked for no +/// reason. const PULL_ATTEMPTS: u32 = 5; /// Delay before the second pull attempt. Doubles, capped by [`PULL_RETRY_MAX`]. @@ -53,11 +53,11 @@ const PULL_RETRY_BASE: Duration = Duration::from_secs(2); /// attempts is guaranteed to span at least one refresh of the lock cache. const PULL_RETRY_MAX: Duration = Duration::from_secs(45); -/// Where a profile stands with respect to the fleet. +/// Where a profile stands with respect to remote execution. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum HandoffState { - /// A session is live on the fleet right now. + /// A session is live remotely right now. Running, /// A session has finished and its work has not been pulled down yet. PendingSync, @@ -167,7 +167,7 @@ pub fn state_for(profile_id: &str) -> Option { with_store(|store| store.get(profile_id).map(|entry| entry.state)) } -/// The session currently holding this profile on the fleet, if any. +/// The session currently holding this profile remotely, if any. /// /// Answers for a `provisioning` session too, which the drivable-session index /// deliberately does not. Stopping a session that has not finished coming up is @@ -196,7 +196,7 @@ pub fn profile_for_session(session_id: &str) -> Option { }) } -/// Record that a session is live on the fleet for this profile. +/// Record that a session is live remotely for this profile. /// /// Written to disk immediately, and this is the point of the whole store: if the /// app is closed while a session runs, nothing on restart would otherwise @@ -339,6 +339,22 @@ pub fn resume_pending_pulls(app_handle: &tauri::AppHandle) { } } +/// Whether a profile with this id exists on THIS device. +/// +/// A transient failure to read the profile list returns `true`, deliberately: +/// the caller only clears a gate when this is `false`, and clearing one during +/// a momentary read error would unblock a local profile whose remote work is +/// genuinely still pending. "Cannot tell" must never mean "gone". +fn profile_exists_locally(profile_id: &str) -> bool { + let Ok(uuid) = uuid::Uuid::parse_str(profile_id) else { + return false; + }; + crate::profile::ProfileManager::instance() + .list_profiles() + .map(|profiles| profiles.iter().any(|p| p.id == uuid)) + .unwrap_or(true) +} + /// Pull one profile's finished session down, then lift its gate. /// /// Spawned rather than awaited by its callers: a stream frame and a stop button @@ -370,6 +386,20 @@ pub fn schedule_pull(app_handle: tauri::AppHandle, profile_id: String) { } Ok(_) => unreachable!("is_completed covers every completed outcome"), Err(e) => { + // A profile that was created and run entirely on a remote host may + // not exist on this device at all: nothing to pull, nothing to gate. + // The pull would fail with "not found" on every attempt and the entry + // would sit in the handoff store for ever. Clear it — the gate only + // protects a LOCAL profile from being opened over unsynced remote + // work, and there is no local profile here. + if !profile_exists_locally(&profile_id) { + log::info!( + "Clearing the post-session gate for profile {profile_id}: it has no local copy \ + (created and run remotely), so there is nothing to pull or protect" + ); + clear(&profile_id); + return; + } log::warn!("Post-session pull for profile {profile_id} failed: {e}"); } } @@ -478,10 +508,11 @@ mod tests { #[test] fn a_closed_session_this_machine_never_watched_does_not_gate_anything() { - // `listForUser` returns closed sessions next to live ones, so the snapshot - // on every reconnect replays every session that ever finished. Treating - // those as fresh handoffs would block the Run button on a perfectly current - // profile at each app start, and block it indefinitely while offline. + // The session listing returns closed sessions next to live ones, so the + // snapshot on every reconnect replays every session that ever finished. + // Treating those as fresh handoffs would block the Run button on a + // perfectly current profile at each app start, and block it indefinitely + // while offline. let _iso = isolated(); assert!(!note_ended("p1", "s-finished-last-week")); assert_eq!(state_for("p1"), None); diff --git a/src-tauri/src/remote_session.rs b/src-tauri/src/remote_session.rs index fc8c36e..1143ff3 100644 --- a/src-tauri/src/remote_session.rs +++ b/src-tauri/src/remote_session.rs @@ -1,10 +1,9 @@ //! Launching a profile on a remote VM. //! -//! The desktop app never talks to the Wayfern manager directly. It asks -//! donutbrowser-infra, which holds the service-account credentials and is the -//! only party that can mint a donut-sync token scoped to this user's namespace. -//! That indirection is the point: a desktop client that could call the manager -//! itself would need credentials capable of launching sessions for anyone. +//! The desktop app never leases a remote host itself. It asks the Donut cloud +//! API, which is the only party holding credentials that can do so. That +//! indirection is the point: a desktop client able to lease directly would need +//! credentials capable of launching sessions for anyone. use crate::cloud_errors::{self, FailureCodes}; use crate::profile::types::BrowserProfile; @@ -116,14 +115,35 @@ pub fn idempotency_key(profile_id: &str, attempt: &str) -> String { format!("run-remote:{profile_id}:{attempt}") } +/// Connect timeout for every remote-session control call. +const CONTROL_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); +/// Total budget for the launch POST. A host is leased while this request is +/// open, so it is the one call that legitimately takes tens of seconds; without +/// a ceiling a hung server hangs the click for minutes. +const LAUNCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90); +/// Total budget for the read, stop and list calls, which are quick or broken. +const CONTROL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +/// An HTTP client for a control call, with a short connect timeout and a total +/// ceiling, built the same way as the session-events client below rather than +/// the bare `reqwest::Client::new()` these calls used to use — which inherited +/// no timeout at all. +fn control_client(total: std::time::Duration) -> reqwest::Client { + reqwest::Client::builder() + .connect_timeout(CONTROL_CONNECT_TIMEOUT) + .timeout(total) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +} + /// Whether this profile's exit rules out running it on a leased host. /// -/// A session runs on a fleet host that pulls the profile — and its proxy record -/// — out of the user's sync namespace, rewriting no addresses along the way. A -/// proxy stored as `127.0.0.1:8080` therefore arrives meaning THAT host's -/// loopback: the browser either cannot connect and the leased hour is burned on -/// a session that never worked, or it falls through and the user's identity -/// egresses from our datacenter. The Cookie Bot has refused this since +/// A session opens the profile — and its stored proxy — on a remote host +/// exactly as this machine wrote them, addresses and all. A proxy stored as +/// `127.0.0.1:8080` therefore arrives meaning THAT host's loopback: the browser +/// either cannot connect and the leased hour is burned on a session that never +/// worked, or it falls through and the user's identity egresses from a +/// datacenter. The Cookie Bot has refused this since /// `remote_exit` existed; interactive sessions take the same profile onto the /// same hosts and did not, so the same mistake cost a leased hour here. /// @@ -136,13 +156,69 @@ pub fn idempotency_key(profile_id: &str, attempt: &str) -> String { /// /// Split out from the launch because that is the only testable seam: /// `exit_reachability` reads this machine's proxy and VPN stores and the launch -/// itself needs a fleet. +/// itself needs a remote host. +/// Local reasons a remote launch is refused, decided from the profile alone. +/// +/// Split from the request and from `local_exit_refusal` so it is unit-testable +/// without a network or a sync scheduler, and so both the REST and the MCP +/// entry points get the identical answer from one place. +/// +/// - The platform must be one a leased host serves. `resolved_os` can return a +/// fingerprint OS like `android` for a profile with no recorded host OS, and +/// no remote host runs that; refusing here gives a clear message instead of +/// leasing a host and taking a generic failure back. +/// - Sync must have completed at least once. A remote session opens the synced +/// copy and hands it back when it stops, so a profile with sync enabled but +/// `last_sync` still None (the first upload failed, or is only queued) would +/// open EMPTY and then come back over the real local copy as emptiness. +/// `remote_launch_precondition` catches sync being off and a sync in flight; +/// this catches the gap between them. +pub fn local_launch_refusal( + profile: &BrowserProfile, + platform: &str, +) -> Option { + if !crate::profile::types::is_host_os(platform) { + return Some(RemoteSessionError::Other( + serde_json::json!({ + "code": "REMOTE_PLATFORM_UNSUPPORTED", + "params": { "platform": platform }, + }) + .to_string(), + )); + } + if profile.is_sync_enabled() && profile.last_sync.is_none() { + return Some(RemoteSessionError::Other( + serde_json::json!({ "code": "REMOTE_PROFILE_NOT_SYNCED" }).to_string(), + )); + } + None +} + fn local_exit_refusal(verdict: &ExitReachability) -> Option { match verdict { // An address anyone can dial, so the leased host can dial it too. ExitReachability::Remote => None, // See the second paragraph above: allowed on purpose, not overlooked. ExitReachability::None => None, + // A protocol no remote host can dial, which is a different sentence from a + // local address: nothing about the ADDRESS is wrong, so "use a proxy with a + // public address" is advice the user cannot act on. VLESS needs a local + // sidecar that is not available remotely, and no retry changes that. + ExitReachability::UnsupportedKind { kind, .. } => { + log::warn!( + "Refusing an interactive remote session: {}", + verdict + .refusal_detail() + .unwrap_or_else(|| format!("the profile's exit is {kind}")) + ); + Some(RemoteSessionError::Other( + serde_json::json!({ + "code": "REMOTE_PROXY_KIND_UNSUPPORTED", + "params": { "kind": kind }, + }) + .to_string(), + )) + } // `LocalOnly`, plus `Unknown` — which `remote_exit` produces when it could // not read the config and which fails closed by design, because "we could // not confirm it" guessed as "yes" is the failure this whole check exists @@ -164,7 +240,7 @@ fn local_exit_refusal(verdict: &ExitReachability) -> Option } } -/// Ask donutbrowser-infra to start a remote session for this profile. +/// Ask the cloud API to start a remote session for this profile. /// /// Goes through `api_call_with_retry` so an expired access token is refreshed /// and the request retried once, rather than surfacing to the user as a @@ -182,6 +258,10 @@ pub async fn start_remote_session( .to_string(); let profile_id = profile.id.to_string(); + if let Some(refusal) = local_launch_refusal(profile, &platform) { + return Err(refusal); + } + // Checked here, before the request: the backend is told which profile to // start but never sees the proxy record, so it cannot derive this — and by // the time it could, an hour is already leased and billed. Resolving a proxy @@ -205,7 +285,7 @@ pub async fn start_remote_session( idempotency_key: key.clone(), }; async move { - let response = reqwest::Client::new() + let response = control_client(LAUNCH_TIMEOUT) .post(&endpoint) .bearer_auth(token) .json(&body) @@ -270,13 +350,12 @@ pub fn note_session_stopped(app: &AppHandle, session_id: &str) { } } -/// Ask donutbrowser-infra to stop a remote session. +/// Ask the cloud API to stop a remote session. /// -/// Without this the only thing that ends a session is the fleet's own two-hour -/// cap, so every launch bills the full 7200s however briefly it was used — a -/// handful of runs exhausts an allowance meant for a hundred. The backend -/// refuses to retire a row it could not stop on the fleet, so a successful -/// return here means the browser is really down and the profile lock released. +/// Without this a session runs to its maximum duration however briefly it was +/// used, so a handful of runs exhausts an allowance meant for many. A stop the +/// server reports as successful means the browser is really down and the +/// profile lock released. pub async fn end_remote_session( session_id: &str, ) -> Result { @@ -290,7 +369,7 @@ pub async fn end_remote_session( .api_call_with_retry(|token| { let endpoint = endpoint.clone(); async move { - let response = reqwest::Client::new() + let response = control_client(CONTROL_TIMEOUT) .delete(&endpoint) .bearer_auth(token) .send() @@ -327,8 +406,8 @@ pub fn classify_error_string(message: &str) -> RemoteSessionError { /// A session as the backend currently sees it. /// /// `POST /api/remote-sessions` hands back the literal string `provisioning` -/// and nothing else, so until this type existed the only way anyone observed a -/// session becoming usable was by reading the production database. +/// and nothing else, so until this type existed the only way to observe a +/// session becoming usable was to keep trying to drive it. #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct RemoteSessionState { pub session_id: String, @@ -338,9 +417,9 @@ pub struct RemoteSessionState { pub platform: Option, /// `provisioning` | `ready` | `live` | `closed` | `error`. /// - /// Named `state` because that is what `RemoteSessionView` in - /// donutbrowser-infra actually sends. It carried the name `status` until a - /// real payload was compared against it, and because the field had no + /// Named `state` because that is what the cloud API actually sends. It + /// carried the name `status` until a real payload was compared against it, + /// and because the field had no /// default, every list and single read failed at `missing field \`status\`` /// and surfaced as CLOUD_UNREACHABLE. The alias keeps the launch reply — /// which predates the reconciled vocabulary and still says `status` — @@ -410,7 +489,7 @@ async fn get_json( .api_call_with_retry(|token| { let endpoint = endpoint.clone(); async move { - let response = reqwest::Client::new() + let response = control_client(CONTROL_TIMEOUT) .get(&endpoint) .bearer_auth(token) .send() @@ -805,8 +884,8 @@ fn is_heartbeat(kind: &str) -> bool { /// Turn one decoded frame into the Tauri event and payload it becomes. /// -/// The discriminator lives INSIDE the JSON, not in the SSE `event:` line: Nest -/// only sets `MessageEvent.type` for the heartbeat, so every real frame arrives +/// The discriminator lives INSIDE the JSON, not in the SSE `event:` line: only +/// the heartbeat arrives with an `event:` name, so every real frame arrives /// as the default `message` event carrying /// `{"type":"snapshot"|"state"|"progress"|"closed","at":…,"sessions"|"session":…}`. /// Routing on the event name alone emitted that whole envelope as a session, so @@ -912,10 +991,10 @@ pub fn session_events_running() -> bool { async fn run_session_events(app: AppHandle) { let mut attempt = 0u32; // Echoed back on reconnect as `Last-Event-ID`, per the SSE spec, IF the - // backend ever labels its frames. It does not today — `stream()` emits no - // `id:` line and keeps no replay buffer — so this stays `None` and nothing is - // resumed. What bounds the loss instead is the stream opening with a full - // snapshot, which re-states every session the caller still owns. + // server ever labels its frames. It does not today — no `id:` line ever + // arrives — so this stays `None` and nothing is resumed. What bounds the loss + // instead is the stream opening with a full snapshot, which re-states every + // session the caller still owns. let mut last_event_id: Option = None; while STREAM_RUNNING.load(Ordering::SeqCst) { @@ -957,7 +1036,11 @@ async fn run_session_events(app: AppHandle) { /// Spread reconnects so every desktop that lost the same backend does not come /// back in the same millisecond. -fn jittered(delay: Duration) -> Duration { +/// +/// Shared with the MCP remote-control bridge: both reconnect to the same host, +/// so a deployment restart would otherwise bring every desktop back on two +/// synchronised timers instead of one spread one. +pub(crate) fn jittered(delay: Duration) -> Duration { use rand::RngExt; let factor = rand::rng().random_range(0.8f64..1.2f64); delay.mul_f64(factor) @@ -977,7 +1060,7 @@ async fn sleep_unless_stopped(total: Duration) { /// Deliberately not the shared one: a total request timeout would kill a /// healthy stream on schedule, so only the connect phase is bounded and /// liveness is enforced by the idle timeout instead. -fn stream_client() -> &'static reqwest::Client { +pub(crate) fn stream_client() -> &'static reqwest::Client { static CLIENT: std::sync::OnceLock = std::sync::OnceLock::new(); CLIENT.get_or_init(|| { reqwest::Client::builder() @@ -1192,11 +1275,10 @@ mod tests { #[test] fn the_stop_response_parses_what_the_backend_actually_sends() { - // Pinned against EndRemoteSessionOutcome in donutbrowser-infra - // (apps/backend/src/remote-sessions/remote-sessions.service.ts). A field - // name that does not match makes every stop fail at the decode step, and - // the session then runs to the 2h cap and bills 7200s — the exact defect - // this endpoint exists to fix, reintroduced silently. + // Pinned against a real stop response from the cloud API. A field name that + // does not match makes every stop fail at the decode step, and the session + // then runs to its maximum duration — the exact defect this endpoint exists + // to fix, reintroduced silently. let outcome: EndRemoteSessionOutcome = serde_json::from_str(r#"{"session_id":"sess-1","status":"closed","billed_seconds":42}"#) .expect("the backend's stop payload must deserialize"); @@ -1239,9 +1321,9 @@ mod tests { #[test] fn a_local_only_exit_is_refused_before_a_host_is_leased() { - // The profile and its proxy record are copied onto the fleet unrewritten, - // so this loopback address would mean the FLEET's loopback. Accepting the - // launch bills an hour for a session that cannot reach the user's exit. + // The profile's stored proxy travels exactly as written, so this loopback + // address would mean the REMOTE host's loopback. Accepting the launch spends + // an hour on a session that cannot reach the user's exit. let refusal = local_exit_refusal(&ExitReachability::LocalOnly { host: "127.0.0.1".to_string(), source: "proxy", @@ -1254,6 +1336,23 @@ mod tests { ); } + #[test] + fn a_proxy_kind_the_fleet_cannot_dial_is_refused_in_its_own_words() { + // Not REMOTE_REQUIRES_REMOTE_EXIT_NODE. A VLESS server is publicly + // routable, so telling this user to "use a proxy with a public address" + // points them at the one part of their config that is already correct. + let refusal = local_exit_refusal(&ExitReachability::UnsupportedKind { + kind: "VLESS".to_string(), + source: "proxy", + }) + .expect("no fleet host runs the xray sidecar VLESS needs"); + + let json: serde_json::Value = + serde_json::from_str(&refusal.to_error_json()).expect("valid envelope"); + assert_eq!(json["code"], "REMOTE_PROXY_KIND_UNSUPPORTED"); + assert_eq!(json["params"]["kind"], "VLESS"); + } + #[test] fn an_exit_that_could_not_be_read_is_refused_too() { // `Unknown` is "we could not confirm this works from elsewhere". Treating @@ -1294,8 +1393,7 @@ mod tests { assert_eq!(json["params"]["granted"], "200"); } - /// A verbatim `RemoteSessionView`, field for field, as `toView` in - /// donutbrowser-infra's `remote-sessions.service.ts` builds it. + /// A verbatim session payload, field for field, as the cloud API sends it. /// /// Hand-written JSON is what let this type declare `status`, `ready_at` and /// `closed_at` while the backend sent `state` and `ended_at`: the test agreed @@ -1311,7 +1409,7 @@ mod tests { #[test] fn the_session_state_payload_matches_what_the_backend_sends() { // The desktop has been blind between launch and stop; every field here is - // one it could previously only learn by reading the production database. + // one it had no way to observe before. let state: RemoteSessionState = serde_json::from_str(SERVER_SESSION_VIEW) .expect("the backend's session payload must deserialize"); @@ -1431,7 +1529,7 @@ mod tests { #[test] fn a_heartbeat_is_not_forwarded_to_the_frontend() { // Emitting one would make every consumer re-render twice a minute for - // nothing. Nest names this one, so it arrives with an `event:` line. + // nothing. This is the one frame that arrives with an `event:` line. assert!(route_wire(b"event: ping\ndata: {}\n\n").is_empty()); assert!(route_wire(b"event: heartbeat\ndata: {}\n\n").is_empty()); // And the same frame with the discriminator inside the JSON instead. @@ -1442,11 +1540,10 @@ mod tests { #[test] fn the_opening_snapshot_reaches_the_snapshot_event() { - // Byte-for-byte what Nest writes for `{type:'snapshot',at,sessions}`: no - // `event:` line, because the controller only sets MessageEvent.type for the - // ping. Routing on the event NAME sent this to `remote-session-state` as a - // raw envelope, so `remote-session-snapshot` was never emitted at all and - // the live view started empty and stayed empty. + // Byte-for-byte what the server sends for a snapshot: no `event:` line, so + // only the ping carries a name. Routing on the event NAME sent this to + // `remote-session-state` as a raw envelope, so `remote-session-snapshot` was + // never emitted at all and the live view started empty and stayed empty. let routed = route_wire( b"data: {\"type\":\"snapshot\",\"at\":\"2026-08-03T00:00:00.000Z\",\"sessions\":[{\"session_id\":\"s1\",\"profile_id\":\"p1\",\"state\":\"live\"}]}\n\n", ); @@ -1659,8 +1756,8 @@ mod tests { feed(&transition("sess-1", "p1", "closed", false)); feed(&transition("sess-2", "p1", "live", true)); - // Out-of-order frames are normal: the reconciler polls the fleet while - // the user is already starting the next session. A stale close arriving + // Out-of-order frames are normal: the server can report a stale session + // while the user is already starting the next one. A stale close arriving // after the new session went live must not make a working browser // unreachable. feed(&transition("sess-1", "p1", "closed", false)); @@ -1758,7 +1855,7 @@ mod tests { #[test] fn the_endpoint_descriptor_matches_what_the_backend_sends() { - // Pinned against `GET /api/remote-sessions/:id/cdp` in donutbrowser-infra. + // Pinned against a real `GET /api/remote-sessions/:id/cdp` response. // A field name that does not match makes every remote attach fail at the // decode step, and the desktop reports a live session as undrivable. let endpoint: CdpEndpoint = serde_json::from_str( @@ -1819,4 +1916,71 @@ mod tests { session.state = "ready".to_string(); assert!(!is_drivable(&session)); } + + fn remote_profile( + sync: crate::profile::types::SyncMode, + last_sync: Option, + ) -> BrowserProfile { + BrowserProfile { + id: uuid::Uuid::new_v4(), + name: "p".to_string(), + browser: "wayfern".to_string(), + version: "1.0".to_string(), + release_type: "stable".to_string(), + sync_mode: sync, + last_sync, + host_os: Some("macos".to_string()), + ..Default::default() + } + } + + fn refusal_code(err: &RemoteSessionError) -> Option { + let RemoteSessionError::Other(body) = err else { + return None; + }; + let v: serde_json::Value = serde_json::from_str(body).ok()?; + Some(v.get("code")?.as_str()?.to_string()) + } + + #[test] + fn a_synced_profile_of_its_own_os_is_allowed_to_launch() { + let profile = remote_profile(crate::profile::types::SyncMode::Regular, Some(1)); + assert!(local_launch_refusal(&profile, "macos").is_none()); + } + + #[test] + fn a_non_host_platform_is_refused_and_names_itself() { + let profile = remote_profile(crate::profile::types::SyncMode::Regular, Some(1)); + let refusal = local_launch_refusal(&profile, "android").expect("android is refused"); + assert_eq!( + refusal_code(&refusal).as_deref(), + Some("REMOTE_PLATFORM_UNSUPPORTED") + ); + // The offending platform rides in params so the toast can name it. + let RemoteSessionError::Other(body) = &refusal else { + panic!("expected an Other refusal"); + }; + let v: serde_json::Value = serde_json::from_str(body).unwrap(); + assert_eq!(v["params"]["platform"], "android"); + } + + #[test] + fn a_profile_that_never_finished_a_sync_is_refused() { + // Sync enabled, but no upload ever completed: pulling it would be pulling + // emptiness, and the push-back would overwrite the real local profile. + let profile = remote_profile(crate::profile::types::SyncMode::Regular, None); + let refusal = local_launch_refusal(&profile, "macos").expect("never-synced is refused"); + assert_eq!( + refusal_code(&refusal).as_deref(), + Some("REMOTE_PROFILE_NOT_SYNCED") + ); + } + + #[test] + fn a_profile_with_sync_off_is_not_refused_by_the_sync_gate_here() { + // Sync being off is `remote_launch_precondition`'s refusal, not this one's, + // so this gate stays silent for it rather than emitting a second message. + let profile = remote_profile(crate::profile::types::SyncMode::Disabled, None); + assert!(local_launch_refusal(&profile, "macos").is_none()); + } } diff --git a/src-tauri/src/settings_manager.rs b/src-tauri/src/settings_manager.rs index 3e2986c..84274b7 100644 --- a/src-tauri/src/settings_manager.rs +++ b/src-tauri/src/settings_manager.rs @@ -6,7 +6,6 @@ use aes_gcm::{ aead::{Aead, KeyInit}, Aes256Gcm, Key, Nonce, }; -use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; use rand::RngExt; #[derive(Debug, Serialize, Deserialize, Clone)] @@ -50,6 +49,29 @@ pub struct AppSettings { pub mcp_port: Option, // Port for MCP server (default 51080) #[serde(default)] pub mcp_token: Option, // Displayed token for user to copy (not persisted, loaded from encrypted file) + /// Let Donut cloud drive this installation's MCP tools over an outbound + /// bridge, so an agent on the website can control this browser. + /// + /// Defaults to OFF and stays off until the user says otherwise. It opens a + /// long-lived socket to Donut cloud and hands the far end the ability to + /// launch and drive profiles, which is not something to switch on for + /// somebody by default because their plan happens to include it. + #[serde(default)] + pub mcp_remote_enabled: bool, + /// The durable `dmk_` credential agents present to the remote MCP endpoint. + /// + /// Plaintext, kept in an encrypted file with the same posture as + /// `mcp_token`: loaded into the struct for a frontend settings read (the fx + /// client cannot take the credential from its config file, so the page + /// offers the export line), and stripped by `save_settings` so the settings + /// JSON never carries it. Absent from the wire when there is none, so a + /// settings file written by an older build stays byte-for-byte unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mcp_remote_key: Option, + /// The server-side id of `mcp_remote_key`, so a rotation can revoke exactly + /// the key it replaces. Not a secret; lives in the settings JSON. + #[serde(default)] + pub mcp_remote_key_id: Option, #[serde(default)] pub language: Option, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ko", "ru", or None for system default #[serde(default)] @@ -71,6 +93,11 @@ pub struct AppSettings { /// copy is always re-encrypted regardless of this flag. #[serde(default)] pub keep_decrypted_profiles_in_ram: bool, + /// How long a deleted profile stays in the trash before it is purged. + /// Clamped to 1..=365 on save; the sweeper reads it through + /// `profile::trash::configured_retention_days`. + #[serde(default = "default_trash_retention_days")] + pub trash_retention_days: u32, } #[derive(Debug, Serialize, Deserialize, Clone, Default)] @@ -87,6 +114,10 @@ fn default_api_port() -> u16 { 10108 } +fn default_trash_retention_days() -> u32 { + crate::profile::trash::DEFAULT_RETENTION_DAYS +} + impl Default for AppSettings { fn default() -> Self { Self { @@ -102,6 +133,9 @@ impl Default for AppSettings { mcp_enabled: false, mcp_port: None, mcp_token: None, + mcp_remote_enabled: false, + mcp_remote_key: None, + mcp_remote_key_id: None, language: None, window_resize_warning_dismissed: false, fingerprint_gate_disabled: false, @@ -109,10 +143,20 @@ impl Default for AppSettings { onboarding_completed: false, disable_auto_updates: false, keep_decrypted_profiles_in_ram: false, + trash_retention_days: crate::profile::trash::DEFAULT_RETENTION_DAYS, } } } +/// The remote MCP credential as it is kept on this machine. +#[derive(Debug, Clone)] +pub struct StoredMcpRemoteKey { + /// The plaintext `dmk_` key. + pub key: String, + /// The server-side id, when the store that wrote the key also recorded it. + pub id: Option, +} + pub struct SettingsManager; impl SettingsManager { @@ -160,8 +204,15 @@ impl SettingsManager { let settings_dir = self.get_settings_dir(); create_dir_all(&settings_dir)?; + // The remote MCP credential works from anywhere on the internet and has + // its own encrypted file; a struct loaded for the frontend carries it, so + // it is dropped at the one place the JSON gets written rather than at + // every caller that happens to hold such a struct. + let mut on_disk = settings.clone(); + on_disk.mcp_remote_key = None; + let settings_file = self.get_settings_file(); - let json = serde_json::to_string_pretty(settings)?; + let json = serde_json::to_string_pretty(&on_disk)?; fs::write(settings_file, json)?; Ok(()) @@ -198,121 +249,74 @@ impl SettingsManager { env!("DONUT_BROWSER_VAULT_PASSWORD").to_string() } - pub async fn generate_api_token( - &self, - app_handle: &tauri::AppHandle, - ) -> Result> { - // Generate a secure random token (base64 encoded for URL safety) - let token_bytes: [u8; 32] = { - use rand::Rng; - let mut rng = rand::rng(); - let mut bytes = [0u8; 32]; - rng.fill_bytes(&mut bytes); - bytes - }; - use base64::{engine::general_purpose, Engine as _}; - let token = general_purpose::URL_SAFE_NO_PAD.encode(token_bytes); - - // Store token securely - self.store_api_token(app_handle, &token).await?; - - Ok(token) - } - - pub async fn store_api_token( - &self, - _app_handle: &tauri::AppHandle, - token: &str, + /// Encrypt `secret` into `file` under the vault password. + /// + /// One implementation for every secret this manager keeps on disk. The API, + /// MCP and sync tokens each carried their own copy of this routine, and the + /// remote MCP credential would have been the fourth; the file layout is the + /// same for all of them and only the five-byte header tells them apart. + fn encrypt_to_file( + file: &std::path::Path, + header: &[u8; 5], + secret: &str, ) -> Result<(), Box> { - // Store token in an encrypted file using Argon2 + AES-GCM - let token_file = self.get_settings_dir().join("api_token.dat"); - - // Create directory if it doesn't exist - if let Some(parent) = token_file.parent() { + if let Some(parent) = file.parent() { std::fs::create_dir_all(parent)?; } let vault_password = Self::get_vault_password(); - - // Generate a random salt for Argon2 let salt_bytes: [u8; 16] = rand::rng().random(); - let salt = - SaltString::encode_b64(&salt_bytes).map_err(|e| format!("Failed to encode salt: {e}"))?; - - // Use Argon2 to derive a 32-byte key from the vault password - let argon2 = Argon2::default(); - let password_hash = argon2 - .hash_password(vault_password.as_bytes(), &salt) - .map_err(|e| format!("Argon2 key derivation failed: {e}"))?; - let hash_value = password_hash.hash.unwrap(); - let hash_bytes = hash_value.as_bytes(); - - // Take first 32 bytes for AES-256 key - let key_bytes: [u8; 32] = hash_bytes[..32] - .try_into() - .map_err(|_| "Invalid key length")?; + let salt = crate::sync::encryption::encode_salt(&salt_bytes); + let key_bytes = + crate::sync::encryption::derive_vault_key(vault_password.as_bytes(), &salt_bytes)?; let key = Key::::from(key_bytes); let cipher = Aes256Gcm::new(&key); - - // Generate a random nonce let nonce_bytes: [u8; 12] = rand::rng().random(); let nonce = Nonce::from(nonce_bytes); - - // Encrypt the token let ciphertext = cipher - .encrypt(&nonce, token.as_bytes()) + .encrypt(&nonce, secret.as_bytes()) .map_err(|e| format!("Encryption failed: {e}"))?; - // Create file data with header, salt, nonce, and encrypted data let mut file_data = Vec::new(); - file_data.extend_from_slice(b"DBAPI"); // 5-byte header + file_data.extend_from_slice(header); file_data.push(2u8); // Version 2 (Argon2 + AES-GCM) - - // Store salt length and salt let salt_str = salt.as_str(); file_data.push(salt_str.len() as u8); file_data.extend_from_slice(salt_str.as_bytes()); - - // Store nonce (12 bytes for AES-GCM) file_data.extend_from_slice(&nonce); - - // Store ciphertext length and ciphertext file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes()); file_data.extend_from_slice(&ciphertext); - std::fs::write(&token_file, file_data)?; - crate::app_dirs::restrict_to_owner(std::path::Path::new(&token_file)); + std::fs::write(file, file_data)?; + crate::app_dirs::restrict_to_owner(file); Ok(()) } - pub async fn get_api_token( - &self, - _app_handle: &tauri::AppHandle, + /// Read back a secret written by `encrypt_to_file`. + /// + /// A missing file, a foreign header or a layout this version does not know + /// all read as "no secret" rather than an error, so a stale or damaged file + /// never blocks the feature it belongs to; the caller simply mints again. + fn decrypt_from_file( + file: &std::path::Path, + header: &[u8; 5], ) -> Result, Box> { - let token_file = self.get_settings_dir().join("api_token.dat"); - - if !token_file.exists() { + if !file.exists() { return Ok(None); } - let file_data = std::fs::read(token_file)?; + let file_data = std::fs::read(file)?; - // Validate header - if file_data.len() < 6 || &file_data[0..5] != b"DBAPI" { + if file_data.len() < 6 || &file_data[0..5] != header { return Ok(None); } let version = file_data[5]; - - // Only support Argon2 + AES-GCM (version 2) if version != 2 { return Ok(None); } - // Argon2 + AES-GCM decryption let mut offset = 6; - - // Read salt if offset >= file_data.len() { return Ok(None); } @@ -324,10 +328,9 @@ impl SettingsManager { } let salt_bytes = &file_data[offset..offset + salt_len]; let salt_str = std::str::from_utf8(salt_bytes).map_err(|_| "Invalid salt encoding")?; - let salt = SaltString::from_b64(salt_str).map_err(|_| "Invalid salt format")?; + let salt_bytes = crate::sync::encryption::decode_salt(salt_str)?; offset += salt_len; - // Read nonce (12 bytes) if offset + 12 > file_data.len() { return Ok(None); } @@ -337,7 +340,6 @@ impl SettingsManager { let nonce = Nonce::from(nonce_bytes); offset += 12; - // Read ciphertext if offset + 4 > file_data.len() { return Ok(None); } @@ -354,22 +356,11 @@ impl SettingsManager { } let ciphertext = &file_data[offset..offset + ciphertext_len]; - // Derive key using Argon2 let vault_password = Self::get_vault_password(); - let argon2 = Argon2::default(); - let password_hash = argon2 - .hash_password(vault_password.as_bytes(), &salt) - .map_err(|e| format!("Argon2 key derivation failed: {e}"))?; - let hash_value = password_hash.hash.unwrap(); - let hash_bytes = hash_value.as_bytes(); - - let key_bytes: [u8; 32] = hash_bytes[..32] - .try_into() - .map_err(|_| "Invalid key length")?; + let key_bytes = + crate::sync::encryption::derive_vault_key(vault_password.as_bytes(), &salt_bytes)?; let key = Key::::from(key_bytes); let cipher = Aes256Gcm::new(&key); - - // Decrypt the token let plaintext = cipher .decrypt(&nonce, ciphertext) .map_err(|_| "Decryption failed")?; @@ -380,23 +371,15 @@ impl SettingsManager { } } - pub async fn remove_api_token( - &self, - _app_handle: &tauri::AppHandle, - ) -> Result<(), Box> { - let token_file = self.get_settings_dir().join("api_token.dat"); - - if token_file.exists() { - std::fs::remove_file(token_file)?; + fn remove_secret_file(file: &std::path::Path) -> Result<(), Box> { + if file.exists() { + std::fs::remove_file(file)?; } - Ok(()) } - pub async fn generate_mcp_token( - &self, - app_handle: &tauri::AppHandle, - ) -> Result> { + /// A fresh 256-bit token, base64url so it is safe in a URL path. + fn random_token() -> String { let token_bytes: [u8; 32] = { use rand::Rng; let mut rng = rand::rng(); @@ -405,7 +388,61 @@ impl SettingsManager { bytes }; use base64::{engine::general_purpose, Engine as _}; - let token = general_purpose::URL_SAFE_NO_PAD.encode(token_bytes); + general_purpose::URL_SAFE_NO_PAD.encode(token_bytes) + } + + fn api_token_file(&self) -> PathBuf { + self.get_settings_dir().join("api_token.dat") + } + + fn mcp_token_file(&self) -> PathBuf { + self.get_settings_dir().join("mcp_token.dat") + } + + fn sync_token_file(&self) -> PathBuf { + self.get_settings_dir().join("sync_token.dat") + } + + fn mcp_remote_key_file(&self) -> PathBuf { + self.get_settings_dir().join("mcp_remote_key.dat") + } + + pub async fn generate_api_token( + &self, + app_handle: &tauri::AppHandle, + ) -> Result> { + let token = Self::random_token(); + self.store_api_token(app_handle, &token).await?; + Ok(token) + } + + pub async fn store_api_token( + &self, + _app_handle: &tauri::AppHandle, + token: &str, + ) -> Result<(), Box> { + Self::encrypt_to_file(&self.api_token_file(), b"DBAPI", token) + } + + pub async fn get_api_token( + &self, + _app_handle: &tauri::AppHandle, + ) -> Result, Box> { + Self::decrypt_from_file(&self.api_token_file(), b"DBAPI") + } + + pub async fn remove_api_token( + &self, + _app_handle: &tauri::AppHandle, + ) -> Result<(), Box> { + Self::remove_secret_file(&self.api_token_file()) + } + + pub async fn generate_mcp_token( + &self, + app_handle: &tauri::AppHandle, + ) -> Result> { + let token = Self::random_token(); self.store_mcp_token(app_handle, &token).await?; Ok(token) } @@ -415,142 +452,21 @@ impl SettingsManager { _app_handle: &tauri::AppHandle, token: &str, ) -> Result<(), Box> { - let token_file = self.get_settings_dir().join("mcp_token.dat"); - - if let Some(parent) = token_file.parent() { - std::fs::create_dir_all(parent)?; - } - - let vault_password = Self::get_vault_password(); - let salt_bytes: [u8; 16] = rand::rng().random(); - let salt = - SaltString::encode_b64(&salt_bytes).map_err(|e| format!("Failed to encode salt: {e}"))?; - let argon2 = Argon2::default(); - let password_hash = argon2 - .hash_password(vault_password.as_bytes(), &salt) - .map_err(|e| format!("Argon2 key derivation failed: {e}"))?; - let hash_value = password_hash.hash.unwrap(); - let hash_bytes = hash_value.as_bytes(); - let key_bytes: [u8; 32] = hash_bytes[..32] - .try_into() - .map_err(|_| "Invalid key length")?; - let key = Key::::from(key_bytes); - let cipher = Aes256Gcm::new(&key); - let nonce_bytes: [u8; 12] = rand::rng().random(); - let nonce = Nonce::from(nonce_bytes); - let ciphertext = cipher - .encrypt(&nonce, token.as_bytes()) - .map_err(|e| format!("Encryption failed: {e}"))?; - - let mut file_data = Vec::new(); - file_data.extend_from_slice(b"DBMCP"); // 5-byte header for MCP token - file_data.push(2u8); // Version 2 (Argon2 + AES-GCM) - let salt_str = salt.as_str(); - file_data.push(salt_str.len() as u8); - file_data.extend_from_slice(salt_str.as_bytes()); - file_data.extend_from_slice(&nonce); - file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes()); - file_data.extend_from_slice(&ciphertext); - - std::fs::write(&token_file, file_data)?; - crate::app_dirs::restrict_to_owner(std::path::Path::new(&token_file)); - Ok(()) + Self::encrypt_to_file(&self.mcp_token_file(), b"DBMCP", token) } pub async fn get_mcp_token( &self, _app_handle: &tauri::AppHandle, ) -> Result, Box> { - let token_file = self.get_settings_dir().join("mcp_token.dat"); - - if !token_file.exists() { - return Ok(None); - } - - let file_data = std::fs::read(token_file)?; - - if file_data.len() < 6 || &file_data[0..5] != b"DBMCP" { - return Ok(None); - } - - let version = file_data[5]; - if version != 2 { - return Ok(None); - } - - let mut offset = 6; - if offset >= file_data.len() { - return Ok(None); - } - let salt_len = file_data[offset] as usize; - offset += 1; - - if offset + salt_len > file_data.len() { - return Ok(None); - } - let salt_bytes = &file_data[offset..offset + salt_len]; - let salt_str = std::str::from_utf8(salt_bytes).map_err(|_| "Invalid salt encoding")?; - let salt = SaltString::from_b64(salt_str).map_err(|_| "Invalid salt format")?; - offset += salt_len; - - if offset + 12 > file_data.len() { - return Ok(None); - } - let nonce_bytes: [u8; 12] = file_data[offset..offset + 12] - .try_into() - .map_err(|_| "Invalid nonce length")?; - let nonce = Nonce::from(nonce_bytes); - offset += 12; - - if offset + 4 > file_data.len() { - return Ok(None); - } - let ciphertext_len = u32::from_le_bytes([ - file_data[offset], - file_data[offset + 1], - file_data[offset + 2], - file_data[offset + 3], - ]) as usize; - offset += 4; - - if offset + ciphertext_len > file_data.len() { - return Ok(None); - } - let ciphertext = &file_data[offset..offset + ciphertext_len]; - - let vault_password = Self::get_vault_password(); - let argon2 = Argon2::default(); - let password_hash = argon2 - .hash_password(vault_password.as_bytes(), &salt) - .map_err(|e| format!("Argon2 key derivation failed: {e}"))?; - let hash_value = password_hash.hash.unwrap(); - let hash_bytes = hash_value.as_bytes(); - let key_bytes: [u8; 32] = hash_bytes[..32] - .try_into() - .map_err(|_| "Invalid key length")?; - let key = Key::::from(key_bytes); - let cipher = Aes256Gcm::new(&key); - let plaintext = cipher - .decrypt(&nonce, ciphertext) - .map_err(|_| "Decryption failed")?; - - match String::from_utf8(plaintext) { - Ok(token) => Ok(Some(token)), - Err(_) => Ok(None), - } + Self::decrypt_from_file(&self.mcp_token_file(), b"DBMCP") } pub async fn remove_mcp_token( &self, _app_handle: &tauri::AppHandle, ) -> Result<(), Box> { - let token_file = self.get_settings_dir().join("mcp_token.dat"); - - if token_file.exists() { - std::fs::remove_file(token_file)?; - } - - Ok(()) + Self::remove_secret_file(&self.mcp_token_file()) } pub async fn store_sync_token( @@ -558,141 +474,66 @@ impl SettingsManager { _app_handle: &tauri::AppHandle, token: &str, ) -> Result<(), Box> { - let token_file = self.get_settings_dir().join("sync_token.dat"); - - if let Some(parent) = token_file.parent() { - std::fs::create_dir_all(parent)?; - } - - let vault_password = Self::get_vault_password(); - let salt_bytes: [u8; 16] = rand::rng().random(); - let salt = - SaltString::encode_b64(&salt_bytes).map_err(|e| format!("Failed to encode salt: {e}"))?; - let argon2 = Argon2::default(); - let password_hash = argon2 - .hash_password(vault_password.as_bytes(), &salt) - .map_err(|e| format!("Argon2 key derivation failed: {e}"))?; - let hash_value = password_hash.hash.unwrap(); - let hash_bytes = hash_value.as_bytes(); - let key_bytes: [u8; 32] = hash_bytes[..32] - .try_into() - .map_err(|_| "Invalid key length")?; - let key = Key::::from(key_bytes); - let cipher = Aes256Gcm::new(&key); - let nonce_bytes: [u8; 12] = rand::rng().random(); - let nonce = Nonce::from(nonce_bytes); - let ciphertext = cipher - .encrypt(&nonce, token.as_bytes()) - .map_err(|e| format!("Encryption failed: {e}"))?; - - let mut file_data = Vec::new(); - file_data.extend_from_slice(b"DBSYN"); // 5-byte header for sync - file_data.push(2u8); // Version 2 (Argon2 + AES-GCM) - let salt_str = salt.as_str(); - file_data.push(salt_str.len() as u8); - file_data.extend_from_slice(salt_str.as_bytes()); - file_data.extend_from_slice(&nonce); - file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes()); - file_data.extend_from_slice(&ciphertext); - - std::fs::write(&token_file, file_data)?; - crate::app_dirs::restrict_to_owner(std::path::Path::new(&token_file)); - Ok(()) + Self::encrypt_to_file(&self.sync_token_file(), b"DBSYN", token) } pub async fn get_sync_token( &self, _app_handle: &tauri::AppHandle, ) -> Result, Box> { - let token_file = self.get_settings_dir().join("sync_token.dat"); - - if !token_file.exists() { - return Ok(None); - } - - let file_data = std::fs::read(token_file)?; - - if file_data.len() < 6 || &file_data[0..5] != b"DBSYN" { - return Ok(None); - } - - let version = file_data[5]; - if version != 2 { - return Ok(None); - } - - let mut offset = 6; - if offset >= file_data.len() { - return Ok(None); - } - let salt_len = file_data[offset] as usize; - offset += 1; - - if offset + salt_len > file_data.len() { - return Ok(None); - } - let salt_bytes = &file_data[offset..offset + salt_len]; - let salt_str = std::str::from_utf8(salt_bytes).map_err(|_| "Invalid salt encoding")?; - let salt = SaltString::from_b64(salt_str).map_err(|_| "Invalid salt format")?; - offset += salt_len; - - if offset + 12 > file_data.len() { - return Ok(None); - } - let nonce_bytes: [u8; 12] = file_data[offset..offset + 12] - .try_into() - .map_err(|_| "Invalid nonce length")?; - let nonce = Nonce::from(nonce_bytes); - offset += 12; - - if offset + 4 > file_data.len() { - return Ok(None); - } - let ciphertext_len = u32::from_le_bytes([ - file_data[offset], - file_data[offset + 1], - file_data[offset + 2], - file_data[offset + 3], - ]) as usize; - offset += 4; - - if offset + ciphertext_len > file_data.len() { - return Ok(None); - } - let ciphertext = &file_data[offset..offset + ciphertext_len]; - - let vault_password = Self::get_vault_password(); - let argon2 = Argon2::default(); - let password_hash = argon2 - .hash_password(vault_password.as_bytes(), &salt) - .map_err(|e| format!("Argon2 key derivation failed: {e}"))?; - let hash_value = password_hash.hash.unwrap(); - let hash_bytes = hash_value.as_bytes(); - let key_bytes: [u8; 32] = hash_bytes[..32] - .try_into() - .map_err(|_| "Invalid key length")?; - let key = Key::::from(key_bytes); - let cipher = Aes256Gcm::new(&key); - let plaintext = cipher - .decrypt(&nonce, ciphertext) - .map_err(|_| "Decryption failed")?; - - match String::from_utf8(plaintext) { - Ok(token) => Ok(Some(token)), - Err(_) => Ok(None), - } + Self::decrypt_from_file(&self.sync_token_file(), b"DBSYN") } pub async fn remove_sync_token( &self, _app_handle: &tauri::AppHandle, ) -> Result<(), Box> { - let token_file = self.get_settings_dir().join("sync_token.dat"); + Self::remove_secret_file(&self.sync_token_file()) + } - if token_file.exists() { - std::fs::remove_file(token_file)?; + /// Keep the remote MCP credential: the `dmk_` key in its own encrypted file + /// and the server-side key id in the settings JSON, so a later rotation can + /// name the key it is retiring. + /// + /// The plaintext is deliberately NOT part of the settings JSON: + /// `save_settings` strips it, and `get_app_settings` is the one reader that + /// loads it back for the frontend, the way the local display tokens are. + pub fn store_mcp_remote_key( + &self, + key: &str, + key_id: &str, + ) -> Result<(), Box> { + Self::encrypt_to_file(&self.mcp_remote_key_file(), b"DBMRK", key)?; + let mut settings = self.load_settings()?; + settings.mcp_remote_key_id = Some(key_id.to_string()); + self.save_settings(&settings) + } + + /// The stored remote MCP credential, if any: the plaintext key and the id + /// the server knows it by. + /// + /// Read with the id from the JSON and the key from its file, so the two + /// cannot disagree: a key file without an id (an interrupted store) still + /// yields the key, and an id without a key file yields nothing at all. + pub fn get_mcp_remote_key( + &self, + ) -> Result, Box> { + let Some(key) = Self::decrypt_from_file(&self.mcp_remote_key_file(), b"DBMRK")? else { + return Ok(None); + }; + let id = self.load_settings()?.mcp_remote_key_id; + Ok(Some(StoredMcpRemoteKey { key, id })) + } + + /// Drop the remote MCP credential from this machine. Does not revoke it: + /// that is the caller's job, because only the caller knows whether it still + /// has a session to revoke with. + pub fn remove_mcp_remote_key(&self) -> Result<(), Box> { + Self::remove_secret_file(&self.mcp_remote_key_file())?; + let mut settings = self.load_settings()?; + if settings.mcp_remote_key_id.take().is_some() { + self.save_settings(&settings)?; } - Ok(()) } @@ -732,6 +573,13 @@ pub async fn get_app_settings(app_handle: tauri::AppHandle) -> Result Result { let manager = SettingsManager::instance(); + // The remote MCP credential is minted by `rotate_mcp_remote_credential` and + // by nothing else. A settings read hands the frontend the plaintext (for the + // fx export line) and the frontend echoes the whole struct back, so the + // field is simply not the frontend's to write: whatever arrived is dropped + // here and the stored key is what the answer below carries. + settings.mcp_remote_key = None; + // Handle API token if settings.api_enabled { if let Some(ref token) = settings.api_token { @@ -790,6 +645,23 @@ pub async fn save_app_settings( .await .map_err(|e| format!("Failed to generate MCP token: {e}"))?; settings.mcp_token = Some(token); + // A running local server now answers on a URL the installed clients + // do not know, so they are rewritten. With the server off there is no + // URL to write yet; `McpServer::start` does this when it comes up. + if crate::mcp_server::McpServer::instance() + .get_port() + .is_some() + { + let failed = + crate::reinstall_mcp_agents(&app_handle, crate::mcp_integrations::McpEndpoint::Local) + .await; + if !failed.is_empty() { + log::warn!( + "[settings] Could not refresh the clients pointing at the local server: {}", + failed.join(", ") + ); + } + } } } } @@ -802,14 +674,30 @@ pub async fn save_app_settings( settings.mcp_token = None; } - // Preserve server-managed flags that the frontend may not have up-to-date. - // Read directly from file to avoid load_settings' save-on-load behavior. + // Preserve the fields the frontend does not own. Read directly from the + // file to avoid load_settings' save-on-load behavior. + // + // `mcp_remote_enabled` is flipped ONLY by `start_mcp_remote_bridge` and + // `stop_mcp_remote_bridge`, which also start and stop the bridge task. A + // settings save that carried the flag could switch the internet-facing + // bridge on for the next launch without ever going through the sign-in and + // terms gates those commands enforce, or switch it off on disk while the + // task kept running. The key id is bookkeeping for the rotation path and is + // never the frontend's to write. if let Ok(content) = std::fs::read_to_string(manager.get_settings_file()) { if let Ok(current) = serde_json::from_str::(&content) { settings.window_resize_warning_dismissed = current.window_resize_warning_dismissed; + settings.mcp_remote_enabled = current.mcp_remote_enabled; + settings.mcp_remote_key_id = current.mcp_remote_key_id; } + } else { + settings.mcp_remote_enabled = false; + settings.mcp_remote_key_id = None; } + settings.trash_retention_days = + crate::profile::trash::clamp_retention_days(settings.trash_retention_days); + let mut persist_settings = settings.clone(); persist_settings.api_token = None; persist_settings.mcp_token = None; @@ -828,6 +716,14 @@ pub async fn save_app_settings( .save_settings(&persist_settings) .map_err(|e| format!("Failed to save settings: {e}"))?; + // Answer with what a fresh read would show, the stored credential included, + // so a page that keeps the answer as its settings does not lose the fx + // export line on every save. + settings.mcp_remote_key = manager + .get_mcp_remote_key() + .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))? + .map(|stored| stored.key); + Ok(settings) } @@ -1198,6 +1094,9 @@ mod tests { mcp_enabled: false, mcp_port: None, mcp_token: None, + mcp_remote_enabled: false, + mcp_remote_key: None, + mcp_remote_key_id: None, language: None, window_resize_warning_dismissed: false, fingerprint_gate_disabled: false, @@ -1205,6 +1104,7 @@ mod tests { onboarding_completed: false, disable_auto_updates: false, keep_decrypted_profiles_in_ram: false, + trash_retention_days: 14, }; let save_result = manager.save_settings(&test_settings); @@ -1222,6 +1122,84 @@ mod tests { loaded_settings.theme, "dark", "Loaded theme should match saved" ); + assert_eq!(loaded_settings.trash_retention_days, 14); + } + + #[test] + fn trash_retention_defaults_when_the_settings_file_predates_it() { + let (manager, _temp_dir, _guard) = create_test_settings_manager(); + let settings_dir = manager.get_settings_dir(); + create_dir_all(&settings_dir).unwrap(); + fs::write(manager.get_settings_file(), r#"{"theme":"light"}"#).unwrap(); + + let loaded = manager.load_settings().unwrap(); + assert_eq!(loaded.theme, "light"); + assert_eq!( + loaded.trash_retention_days, + crate::profile::trash::DEFAULT_RETENTION_DAYS + ); + } + + #[test] + fn the_remote_key_round_trips_and_never_reaches_the_settings_json() { + let (manager, _temp_dir, _guard) = create_test_settings_manager(); + + assert!(manager.get_mcp_remote_key().unwrap().is_none()); + + manager + .store_mcp_remote_key("dmk_abcdefghijklmnop", "key-1") + .unwrap(); + let stored = manager.get_mcp_remote_key().unwrap().expect("stored"); + assert_eq!(stored.key, "dmk_abcdefghijklmnop"); + assert_eq!(stored.id.as_deref(), Some("key-1")); + + // The id is bookkeeping and belongs in the JSON; the key is a credential + // that works from anywhere on the internet and must not. + let json = std::fs::read_to_string(manager.get_settings_file()).unwrap(); + assert!(json.contains("\"mcp_remote_key_id\": \"key-1\""), "{json}"); + assert!(!json.contains("dmk_abcdefghijklmnop"), "{json}"); + assert!(!json.contains("\"mcp_remote_key\""), "{json}"); + + // A struct loaded for the frontend carries the plaintext (the fx export + // line needs it), so the write path is what keeps it off the disk: saving + // such a struct must not plant the key in the JSON. + let mut settings = manager.load_settings().unwrap(); + settings.mcp_remote_key = Some("dmk_abcdefghijklmnop".to_string()); + manager.save_settings(&settings).unwrap(); + let json = std::fs::read_to_string(manager.get_settings_file()).unwrap(); + assert!(!json.contains("dmk_"), "{json}"); + assert!(!json.contains("\"mcp_remote_key\""), "{json}"); + assert_eq!( + manager + .load_settings() + .unwrap() + .mcp_remote_key_id + .as_deref(), + Some("key-1") + ); + + manager.remove_mcp_remote_key().unwrap(); + assert!(manager.get_mcp_remote_key().unwrap().is_none()); + assert!(manager.load_settings().unwrap().mcp_remote_key_id.is_none()); + } + + #[test] + fn a_key_file_without_an_id_still_yields_the_key() { + // An interrupted store, or a settings file rewritten by an older build + // that did not know the field: the credential is still on disk and still + // valid, so it must still be usable. Only the id is missing, and the + // rotation path treats a missing id as "nothing to revoke". + let (manager, _temp_dir, _guard) = create_test_settings_manager(); + manager + .store_mcp_remote_key("dmk_zzzzzzzzzzzz", "key-2") + .unwrap(); + let mut settings = manager.load_settings().unwrap(); + settings.mcp_remote_key_id = None; + manager.save_settings(&settings).unwrap(); + + let stored = manager.get_mcp_remote_key().unwrap().expect("stored"); + assert_eq!(stored.key, "dmk_zzzzzzzzzzzz"); + assert!(stored.id.is_none()); } #[test] diff --git a/src-tauri/src/socks5_local.rs b/src-tauri/src/socks5_local.rs index 2311846..488f919 100644 --- a/src-tauri/src/socks5_local.rs +++ b/src-tauri/src/socks5_local.rs @@ -188,7 +188,8 @@ fn udp_mode(upstream_url: Option<&str>) -> UdpMode { Some("DIRECT") => UdpMode::Direct, Some(url) => match Url::parse(url).ok().map(|u| u.scheme().to_lowercase()) { Some(scheme) if scheme == "socks5" => UdpMode::Socks5Upstream, - // http / https / socks4 / ss / shadowsocks / anything else: TCP-only. + // http / https / httpstls / socks4 / ss / shadowsocks / anything else: + // TCP-only. An HTTP CONNECT upstream carries no UDP, TLS-wrapped or not. _ => UdpMode::Refuse, }, } diff --git a/src-tauri/src/sync/encryption.rs b/src-tauri/src/sync/encryption.rs index 6c3d700..e2e7dc0 100644 --- a/src-tauri/src/sync/encryption.rs +++ b/src-tauri/src/sync/encryption.rs @@ -2,8 +2,42 @@ use aes_gcm::{ aead::{Aead, KeyInit}, Aes256Gcm, Key, }; -use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use argon2::Argon2; +use base64::{ + engine::general_purpose::{STANDARD as BASE64, STANDARD_NO_PAD as SALT_B64}, + Engine, +}; + +/// Derive a 32-byte AES key from a password and a raw salt with Argon2id at +/// the crate's default parameters (m=19456 KiB, t=2, p=1, 32-byte output). +/// +/// ONE function for every vault in the app, so the parameters can never drift +/// between the sync, settings and cloud-auth stores. Byte-compatible with the +/// PHC-string path used before argon2 0.6: that path hashed the DECODED salt +/// with the same defaults and the key was its 32-byte output, which is exactly +/// what `hash_password_into` produces here. A different parameter set would +/// silently lock every user out of their encrypted data, so the defaults are +/// pinned by the test below rather than trusted. +pub fn derive_vault_key(password: &[u8], salt: &[u8]) -> Result<[u8; 32], String> { + let mut key = [0u8; 32]; + Argon2::default() + .hash_password_into(password, salt, &mut key) + .map_err(|e| format!("Argon2 key derivation failed: {e}"))?; + Ok(key) +} + +/// The on-disk salt encoding: PHC "B64", the standard alphabet with no +/// padding, exactly what the retired `SaltString` wrote, so files written by +/// earlier builds decode unchanged. +pub fn encode_salt(salt: &[u8]) -> String { + SALT_B64.encode(salt) +} + +pub fn decode_salt(salt: &str) -> Result, String> { + SALT_B64 + .decode(salt) + .map_err(|e| format!("Invalid salt: {e}")) +} use rand::RngExt; use std::collections::HashMap; use std::sync::Mutex; @@ -57,18 +91,8 @@ pub fn store_e2e_password(password: &str) -> Result<(), String> { let vault_password = get_vault_password(); let salt_bytes: [u8; 16] = rand::rng().random(); - let salt = - SaltString::encode_b64(&salt_bytes).map_err(|e| format!("Failed to encode salt: {e}"))?; - let argon2 = Argon2::default(); - let password_hash = argon2 - .hash_password(vault_password.as_bytes(), &salt) - .map_err(|e| format!("Argon2 key derivation failed: {e}"))?; - let hash_value = password_hash.hash.unwrap(); - let hash_bytes = hash_value.as_bytes(); - - let key_bytes: [u8; 32] = hash_bytes[..32] - .try_into() - .map_err(|_| "Invalid key length")?; + let salt = encode_salt(&salt_bytes); + let key_bytes = derive_vault_key(vault_password.as_bytes(), &salt_bytes)?; let key = Key::::from(key_bytes); let cipher = Aes256Gcm::new(&key); let nonce_bytes: [u8; 12] = rand::rng().random(); @@ -133,7 +157,7 @@ pub fn load_e2e_password() -> Result, String> { .map_err(|_| "Invalid salt encoding")?; offset += salt_len; - let salt = SaltString::from_b64(salt_str).map_err(|e| format!("Invalid salt: {e}"))?; + let salt_bytes = decode_salt(salt_str)?; if offset + 12 > file_data.len() { return Ok(None); @@ -157,16 +181,7 @@ pub fn load_e2e_password() -> Result, String> { let ciphertext = &file_data[offset..offset + ciphertext_len]; let vault_password = get_vault_password(); - let argon2 = Argon2::default(); - let password_hash = argon2 - .hash_password(vault_password.as_bytes(), &salt) - .map_err(|e| format!("Argon2 key derivation failed: {e}"))?; - let hash_value = password_hash.hash.unwrap(); - let hash_bytes = hash_value.as_bytes(); - - let key_bytes: [u8; 32] = hash_bytes[..32] - .try_into() - .map_err(|_| "Invalid key length")?; + let key_bytes = derive_vault_key(vault_password.as_bytes(), &salt_bytes)?; let key = Key::::from(key_bytes); let cipher = Aes256Gcm::new(&key); @@ -212,18 +227,7 @@ pub fn derive_profile_key(user_password: &str, profile_salt: &str) -> Result<[u8 .decode(profile_salt) .map_err(|e| format!("Invalid salt encoding: {e}"))?; - let salt = SaltString::encode_b64(&salt_bytes) - .map_err(|e| format!("Failed to create salt string: {e}"))?; - - let argon2 = Argon2::default(); - let password_hash = argon2 - .hash_password(user_password.as_bytes(), &salt) - .map_err(|e| format!("Key derivation failed: {e}"))?; - let hash_value = password_hash.hash.unwrap(); - let hash_bytes = hash_value.as_bytes(); - - let mut key = [0u8; 32]; - key.copy_from_slice(&hash_bytes[..32]); + let key = derive_vault_key(user_password.as_bytes(), &salt_bytes)?; if let Ok(mut cache) = KEY_CACHE.lock() { cache.insert(cache_key, key); @@ -364,12 +368,12 @@ pub async fn delete_e2e_password() -> Result<(), String> { remove_e2e_password() } -/// On Team plans, only the team owner is allowed to flip the E2E password -/// state — otherwise members could lock each other out by changing the key. +/// Only the team owner may flip the E2E password state — otherwise members +/// could lock each other out by changing the key. async fn enforce_team_owner_for_encryption_change() -> Result<(), String> { use crate::cloud_auth::CLOUD_AUTH; if let Some(state) = CLOUD_AUTH.get_user().await { - if state.user.plan == "team" && state.user.team_role.as_deref() != Some("owner") { + if state.user.effective_plan() == "team" && state.user.team_role.as_deref() != Some("owner") { return Err("TEAM_OWNER_ONLY".to_string()); } } @@ -485,3 +489,30 @@ mod tests { assert!(decrypt_bytes(&key, &[0u8; 5]).is_err()); } } + +#[cfg(test)] +mod vault_key_tests { + use super::{decode_salt, derive_vault_key, encode_salt}; + + /// A stored vault is only readable while this vector holds. It pins the + /// Argon2id parameters and the salt encoding together: a dependency bump + /// that changed either would fail here instead of at the user's data. + #[test] + fn vault_key_derivation_is_pinned() { + let key = derive_vault_key(b"correct horse battery staple", &[7u8; 16]).unwrap(); + let hex: String = key.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!( + hex, + "799f12b9e17710824482d829835acb69f5a9355bf774c4f07342823b11b90928" + ); + } + + #[test] + fn salt_encoding_round_trips_without_padding() { + let salt = [0u8, 1, 2, 3, 250, 251, 252, 253, 254, 255, 9, 8, 7, 6, 5, 4]; + let encoded = encode_salt(&salt); + assert!(!encoded.contains('='), "PHC B64 carries no padding"); + assert_eq!(decode_salt(&encoded).unwrap(), salt); + assert!(decode_salt("not*valid").is_err()); + } +} diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index a2d4099..269fb67 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -192,6 +192,31 @@ fn is_safe_manifest_path(path: &str) -> bool { .all(|c| matches!(c, Component::Normal(_) | Component::CurDir)) } +/// Parse an S3 `lastModified` (RFC3339) into unix seconds. +fn rfc3339_secs(value: &str) -> Option { + DateTime::parse_from_rfc3339(value) + .ok() + .and_then(|dt| u64::try_from(dt.timestamp()).ok()) +} + +/// Whether a config entity's tombstone must stop this reconcile, given when the +/// tombstone was written and the local entity's own last edit (0 once the local +/// copy is gone). +/// +/// A tombstone at least as new as the local edit wins. That keeps last-write-wins +/// intact: an edit made strictly after the delete still uploads. A tombstone +/// with no readable write time is treated as newer, because a skipped sync is +/// retried and a resurrection is not self-correcting. +fn tombstone_outranks_local(exists: bool, written_at: Option, local_updated_at: u64) -> bool { + if !exists { + return false; + } + match written_at { + Some(secs) => secs >= local_updated_at, + None => true, + } +} + /// Checkpoint all SQLite WAL files in a profile directory. /// /// When a browser crashes or is killed, SQLite WAL files may contain @@ -395,9 +420,9 @@ impl SyncProgressTracker { /// Check if sync is configured (cloud or self-hosted) pub fn is_sync_configured() -> bool { - // Cloud backup is a plan capability. Every paid plan (incl. the future - // "solo" tier) grants it, but gating on the capability — not just "is paid" - // — keeps this correct if a plan without cloud backup is ever added. + // Cloud backup is a plan capability. Gating on the capability — not just + // "is paid" — keeps this correct if a plan without cloud backup is ever + // added. if crate::cloud_auth::CLOUD_AUTH.can_use_cloud_backup_sync() { return true; } @@ -528,6 +553,46 @@ impl SyncEngine { Ok(()) } + /// Whether a remote tombstone forbids syncing this config entity right now. + /// + /// Every `sync_X` reconciles local against remote by presence alone, so + /// without this a device that has not yet drained its tombstone queue + /// re-uploads an entity another device just deleted, then downloads its own + /// resurrection back once the drain removes the local copy. The delete never + /// sticks on either side. + /// + /// A failed stat is propagated, not swallowed, so the pass is reported as + /// failed rather than as a silent success. A caller that cannot propagate + /// treats the error as blocking: a skipped pass is retried, a resurrection is + /// not self-correcting. + async fn tombstone_blocks( + &self, + kind: &str, + id: &str, + local_updated_at: u64, + ) -> SyncResult { + let tombstone_key = format!("tombstones/{}/{}.json", kind, id); + let stat = match self.client.stat(&tombstone_key).await { + Ok(stat) => stat, + Err(e) => { + log::warn!( + "Could not check {} before syncing {} {}; skipping this pass: {}", + tombstone_key, + kind, + id, + e + ); + return Err(e); + } + }; + let written_at = stat.last_modified.as_deref().and_then(rfc3339_secs); + let blocked = tombstone_outranks_local(stat.exists, written_at, local_updated_at); + if blocked { + log::info!("Skipping sync of {} {}: deleted remotely", kind, id); + } + Ok(blocked) + } + pub async fn sync_profile( &self, app_handle: &tauri::AppHandle, @@ -551,6 +616,18 @@ impl SyncEngine { app_handle: &tauri::AppHandle, profile: &BrowserProfile, bias: DiffBias, + ) -> SyncResult { + self + .sync_profile_inner(app_handle, profile, bias, false) + .await + } + + async fn sync_profile_inner( + &self, + app_handle: &tauri::AppHandle, + profile: &BrowserProfile, + bias: DiffBias, + reencrypt: bool, ) -> SyncResult { if profile.is_cross_os() { log::info!( @@ -694,14 +771,21 @@ impl SyncEngine { // Try to download remote manifest let remote_manifest_key = format!("{}profiles/{}/manifest.json", key_prefix, profile_id); - let remote_manifest = self - .download_manifest(&remote_manifest_key, encryption_key.as_ref()) - .await?; + let remote_manifest = if reencrypt { + // Compare against an empty manifest locally so every file is rewritten. + // Deleting it remotely would let a device with the previous password + // race this upload and recreate a manifest we can no longer decrypt. + None + } else { + self + .download_manifest(&remote_manifest_key, encryption_key.as_ref()) + .await? + }; // Compute diff let diff = compute_diff_with_bias(&local_manifest, remote_manifest.as_ref(), bias); - if diff.is_empty() { + if diff.is_empty() && !reencrypt { log::info!("Profile {} is already in sync", profile_id); let _ = events::emit( "profile-sync-status", @@ -1641,18 +1725,25 @@ impl SyncEngine { let proxies = proxy_manager.get_stored_proxies(); let local_proxy = proxies.iter().find(|p| p.id == proxy_id).cloned(); + let local_updated_at = local_proxy.as_ref().and_then(|p| p.updated_at).unwrap_or(0); + if self + .tombstone_blocks("proxies", proxy_id, local_updated_at) + .await? + { + return Ok(()); + } + let remote_key = format!("proxies/{}.json", proxy_id); let stat = self.client.stat(&remote_key).await?; match (local_proxy, stat.exists) { (Some(proxy), true) => { // Both exist - resolve by user-edit timestamp (last-write-wins). - let local_updated = proxy.updated_at.unwrap_or(0); let remote_updated = self.remote_updated_at(&stat, &remote_key).await; - if remote_updated > local_updated { + if remote_updated > local_updated_at { self.download_proxy(proxy_id, app_handle).await?; - } else if local_updated > remote_updated { + } else if local_updated_at > remote_updated { self.upload_proxy(&proxy).await?; } } @@ -1780,18 +1871,25 @@ impl SyncEngine { groups.into_iter().find(|g| g.id == group_id) }; + let local_updated_at = local_group.as_ref().and_then(|g| g.updated_at).unwrap_or(0); + if self + .tombstone_blocks("groups", group_id, local_updated_at) + .await? + { + return Ok(()); + } + let remote_key = format!("groups/{}.json", group_id); let stat = self.client.stat(&remote_key).await?; match (local_group, stat.exists) { (Some(group), true) => { // Both exist - resolve by user-edit timestamp (last-write-wins). - let local_updated = group.updated_at.unwrap_or(0); let remote_updated = self.remote_updated_at(&stat, &remote_key).await; - if remote_updated > local_updated { + if remote_updated > local_updated_at { self.download_group(group_id, app_handle).await?; - } else if local_updated > remote_updated { + } else if local_updated_at > remote_updated { self.upload_group(&group).await?; } } @@ -1980,18 +2078,25 @@ impl SyncEngine { storage.load_config(vpn_id).ok() }; + let local_updated_at = local_vpn.as_ref().and_then(|v| v.updated_at).unwrap_or(0); + if self + .tombstone_blocks("vpns", vpn_id, local_updated_at) + .await? + { + return Ok(()); + } + let remote_key = format!("vpns/{}.json", vpn_id); let stat = self.client.stat(&remote_key).await?; match (local_vpn, stat.exists) { (Some(vpn), true) => { // Both exist - resolve by user-edit timestamp (last-write-wins). - let local_updated = vpn.updated_at.unwrap_or(0); let remote_updated = self.remote_updated_at(&stat, &remote_key).await; - if remote_updated > local_updated { + if remote_updated > local_updated_at { self.download_vpn(vpn_id, app_handle).await?; - } else if local_updated > remote_updated { + } else if local_updated_at > remote_updated { self.upload_vpn(&vpn).await?; } } @@ -2053,6 +2158,12 @@ impl SyncEngine { let mut vpn: crate::vpn::VpnConfig = serde_json::from_slice(&data) .map_err(|e| SyncError::SerializationError(format!("Failed to parse VPN JSON: {e}")))?; + // Sync is not a second way in for a config the import path refuses: a + // multi-peer file resolves to whichever peer is listed last at connect + // time, which is not the tunnel its author described. + crate::vpn::VpnStorage::ensure_single_peer(vpn.vpn_type, &vpn.config_data) + .map_err(|e| SyncError::InvalidData(format!("Rejected synced VPN {vpn_id}: {e}")))?; + vpn.last_sync = Some( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -2125,18 +2236,25 @@ impl SyncEngine { return Ok(()); } + let local_updated_at = local_ext.as_ref().map_or(0, |e| e.updated_at); + if self + .tombstone_blocks("extensions", ext_id, local_updated_at) + .await? + { + return Ok(()); + } + let remote_key = format!("extensions/{}.json", ext_id); let stat = self.client.stat(&remote_key).await?; match (local_ext, stat.exists) { (Some(ext), true) => { // Both exist - resolve by user-edit timestamp (last-write-wins). - let local_updated = ext.updated_at; let remote_updated = self.remote_updated_at(&stat, &remote_key).await; - if remote_updated > local_updated { + if remote_updated > local_updated_at { self.download_extension(ext_id, app_handle).await?; - } else if local_updated > remote_updated { + } else if local_updated_at > remote_updated { self.upload_extension(&ext).await?; } } @@ -2317,18 +2435,25 @@ impl SyncEngine { manager.get_group(group_id).ok() }; + let local_updated_at = local_group.as_ref().map_or(0, |g| g.updated_at); + if self + .tombstone_blocks("extension_groups", group_id, local_updated_at) + .await? + { + return Ok(()); + } + let remote_key = format!("extension_groups/{}.json", group_id); let stat = self.client.stat(&remote_key).await?; match (local_group, stat.exists) { (Some(group), true) => { // Both exist - resolve by user-edit timestamp (last-write-wins). - let local_updated = group.updated_at; let remote_updated = self.remote_updated_at(&stat, &remote_key).await; - if remote_updated > local_updated { + if remote_updated > local_updated_at { self.download_extension_group(group_id, app_handle).await?; - } else if local_updated > remote_updated { + } else if local_updated_at > remote_updated { self.upload_extension_group(&group).await?; } } @@ -2980,11 +3105,12 @@ impl SyncEngine { .iter() .any(|p| p.id == proxy_id); if !exists_locally { - let tombstone_key = format!("tombstones/proxies/{}.json", proxy_id); - if let Ok(stat) = self.client.stat(&tombstone_key).await { - if stat.exists { - continue; - } + if self + .tombstone_blocks("proxies", proxy_id, 0) + .await + .unwrap_or(true) + { + continue; } log::info!( "Proxy {} exists remotely but not locally, downloading...", @@ -3014,11 +3140,12 @@ impl SyncEngine { .any(|g| g.id == group_id) }; if !exists_locally { - let tombstone_key = format!("tombstones/groups/{}.json", group_id); - if let Ok(stat) = self.client.stat(&tombstone_key).await { - if stat.exists { - continue; - } + if self + .tombstone_blocks("groups", group_id, 0) + .await + .unwrap_or(true) + { + continue; } log::info!( "Group {} exists remotely but not locally, downloading...", @@ -3044,11 +3171,12 @@ impl SyncEngine { storage.load_config(vpn_id).is_ok() }; if !exists_locally { - let tombstone_key = format!("tombstones/vpns/{}.json", vpn_id); - if let Ok(stat) = self.client.stat(&tombstone_key).await { - if stat.exists { - continue; - } + if self + .tombstone_blocks("vpns", vpn_id, 0) + .await + .unwrap_or(true) + { + continue; } log::info!( "VPN {} exists remotely but not locally, downloading...", @@ -3081,11 +3209,12 @@ impl SyncEngine { .any(|e| e.id == ext_id) }; if !exists_locally { - let tombstone_key = format!("tombstones/extensions/{}.json", ext_id); - if let Ok(stat) = self.client.stat(&tombstone_key).await { - if stat.exists { - continue; - } + if self + .tombstone_blocks("extensions", ext_id, 0) + .await + .unwrap_or(true) + { + continue; } log::info!( "Extension {} exists remotely but not locally, downloading...", @@ -3116,11 +3245,12 @@ impl SyncEngine { .any(|g| g.id == group_id) }; if !exists_locally { - let tombstone_key = format!("tombstones/extension_groups/{}.json", group_id); - if let Ok(stat) = self.client.stat(&tombstone_key).await { - if stat.exists { - continue; - } + if self + .tombstone_blocks("extension_groups", group_id, 0) + .await + .unwrap_or(true) + { + continue; } log::info!( "Extension group {} exists remotely but not locally, downloading...", @@ -3438,10 +3568,11 @@ pub async fn set_profile_sync_mode( .save_profile(&profile) .map_err(|e| format!("Failed to save profile: {e}"))?; - // The bot materialises the profile from donut-sync, so switching sync off (or - // to Encrypted, which the host cannot decrypt) is a refusal reason. The server - // holds only the copy this machine declared; without this, an enrolment keeps - // claiming a syncable profile every night after the user turned sync off. + // A remote run obtains the profile through sync, so a local-only profile has + // nothing there — and switching to Encrypted leaves a copy that cannot be + // decrypted remotely. Either is a refusal reason. The server holds only the + // copy this machine declared; without this, an enrolment keeps claiming a + // syncable profile every night after the user turned sync off. crate::cookie_bot::report_profile_state(&profile); let _ = events::emit("profiles-changed", ()); @@ -3697,6 +3828,22 @@ pub async fn pull_profile_after_remote_session( .map_err(|e| format!("Sync failed: {e}")) } +/// Drop a stale tombstone for a config entity that is being (re-)enabled for +/// sync. +/// +/// `sync_X` refuses to touch an entity whose tombstone is newer than its last +/// edit, so an id that was ever deleted could otherwise never be uploaded +/// again, which matters when an import restores a previously-deleted id. The +/// profile path clears its own tombstone on re-enable for the same reason. +async fn clear_config_tombstone(app_handle: &tauri::AppHandle, kind: &str, id: &str) { + if let Ok(engine) = SyncEngine::create_from_settings(app_handle).await { + let tombstone_key = format!("tombstones/{}/{}.json", kind, id); + if let Err(e) = engine.client.delete(&tombstone_key, None).await { + log::warn!("Failed to clear tombstone {}: {}", tombstone_key, e); + } + } +} + #[tauri::command] pub async fn set_proxy_sync_enabled( app_handle: tauri::AppHandle, @@ -3735,6 +3882,8 @@ pub async fn set_proxy_sync_enabled( let _ = events::emit("stored-proxies-changed", ()); if enabled { + clear_config_tombstone(&app_handle, "proxies", &proxy_id).await; + let _ = events::emit( "proxy-sync-status", serde_json::json!({ @@ -3805,6 +3954,8 @@ pub async fn set_group_sync_enabled( let _ = events::emit("groups-changed", ()); if enabled { + clear_config_tombstone(&app_handle, "groups", &group_id).await; + let _ = events::emit( "group-sync-status", serde_json::json!({ @@ -3877,6 +4028,8 @@ pub async fn set_vpn_sync_enabled( let _ = events::emit("vpn-configs-changed", ()); if enabled { + clear_config_tombstone(&app_handle, "vpns", &vpn_id).await; + let _ = events::emit( "vpn-sync-status", serde_json::json!({ @@ -4101,6 +4254,8 @@ pub async fn set_extension_sync_enabled( let _ = events::emit("extensions-changed", ()); if enabled { + clear_config_tombstone(&app_handle, "extensions", &extension_id).await; + if let Some(scheduler) = super::get_global_scheduler() { scheduler.queue_extension_sync(extension_id).await; } @@ -4143,6 +4298,8 @@ pub async fn set_extension_group_sync_enabled( let _ = events::emit("extensions-changed", ()); if enabled { + clear_config_tombstone(&app_handle, "extension_groups", &extension_group_id).await; + if let Some(scheduler) = super::get_global_scheduler() { scheduler .queue_extension_group_sync(extension_group_id) @@ -4196,10 +4353,9 @@ pub async fn rollover_encryption_for_all_entities( let total_profiles = synced_profiles.len(); for (i, profile) in synced_profiles.iter().enumerate() { let id_str = profile.id.to_string(); - // The remote manifest may be encrypted with the previous password. Delete - // only that manifest so the normal sync path treats every local file as an - // upload and rewrites it with the current password. Existing remote files - // remain available until their replacements have uploaded. + // Keep the old manifest present until the re-encrypted files are uploaded. + // Other devices must never interpret a missing manifest as an empty remote + // profile and repopulate it with files encrypted by the previous password. let key_prefix = SyncEngine::get_team_key_prefix(profile).await; engine .upload_profile_metadata(&id_str, profile, &key_prefix) @@ -4209,14 +4365,8 @@ pub async fn rollover_encryption_for_all_entities( "Failed to roll over profile metadata {id_str}: {e}" )) })?; - let manifest_key = format!("{key_prefix}profiles/{id_str}/manifest.json"); engine - .client - .delete(&manifest_key, None) - .await - .map_err(|e| internal_error(format!("Failed to reset profile manifest: {e}")))?; - engine - .sync_profile(&app_handle, profile) + .sync_profile_inner(&app_handle, profile, DiffBias::Auto, true) .await .map_err(|e| internal_error(format!("Failed to roll over profile {id_str}: {e}")))?; let _ = events::emit( @@ -4497,6 +4647,42 @@ mod tests { } } + #[test] + fn test_tombstone_outranks_local() { + // No tombstone: the reconcile runs as before. + assert!(!tombstone_outranks_local(false, Some(500), 100)); + + // The delete happened after the local edit, so it wins and the entity is + // never re-uploaded. This is the resurrection loop's entry point. + assert!(tombstone_outranks_local(true, Some(500), 100)); + + // Local copy already gone (updated_at 0): nothing may be downloaded back. + assert!(tombstone_outranks_local(true, Some(500), 0)); + + // Same second resolves in favour of the delete. + assert!(tombstone_outranks_local(true, Some(500), 500)); + + // A local edit made strictly after the delete still wins (last-write-wins). + assert!(!tombstone_outranks_local(true, Some(500), 501)); + + // An unreadable write time fails closed. + assert!(tombstone_outranks_local(true, None, 501)); + } + + #[test] + fn test_rfc3339_secs() { + assert_eq!(rfc3339_secs("1970-01-01T00:00:00Z"), Some(0)); + assert_eq!(rfc3339_secs("2024-01-01T00:00:00Z"), Some(1_704_067_200)); + // S3 returns sub-second precision and offsets other than Z. + assert_eq!( + rfc3339_secs("2024-01-01T01:00:00.500+01:00"), + Some(1_704_067_200) + ); + assert_eq!(rfc3339_secs("not a date"), None); + // Pre-epoch cannot be a tombstone write time; treat it as unreadable. + assert_eq!(rfc3339_secs("1969-12-31T23:59:59Z"), None); + } + #[test] fn test_is_safe_manifest_path() { // Legitimate profile-relative paths are accepted. diff --git a/src-tauri/src/sync/manifest.rs b/src-tauri/src/sync/manifest.rs index ec2ebb6..276ae7d 100644 --- a/src-tauri/src/sync/manifest.rs +++ b/src-tauri/src/sync/manifest.rs @@ -52,7 +52,12 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[ "**/LOCK", "**/*-journal", "**/*-wal", + "**/*-shm", "**/SingletonLock", + // Rewritten by donut before every launch from the profile metadata that + // already syncs; uploading it would only duplicate that state. + "**/wayfern-identity.json", + "**/wayfern-persona.json", "**/SingletonSocket", "**/SingletonCookie", "**/Secure Preferences", @@ -423,7 +428,7 @@ pub enum DiffBias { /// Remote wins regardless of timestamps. /// /// Used for exactly one thing: the pull that follows a remote session. A - /// leased host has just written the authoritative copy of this profile, and + /// remote host has just written the authoritative copy of this profile, and /// the local directory is whatever it was before the session started. If the /// user launched locally in between, local mtimes are NEWER than the host's /// push, so `Auto` would upload the stale copy and put every file the host @@ -674,6 +679,36 @@ mod tests { ); } + #[test] + fn test_generate_manifest_excludes_sqlite_shm_sidecars() { + let temp_dir = TempDir::new().unwrap(); + let profile_dir = temp_dir.path().join("profile_root"); + let default_dir = profile_dir.join("profile/Default"); + fs::create_dir_all(&default_dir).unwrap(); + + fs::write(profile_dir.join("Cookies-shm"), "scratch").unwrap(); + fs::write(default_dir.join("History-shm"), "scratch").unwrap(); + fs::write(default_dir.join("History-wal"), "scratch").unwrap(); + fs::write(default_dir.join("History"), "keep").unwrap(); + + let mut cache = HashCache::default(); + let manifest = generate_manifest("test-profile", &profile_dir, &mut cache).unwrap(); + + let paths: Vec<&str> = manifest.files.iter().map(|f| f.path.as_str()).collect(); + assert!( + !paths.iter().any(|p| p.ends_with("-shm")), + "SQLite -shm sidecars are scratch state and must not sync: {paths:?}" + ); + assert!( + !paths.iter().any(|p| p.ends_with("-wal")), + "-wal sidecars stay excluded: {paths:?}" + ); + assert!( + paths.contains(&"profile/Default/History"), + "the database itself must still sync: {paths:?}" + ); + } + #[test] fn test_compute_diff_upload_all_when_no_remote() { let local = SyncManifest { diff --git a/src-tauri/src/sync/preflight.rs b/src-tauri/src/sync/preflight.rs index b8c076a..8abd5a7 100644 --- a/src-tauri/src/sync/preflight.rs +++ b/src-tauri/src/sync/preflight.rs @@ -34,7 +34,7 @@ pub struct SyncServerCheck { /// one. pub storage_ready: Option, /// The host the server signs into presigned URLs, when it discloses one. - /// Withheld by cloud deployments on purpose. + /// Optional: a deployment need not publish it. pub storage_endpoint: Option, /// Whether that host answered *this device*. `None` when there was nothing /// to probe. diff --git a/src-tauri/src/sync/scheduler.rs b/src-tauri/src/sync/scheduler.rs index 1a38ed1..fb4afa3 100644 --- a/src-tauri/src/sync/scheduler.rs +++ b/src-tauri/src/sync/scheduler.rs @@ -458,13 +458,15 @@ impl SyncScheduler { } async fn process_pending(&self, app_handle: &tauri::AppHandle) { + // Deletions first. A queued sync for an entity another device deleted would + // otherwise re-upload it from the local copy this tick is about to remove. + self.process_pending_tombstones(app_handle).await; self.process_pending_profiles(app_handle).await; self.process_pending_proxies(app_handle).await; self.process_pending_groups(app_handle).await; self.process_pending_vpns(app_handle).await; self.process_pending_extensions(app_handle).await; self.process_pending_extension_groups(app_handle).await; - self.process_pending_tombstones(app_handle).await; } async fn process_pending_profiles(&self, app_handle: &tauri::AppHandle) { @@ -830,6 +832,29 @@ impl SyncScheduler { } } + /// Forget a queued config sync for an entity whose deletion is being applied + /// this tick, so the drain that follows cannot re-upload it. + async fn drop_pending_config_sync(&self, entity_type: &str, entity_id: &str) { + match entity_type { + "proxy" => { + self.pending_proxies.lock().await.remove(entity_id); + } + "group" => { + self.pending_groups.lock().await.remove(entity_id); + } + "vpn" => { + self.pending_vpns.lock().await.remove(entity_id); + } + "extension" => { + self.pending_extensions.lock().await.remove(entity_id); + } + "extension_group" => { + self.pending_extension_groups.lock().await.remove(entity_id); + } + _ => {} + } + } + async fn process_pending_tombstones(&self, _app_handle: &tauri::AppHandle) { let tombstones: Vec<(String, String)> = { let mut pending = self.pending_tombstones.lock().await; @@ -840,6 +865,10 @@ impl SyncScheduler { return; } + for (entity_type, entity_id) in &tombstones { + self.drop_pending_config_sync(entity_type, entity_id).await; + } + for (entity_type, entity_id) in tombstones { log::info!("Processing tombstone for {} {}", entity_type, entity_id); match entity_type.as_str() { @@ -990,4 +1019,22 @@ mod tests { // retired instance coming back to life could only ever be a duplicate. assert_eq!(scheduler.claim_start_slot(), StartDecision::Retired); } + + #[tokio::test] + async fn test_drop_pending_config_sync_removes_only_the_deleted_entity() { + let scheduler = SyncScheduler::new(); + scheduler.queue_proxy_sync("proxy-1".to_string()).await; + scheduler.queue_group_sync("group-1".to_string()).await; + assert!(scheduler.is_sync_in_progress().await); + + // A tombstone drops that entity's queued sync, otherwise the drain that + // follows re-uploads the copy this tick is about to delete. Every other + // queued entity is left alone. + scheduler.drop_pending_config_sync("proxy", "proxy-1").await; + assert!(scheduler.pending_proxies.lock().await.is_empty()); + assert!(scheduler.pending_groups.lock().await.contains("group-1")); + + scheduler.drop_pending_config_sync("group", "group-1").await; + assert!(!scheduler.is_sync_in_progress().await); + } } diff --git a/src-tauri/src/synchronizer.rs b/src-tauri/src/synchronizer.rs index dd75af9..a85bbbe 100644 --- a/src-tauri/src/synchronizer.rs +++ b/src-tauri/src/synchronizer.rs @@ -53,6 +53,10 @@ pub struct SyncFollowerState { pub profile_name: String, /// None = healthy, Some(url) = desynced at this URL pub failed_at_url: Option, + /// Held out of the mirroring on purpose. The window stays open and usable; + /// it simply stops receiving what the leader does until it is brought back. + #[serde(default)] + pub held: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -61,6 +65,174 @@ pub struct SyncSessionInfo { pub leader_profile_id: String, pub leader_profile_name: String, pub followers: Vec, + /// Mirroring is suspended for the whole session, so the leader can be used + /// on its own without every follower copying it. + #[serde(default)] + pub paused: bool, +} + +/// How the follower windows are placed on the host display. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum WindowLayout { + /// Even tiles, as square a grid as the count allows. Never overlapping. + Grid, + /// One full-height column per window, left to right. + Columns, + /// Stacked with a fixed offset, so every title bar stays reachable. + Cascade, +} + +/// A window position in the host display's CSS pixels. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct WindowRect { + pub left: i32, + pub top: i32, + pub width: u32, + pub height: u32, +} + +/// Smallest window an arrangement will ask for. Below this a browser window is +/// not usable, and Chromium clamps it anyway, which would break the "no +/// overlap" promise of the grid without anyone noticing. +const MIN_WINDOW_WIDTH: u32 = 240; +const MIN_WINDOW_HEIGHT: u32 = 180; + +/// Cascade offsets, capped so a long session does not walk the last window off +/// the bottom of the display. +const CASCADE_MAX_STEP_X: u32 = 48; +const CASCADE_MAX_STEP_Y: u32 = 40; + +/// Where each of `count` follower windows goes on a `screen_width` by +/// `screen_height` display. +/// +/// Pure arithmetic on purpose: it is the part that can be wrong in a way no +/// screenshot would show, and it is the part a test can hold to "every window +/// is on the display, and the grid never overlaps". +pub fn layout_windows( + layout: WindowLayout, + count: usize, + screen_width: u32, + screen_height: u32, +) -> Vec { + if count == 0 || screen_width == 0 || screen_height == 0 { + return Vec::new(); + } + + match layout { + WindowLayout::Grid => { + let columns = (count as f64).sqrt().ceil() as usize; + let rows = count.div_ceil(columns); + let cell_width = (screen_width / columns as u32).max(1); + let cell_height = (screen_height / rows as u32).max(1); + (0..count) + .map(|index| { + let column = index % columns; + let row = index / columns; + WindowRect { + left: (column as u32 * cell_width) as i32, + top: (row as u32 * cell_height) as i32, + width: cell_width, + height: cell_height, + } + }) + .collect() + } + WindowLayout::Columns => { + let width = (screen_width / count as u32).max(1); + (0..count) + .map(|index| WindowRect { + left: (index as u32 * width) as i32, + top: 0, + width, + height: screen_height, + }) + .collect() + } + WindowLayout::Cascade => { + let width = (screen_width * 2 / 3).clamp(1, screen_width); + let height = (screen_height * 2 / 3).clamp(1, screen_height); + let steps = count.saturating_sub(1) as u32; + // Spread the leftover room over the gaps, then cap it, so the last + // window lands inside the display however many there are. + let step_x = (screen_width - width) + .checked_div(steps) + .map_or(0, |step| step.min(CASCADE_MAX_STEP_X)); + let step_y = (screen_height - height) + .checked_div(steps) + .map_or(0, |step| step.min(CASCADE_MAX_STEP_Y)); + (0..count) + .map(|index| WindowRect { + left: (index as u32 * step_x) as i32, + top: (index as u32 * step_y) as i32, + width, + height, + }) + .collect() + } + } +} + +/// Whether an arrangement is worth asking a browser for. A display too small +/// to give every window a usable size is better left alone than tiled into +/// slivers Chromium will silently refuse to make. +pub fn layout_fits(rects: &[WindowRect]) -> bool { + rects + .iter() + .all(|rect| rect.width >= MIN_WINDOW_WIDTH && rect.height >= MIN_WINDOW_HEIGHT) +} + +/// What the leader's events are currently allowed to reach. +/// +/// Kept apart from the session record so the hot event path can read it +/// without an async lock, and so the rules are testable on their own. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(crate) struct MirrorGate { + paused: bool, + held: std::collections::HashSet, +} + +impl MirrorGate { + /// True when this follower should receive what the leader just did. + pub(crate) fn accepts(&self, follower_id: &str) -> bool { + !self.paused && !self.held.contains(follower_id) + } + + pub(crate) fn set_paused(&mut self, paused: bool) { + self.paused = paused; + } + + pub(crate) fn is_paused(&self) -> bool { + self.paused + } + + pub(crate) fn set_held(&mut self, follower_id: &str, held: bool) { + if held { + self.held.insert(follower_id.to_string()); + } else { + self.held.remove(follower_id); + } + } + + pub(crate) fn is_held(&self, follower_id: &str) -> bool { + self.held.contains(follower_id) + } + + /// Drop a follower that has left the session, so its id cannot linger and + /// silence a profile that is later added back. + pub(crate) fn forget(&mut self, follower_id: &str) { + self.held.remove(follower_id); + } +} + +type SharedGate = Arc>; + +/// Everything the listener task needs to know about the session it drives. +struct SessionLoopContext { + session_id: String, + leader_profile_id: String, + follower_profile_ids: Vec, + gate: SharedGate, } /// Internal session state @@ -68,11 +240,55 @@ struct SyncSession { id: String, leader_profile_id: String, leader_profile_name: String, - followers: HashMap, + /// Ordered, because the arrangement numbers the windows by it and a list + /// that reshuffles itself between renders is unusable. + followers: Vec, + gate: SharedGate, /// Cancellation token — drop sender to stop the listener task cancel_tx: tokio::sync::watch::Sender, } +impl SyncSession { + /// The session as the page sees it. + /// + /// `paused` and every `held` flag are read back off the gate rather than + /// mirrored into the follower records, so what the panel shows is what the + /// event path actually enforces and the two can never drift apart. + fn info(&self) -> SyncSessionInfo { + let gate = self.gate.read().ok(); + SyncSessionInfo { + id: self.id.clone(), + leader_profile_id: self.leader_profile_id.clone(), + leader_profile_name: self.leader_profile_name.clone(), + followers: self + .followers + .iter() + .map(|follower| SyncFollowerState { + held: gate + .as_ref() + .is_some_and(|gate| gate.is_held(&follower.profile_id)), + ..follower.clone() + }) + .collect(), + paused: gate.as_ref().is_some_and(|gate| gate.is_paused()), + } + } + + fn follower_mut(&mut self, profile_id: &str) -> Option<&mut SyncFollowerState> { + self + .followers + .iter_mut() + .find(|follower| follower.profile_id == profile_id) + } + + fn has_follower(&self, profile_id: &str) -> bool { + self + .followers + .iter() + .any(|follower| follower.profile_id == profile_id) + } +} + pub struct SynchronizerManager { inner: Arc>, } @@ -150,7 +366,7 @@ impl SynchronizerManager { return Err("Leader profile is already in another sync session.".to_string()); } for fid in &follower_profile_ids { - if session.leader_profile_id == *fid || session.followers.contains_key(fid) { + if session.leader_profile_id == *fid || session.has_follower(fid) { return Err(format!( "Profile '{fid}' is already part of another sync session." )); @@ -211,35 +427,30 @@ impl SynchronizerManager { // Bring leader window to front after all followers launched Self::focus_leader_window(&leader).await; - // Build follower states - let mut followers = HashMap::new(); - for fp in &follower_profiles { - followers.insert( - fp.id.to_string(), - SyncFollowerState { - profile_id: fp.id.to_string(), - profile_name: fp.name.clone(), - failed_at_url: None, - }, - ); - } + // Build follower states, keeping the order the user selected them in. + let followers: Vec = follower_profiles + .iter() + .map(|fp| SyncFollowerState { + profile_id: fp.id.to_string(), + profile_name: fp.name.clone(), + failed_at_url: None, + held: false, + }) + .collect(); let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); + let gate: SharedGate = Arc::new(std::sync::RwLock::new(MirrorGate::default())); let session = SyncSession { id: session_id.clone(), leader_profile_id: leader_profile_id.clone(), leader_profile_name: leader.name.clone(), followers: followers.clone(), + gate: gate.clone(), cancel_tx, }; - let info = SyncSessionInfo { - id: session_id.clone(), - leader_profile_id: leader_profile_id.clone(), - leader_profile_name: leader.name.clone(), - followers: followers.values().cloned().collect(), - }; + let info = session.info(); { let mut inner = self.inner.lock().await; @@ -268,9 +479,12 @@ impl SynchronizerManager { if let Err(e) = Self::run_session_loop( ah.clone(), manager.clone(), - sid.clone(), - lid, - fids, + SessionLoopContext { + session_id: sid.clone(), + leader_profile_id: lid, + follower_profile_ids: fids, + gate, + }, cancel_rx, ready_tx, ) @@ -338,12 +552,16 @@ impl SynchronizerManager { async fn run_session_loop( app_handle: tauri::AppHandle, manager: Arc>, - session_id: String, - leader_profile_id: String, - follower_profile_ids: Vec, + session: SessionLoopContext, mut cancel_rx: tokio::sync::watch::Receiver, ready_tx: tokio::sync::oneshot::Sender>, ) -> Result<(), String> { + let SessionLoopContext { + session_id, + leader_profile_id, + follower_profile_ids, + gate, + } = session; use futures_util::sink::SinkExt; use futures_util::stream::StreamExt; use tokio_tungstenite::connect_async; @@ -551,15 +769,7 @@ impl SynchronizerManager { // Process any events that were buffered during setup for event in pending_events.drain(..) { - Self::handle_cdp_event( - &event, - &app_handle, - &manager, - &session_id, - &follower_senders, - false, - ) - .await; + Self::handle_cdp_event(&event, &gate, &follower_senders, false).await; } // Main event loop — listen for Wayfern.inputCaptured events @@ -597,9 +807,7 @@ impl SynchronizerManager { Self::handle_cdp_event( &value, - &app_handle, - &manager, - &session_id, + &gate, &follower_senders, recent_user_event, ).await; @@ -623,7 +831,11 @@ impl SynchronizerManager { let follower_ids: Vec = { let inner = manager.lock().await; if let Some(session) = inner.sessions.get(&session_id) { - session.followers.keys().cloned().collect() + session + .followers + .iter() + .map(|follower| follower.profile_id.clone()) + .collect() } else { Vec::new() } @@ -638,12 +850,14 @@ impl SynchronizerManager { Ok(()) } - /// Handle a single CDP event from the leader + /// Handle a single CDP event from the leader. + /// + /// The gate is read here rather than in the per-follower replay task so a + /// paused session queues nothing at all: an event dropped now cannot arrive + /// late once mirroring resumes. async fn handle_cdp_event( value: &serde_json::Value, - _app_handle: &tauri::AppHandle, - _manager: &Arc>, - _session_id: &str, + gate: &SharedGate, follower_senders: &HashMap>, recent_user_event: bool, ) { @@ -659,9 +873,7 @@ impl SynchronizerManager { } if let Ok(event) = serde_json::from_value::(params.clone()) { log::info!("Synchronizer: captured {event_type}"); - for tx in follower_senders.values() { - let _ = tx.send(event.clone()); - } + Self::fan_out(gate, follower_senders, &event); } } } @@ -691,9 +903,7 @@ impl SynchronizerManager { delta_y: None, timestamp: None, }; - for tx in follower_senders.values() { - let _ = tx.send(nav_event.clone()); - } + Self::fan_out(gate, follower_senders, &nav_event); } } } @@ -702,6 +912,30 @@ impl SynchronizerManager { } } + /// Send one captured event to every follower the gate still admits. + /// + /// The lock is held across the whole fan-out and released before any await, + /// so a pause taking effect mid-event cannot deliver to half the followers. + fn fan_out( + gate: &SharedGate, + follower_senders: &HashMap>, + event: &CapturedEvent, + ) { + let Ok(gate) = gate.read() else { + log::warn!("Synchronizer: mirroring gate is poisoned; dropping the event"); + return; + }; + if gate.is_paused() { + return; + } + for (follower_id, tx) in follower_senders { + if !gate.accepts(follower_id) { + continue; + } + let _ = tx.send(event.clone()); + } + } + /// Dedicated replay loop for a single follower with a persistent WebSocket connection. /// Processes events from the channel sequentially — no per-event connection overhead. async fn follower_replay_loop( @@ -805,14 +1039,9 @@ impl SynchronizerManager { // Mark as desynced let mut inner = manager.lock().await; if let Some(session) = inner.sessions.get_mut(&session_id) { - if let Some(follower) = session.followers.get_mut(&follower_id) { + if let Some(follower) = session.follower_mut(&follower_id) { follower.failed_at_url = Some("connection lost".to_string()); - let info = SyncSessionInfo { - id: session.id.clone(), - leader_profile_id: session.leader_profile_id.clone(), - leader_profile_name: session.leader_profile_name.clone(), - followers: session.followers.values().cloned().collect(), - }; + let info = session.info(); let _ = app_handle.emit("sync-session-changed", &info); } } @@ -840,8 +1069,8 @@ impl SynchronizerManager { let _ = session.cancel_tx.send(true); // Kill followers - for fid in session.followers.keys() { - if let Ok(fp) = Self::get_profile(fid) { + for follower in &session.followers { + if let Ok(fp) = Self::get_profile(&follower.profile_id) { let _ = crate::browser_runner::kill_browser_profile(app_handle.clone(), fp).await; } } @@ -868,7 +1097,15 @@ impl SynchronizerManager { .get_mut(session_id) .ok_or("Session not found")?; - session.followers.remove(follower_profile_id); + session + .followers + .retain(|follower| follower.profile_id != follower_profile_id); + // A removed follower must not leave its id sitting in the held set: the + // profile could be a follower again in a later session and would then be + // silently ignored. + if let Ok(mut gate) = session.gate.write() { + gate.forget(follower_profile_id); + } // Kill the follower browser if let Ok(fp) = Self::get_profile(follower_profile_id) { @@ -876,30 +1113,226 @@ impl SynchronizerManager { } // Emit updated session info - let info = SyncSessionInfo { - id: session.id.clone(), - leader_profile_id: session.leader_profile_id.clone(), - leader_profile_name: session.leader_profile_name.clone(), - followers: session.followers.values().cloned().collect(), - }; + let info = session.info(); let _ = app_handle.emit("sync-session-changed", &info); Ok(()) } + /// Suspend or resume mirroring for a whole session. + /// + /// Nothing is queued while it is paused: the leader can be used on its own + /// and the followers stay exactly where they were, rather than replaying a + /// backlog the moment mirroring comes back. + pub async fn set_paused( + &self, + app_handle: tauri::AppHandle, + session_id: &str, + paused: bool, + ) -> Result { + let mut inner = self.inner.lock().await; + let session = inner + .sessions + .get_mut(session_id) + .ok_or_else(|| serde_json::json!({ "code": "SYNC_SESSION_NOT_FOUND" }).to_string())?; + + session + .gate + .write() + .map_err(|_| serde_json::json!({ "code": "SYNC_SESSION_UNAVAILABLE" }).to_string())? + .set_paused(paused); + + let info = session.info(); + let _ = app_handle.emit("sync-session-changed", &info); + log::info!( + "Synchronizer session {session_id}: mirroring {}", + if paused { "paused" } else { "resumed" } + ); + Ok(info) + } + + /// Hold one follower out of the mirroring, or bring it back. + /// + /// Unlike removing a follower this keeps the browser open, so a person can + /// do something in that one window and then rejoin it to the session. + pub async fn set_follower_held( + &self, + app_handle: tauri::AppHandle, + session_id: &str, + follower_profile_id: &str, + held: bool, + ) -> Result { + let mut inner = self.inner.lock().await; + let session = inner + .sessions + .get_mut(session_id) + .ok_or_else(|| serde_json::json!({ "code": "SYNC_SESSION_NOT_FOUND" }).to_string())?; + + if !session.has_follower(follower_profile_id) { + return Err(serde_json::json!({ "code": "SYNC_FOLLOWER_NOT_FOUND" }).to_string()); + } + + session + .gate + .write() + .map_err(|_| serde_json::json!({ "code": "SYNC_SESSION_UNAVAILABLE" }).to_string())? + .set_held(follower_profile_id, held); + + let info = session.info(); + let _ = app_handle.emit("sync-session-changed", &info); + Ok(info) + } + + /// Place the session's follower windows on the host display. + /// + /// Held-out followers are placed too: they are still windows on the screen, + /// and a person who held one out is usually the one who wants to see it. + pub async fn arrange_windows( + &self, + app_handle: tauri::AppHandle, + session_id: &str, + layout: WindowLayout, + ) -> Result { + let (info, follower_ids) = { + let inner = self.inner.lock().await; + let session = inner + .sessions + .get(session_id) + .ok_or_else(|| serde_json::json!({ "code": "SYNC_SESSION_NOT_FOUND" }).to_string())?; + ( + session.info(), + session + .followers + .iter() + .map(|follower| follower.profile_id.clone()) + .collect::>(), + ) + }; + + if follower_ids.is_empty() { + return Ok(info); + } + + let (screen_width, screen_height) = crate::wayfern_manager::host_screen_size(&app_handle) + .ok_or_else(|| serde_json::json!({ "code": "SYNC_DISPLAY_UNAVAILABLE" }).to_string())?; + + let rects = layout_windows(layout, follower_ids.len(), screen_width, screen_height); + if !layout_fits(&rects) { + return Err( + serde_json::json!({ + "code": "SYNC_DISPLAY_TOO_SMALL", + "params": { "count": follower_ids.len().to_string() } + }) + .to_string(), + ); + } + + let mut placed = 0usize; + for (follower_id, rect) in follower_ids.iter().zip(rects) { + match Self::place_window(follower_id, rect).await { + Ok(()) => placed += 1, + Err(e) => log::warn!("Synchronizer: could not place follower {follower_id}: {e}"), + } + } + + if placed == 0 { + return Err(serde_json::json!({ "code": "SYNC_ARRANGE_FAILED" }).to_string()); + } + log::info!( + "Synchronizer session {session_id}: placed {placed} of {} windows", + follower_ids.len() + ); + Ok(info) + } + + /// Move one follower's browser window through CDP. + async fn place_window(follower_profile_id: &str, rect: WindowRect) -> Result<(), String> { + use futures_util::sink::SinkExt; + use futures_util::stream::StreamExt; + use tokio_tungstenite::tungstenite::Message; + + let profile = Self::get_profile(follower_profile_id)?; + let port = Self::get_cdp_port(&profile).await?; + let ws_url = Self::get_page_ws_url(port).await?; + let (mut ws, _) = tokio_tungstenite::connect_async(&ws_url) + .await + .map_err(|e| format!("Failed to connect to follower CDP: {e}"))?; + + let ask = serde_json::json!({ "id": 1, "method": "Browser.getWindowForTarget", "params": {} }); + ws.send(Message::Text(ask.to_string().into())) + .await + .map_err(|e| format!("Failed to ask for the window: {e}"))?; + + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(5); + let window_id = loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Err("Timed out waiting for the window id".to_string()); + } + match tokio::time::timeout(remaining, ws.next()).await { + Ok(Some(Ok(Message::Text(text)))) => { + let Ok(response) = serde_json::from_str::(text.as_str()) else { + continue; + }; + if response.get("id") != Some(&serde_json::json!(1)) { + continue; + } + match response + .get("result") + .and_then(|r| r.get("windowId")) + .and_then(|w| w.as_i64()) + { + Some(id) => break id, + None => return Err("The browser reported no window".to_string()), + } + } + Ok(Some(Ok(_))) => continue, + Ok(Some(Err(e))) => return Err(format!("WebSocket error: {e}")), + Ok(None) => return Err("WebSocket closed".to_string()), + Err(_) => return Err("Timed out waiting for the window id".to_string()), + } + }; + + // `normal` travels with the bounds so a maximised or minimised window is + // restored first; without it Chromium refuses to move the window at all. + let place = serde_json::json!({ + "id": 2, + "method": "Browser.setWindowBounds", + "params": { + "windowId": window_id, + "bounds": { + "left": rect.left, + "top": rect.top, + "width": rect.width, + "height": rect.height, + "windowState": "normal", + } + } + }); + ws.send(Message::Text(place.to_string().into())) + .await + .map_err(|e| format!("Failed to move the window: {e}"))?; + + // Wait for the acknowledgement rather than firing and forgetting: the + // panel reports how many windows actually moved. + match tokio::time::timeout(std::time::Duration::from_secs(5), ws.next()).await { + Ok(Some(Ok(Message::Text(text)))) => { + let response: serde_json::Value = serde_json::from_str(text.as_str()).unwrap_or_default(); + if let Some(error) = response.get("error") { + return Err(format!("CDP refused the move: {error}")); + } + Ok(()) + } + Ok(Some(Ok(_))) | Ok(None) => Ok(()), + Ok(Some(Err(e))) => Err(format!("WebSocket error: {e}")), + Err(_) => Err("Timed out moving the window".to_string()), + } + } + /// Get all active sync sessions. pub async fn get_sessions(&self) -> Vec { let inner = self.inner.lock().await; - inner - .sessions - .values() - .map(|s| SyncSessionInfo { - id: s.id.clone(), - leader_profile_id: s.leader_profile_id.clone(), - leader_profile_name: s.leader_profile_name.clone(), - followers: s.followers.values().cloned().collect(), - }) - .collect() + inner.sessions.values().map(SyncSession::info).collect() } // --- Helper methods --- @@ -1004,3 +1437,332 @@ pub async fn remove_sync_follower( pub async fn get_sync_sessions() -> Result, String> { Ok(SynchronizerManager::instance().get_sessions().await) } + +#[tauri::command] +pub async fn set_sync_session_paused( + app_handle: tauri::AppHandle, + session_id: String, + paused: bool, +) -> Result { + SynchronizerManager::instance() + .set_paused(app_handle, &session_id, paused) + .await +} + +#[tauri::command] +pub async fn set_sync_follower_held( + app_handle: tauri::AppHandle, + session_id: String, + follower_profile_id: String, + held: bool, +) -> Result { + SynchronizerManager::instance() + .set_follower_held(app_handle, &session_id, &follower_profile_id, held) + .await +} + +#[tauri::command] +pub async fn arrange_sync_windows( + app_handle: tauri::AppHandle, + session_id: String, + layout: WindowLayout, +) -> Result { + SynchronizerManager::instance() + .arrange_windows(app_handle, &session_id, layout) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + + fn overlaps(a: &WindowRect, b: &WindowRect) -> bool { + let a_right = a.left + a.width as i32; + let a_bottom = a.top + a.height as i32; + let b_right = b.left + b.width as i32; + let b_bottom = b.top + b.height as i32; + a.left < b_right && b.left < a_right && a.top < b_bottom && b.top < a_bottom + } + + fn on_screen(rect: &WindowRect, width: u32, height: u32) -> bool { + rect.left >= 0 + && rect.top >= 0 + && rect.width > 0 + && rect.height > 0 + && rect.left + rect.width as i32 <= width as i32 + && rect.top + rect.height as i32 <= height as i32 + } + + #[test] + fn every_layout_keeps_one_to_nine_windows_on_the_display() { + for (width, height) in [(1920u32, 1080u32), (1280, 800), (2560, 1440), (1440, 900)] { + for count in 1..=9usize { + for layout in [ + WindowLayout::Grid, + WindowLayout::Columns, + WindowLayout::Cascade, + ] { + let rects = layout_windows(layout, count, width, height); + assert_eq!(rects.len(), count, "{layout:?} produced the wrong count"); + for rect in &rects { + assert!( + on_screen(rect, width, height), + "{layout:?} put {rect:?} outside {width}x{height} for {count} windows" + ); + } + } + } + } + } + + #[test] + fn the_grid_never_overlaps() { + for (width, height) in [(1920u32, 1080u32), (1366, 768)] { + for count in 1..=9usize { + let rects = layout_windows(WindowLayout::Grid, count, width, height); + for (i, a) in rects.iter().enumerate() { + for b in rects.iter().skip(i + 1) { + assert!(!overlaps(a, b), "{a:?} overlaps {b:?} for {count} windows"); + } + } + } + } + } + + #[test] + fn the_grid_is_as_square_as_the_count_allows() { + // Four windows are two by two, not four in a row: the whole point of the + // grid rather than the columns layout. + let rects = layout_windows(WindowLayout::Grid, 4, 1600, 1000); + assert_eq!( + rects[0], + WindowRect { + left: 0, + top: 0, + width: 800, + height: 500 + } + ); + assert_eq!( + rects[1], + WindowRect { + left: 800, + top: 0, + width: 800, + height: 500 + } + ); + assert_eq!( + rects[2], + WindowRect { + left: 0, + top: 500, + width: 800, + height: 500 + } + ); + assert_eq!( + rects[3], + WindowRect { + left: 800, + top: 500, + width: 800, + height: 500 + } + ); + + // A single window fills the display rather than sitting in a corner. + assert_eq!( + layout_windows(WindowLayout::Grid, 1, 1600, 1000), + vec![WindowRect { + left: 0, + top: 0, + width: 1600, + height: 1000 + }] + ); + } + + #[test] + fn columns_run_left_to_right_at_full_height() { + let rects = layout_windows(WindowLayout::Columns, 3, 1500, 900); + for (index, rect) in rects.iter().enumerate() { + assert_eq!(rect.top, 0); + assert_eq!(rect.height, 900); + assert_eq!(rect.width, 500); + assert_eq!(rect.left, index as i32 * 500); + } + for (i, a) in rects.iter().enumerate() { + for b in rects.iter().skip(i + 1) { + assert!(!overlaps(a, b), "columns must not overlap either"); + } + } + } + + #[test] + fn a_cascade_steps_down_and_right_without_leaving_the_display() { + let rects = layout_windows(WindowLayout::Cascade, 5, 1920, 1080); + for pair in rects.windows(2) { + assert!(pair[1].left > pair[0].left, "each window steps right"); + assert!(pair[1].top > pair[0].top, "each window steps down"); + assert_eq!(pair[1].width, pair[0].width, "a cascade keeps one size"); + } + let last = rects.last().unwrap(); + assert!(on_screen(last, 1920, 1080)); + + // One window has nowhere to step to, so it sits at the origin. + let single = layout_windows(WindowLayout::Cascade, 1, 1920, 1080); + assert_eq!(single[0].left, 0); + assert_eq!(single[0].top, 0); + } + + #[test] + fn nothing_is_arranged_without_windows_or_a_display() { + assert!(layout_windows(WindowLayout::Grid, 0, 1920, 1080).is_empty()); + assert!(layout_windows(WindowLayout::Columns, 3, 0, 1080).is_empty()); + assert!(layout_windows(WindowLayout::Cascade, 3, 1920, 0).is_empty()); + } + + #[test] + fn a_display_too_small_to_tile_is_reported_rather_than_tiled() { + assert!(layout_fits(&layout_windows( + WindowLayout::Grid, + 9, + 1920, + 1080 + ))); + // Nine columns on a 1280 wide display gives 142px windows, which no + // browser will honour. + assert!(!layout_fits(&layout_windows( + WindowLayout::Columns, + 9, + 1280, + 800 + ))); + } + + #[test] + fn holding_a_follower_out_stops_only_that_one() { + let mut gate = MirrorGate::default(); + assert!(gate.accepts("a")); + assert!(gate.accepts("b")); + + gate.set_held("a", true); + assert!(gate.is_held("a")); + assert!(!gate.accepts("a")); + assert!( + gate.accepts("b"), + "holding one follower must not silence another" + ); + + gate.set_held("a", false); + assert!(!gate.is_held("a")); + assert!(gate.accepts("a"), "a follower comes back exactly as it was"); + + // Setting the same state twice is not a toggle. + gate.set_held("a", true); + gate.set_held("a", true); + assert!(!gate.accepts("a")); + gate.set_held("a", false); + assert!(gate.accepts("a")); + } + + #[test] + fn pausing_stops_everyone_and_resuming_restores_the_held_set() { + let mut gate = MirrorGate::default(); + gate.set_held("held", true); + + gate.set_paused(true); + assert!(gate.is_paused()); + assert!(!gate.accepts("free"), "a pause covers every follower"); + assert!(!gate.accepts("held")); + + gate.set_paused(false); + assert!(!gate.is_paused()); + assert!(gate.accepts("free")); + assert!( + !gate.accepts("held"), + "resuming must not quietly un-hold a follower the user held out" + ); + } + + #[test] + fn a_follower_that_leaves_does_not_stay_held() { + let mut gate = MirrorGate::default(); + gate.set_held("gone", true); + gate.forget("gone"); + assert!(!gate.is_held("gone")); + assert!( + gate.accepts("gone"), + "a profile that rejoins later must not inherit the old hold" + ); + } + + #[test] + fn a_layout_round_trips_through_the_wire_format() { + // The panel sends these names; a rename here has to fail loudly. + assert_eq!( + serde_json::from_str::("\"grid\"").unwrap(), + WindowLayout::Grid + ); + assert_eq!( + serde_json::from_str::("\"columns\"").unwrap(), + WindowLayout::Columns + ); + assert_eq!( + serde_json::from_str::("\"cascade\"").unwrap(), + WindowLayout::Cascade + ); + assert!(serde_json::from_str::("\"tiled\"").is_err()); + } + + #[test] + fn session_info_carries_the_pause_and_hold_state() { + let gate: SharedGate = Arc::new(std::sync::RwLock::new(MirrorGate::default())); + let (cancel_tx, _cancel_rx) = tokio::sync::watch::channel(false); + let session = SyncSession { + id: "session".to_string(), + leader_profile_id: "leader".to_string(), + leader_profile_name: "Leader".to_string(), + followers: vec![ + SyncFollowerState { + profile_id: "one".to_string(), + profile_name: "One".to_string(), + failed_at_url: None, + held: false, + }, + SyncFollowerState { + profile_id: "two".to_string(), + profile_name: "Two".to_string(), + failed_at_url: None, + held: false, + }, + ], + gate: gate.clone(), + cancel_tx, + }; + + let info = session.info(); + assert!(!info.paused); + // The order the followers were chosen in is the order the panel and the + // arrangement number them by. + assert_eq!( + info + .followers + .iter() + .map(|f| f.profile_id.as_str()) + .collect::>(), + vec!["one", "two"] + ); + + gate.write().unwrap().set_paused(true); + gate.write().unwrap().set_held("two", true); + + let info = session.info(); + assert!(info.paused); + assert!(!info.followers[0].held); + assert!(info.followers[1].held); + assert!(session.has_follower("one")); + assert!(!session.has_follower("missing")); + } +} diff --git a/src-tauri/src/team_lock.rs b/src-tauri/src/team_lock.rs index cc81941..54cc93b 100644 --- a/src-tauri/src/team_lock.rs +++ b/src-tauri/src/team_lock.rs @@ -279,14 +279,13 @@ impl ProfileLockManager { } } -/// Separator the backend puts between a user id and a non-desktop holder's -/// sub-identity. Mirrors `HOLDER_SEPARATOR` in donutbrowser-infra's -/// `profile-locks.service.ts`. +/// Separator the cloud API puts between a user id and a non-desktop holder's +/// sub-identity. Must match the server's holder format exactly. /// /// A remote VM session takes the lock under `:vm:` so it /// contends with this desktop instead of silently sharing its lock. That makes /// the holder string the one place a client can tell "a teammate has this open" -/// apart from "this is my own profile, running on the fleet" — two refusals that +/// apart from "this is my own profile, running remotely" — two refusals that /// need completely different words. const VM_HOLDER_SEPARATOR: &str = ":vm:"; @@ -381,7 +380,7 @@ mod tests { #[test] fn a_users_own_remote_session_is_not_reported_as_a_teammate() { - // The holder for a fleet session is `:vm:` and the row + // The holder for a remote session is `:vm:` and it // carries the OWNER's email, so the previous message read "Profile is in use // by you@example.com" — the user's own address, about their own profile. let err = lock_conflict_error( diff --git a/src-tauri/src/vpn/config.rs b/src-tauri/src/vpn/config.rs index 9910f38..d645437 100644 --- a/src-tauri/src/vpn/config.rs +++ b/src-tauri/src/vpn/config.rs @@ -135,7 +135,13 @@ pub fn parse_wireguard_config(content: &str) -> Result Result usize { + let content = content.strip_prefix('\u{feff}').unwrap_or(content); + content + .lines() + .filter(|line| line.trim() == "[Peer]") + .count() +} + /// Validate that a WireGuard key is a base64-encoded 32-byte value. /// Reports the field name and a short preview of the bad value so users can /// see exactly what went wrong (e.g. a redacted/masked key). @@ -326,6 +345,50 @@ Endpoint = 1.2.3.4:51820 assert_eq!(config.peer_endpoint, "1.2.3.4:51820"); } + #[test] + fn test_parse_wireguard_config_takes_the_last_peer_whole() { + // Peer A carries a preshared key and a split-tunnel AllowedIPs, peer B + // carries neither. The parsed peer must be B alone: before the per-section + // reset, B's identity inherited A's preshared key and the tunnel handshook + // against a peer that existed in no input block. + let content = r#" +[Interface] +PrivateKey = YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE= +Address = 10.0.0.2/24 + +[Peer] +PublicKey = YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI= +Endpoint = a.example.com:51820 +AllowedIPs = 10.0.0.0/24 +PresharedKey = ZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGQ= + +[Peer] +PublicKey = Y2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2M= +Endpoint = b.example.com:51820 +"#; + + let config = parse_wireguard_config(content).unwrap(); + assert_eq!( + config.peer_public_key, + "Y2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2M=" + ); + assert_eq!(config.peer_endpoint, "b.example.com:51820"); + assert!(config.preshared_key.is_none()); + assert_eq!(config.allowed_ips, vec!["0.0.0.0/0"]); + } + + #[test] + fn test_wireguard_peer_count() { + let single = "[Interface]\nPrivateKey = k\n\n[Peer]\nPublicKey = p\n"; + let multi = "[Interface]\nPrivateKey = k\n\n[Peer]\nPublicKey = p\n\n[Peer]\nPublicKey = q\n"; + + assert_eq!(wireguard_peer_count(single), 1); + assert_eq!(wireguard_peer_count(multi), 2); + assert_eq!(wireguard_peer_count("[Interface]\nPrivateKey = k\n"), 0); + // A commented-out header is not a section. + assert_eq!(wireguard_peer_count("# [Peer]\n"), 0); + } + #[test] fn test_parse_wireguard_missing_private_key() { let content = r#" diff --git a/src-tauri/src/vpn/storage.rs b/src-tauri/src/vpn/storage.rs index 5edecb3..a9b3fb9 100644 --- a/src-tauri/src/vpn/storage.rs +++ b/src-tauri/src/vpn/storage.rs @@ -333,6 +333,25 @@ impl VpnStorage { } } + /// Refuse a config that declares more peers than a tunnel can carry. + /// + /// A stored config is re-parsed at connect time into a single-peer tunnel, so + /// a file with several `[Peer]` blocks silently routes through whichever peer + /// is listed last and surfaces only as an opaque handshake timeout. Every way + /// a config gets in (manual create, file import, sync download) refuses one; + /// configs already on disk keep connecting exactly as before. + pub fn ensure_single_peer(vpn_type: VpnType, content: &str) -> Result<(), VpnError> { + let peers = match vpn_type { + VpnType::WireGuard => super::config::wireguard_peer_count(content), + }; + if peers > 1 { + return Err(VpnError::InvalidWireGuard(format!( + "Config declares {peers} [Peer] sections; exactly one peer is supported" + ))); + } + Ok(()) + } + /// Create a VPN config manually from validated data pub fn create_config_manual( &self, @@ -345,6 +364,7 @@ impl VpnStorage { super::parse_wireguard_config(config_data)?; } } + Self::ensure_single_peer(vpn_type, config_data)?; let id = Uuid::new_v4().to_string(); let sync_enabled = crate::sync::is_sync_configured(); @@ -407,6 +427,7 @@ impl VpnStorage { super::parse_wireguard_config(content)?; } } + Self::ensure_single_peer(vpn_type, content)?; let id = Uuid::new_v4().to_string(); let display_name = name.unwrap_or_else(|| { @@ -548,4 +569,41 @@ mod tests { let result = storage.load_config("nonexistent"); assert!(result.is_err()); } + + #[test] + fn test_ensure_single_peer() { + let single = "[Interface]\nPrivateKey = k\n\n[Peer]\nPublicKey = p\n"; + let multi = "[Interface]\nPrivateKey = k\n\n[Peer]\nPublicKey = p\n\n[Peer]\nPublicKey = q\n"; + + assert!(VpnStorage::ensure_single_peer(VpnType::WireGuard, single).is_ok()); + // A listing hands out an empty config body; it declares no peer to reject. + assert!(VpnStorage::ensure_single_peer(VpnType::WireGuard, "").is_ok()); + + let err = VpnStorage::ensure_single_peer(VpnType::WireGuard, multi).unwrap_err(); + assert!(err.to_string().contains("[Peer]")); + } + + #[test] + fn test_import_config_rejects_multi_peer() { + let (storage, _temp) = create_test_storage(); + let content = concat!( + "[Interface]\n", + "PrivateKey = YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=\n", + "Address = 10.0.0.2/24\n", + "\n", + "[Peer]\n", + "PublicKey = YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI=\n", + "Endpoint = a.example.com:51820\n", + "\n", + "[Peer]\n", + "PublicKey = Y2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2M=\n", + "Endpoint = b.example.com:51820\n", + ); + + // Every field parses; the file is refused only because a tunnel built from + // it would silently use the second peer. + assert!(crate::vpn::parse_wireguard_config(content).is_ok()); + let imported = storage.import_config(content, "two-peers.conf", None); + assert!(imported.is_err()); + } } diff --git a/src-tauri/src/vpn_extension_detect/rules.rs b/src-tauri/src/vpn_extension_detect/rules.rs index 1fd1eca..5311eb5 100644 --- a/src-tauri/src/vpn_extension_detect/rules.rs +++ b/src-tauri/src/vpn_extension_detect/rules.rs @@ -119,11 +119,14 @@ pub fn signals_from_manifest(manifest: &serde_json::Value) -> ManifestSignals { let has = |list: &[&str], name: &str| list.contains(&name); - // MV2 keeps host patterns inside `permissions`; MV3 splits them into - // `host_permissions`. Look in both so one manifest version isn't silently - // under-detected. + // Host patterns can live in any of the four permission keys: MV2 keeps them + // in `permissions` and `optional_permissions`, MV3 splits them out into + // `host_permissions` and `optional_host_permissions`. Look in all four so one + // manifest version isn't silently under-detected. Only real host patterns can + // set the flag (`is_broad_host`), so API strings sharing these arrays are inert. let all_hosts: Vec<&str> = permissions .iter() + .chain(optional_permissions.iter()) .chain(host_permissions.iter()) .chain(optional_host_permissions.iter()) .copied() @@ -461,6 +464,30 @@ mod tests { ); } + #[test] + fn broad_hosts_detected_from_mv2_optional_permissions() { + // MV2 is where Chrome documents host patterns living in + // `optional_permissions`. Scanning the MV3 optional key but not this one + // dropped the extension from the scan result entirely. + let s = signals_of(json!({ + "manifest_version": 2, + "permissions": ["webRequest", "webRequestBlocking"], + "optional_permissions": [""] + })); + assert!(s.broad_host_permissions); + assert_eq!( + classify(None, &s, vpn_keyword_hit("Free VPN Proxy", None)), + Some("likely") + ); + assert!(signal_labels(None, &s, true).contains(&"broadHostPermissions".to_string())); + } + + #[test] + fn api_names_in_optional_permissions_are_not_broad_hosts() { + let s = signals_of(json!({ "optional_permissions": ["proxy", "storage"] })); + assert!(!s.broad_host_permissions); + } + #[test] fn keyword_matching_reads_the_name_broadly_and_the_description_narrowly() { assert!(vpn_keyword_hit("TouchVPN", None)); diff --git a/src-tauri/src/vpn_worker_runner.rs b/src-tauri/src/vpn_worker_runner.rs index 85a0856..b9ac85e 100644 --- a/src-tauri/src/vpn_worker_runner.rs +++ b/src-tauri/src/vpn_worker_runner.rs @@ -5,7 +5,9 @@ use crate::vpn_worker_storage::{ get_vpn_worker_config, list_vpn_worker_configs, save_vpn_worker_config, vpn_worker_config_path, VpnWorkerConfig, }; +use std::collections::HashMap; use std::process::Stdio; +use std::sync::{LazyLock, Mutex}; const VPN_WORKER_POLL_INTERVAL_MS: u64 = 100; const VPN_WORKER_STARTUP_TIMEOUT_MS: u64 = 30_000; @@ -33,6 +35,23 @@ async fn vpn_worker_accepting_connections(config: &VpnWorkerConfig) -> bool { ) } +/// Is this worker's recorded process still the same live process? +/// +/// Identity-checked whenever a start time was recorded, so a PID the OS has +/// since recycled reads as dead instead of as a live tunnel. Configs written +/// before `pid_start_time` existed fall back to a bare existence check, so the +/// first run after an upgrade does not declare every surviving worker dead. +/// Mirrors `proxy_storage::browser_owner_is_alive`. +pub fn vpn_worker_alive(config: &VpnWorkerConfig) -> bool { + let Some(pid) = config.pid else { + return false; + }; + match config.pid_start_time { + Some(start_time) => crate::proxy_storage::process_identity_matches(pid, Some(start_time)), + None => is_process_running(pid), + } +} + fn worker_log_path(id: &str) -> std::path::PathBuf { std::env::temp_dir().join(format!("donut-vpn-{}.log", id)) } @@ -61,7 +80,7 @@ async fn wait_for_vpn_worker_ready( .await; if let Some(updated_config) = get_vpn_worker_config(id) { - let process_running = updated_config.pid.map(is_process_running).unwrap_or(false); + let process_running = vpn_worker_alive(&updated_config); if !process_running && attempts > 2 { let log_output = read_worker_log(id); @@ -77,7 +96,7 @@ async fn wait_for_vpn_worker_ready( attempts += 1; if tokio::time::Instant::now() >= startup_deadline { if let Some(config) = get_vpn_worker_config(id) { - let process_running = config.pid.map(is_process_running).unwrap_or(false); + let process_running = vpn_worker_alive(&config); let log_output = read_worker_log(id); delete_vpn_worker_config(id); return Err( @@ -106,12 +125,66 @@ async fn wait_for_vpn_worker_ready( /// `xray_worker_runner::XRAY_START_LOCK`. static VPN_START_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +/// How many in-flight launches currently hold a worker for each vpn_id. +/// +/// A launch is invisible to `vpn_id_in_use_by_running_browser` until its +/// browser PID is persisted, which happens seconds after the worker is adopted: +/// past the fingerprint gate, the local proxy worker, the decrypted profile +/// copy and the browser spawn. Without this, a sibling launch failing inside +/// that window stopped the shared worker out from under the adopter. +static VPN_LAUNCH_CLAIMS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// The critical section is a map bump that cannot panic, so a poisoned lock +/// carries no torn state worth refusing. +fn launch_claims() -> std::sync::MutexGuard<'static, HashMap> { + VPN_LAUNCH_CLAIMS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// One launch's hold on a VPN worker, taken while `VPN_START_LOCK` is held and +/// released only when the launch scope ends. Strictly RAII: nothing increments +/// the count outside `start_vpn_worker_tracked`, so a panicking launch cannot +/// pin a worker up for good. +pub struct VpnLaunchClaim { + vpn_id: String, +} + +impl VpnLaunchClaim { + fn take(vpn_id: &str) -> Self { + *launch_claims().entry(vpn_id.to_string()).or_insert(0) += 1; + Self { + vpn_id: vpn_id.to_string(), + } + } +} + +impl Drop for VpnLaunchClaim { + fn drop(&mut self) { + let mut claims = launch_claims(); + if let Some(count) = claims.get_mut(&self.vpn_id) { + *count = count.saturating_sub(1); + if *count == 0 { + claims.remove(&self.vpn_id); + } + } + } +} + +fn vpn_id_is_claimed_by_launch(vpn_id: &str) -> bool { + launch_claims().get(vpn_id).is_some_and(|count| *count > 0) +} + /// A started VPN worker plus whether *this* call spawned it. pub struct VpnWorkerStart { pub config: VpnWorkerConfig, /// False when an already-running worker was adopted. Only the creator may /// stop it while unwinding a failed launch. pub created: bool, + /// Held for the rest of the launch, so a sibling launch failing before this + /// one publishes its browser PID cannot stop the worker underneath it. + pub claim: VpnLaunchClaim, } /// Whether any profile with a live browser process is routing through this VPN. @@ -119,6 +192,11 @@ pub struct VpnWorkerStart { /// Extracted from the startup sweep so the launch guard and the sweep agree on /// what "in use" means instead of each carrying its own copy. pub fn vpn_id_in_use_by_running_browser(vpn_id: &str) -> bool { + // A launch that has taken the worker but has not yet persisted its browser + // PID is invisible to the profile scan below, so consult the claims first. + if vpn_id_is_claimed_by_launch(vpn_id) { + return true; + } let Ok(profiles) = crate::profile::ProfileManager::instance().list_profiles() else { // Unable to tell — assume in use rather than tear down a live tunnel. return true; @@ -146,33 +224,29 @@ pub async fn start_vpn_worker_tracked( crate::proxy_runner::ensure_sidecar_version().await?; for config in list_vpn_worker_configs() { - if let Some(pid) = config.pid { - if !is_process_running(pid) { - delete_vpn_worker_config(&config.id); - } - } else { + if !vpn_worker_alive(&config) { delete_vpn_worker_config(&config.id); } } // Check if a VPN worker for this vpn_id already exists and is running if let Some(existing) = find_vpn_worker_by_vpn_id(vpn_id) { - if let Some(pid) = existing.pid { - if is_process_running(pid) { - if vpn_worker_accepting_connections(&existing).await { - return Ok(VpnWorkerStart { - config: existing, - created: false, - }); - } - - return wait_for_vpn_worker_ready(&existing.id) - .await - .map(|config| VpnWorkerStart { - config, - created: false, - }); + if vpn_worker_alive(&existing) { + if vpn_worker_accepting_connections(&existing).await { + return Ok(VpnWorkerStart { + config: existing, + created: false, + claim: VpnLaunchClaim::take(vpn_id), + }); } + + return wait_for_vpn_worker_ready(&existing.id) + .await + .map(|config| VpnWorkerStart { + config, + created: false, + claim: VpnLaunchClaim::take(vpn_id), + }); } // Worker config exists but process is dead, clean up delete_vpn_worker_config(&existing.id); @@ -266,6 +340,7 @@ pub async fn start_vpn_worker_tracked( let mut config_with_pid = config.clone(); config_with_pid.pid = Some(pid); + config_with_pid.pid_start_time = crate::proxy_storage::resolve_process_start_time(pid); config_with_pid.local_port = Some(local_port); save_vpn_worker_config(&config_with_pid)?; @@ -308,6 +383,7 @@ pub async fn start_vpn_worker_tracked( let mut config_with_pid = config.clone(); config_with_pid.pid = Some(pid); + config_with_pid.pid_start_time = crate::proxy_storage::resolve_process_start_time(pid); config_with_pid.local_port = Some(local_port); save_vpn_worker_config(&config_with_pid)?; @@ -319,6 +395,7 @@ pub async fn start_vpn_worker_tracked( .map(|config| VpnWorkerStart { config, created: true, + claim: VpnLaunchClaim::take(vpn_id), }) } @@ -327,26 +404,40 @@ pub async fn stop_vpn_worker(id: &str) -> Result Result<(), Box> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn worker(pid: Option, pid_start_time: Option) -> VpnWorkerConfig { + VpnWorkerConfig { + id: "vpnw_test".to_string(), + vpn_id: "vpn_test".to_string(), + vpn_type: "wireguard".to_string(), + config_file_path: String::new(), + local_port: None, + local_url: None, + pid, + pid_start_time, + } + } + + #[test] + fn a_recycled_pid_does_not_read_as_a_live_worker() { + let pid = std::process::id(); + let start_time = + crate::proxy_storage::process_start_time(pid).expect("current process should be visible"); + + assert!(vpn_worker_alive(&worker(Some(pid), Some(start_time)))); + + // The same PID with a start time it cannot have: the worker that recorded + // it is gone and the OS handed its PID to something else. + assert!(!vpn_worker_alive(&worker( + Some(pid), + Some(start_time.saturating_add(1)) + ))); + + // Written before the field existed, so bare existence is all the + // information the record carries. Upgrading must not reap live workers. + assert!(vpn_worker_alive(&worker(Some(pid), None))); + + assert!(!vpn_worker_alive(&worker(None, None))); + assert!(!vpn_worker_alive(&worker(None, Some(start_time)))); + } + + #[test] + fn a_launch_claim_covers_the_worker_until_every_launch_ends() { + // A vpn_id private to this test, so a parallel test's claims are neither + // observed here nor disturbed by it. + let vpn_id = format!("vpn_claim_test_{}", rand::random::()); + + assert!(!vpn_id_is_claimed_by_launch(&vpn_id)); + + let creator = VpnLaunchClaim::take(&vpn_id); + assert!(vpn_id_is_claimed_by_launch(&vpn_id)); + + // An adopter joins, then the creator's launch fails: the worker is still + // covered, which is what stops the creator's guard tearing it down. + let adopter = VpnLaunchClaim::take(&vpn_id); + drop(creator); + assert!(vpn_id_is_claimed_by_launch(&vpn_id)); + + // With no launch left holding it, nothing keeps the worker up. A creator + // whose launch fails alone must still be able to stop what it started. + drop(adopter); + assert!(!vpn_id_is_claimed_by_launch(&vpn_id)); + } +} diff --git a/src-tauri/src/vpn_worker_storage.rs b/src-tauri/src/vpn_worker_storage.rs index 88eac23..8745890 100644 --- a/src-tauri/src/vpn_worker_storage.rs +++ b/src-tauri/src/vpn_worker_storage.rs @@ -12,6 +12,11 @@ pub struct VpnWorkerConfig { pub local_port: Option, pub local_url: Option, pub pid: Option, + /// Pins `pid` to one exact process, so a recycled PID cannot make a dead + /// worker look alive or get an unrelated process signalled. Defaulted + /// because configs written before this field existed must still deserialize. + #[serde(default)] + pub pid_start_time: Option, } impl VpnWorkerConfig { @@ -24,6 +29,7 @@ impl VpnWorkerConfig { local_port: None, local_url: None, pid: None, + pid_start_time: None, } } } diff --git a/src-tauri/src/wayfern_cdp.rs b/src-tauri/src/wayfern_cdp.rs new file mode 100644 index 0000000..9ba2088 --- /dev/null +++ b/src-tauri/src/wayfern_cdp.rs @@ -0,0 +1,2145 @@ +//! Typed wrappers over the Wayfern 152 automation surface. +//! +//! Wayfern 152 answers four things an older build cannot: a native page +//! perception snapshot (`Wayfern.capturePagePerception`), a locator resolver +//! that refuses to guess (`Wayfern.resolveLocator`), structured extraction +//! (`Wayfern.extractStructured`) and an element picker driven by the user's own +//! click. It also carries the `Vellum` domain: a virtual pointer that glides, +//! strikes and types through the real input path, with timing that is stable +//! per profile. +//! +//! Everything here speaks to a PAGE session. A locally launched browser is +//! reached on its page socket, and a relayed one is attached flat to a page by +//! [`crate::cdp_target`], so on both arms `Vellum.acquire` resolves its surface +//! from the session's own frame and `surface` is never sent. The browser-target +//! form, which needs `surface`, is deliberately not used: it would mean a second +//! connection model for one feature. +//! +//! Nothing in this module decides WHICH engine a profile gets. That is +//! [`Engine::for_version`], read from the profile at the point of use, and the +//! pre-152 fallbacks live with the tools that own them. + +use crate::cdp_target::{CdpConnection, CdpError, CdpTarget}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::time::Duration; +use utoipa::ToSchema; + +/// How long an ordinary command may wait for its reply. +/// +/// The perception, extraction and typing commands carry their own budgets and +/// wait longer; this covers the rest, and matches the connection layer's own +/// ceiling so a hung browser is reported rather than waited on. +const COMMAND_TIMEOUT: Duration = Duration::from_secs(60); + +/// Headroom added to a budget the browser itself enforces, so the reply for a +/// capture that ran to its full budget still arrives before this side gives up. +const BUDGET_HEADROOM: Duration = Duration::from_secs(10); + +/// Ceiling on cursor pages followed in one perception call. +/// +/// A browser that always answers "truncated, here is a cursor" must not hold a +/// tool call forever. Sixty-four pages at the smallest page size the browser +/// allows is still far more than any real document. +const MAX_PERCEPTION_PAGES: usize = 64; + +/// Default total byte cap for one perception call, across cursor pages. +pub const DEFAULT_PERCEPTION_BYTE_CAP: u64 = 1024 * 1024; + +/// The largest total a caller may ask a perception call for. +/// +/// Sits under the largest result frame the bridge will carry, with room for the +/// pretty-printed JSON envelope the tool result travels in, so a result that +/// fits here is a result that can actually be delivered. +pub const MAX_PERCEPTION_BYTE_CAP: u64 = 4 * 1024 * 1024; + +/// The smallest byte cap the browser accepts for one page. +const MIN_PERCEPTION_BYTE_CAP: u64 = 1024; + +/// Which implementation answered a tool call. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum Engine { + /// The browser's native domains: no script in the page, real input path. + Wayfern, + /// The script-and-`Input.dispatch*` path an older Wayfern is driven with. + Fallback, +} + +impl Engine { + /// The engine a profile on `version` gets, read at the point of use. + pub fn for_version(version: &str) -> Self { + if crate::wayfern_manager::supports_wayfern_152(version) { + Self::Wayfern + } else { + Self::Fallback + } + } + + /// Serde default for results the browser produced: they are, by definition, + /// the native engine's. + fn wayfern() -> Self { + Self::Wayfern + } + + pub fn is_wayfern(self) -> bool { + self == Self::Wayfern + } +} + +/// What went wrong with a Wayfern command, beyond the transport. +/// +/// The locator failures are deliberately their own variants: the browser +/// encodes the candidates in its error message, and a caller that only sees +/// "CDP error" cannot show a user what was matched. +#[derive(Debug)] +pub enum WayfernError { + /// The connection layer failed, or the browser answered with an error + /// object this module does not interpret further. + Cdp(CdpError), + /// `resolveLocator` matched several nodes. `candidates` is the browser's own + /// enumeration, capped at the requested limit; `match_count` is not. + AmbiguousLocator { + match_count: u64, + candidates: Vec, + message: String, + }, + /// `resolveLocator` matched nothing. + NoMatch { message: String }, + /// Nobody picked an element before the deadline. + PickerTimedOut { timeout_ms: u64 }, + /// The picker ended without a pick: "escape", "stopped" or "navigated". + PickerCancelled { reason: String }, + /// A reply did not have the shape the protocol documents. + Malformed(String), +} + +impl std::fmt::Display for WayfernError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Cdp(e) => write!(f, "{e}"), + Self::AmbiguousLocator { message, .. } | Self::NoMatch { message } => f.write_str(message), + Self::PickerTimedOut { timeout_ms } => { + write!(f, "no element was picked within {timeout_ms} ms") + } + Self::PickerCancelled { reason } => write!(f, "the element picker was cancelled ({reason})"), + Self::Malformed(m) => write!(f, "unexpected reply from the browser: {m}"), + } + } +} + +impl From for WayfernError { + fn from(error: CdpError) -> Self { + Self::Cdp(error) + } +} + +/// A refusal the browser's own gate produced, told apart from a real failure. +/// +/// Every command this module sends can be refused by the browser for the same +/// two reasons `Runtime.evaluate` can: no paid plan, or too many calls too +/// fast. The messages are the browser's contract, and it keeps the two +/// deliberately distinct so a client can tell "not entitled" from +/// "entitled but too fast". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserRefusal { + /// "Browser automation requires a paid Donut Browser plan." + PaymentRequired, + /// "Automation rate limit exceeded (N requests/minute). Retry shortly." + RateLimited, + /// The browser could not confirm this account's plan and asks for a retry + /// rather than denying. + AuthorizationUnavailable, +} + +/// The `message` of the CDP error object a [`CdpError::Protocol`] carries. +pub fn protocol_message(error: &CdpError) -> Option { + let CdpError::Protocol(raw) = error else { + return None; + }; + let parsed: Value = serde_json::from_str(raw).ok()?; + parsed + .get("message") + .and_then(Value::as_str) + .map(str::to_string) +} + +/// The `code` of the CDP error object a [`CdpError::Protocol`] carries. +/// +/// `-32602` is the browser refusing the parameters, which is the caller's +/// problem; `-32000` is the browser failing to do what it was asked. +pub fn protocol_code(error: &CdpError) -> Option { + let CdpError::Protocol(raw) = error else { + return None; + }; + let parsed: Value = serde_json::from_str(raw).ok()?; + parsed.get("code").and_then(Value::as_i64) +} + +/// Whether `error` is the browser's gate saying no, and which no it said. +pub fn classify_refusal(error: &CdpError) -> Option { + let message = protocol_message(error)?; + if message.contains("requires a paid Donut Browser plan") { + Some(BrowserRefusal::PaymentRequired) + } else if message.starts_with("Automation rate limit exceeded") { + Some(BrowserRefusal::RateLimited) + } else if message.contains("authorization service is temporarily unavailable") { + Some(BrowserRefusal::AuthorizationUnavailable) + } else { + None + } +} + +// --- Locators --------------------------------------------------------------- + +/// One `name=value` pair a locator requires, or a matched node carries. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct LocatorAttribute { + pub name: String, + pub value: String, +} + +/// How a caller names an element without a selector. +/// +/// Mirrors the locator shape the browser takes. Keys are the browser's own +/// (`nameContains`, `textContains`); the snake_case spellings are accepted on +/// input so a REST body written in this API's usual style still parses. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct LocatorDescription { + /// AX role token, matched case- and separator-insensitively. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role: Option, + /// Computed accessible name, exact after whitespace collapse. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Substring form of `name`. + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "name_contains" + )] + pub name_contains: Option, + /// Visible text content, from the live layout. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text: Option, + /// Substring form of `text`. + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "text_contains" + )] + pub text_contains: Option, + /// Attribute pairs that must all match. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attributes: Option>, +} + +impl LocatorDescription { + /// A locator with nothing in it matches everything, which is never what a + /// caller meant; refused here before the browser is asked. + pub fn is_empty(&self) -> bool { + self.role.is_none() + && self.name.is_none() + && self.name_contains.is_none() + && self.text.is_none() + && self.text_contains.is_none() + && self.attributes.as_ref().is_none_or(Vec::is_empty) + } +} + +/// Where a matched node is, in root-document CSS pixels. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, ToSchema)] +pub struct LocatorBounds { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +/// Everything a caller needs about one matched node. +/// +/// `value` is omitted, never blanked, for a control the page marked protected; +/// `backendNodeId` is absent only on the fallback engine, which has no DOM +/// agent behind it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct LocatorCandidate { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend_node_id: Option, + pub role: String, + pub name: String, + pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + /// Per-profile deterministic identifier for the node's structural position. + pub signature: String, + #[serde(default)] + pub attributes: Vec, + pub bounds: LocatorBounds, +} + +/// A locator resolved to exactly one node. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct LocatorResolution { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend_node_id: Option, + /// Always 1: present so a caller can assert it rather than infer it. + pub match_count: u64, + #[serde(rename = "match")] + pub matched: LocatorCandidate, + /// The canonical form of the locator that resolved. + pub locator: LocatorDescription, + #[serde(skip_deserializing, default = "Engine::wayfern")] + pub engine: Engine, +} + +/// Options for `Wayfern.resolveLocator` beyond the locator itself. +#[derive(Debug, Clone, Copy, Default)] +pub struct ResolveOptions { + /// How many candidates an ambiguity error enumerates. Default 10, ceiling 100. + pub candidate_limit: Option, + /// Node cap for the snapshot. Default 20000, ceiling 200000. + pub max_nodes: Option, + /// Snapshot budget in milliseconds. Default 3000, ceiling 30000. + pub time_budget_ms: Option, +} + +// --- Perception ------------------------------------------------------------- + +/// What a caller may ask `Wayfern.capturePagePerception` for. +/// +/// Field names are this API's snake_case; the browser's camelCase spellings +/// are accepted too. +#[derive(Debug, Clone, Default, Deserialize, ToSchema)] +pub struct PerceptionRequest { + /// Total byte cap across cursor pages. Default 1 MiB, ceiling 4 MiB. + #[serde(default, alias = "maxBytes")] + pub max_bytes: Option, + /// Capture budget in milliseconds. Default 5000, clamped to [100, 60000]. + #[serde(default, alias = "budgetMs")] + pub budget_ms: Option, + /// Per-frame node ceiling. Default 100000; 0 for no limit. + #[serde(default, alias = "maxNodes")] + pub max_nodes: Option, + /// Include readable text. Default true. + #[serde(default, alias = "includeText")] + pub include_text: Option, + /// Drop nodes outside the viewport. Default false. + #[serde(default, alias = "viewportOnly")] + pub viewport_only: Option, + /// "reading" (default) or "visual". + #[serde(default, alias = "textOrder")] + pub text_order: Option, + /// Continue an earlier capture from the cursor it returned. + #[serde(default)] + pub cursor: Option, +} + +impl PerceptionRequest { + /// The total byte cap this request asks for, within the allowed range. + pub fn byte_cap(&self) -> u64 { + self + .max_bytes + .unwrap_or(DEFAULT_PERCEPTION_BYTE_CAP) + .clamp(MIN_PERCEPTION_BYTE_CAP, MAX_PERCEPTION_BYTE_CAP) + } + + /// The parameters of the FIRST capture. A continuation carries the cursor + /// alone, because the browser ignores everything else when one is present. + fn browser_params(&self, byte_cap: u64) -> Value { + let mut params = serde_json::Map::new(); + params.insert("maxBytes".into(), Value::from(byte_cap)); + if let Some(budget) = self.budget_ms { + params.insert("budgetMs".into(), Value::from(budget)); + } + if let Some(nodes) = self.max_nodes { + params.insert("maxNodes".into(), Value::from(nodes)); + } + if let Some(text) = self.include_text { + params.insert("includeText".into(), Value::from(text)); + } + if let Some(viewport) = self.viewport_only { + params.insert("viewportOnly".into(), Value::from(viewport)); + } + if let Some(order) = &self.text_order { + params.insert("textOrder".into(), Value::from(order.as_str())); + } + Value::Object(params) + } +} + +/// One node of a perception snapshot. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PerceptionNode { + /// Short, stable, frame-qualified handle. + pub id: String, + pub frame_id: String, + pub role: String, + /// Root-document page coordinates in CSS pixels, unclipped. + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, + pub in_viewport: bool, + pub visible: bool, + pub focused: bool, + pub disabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, + /// "true", "false" or "mixed"; absent for anything not checkable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checked: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expanded: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scrollable: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scroll_container_id: Option, +} + +/// One frame of a perception snapshot. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PerceptionFrame { + pub frame_id: String, + pub url: String, + pub cross_origin: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_frame_id: Option, +} + +/// How much a capture covered, and how much it cost. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PerceptionStats { + /// Nodes in the whole snapshot, not on this page. + pub total_nodes: u64, + pub returned_nodes: u64, + /// Serialized size of the nodes and text returned. + pub bytes: u64, + pub elapsed_ms: u64, + pub frames_visited: u64, + /// Frames whose renderer did not answer within the budget. + pub frames_failed: u64, +} + +/// A perception snapshot, or as much of one as the byte cap allowed. +/// +/// When `truncated` is true a `cursor` follows: pass it back to continue from +/// where this call stopped. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PerceptionPage { + pub snapshot_id: String, + pub nodes: Vec, + pub frames: Vec, + /// Readable text for exactly the nodes returned. + pub text: String, + pub truncated: bool, + pub stats: PerceptionStats, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, + #[serde(skip_deserializing, default = "Engine::wayfern")] + pub engine: Engine, +} + +// --- Extraction ------------------------------------------------------------- + +/// One output column of a structured extraction. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ExtractionField { + /// The key this column appears under in each row's values. + pub key: String, + /// Evaluated inside each container; the first match wins. + pub locator: LocatorDescription, + /// "text", "attribute" or "link". + pub source: String, + /// Required when `source` is "attribute". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attribute: Option, +} + +/// What a caller asks `Wayfern.extractStructured` for. +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct ExtractionRequest { + /// Matches every row container; several matches are the expected case. + pub container: LocatorDescription, + /// The columns. The browser calls this `fieldMap`; `fields` is accepted too. + #[serde(alias = "fieldMap", alias = "fields")] + pub field_map: Vec, + /// The control clicked to advance a page. Absent means one page. + #[serde(default, alias = "nextPage")] + pub next_page: Option, + /// Default 1, ceiling 200. + #[serde(default, alias = "maxPages")] + pub max_pages: Option, + /// Default 1000, ceiling 100000. + #[serde(default, alias = "maxRows")] + pub max_rows: Option, + /// Default 262144, ceiling 8388608. + #[serde(default, alias = "maxBytes")] + pub max_bytes: Option, + /// Node cap for each snapshot. Default 20000, ceiling 200000. + #[serde(default, alias = "maxNodes")] + pub max_nodes: Option, + /// Default 8000, ceiling 120000. + #[serde(default, alias = "timeBudgetMs")] + pub time_budget_ms: Option, +} + +impl ExtractionRequest { + fn browser_params(&self) -> Result { + let mut params = serde_json::Map::new(); + params.insert( + "container".into(), + serde_json::to_value(&self.container).map_err(|e| WayfernError::Malformed(e.to_string()))?, + ); + params.insert( + "fieldMap".into(), + serde_json::to_value(&self.field_map).map_err(|e| WayfernError::Malformed(e.to_string()))?, + ); + if let Some(next) = &self.next_page { + params.insert( + "nextPage".into(), + serde_json::to_value(next).map_err(|e| WayfernError::Malformed(e.to_string()))?, + ); + } + for (key, value) in [ + ("maxPages", self.max_pages), + ("maxRows", self.max_rows), + ("maxBytes", self.max_bytes), + ("maxNodes", self.max_nodes), + ("timeBudgetMs", self.time_budget_ms), + ] { + if let Some(value) = value { + params.insert(key.into(), Value::from(value)); + } + } + Ok(Value::Object(params)) + } + + /// How long to wait for the reply: the browser's own budget plus headroom. + fn reply_timeout(&self) -> Duration { + let budget = self.time_budget_ms.unwrap_or(8_000).clamp(1, 120_000); + Duration::from_millis(budget) + BUDGET_HEADROOM + } +} + +/// One extracted row. A field that matched nothing is an absent key. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ExtractionRow { + /// Global across pages. + pub index: u64, + /// Zero-based page this row came from. + pub page: u64, + #[schema(value_type = Object)] + pub values: serde_json::Map, +} + +/// The rows an extraction produced and why it stopped. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Extraction { + pub rows: Vec, + pub row_count: u64, + pub page_count: u64, + /// Byte length of the compact JSON serialization of `rows`. + pub byte_size: u64, + pub truncated: bool, + /// "complete", "no-container", "no-next", "page-cap", "row-cap", + /// "byte-cap" or "time-budget". + pub stop_reason: String, + #[serde(skip_deserializing, default = "Engine::wayfern")] + pub engine: Engine, +} + +// --- Picker ----------------------------------------------------------------- + +/// What the user clicked while the picker was armed. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PickedElement { + pub backend_node_id: i64, + /// The smallest description that still resolves to this node. + pub locator: LocatorDescription, + /// How many nodes `locator` matches; more than 1 when the page genuinely + /// holds indistinguishable elements. + pub match_count: u64, + pub node: LocatorCandidate, + #[serde(skip_deserializing, default = "Engine::wayfern")] + pub engine: Engine, +} + +// --- Session ---------------------------------------------------------------- + +/// One open page session, with command ids handed out in order. +/// +/// [`CdpConnection`] leaves id allocation to its caller because the one-shot +/// runners in `cdp_target` never send more than three commands. A gesture here +/// sends five or more on one socket, and a reply matched to the wrong id is a +/// silent wrong answer, so the ids are owned in one place. +pub struct WayfernSession { + connection: CdpConnection, + next_id: u64, +} + +impl WayfernSession { + /// Open a page session on `target`. + pub async fn open(target: &CdpTarget) -> Result { + Ok(Self { + connection: target.connect().await?, + next_id: 1, + }) + } + + fn allocate_id(&mut self) -> u64 { + let id = self.next_id; + self.next_id += 1; + id + } + + /// Send `method` and wait for its reply within the ordinary budget. + pub async fn call(&mut self, method: &str, params: Value) -> Result { + self + .call_with_timeout(method, params, COMMAND_TIMEOUT) + .await + } + + /// Send `method` and wait up to `timeout` for its reply. + pub async fn call_with_timeout( + &mut self, + method: &str, + params: Value, + timeout: Duration, + ) -> Result { + let id = self.allocate_id(); + self.connection.send_command(id, method, params).await?; + Ok(self.connection.await_reply(id, timeout).await?) + } + + /// Send `method`, then keep reading until BOTH its reply and `event` have + /// arrived, or `timeout` passes. + /// + /// The reply alone is never enough for an action that may navigate: the + /// browser answers `Vellum.strike` as soon as the release is dispatched, and + /// the load that follows is what the caller is waiting for. The reply alone + /// is, however, still the answer when no load ever comes; that is the normal + /// case for a click that only changed state on the page. + /// + /// Returns the reply and whether `event` was seen. An error reply is an + /// error; a socket that dies before the reply is one too. + pub async fn call_then_await_event( + &mut self, + method: &str, + params: Value, + event: &str, + timeout: Duration, + ) -> Result<(Value, bool), WayfernError> { + let id = self.allocate_id(); + self.connection.send_command(id, method, params).await?; + + let deadline = tokio::time::Instant::now() + timeout; + let mut reply: Option = None; + let mut event_seen = false; + + loop { + if reply.is_some() && event_seen { + break; + } + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + let text = match tokio::time::timeout(remaining, self.connection.next_text()).await { + Ok(Some(Ok(text))) => text, + Ok(Some(Err(e))) => { + if reply.is_none() { + return Err(e.into()); + } + break; + } + Ok(None) => { + if reply.is_none() { + return Err( + self + .connection + .closed_error("no response received from CDP") + .into(), + ); + } + break; + } + Err(_) => break, + }; + + let message: Value = serde_json::from_str(&text).unwrap_or_default(); + if message.get("id") == Some(&Value::from(id)) { + if let Some(error) = message.get("error") { + return Err(CdpError::Protocol(error.to_string()).into()); + } + reply = Some( + message + .get("result") + .cloned() + .unwrap_or_else(|| serde_json::json!({})), + ); + continue; + } + if message.get("method").and_then(Value::as_str) == Some(event) { + event_seen = true; + } + } + + match reply { + Some(reply) => Ok((reply, event_seen)), + None => { + Err(CdpError::Transport(format!("timed out waiting for the reply to {method}")).into()) + } + } + } + + /// Wait for the first of `events`, discarding everything else. + /// + /// `Ok(None)` is the deadline passing; a dead socket is an error. + pub async fn await_any_event( + &mut self, + events: &[&str], + timeout: Duration, + ) -> Result, WayfernError> { + let deadline = tokio::time::Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Ok(None); + } + let text = match tokio::time::timeout(remaining, self.connection.next_text()).await { + Ok(Some(Ok(text))) => text, + Ok(Some(Err(e))) => return Err(e.into()), + Ok(None) => { + return Err( + self + .connection + .closed_error("the browser closed the session while an event was awaited") + .into(), + ) + } + Err(_) => return Ok(None), + }; + let message: Value = serde_json::from_str(&text).unwrap_or_default(); + let Some(method) = message.get("method").and_then(Value::as_str) else { + continue; + }; + if events.contains(&method) { + let params = message + .get("params") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + return Ok(Some((method.to_string(), params))); + } + } + } + + /// Hang up politely. + pub async fn close(self) { + self.connection.close().await; + } +} + +// --- Perception, locators, extraction, picker -------------------------------- + +/// Capture the page, following cursors until the browser is done or the byte +/// cap is reached. +/// +/// Pages are merged into one result. `truncated` and `cursor` describe what is +/// left AFTER this call: false and absent when the browser said it was done, +/// true with the next cursor when this side stopped at the cap. +pub async fn capture_page_perception( + session: &mut WayfernSession, + request: &PerceptionRequest, +) -> Result { + let byte_cap = request.byte_cap(); + let budget = request.budget_ms.unwrap_or(5_000).clamp(100, 60_000); + let reply_timeout = Duration::from_millis(budget) + BUDGET_HEADROOM; + + let mut params = match &request.cursor { + Some(cursor) if !cursor.is_empty() => serde_json::json!({ "cursor": cursor }), + _ => request.browser_params(byte_cap), + }; + + let mut merged: Option = None; + let mut accumulated: u64 = 0; + + for _ in 0..MAX_PERCEPTION_PAGES { + let raw = session + .call_with_timeout("Wayfern.capturePagePerception", params, reply_timeout) + .await?; + let page: PerceptionPage = serde_json::from_value(raw) + .map_err(|e| WayfernError::Malformed(format!("capturePagePerception: {e}")))?; + accumulated = accumulated.saturating_add(page.stats.bytes); + + let next_cursor = page.cursor.clone().filter(|c| !c.is_empty()); + let more = page.truncated && next_cursor.is_some(); + + merged = Some(match merged { + None => page, + Some(mut whole) => { + whole.nodes.extend(page.nodes); + whole.text.push_str(&page.text); + whole.frames = page.frames; + whole.stats.total_nodes = page.stats.total_nodes; + whole.stats.returned_nodes += page.stats.returned_nodes; + whole.stats.bytes += page.stats.bytes; + whole.stats.elapsed_ms += page.stats.elapsed_ms; + whole.stats.frames_visited = whole.stats.frames_visited.max(page.stats.frames_visited); + whole.stats.frames_failed = whole.stats.frames_failed.max(page.stats.frames_failed); + whole.truncated = page.truncated; + whole.cursor = page.cursor; + whole + } + }); + + if !more || accumulated >= byte_cap { + break; + } + params = serde_json::json!({ "cursor": next_cursor }); + } + + let mut result = merged + .ok_or_else(|| WayfernError::Malformed("capturePagePerception answered nothing".into()))?; + result.engine = Engine::Wayfern; + if !result.truncated { + result.cursor = None; + } + Ok(result) +} + +/// Resolve `locator` to exactly one node, or say precisely why not. +pub async fn resolve_locator( + session: &mut WayfernSession, + locator: &LocatorDescription, + options: ResolveOptions, +) -> Result { + let mut params = serde_json::Map::new(); + params.insert( + "locator".into(), + serde_json::to_value(locator).map_err(|e| WayfernError::Malformed(e.to_string()))?, + ); + for (key, value) in [ + ("candidateLimit", options.candidate_limit), + ("maxNodes", options.max_nodes), + ("timeBudgetMs", options.time_budget_ms), + ] { + if let Some(value) = value { + params.insert(key.into(), Value::from(value)); + } + } + let budget = options.time_budget_ms.unwrap_or(3_000).clamp(1, 30_000); + let reply_timeout = Duration::from_millis(budget) + BUDGET_HEADROOM; + + let raw = match session + .call_with_timeout( + "Wayfern.resolveLocator", + Value::Object(params), + reply_timeout, + ) + .await + { + Ok(raw) => raw, + Err(WayfernError::Cdp(error)) => return Err(classify_locator_error(error)), + Err(other) => return Err(other), + }; + let mut resolution: LocatorResolution = serde_json::from_value(raw) + .map_err(|e| WayfernError::Malformed(format!("resolveLocator: {e}")))?; + resolution.engine = Engine::Wayfern; + Ok(resolution) +} + +/// Turn the browser's locator refusals into their structured form. +/// +/// The ambiguity message is machine-readable by contract: the `Candidates: ` +/// marker and the JSON after it are part of the protocol, not decoration. +pub fn classify_locator_error(error: CdpError) -> WayfernError { + let Some(message) = protocol_message(&error) else { + return WayfernError::Cdp(error); + }; + if let Some(rest) = message.strip_prefix("Ambiguous locator: ") { + let match_count = rest + .split(|c: char| !c.is_ascii_digit()) + .next() + .and_then(|digits| digits.parse::().ok()) + .unwrap_or(0); + let candidates = message + .split_once("Candidates: ") + .and_then(|(_, json)| serde_json::from_str::>(json.trim()).ok()) + .unwrap_or_default(); + return WayfernError::AmbiguousLocator { + match_count, + candidates, + message, + }; + } + if message.starts_with("No node matches locator") { + return WayfernError::NoMatch { message }; + } + WayfernError::Cdp(error) +} + +/// Read rows off the live page. +pub async fn extract_structured( + session: &mut WayfernSession, + request: &ExtractionRequest, +) -> Result { + let params = request.browser_params()?; + let raw = session + .call_with_timeout("Wayfern.extractStructured", params, request.reply_timeout()) + .await?; + let mut extraction: Extraction = serde_json::from_value(raw) + .map_err(|e| WayfernError::Malformed(format!("extractStructured: {e}")))?; + extraction.engine = Engine::Wayfern; + Ok(extraction) +} + +/// Arm the picker and wait for the user to click something. +/// +/// On the deadline the picker is disarmed before the error is returned, so a +/// highlight is never left on the page after the tool call that asked for it +/// has answered. The `elementPickerCancelled("stopped")` that disarming emits +/// is not read: the session is closed by the caller right after. +pub async fn pick_element( + session: &mut WayfernSession, + timeout: Duration, + highlight: bool, +) -> Result { + session + .call( + "Wayfern.startElementPicker", + serde_json::json!({ "highlight": highlight }), + ) + .await?; + + let outcome = session + .await_any_event( + &["Wayfern.elementPicked", "Wayfern.elementPickerCancelled"], + timeout, + ) + .await; + + match outcome { + Ok(Some((method, params))) if method == "Wayfern.elementPicked" => { + let mut picked: PickedElement = serde_json::from_value(params) + .map_err(|e| WayfernError::Malformed(format!("elementPicked: {e}")))?; + picked.engine = Engine::Wayfern; + Ok(picked) + } + Ok(Some((_, params))) => Err(WayfernError::PickerCancelled { + reason: params + .get("reason") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(), + }), + Ok(None) => { + let _ = session + .call("Wayfern.stopElementPicker", serde_json::json!({})) + .await; + Err(WayfernError::PickerTimedOut { + timeout_ms: timeout.as_millis() as u64, + }) + } + Err(e) => Err(e), + } +} + +// --- Geometry --------------------------------------------------------------- + +/// Where to put the pointer for a node, in viewport CSS pixels. +/// +/// `Vellum` takes the coordinates `Input.dispatchMouseEvent` takes, which are +/// relative to the viewport, while locator bounds are page coordinates. The +/// node is scrolled into view first, and the point is the centre of the part +/// of it that is actually visible, so an element taller than the window is +/// still struck somewhere on screen. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ViewportTarget { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +/// The layout viewport's size in CSS pixels. +pub async fn layout_viewport(session: &mut WayfernSession) -> Result<(f64, f64), WayfernError> { + let metrics = session + .call("Page.getLayoutMetrics", serde_json::json!({})) + .await?; + let viewport = metrics + .get("cssLayoutViewport") + .or_else(|| metrics.get("layoutViewport")) + .ok_or_else(|| WayfernError::Malformed("getLayoutMetrics has no layout viewport".into()))?; + let width = viewport + .get("clientWidth") + .and_then(Value::as_f64) + .unwrap_or(0.0); + let height = viewport + .get("clientHeight") + .and_then(Value::as_f64) + .unwrap_or(0.0); + Ok((width, height)) +} + +/// Scroll a node into view and answer where to strike it. +pub async fn viewport_target( + session: &mut WayfernSession, + backend_node_id: i64, +) -> Result { + // A node that cannot be scrolled (already visible in a non-scrolling + // container, say) still has quads; only the quads decide. + let _ = session + .call( + "DOM.scrollIntoViewIfNeeded", + serde_json::json!({ "backendNodeId": backend_node_id }), + ) + .await; + let quads = session + .call( + "DOM.getContentQuads", + serde_json::json!({ "backendNodeId": backend_node_id }), + ) + .await?; + let (viewport_width, viewport_height) = layout_viewport(session).await?; + let quads = quads + .get("quads") + .and_then(Value::as_array) + .ok_or_else(|| WayfernError::Malformed("getContentQuads answered no quads".into()))?; + quads + .iter() + .filter_map(|quad| quad_target(quad, viewport_width, viewport_height)) + .next() + .ok_or_else(|| { + WayfernError::Malformed( + "the node has no visible box to strike; it may be hidden or clipped".into(), + ) + }) +} + +/// The visible centre of one content quad, or `None` when nothing of it is on +/// screen. +fn quad_target(quad: &Value, viewport_width: f64, viewport_height: f64) -> Option { + let points: Vec = quad.as_array()?.iter().filter_map(Value::as_f64).collect(); + if points.len() < 8 { + return None; + } + let xs = points.iter().step_by(2); + let ys = points.iter().skip(1).step_by(2); + let (mut left, mut right) = (f64::INFINITY, f64::NEG_INFINITY); + let (mut top, mut bottom) = (f64::INFINITY, f64::NEG_INFINITY); + for x in xs { + left = left.min(*x); + right = right.max(*x); + } + for y in ys { + top = top.min(*y); + bottom = bottom.max(*y); + } + if viewport_width > 0.0 && viewport_height > 0.0 { + left = left.max(0.0); + top = top.max(0.0); + right = right.min(viewport_width); + bottom = bottom.min(viewport_height); + } + let width = right - left; + let height = bottom - top; + if !(width > 0.0 && height > 0.0) { + return None; + } + Some(ViewportTarget { + x: left + width / 2.0, + y: top + height / 2.0, + width, + height, + }) +} + +/// Where a glide to `target` starts. +/// +/// A pointer has to come from somewhere, and a glide of zero length is not a +/// gesture. The origin sits between the target and the middle of the window, +/// nearer the middle, which is where a hand tends to rest; when the target IS +/// the middle, the origin is pulled towards the top-left instead. +pub fn glide_origin(target: &ViewportTarget, viewport: (f64, f64)) -> (f64, f64) { + let (width, height) = viewport; + let (centre_x, centre_y) = (width / 2.0, height / 2.0); + let mut x = target.x + (centre_x - target.x) * 0.7; + let mut y = target.y + (centre_y - target.y) * 0.7; + if (x - target.x).abs() < 12.0 && (y - target.y).abs() < 12.0 { + x = (target.x * 0.5).max(4.0); + y = (target.y * 0.5).max(4.0); + } + (x.max(0.0), y.max(0.0)) +} + +// --- Vellum ----------------------------------------------------------------- + +/// The humanized-input domain. +/// +/// A pointer is acquired for one tool call and released when that call is +/// done, on every path. A pointer left behind is not a leak in the browser, +/// which destroys it with the session, but a gesture still running against it +/// when the next one starts fails with "Pointer is already moving". +pub mod vellum { + use super::{WayfernError, WayfernSession}; + use futures_util::future::BoxFuture; + use serde::{Deserialize, Serialize}; + use serde_json::Value; + use std::time::Duration; + + /// An acquired pointer. Release it with [`release`] (or [`with_pointer`], + /// which does so on every path); dropping one that was never released is + /// logged, because the browser side is only cleaned up when the session + /// closes. + #[derive(Debug)] + pub struct VellumPointer { + id: String, + released: bool, + } + + impl Drop for VellumPointer { + fn drop(&mut self) { + if !self.released { + log::warn!( + "[vellum] pointer {} dropped without release; the browser frees it with the session", + self.id + ); + } + } + } + + /// What `Vellum.glide` reported. + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] + #[serde(rename_all = "camelCase")] + pub struct Glide { + /// Move events dispatched. + pub samples: u64, + pub duration_ms: f64, + } + + /// What `Vellum.strike` reported. + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] + #[serde(rename_all = "camelCase")] + pub struct Strike { + /// How long this profile held the press, in milliseconds. + pub dwell_ms: f64, + } + + /// What `Vellum.inscribe` reported. + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] + #[serde(rename_all = "camelCase")] + pub struct Inscription { + /// Characters of the text delivered; equals its length on success. + pub characters: u64, + /// Mistyped characters that were corrected. + pub corrections: u64, + pub duration_ms: f64, + } + + /// Create a pointer at (`x`, `y`) in viewport CSS pixels. + pub async fn acquire( + session: &mut WayfernSession, + x: f64, + y: f64, + ) -> Result { + let reply = session + .call("Vellum.acquire", serde_json::json!({ "x": x, "y": y })) + .await?; + let id = reply + .get("pointer") + .and_then(Value::as_str) + .filter(|p| !p.is_empty()) + .ok_or_else(|| WayfernError::Malformed("Vellum.acquire answered no pointer".into()))?; + Ok(VellumPointer { + id: id.to_string(), + released: false, + }) + } + + /// Move the pointer to (`x`, `y`) along a human path. `width` is the Fitts + /// target width in CSS pixels. + pub async fn glide( + session: &mut WayfernSession, + pointer: &VellumPointer, + x: f64, + y: f64, + width: Option, + ) -> Result { + let mut params = serde_json::json!({ "pointer": pointer.id, "x": x, "y": y }); + if let Some(width) = width.filter(|w| w.is_finite() && *w > 0.0) { + params["width"] = Value::from(width); + } + let reply = session.call("Vellum.glide", params).await?; + serde_json::from_value(reply).map_err(|e| WayfernError::Malformed(format!("Vellum.glide: {e}"))) + } + + /// The parameters of `Vellum.strike`. + pub fn strike_params( + pointer: &VellumPointer, + button: Option<&str>, + click_count: Option, + ) -> Value { + let mut params = serde_json::json!({ "pointer": pointer.id }); + if let Some(button) = button { + params["button"] = Value::from(button); + } + if let Some(count) = click_count { + params["clickCount"] = Value::from(count); + } + params + } + + /// Press and release where the pointer is. + pub async fn strike( + session: &mut WayfernSession, + pointer: &VellumPointer, + button: Option<&str>, + click_count: Option, + ) -> Result { + let reply = session + .call("Vellum.strike", strike_params(pointer, button, click_count)) + .await?; + serde_json::from_value(reply) + .map_err(|e| WayfernError::Malformed(format!("Vellum.strike: {e}"))) + } + + /// Press and release, then wait up to `load_timeout` for a page load. + /// + /// `Page.enable` must already be on, or the load event is never delivered. + /// Returns the strike and whether a load was seen. + pub async fn strike_awaiting_load( + session: &mut WayfernSession, + pointer: &VellumPointer, + button: Option<&str>, + click_count: Option, + load_timeout: Duration, + ) -> Result<(Strike, bool), WayfernError> { + let (reply, navigated) = session + .call_then_await_event( + "Vellum.strike", + strike_params(pointer, button, click_count), + "Page.loadEventFired", + load_timeout, + ) + .await?; + let strike: Strike = serde_json::from_value(reply) + .map_err(|e| WayfernError::Malformed(format!("Vellum.strike: {e}")))?; + Ok((strike, navigated)) + } + + /// Type `text` into whatever is focused, one key at a time. + /// + /// The browser paces the keys, so the reply arrives only once the last one + /// is delivered; `timeout` must cover the whole text. + pub async fn inscribe( + session: &mut WayfernSession, + pointer: &VellumPointer, + text: &str, + typos: bool, + timeout: Duration, + ) -> Result { + let reply = session + .call_with_timeout( + "Vellum.inscribe", + serde_json::json!({ "pointer": pointer.id, "text": text, "typos": typos }), + timeout, + ) + .await?; + serde_json::from_value(reply) + .map_err(|e| WayfernError::Malformed(format!("Vellum.inscribe: {e}"))) + } + + /// Destroy the pointer. + pub async fn release( + session: &mut WayfernSession, + mut pointer: VellumPointer, + ) -> Result<(), WayfernError> { + let result = session + .call( + "Vellum.release", + serde_json::json!({ "pointer": pointer.id }), + ) + .await; + // Released as far as this side is concerned whether or not the browser + // agreed: a second attempt could only fail with "No such pointer". + pointer.released = true; + result.map(|_| ()) + } + + /// Run `steps` against a freshly acquired pointer and release it afterwards, + /// whether the steps succeeded or not. + /// + /// This is the shape every gesture takes. The release is the last thing on + /// the socket in both outcomes, so a strike that failed still leaves the + /// session clean for whatever the caller does next. A release that itself + /// fails after a successful gesture is logged rather than reported: the + /// click or the typing already happened, and the browser frees the pointer + /// with the session regardless. + pub async fn with_pointer( + session: &mut WayfernSession, + x: f64, + y: f64, + steps: F, + ) -> Result + where + E: From, + F: for<'a> FnOnce(&'a mut WayfernSession, &'a VellumPointer) -> BoxFuture<'a, Result>, + { + let pointer = acquire(session, x, y).await?; + let outcome = steps(session, &pointer).await; + let pointer_id = pointer.id.clone(); + let released = release(session, pointer).await; + match outcome { + Ok(value) => { + if let Err(e) = released { + log::warn!( + "[vellum] pointer {pointer_id} could not be released after a completed gesture: {e}" + ); + } + Ok(value) + } + Err(e) => { + if let Err(release_error) = released { + log::warn!("[vellum] pointer {pointer_id} could not be released after a failed gesture: {release_error}"); + } + Err(e) + } + } + } +} + +/// A stand-in for a Wayfern 152 page socket, for this module's tests and the +/// tool layer's. +/// +/// Answers every command the wrappers send the way the browser does, records +/// each frame it receives, and serves any number of connections in turn, so a +/// tool that opens one socket per step is exercised end to end. +#[cfg(test)] +pub(crate) mod test_support { + use crate::cdp_target::CdpTarget; + use futures_util::sink::SinkExt; + use futures_util::stream::StreamExt; + use serde_json::Value; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + use tokio_tungstenite::tungstenite::Message; + + /// How the fake browser behaves. + #[derive(Clone, Copy, PartialEq, Eq, Debug)] + pub(crate) enum Fake { + /// Answer every command with a plausible result. + Cooperative, + /// Answer `Vellum.strike` with an error. + StrikeFails, + /// Every perception page is truncated and carries a cursor, forever. + EndlessPerception, + /// `resolveLocator` answers with the ambiguity error, and the fallback + /// resolver script with two matches. + AmbiguousLocator, + /// Arm the picker, then emit `elementPicked` after a short pause. + PickerPicks, + /// Arm the picker, then emit `elementPickerCancelled`. + PickerCancels, + /// Arm the picker and never emit anything. + PickerSilent, + /// Answer `Vellum.strike`, then emit `Page.loadEventFired`. + StrikeNavigates, + } + + pub(crate) type Frames = Arc>>; + + /// The candidate the fake resolves every locator to. + fn canned_candidate() -> Value { + serde_json::json!({ + "backendNodeId": 7, "role": "button", "name": "Save", "text": "Save", + "signature": "s7", "attributes": [{ "name": "id", "value": "save" }], + "bounds": { "x": 10.0, "y": 20.0, "width": 30.0, "height": 40.0 } + }) + } + + /// What the fallback scripts answer with, keyed off the script's own text. + fn script_answer(expression: &str, behaviour: Fake) -> Value { + let value = if expression.contains("\"mode\":\"resolve\"") { + if behaviour == Fake::AmbiguousLocator { + serde_json::json!({ + "matchCount": 2, + "candidates": [canned_candidate(), canned_candidate()] + }) + } else { + serde_json::json!({ + "matchCount": 1, + "candidates": [canned_candidate()], + "match": canned_candidate(), + "center": { "x": 140.0, "y": 215.0, "width": 80.0, "height": 30.0, "visible": true } + }) + } + } else if expression.contains("\"mode\":\"perceive\"") { + serde_json::json!({ + "nodes": [{ + "id": "n0", "frameId": "f0", "role": "button", + "x": 1.0, "y": 2.0, "width": 3.0, "height": 4.0, + "inViewport": true, "visible": true, "focused": false, "disabled": false, + "name": "Save" + }], + "frames": [{ "frameId": "f0", "url": "https://example.com/", "crossOrigin": false }], + "text": "Save", + "truncated": false, + "stats": { "totalNodes": 5, "returnedNodes": 1, "bytes": 120, "elapsedMs": 1, "framesVisited": 1, "framesFailed": 0 } + }) + } else if expression.contains("getBoundingClientRect") { + serde_json::json!({ "x": 140.0, "y": 215.0, "width": 80.0, "height": 30.0 }) + } else { + Value::Bool(true) + }; + let text = match value { + Value::Bool(b) => return serde_json::json!({ "result": { "type": "boolean", "value": b } }), + other => other.to_string(), + }; + serde_json::json!({ "result": { "type": "string", "value": text } }) + } + + pub(crate) async fn fake_browser(behaviour: Fake) -> (CdpTarget, Frames) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("the fake browser must bind"); + let port = listener.local_addr().expect("a bound port").port(); + let frames: Frames = Arc::new(Mutex::new(Vec::new())); + let recorded = frames.clone(); + + tokio::spawn(async move { + while let Ok((socket, _)) = listener.accept().await { + let recorded = recorded.clone(); + tokio::spawn(async move { + let Ok(mut stream) = tokio_tungstenite::accept_async(socket).await else { + return; + }; + let mut perception_page = 0u64; + + while let Some(Ok(message)) = stream.next().await { + let Message::Text(text) = message else { + continue; + }; + let Ok(request) = serde_json::from_str::(&text) else { + continue; + }; + recorded.lock().unwrap().push(request.clone()); + let id = request.get("id").cloned().unwrap_or(Value::Null); + let method = request.get("method").and_then(Value::as_str).unwrap_or(""); + let params = request.get("params").cloned().unwrap_or_default(); + + let error = |message: &str| serde_json::json!({ "id": id, "error": { "code": -32000, "message": message } }); + let ok = |result: Value| serde_json::json!({ "id": id, "result": result }); + + let mut follow_up: Option = None; + let reply = match method { + "Vellum.acquire" => ok(serde_json::json!({ "pointer": "0000000000000001" })), + "Vellum.glide" => ok(serde_json::json!({ "samples": 23, "durationMs": 412.5 })), + "Vellum.strike" if behaviour == Fake::StrikeFails => error("Surface went away"), + "Vellum.strike" => { + if behaviour == Fake::StrikeNavigates { + follow_up = Some(serde_json::json!({ + "method": "Page.loadEventFired", "params": { "timestamp": 1.0 } + })); + } + ok(serde_json::json!({ "dwellMs": 71.0 })) + } + "Vellum.inscribe" => { + let text = params.get("text").and_then(Value::as_str).unwrap_or(""); + ok(serde_json::json!({ + "characters": text.chars().count(), "corrections": 1, "durationMs": 900.0 + })) + } + "Wayfern.startElementPicker" => { + follow_up = match behaviour { + Fake::PickerPicks => Some(serde_json::json!({ + "method": "Wayfern.elementPicked", + "params": { + "backendNodeId": 42, + "locator": { "role": "button", "name": "Save" }, + "matchCount": 1, + "node": { + "backendNodeId": 42, "role": "button", "name": "Save", "text": "Save", + "signature": "sig-42", "attributes": [], + "bounds": { "x": 1.0, "y": 2.0, "width": 30.0, "height": 10.0 } + } + } + })), + Fake::PickerCancels => Some(serde_json::json!({ + "method": "Wayfern.elementPickerCancelled", "params": { "reason": "escape" } + })), + _ => None, + }; + ok(serde_json::json!({})) + } + "Vellum.release" + | "Page.enable" + | "Page.disable" + | "Wayfern.stopElementPicker" + | "DOM.scrollIntoViewIfNeeded" + | "Input.dispatchMouseEvent" + | "Input.dispatchKeyEvent" => ok(serde_json::json!({})), + "Wayfern.capturePagePerception" => { + perception_page += 1; + let endless = behaviour == Fake::EndlessPerception; + let mut result = serde_json::json!({ + "snapshotId": "snap-1", + "nodes": [{ + "id": format!("n{perception_page}"), "frameId": "f0", "role": "button", + "x": 1.0, "y": 2.0, "width": 3.0, "height": 4.0, + "inViewport": true, "visible": true, "focused": false, "disabled": false, + "name": "Save" + }], + "frames": [{ "frameId": "f0", "url": "https://example.com/", "crossOrigin": false }], + "text": format!("page {perception_page} "), + "truncated": endless, + "stats": { + "totalNodes": 999, "returnedNodes": 1, "bytes": 600, + "elapsedMs": 5, "framesVisited": 1, "framesFailed": 0 + } + }); + if endless { + result["cursor"] = Value::from(format!("snap-1.{perception_page}")); + } + ok(result) + } + "Wayfern.resolveLocator" if behaviour == Fake::AmbiguousLocator => error( + "Ambiguous locator: 2 nodes match. Refine it with a role, a stable attribute, or more exact text. Candidates: [{\"backendNodeId\":7,\"role\":\"button\",\"name\":\"Save\",\"text\":\"Save\",\"signature\":\"s7\",\"attributes\":[]},{\"backendNodeId\":8,\"role\":\"button\",\"name\":\"Save\",\"text\":\"Save\",\"signature\":\"s8\",\"attributes\":[]}]", + ), + "Wayfern.resolveLocator" => ok(serde_json::json!({ + "backendNodeId": 7, "matchCount": 1, + "match": canned_candidate(), + "locator": params.get("locator").cloned().unwrap_or_default() + })), + "Wayfern.extractStructured" => ok(serde_json::json!({ + "rows": [{ "index": 0, "page": 0, "values": { "title": "One" } }], + "rowCount": 1, "pageCount": 1, "byteSize": 40, "truncated": false, + "stopReason": "complete" + })), + "DOM.getContentQuads" => ok(serde_json::json!({ + "quads": [[100.0, 200.0, 180.0, 200.0, 180.0, 230.0, 100.0, 230.0]] + })), + "DOM.resolveNode" => ok(serde_json::json!({ + "object": { "type": "object", "objectId": "obj-7" } + })), + "Runtime.callFunctionOn" => { + ok(serde_json::json!({ "result": { "type": "boolean", "value": true } })) + } + "Page.getLayoutMetrics" => ok(serde_json::json!({ + "cssLayoutViewport": { "clientWidth": 1280.0, "clientHeight": 720.0 } + })), + "Runtime.evaluate" => { + let expression = params + .get("expression") + .and_then(Value::as_str) + .unwrap_or(""); + ok(script_answer(expression, behaviour)) + } + _ => error(&format!("'{method}' wasn't found")), + }; + + if stream + .send(Message::Text(reply.to_string().into())) + .await + .is_err() + { + break; + } + if let Some(event) = follow_up { + tokio::time::sleep(Duration::from_millis(50)).await; + if stream + .send(Message::Text(event.to_string().into())) + .await + .is_err() + { + break; + } + } + } + }); + } + }); + + ( + CdpTarget::Local { + ws_url: format!("ws://127.0.0.1:{port}"), + }, + frames, + ) + } + + /// The methods the fake saw, in order. + pub(crate) fn methods(frames: &Frames) -> Vec { + frames + .lock() + .unwrap() + .iter() + .filter_map(|f| f.get("method").and_then(Value::as_str).map(str::to_string)) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::vellum; + use super::*; + + #[test] + fn the_engine_follows_the_profile_version() { + // An older build takes the fallback path; a 152 or newer one takes the + // native domains. An unparsable version takes the older path, which is + // the one that cannot be refused by the browser. + assert_eq!(Engine::for_version("151.0.7922.76"), Engine::Fallback); + assert_eq!(Engine::for_version("152.0.7977.64"), Engine::Wayfern); + assert_eq!(Engine::for_version("153.0.1.1"), Engine::Wayfern); + assert_eq!(Engine::for_version("garbage"), Engine::Fallback); + assert_eq!( + serde_json::to_value(Engine::Wayfern).unwrap(), + Value::from("wayfern") + ); + assert_eq!( + serde_json::to_value(Engine::Fallback).unwrap(), + Value::from("fallback") + ); + } + + #[test] + fn a_locator_accepts_both_spellings_and_serializes_the_browsers() { + let snake: LocatorDescription = serde_json::from_value(serde_json::json!({ + "role": "button", "name_contains": "Save", "text_contains": "Sa", + "attributes": [{ "name": "id", "value": "save" }] + })) + .unwrap(); + let camel: LocatorDescription = serde_json::from_value(serde_json::json!({ + "role": "button", "nameContains": "Save", "textContains": "Sa", + "attributes": [{ "name": "id", "value": "save" }] + })) + .unwrap(); + assert_eq!(snake, camel); + let wire = serde_json::to_value(&camel).unwrap(); + assert_eq!(wire["nameContains"], "Save"); + assert_eq!(wire["textContains"], "Sa"); + assert!(wire.get("name_contains").is_none()); + assert!( + wire.get("name").is_none(), + "absent parts must stay absent on the wire" + ); + + assert!(LocatorDescription::default().is_empty()); + assert!( + serde_json::from_value::(serde_json::json!({ "attributes": [] })) + .unwrap() + .is_empty() + ); + assert!(!camel.is_empty()); + } + + #[test] + fn the_browsers_gate_is_told_apart_from_a_failure() { + let refusal = |message: &str| { + CdpError::Protocol(serde_json::json!({ "code": -32000, "message": message }).to_string()) + }; + assert_eq!( + classify_refusal(&refusal( + "Browser automation requires a paid Donut Browser plan." + )), + Some(BrowserRefusal::PaymentRequired) + ); + assert_eq!( + classify_refusal(&refusal( + "Automation rate limit exceeded (60 requests/minute). Retry shortly." + )), + Some(BrowserRefusal::RateLimited) + ); + assert_eq!( + classify_refusal(&refusal( + "Browser automation authorization service is temporarily unavailable. Retry the command." + )), + Some(BrowserRefusal::AuthorizationUnavailable) + ); + assert_eq!(classify_refusal(&refusal("No such pointer")), None); + assert_eq!(classify_refusal(&CdpError::Transport("x".into())), None); + assert_eq!( + protocol_code(&CdpError::Protocol( + serde_json::json!({ "code": -32602, "message": "Position must be finite" }).to_string() + )), + Some(-32602) + ); + } + + #[test] + fn an_ambiguous_locator_becomes_a_candidate_list() { + // The browser's message is machine-readable by contract: the count is the + // FULL number of matches even when the list is capped. + let message = "Ambiguous locator: 3 nodes match. Refine it with a role, a stable attribute, or more exact text. Candidates: [{\"backendNodeId\":11,\"role\":\"button\",\"name\":\"Save\",\"text\":\"Save\",\"signature\":\"a1\",\"attributes\":[{\"name\":\"id\",\"value\":\"s1\"}]},{\"backendNodeId\":12,\"role\":\"button\",\"name\":\"Save\",\"text\":\"Save\",\"signature\":\"a2\",\"attributes\":[]}]"; + let error = classify_locator_error(CdpError::Protocol( + serde_json::json!({ "code": -32000, "message": message }).to_string(), + )); + match error { + WayfernError::AmbiguousLocator { + match_count, + candidates, + message: carried, + } => { + assert_eq!(match_count, 3); + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0]["backendNodeId"], 11); + assert_eq!(candidates[1]["signature"], "a2"); + assert!(carried.starts_with("Ambiguous locator")); + } + other => panic!("expected an ambiguity, got {other:?}"), + } + + let missing = classify_locator_error(CdpError::Protocol( + serde_json::json!({ "code": -32000, "message": "No node matches locator (role=button, name=Nope)." }) + .to_string(), + )); + assert!( + matches!(missing, WayfernError::NoMatch { .. }), + "{missing:?}" + ); + + // Anything else stays what it was. + let other = classify_locator_error(CdpError::Protocol( + serde_json::json!({ "code": -32000, "message": "No page is attached." }).to_string(), + )); + assert!(matches!(other, WayfernError::Cdp(_))); + } + + #[test] + fn a_quad_is_struck_at_the_centre_of_its_visible_part() { + // An element taller than the window has its centre off screen; the point + // must be inside the viewport or the strike lands on nothing. + let quad = serde_json::json!([10.0, -500.0, 110.0, -500.0, 110.0, 900.0, 10.0, 900.0]); + let target = quad_target(&quad, 800.0, 600.0).expect("a visible box"); + assert_eq!(target.x, 60.0); + assert_eq!(target.y, 300.0); + assert_eq!(target.width, 100.0); + assert_eq!(target.height, 600.0); + + // Fully off screen is not a target. + let hidden = serde_json::json!([900.0, 10.0, 950.0, 10.0, 950.0, 50.0, 900.0, 50.0]); + assert!(quad_target(&hidden, 800.0, 600.0).is_none()); + assert!(quad_target(&serde_json::json!([1.0, 2.0]), 800.0, 600.0).is_none()); + + // The origin of a glide is never the target itself. + let origin = glide_origin(&target, (800.0, 600.0)); + assert!(origin != (target.x, target.y)); + let centred = ViewportTarget { + x: 400.0, + y: 300.0, + width: 20.0, + height: 20.0, + }; + let origin = glide_origin(¢red, (800.0, 600.0)); + assert!((origin.0 - 400.0).abs() > 12.0 || (origin.1 - 300.0).abs() > 12.0); + } + + #[test] + fn a_perception_request_only_sends_what_the_caller_set() { + let request: PerceptionRequest = serde_json::from_value(serde_json::json!({ + "max_bytes": 4096, "viewportOnly": true, "text_order": "visual" + })) + .unwrap(); + assert_eq!(request.byte_cap(), 4096); + let params = request.browser_params(request.byte_cap()); + assert_eq!(params["maxBytes"], 4096); + assert_eq!(params["viewportOnly"], true); + assert_eq!(params["textOrder"], "visual"); + assert!(params.get("budgetMs").is_none()); + assert!(params.get("includeText").is_none()); + + // The cap is bounded both ways. + let tiny: PerceptionRequest = + serde_json::from_value(serde_json::json!({ "max_bytes": 1 })).unwrap(); + assert_eq!(tiny.byte_cap(), MIN_PERCEPTION_BYTE_CAP); + let huge: PerceptionRequest = + serde_json::from_value(serde_json::json!({ "max_bytes": 1u64 << 40 })).unwrap(); + assert_eq!(huge.byte_cap(), MAX_PERCEPTION_BYTE_CAP); + assert_eq!( + PerceptionRequest::default().byte_cap(), + DEFAULT_PERCEPTION_BYTE_CAP + ); + } + + #[test] + fn an_extraction_request_speaks_the_browsers_parameter_names() { + let request: ExtractionRequest = serde_json::from_value(serde_json::json!({ + "container": { "role": "listitem" }, + "fields": [ + { "key": "title", "locator": { "role": "link" }, "source": "text" }, + { "key": "href", "locator": { "role": "link" }, "source": "link" } + ], + "next_page": { "role": "button", "name": "Next" }, + "max_pages": 3 + })) + .unwrap(); + let params = request.browser_params().unwrap(); + assert_eq!(params["container"]["role"], "listitem"); + assert_eq!(params["fieldMap"].as_array().unwrap().len(), 2); + assert_eq!(params["fieldMap"][1]["source"], "link"); + assert_eq!(params["nextPage"]["name"], "Next"); + assert_eq!(params["maxPages"], 3); + assert!( + params.get("fields").is_none(), + "the browser's name is fieldMap" + ); + assert!(params.get("maxRows").is_none()); + } + + // --- Against a fake browser ---------------------------------------------- + // + // Everything above is pure. What follows drives the wrappers against the + // fake Wayfern socket in `test_support`, because the properties that + // matter, the frames actually put on the wire and the release that has to + // follow a failed strike, only show up when something answers. + use super::test_support::{fake_browser, methods, Fake}; + + #[tokio::test] + async fn a_gesture_releases_its_pointer_after_success() { + let (target, frames) = fake_browser(Fake::Cooperative).await; + let mut session = WayfernSession::open(&target).await.unwrap(); + + let strike: vellum::Strike = vellum::with_pointer(&mut session, 5.0, 6.0, |s, p| { + Box::pin(async move { + vellum::glide(s, p, 140.0, 215.0, Some(30.0)).await?; + vellum::strike(s, p, None, None).await + }) + }) + .await + .expect("a cooperative browser completes the gesture"); + assert_eq!(strike.dwell_ms, 71.0); + session.close().await; + + assert_eq!( + methods(&frames), + vec![ + "Vellum.acquire", + "Vellum.glide", + "Vellum.strike", + "Vellum.release" + ] + ); + let frames = frames.lock().unwrap(); + // The page session drives its own frame: no surface is ever named. + assert!(frames[0]["params"].get("surface").is_none()); + assert_eq!(frames[0]["params"]["x"], 5.0); + assert_eq!(frames[1]["params"]["pointer"], "0000000000000001"); + assert_eq!(frames[1]["params"]["width"], 30.0); + assert_eq!(frames[3]["params"]["pointer"], "0000000000000001"); + // Local page sockets carry no session id. + assert!(frames.iter().all(|f| f.get("sessionId").is_none())); + } + + #[tokio::test] + async fn a_gesture_releases_its_pointer_after_a_failure_mid_sequence() { + // The property the guard exists for: a strike the browser refused must not + // leave the pointer behind, or the next gesture on this session fails with + // "Pointer is already moving". + let (target, frames) = fake_browser(Fake::StrikeFails).await; + let mut session = WayfernSession::open(&target).await.unwrap(); + + let error: WayfernError = vellum::with_pointer(&mut session, 5.0, 6.0, |s, p| { + Box::pin(async move { + vellum::glide(s, p, 140.0, 215.0, None).await?; + vellum::strike(s, p, Some("left"), Some(1)).await + }) + }) + .await + .expect_err("the refused strike must surface"); + assert!( + matches!(&error, WayfernError::Cdp(CdpError::Protocol(m)) if m.contains("Surface went away")), + "{error:?}" + ); + session.close().await; + + assert_eq!( + methods(&frames), + vec![ + "Vellum.acquire", + "Vellum.glide", + "Vellum.strike", + "Vellum.release" + ] + ); + } + + #[tokio::test] + async fn typing_goes_through_inscribe_and_reports_what_the_browser_did() { + let (target, frames) = fake_browser(Fake::Cooperative).await; + let mut session = WayfernSession::open(&target).await.unwrap(); + + let report: vellum::Inscription = vellum::with_pointer(&mut session, 1.0, 1.0, |s, p| { + Box::pin(async move { + vellum::glide(s, p, 50.0, 50.0, Some(20.0)).await?; + vellum::strike(s, p, None, None).await?; + vellum::inscribe(s, p, "héllo", true, Duration::from_secs(5)).await + }) + }) + .await + .unwrap(); + assert_eq!(report.characters, 5); + assert_eq!(report.corrections, 1); + session.close().await; + + let frames = frames.lock().unwrap(); + let inscribe = frames + .iter() + .find(|f| f["method"] == "Vellum.inscribe") + .expect("inscribe was sent"); + assert_eq!(inscribe["params"]["text"], "héllo"); + assert_eq!(inscribe["params"]["typos"], true); + assert_eq!(frames.last().unwrap()["method"], "Vellum.release"); + } + + #[tokio::test] + async fn a_strike_that_navigates_reports_the_load() { + let (target, frames) = fake_browser(Fake::StrikeNavigates).await; + let mut session = WayfernSession::open(&target).await.unwrap(); + session + .call("Page.enable", serde_json::json!({})) + .await + .unwrap(); + + let (strike, navigated): (vellum::Strike, bool) = + vellum::with_pointer(&mut session, 1.0, 1.0, |s, p| { + Box::pin(async move { + vellum::strike_awaiting_load(s, p, None, None, Duration::from_secs(5)).await + }) + }) + .await + .unwrap(); + assert_eq!(strike.dwell_ms, 71.0); + assert!( + navigated, + "the load event that followed the strike must be seen" + ); + + // And a strike nothing follows still answers, on its own reply, without + // outliving a long wait. + let (target2, _) = fake_browser(Fake::Cooperative).await; + let mut quiet = WayfernSession::open(&target2).await.unwrap(); + let started = std::time::Instant::now(); + let (_, navigated): (vellum::Strike, bool) = + vellum::with_pointer(&mut quiet, 1.0, 1.0, |s, p| { + Box::pin(async move { + vellum::strike_awaiting_load(s, p, None, None, Duration::from_millis(300)).await + }) + }) + .await + .unwrap(); + assert!(!navigated); + assert!(started.elapsed() < Duration::from_secs(3)); + session.close().await; + quiet.close().await; + assert!(methods(&frames).contains(&"Vellum.release".to_string())); + } + + #[tokio::test] + async fn the_perception_loop_stops_at_the_byte_cap_and_hands_back_the_cursor() { + // Every page the fake serves is 600 bytes and "truncated, here is a + // cursor". With a 2 KiB cap the loop must stop after the fourth page and + // say so, rather than follow cursors until the sixty-four page ceiling. + let (target, frames) = fake_browser(Fake::EndlessPerception).await; + let mut session = WayfernSession::open(&target).await.unwrap(); + + let request: PerceptionRequest = + serde_json::from_value(serde_json::json!({ "max_bytes": 2048, "budget_ms": 500 })).unwrap(); + let page = capture_page_perception(&mut session, &request) + .await + .unwrap(); + session.close().await; + + assert_eq!(page.nodes.len(), 4, "four pages of one node each"); + assert_eq!(page.text, "page 1 page 2 page 3 page 4 "); + assert_eq!(page.stats.bytes, 2400); + assert_eq!(page.stats.returned_nodes, 4); + assert_eq!(page.stats.total_nodes, 999); + assert!(page.truncated); + assert_eq!(page.cursor.as_deref(), Some("snap-1.4")); + assert_eq!(page.engine, Engine::Wayfern); + + let sent = frames.lock().unwrap(); + assert_eq!(sent.len(), 4); + assert_eq!(sent[0]["params"]["maxBytes"], 2048); + assert_eq!(sent[0]["params"]["budgetMs"], 500); + // A continuation carries the cursor and nothing else: the browser ignores + // every other parameter when one is present. + assert_eq!(sent[1]["params"]["cursor"], "snap-1.1"); + assert!(sent[1]["params"].get("maxBytes").is_none()); + assert_eq!(sent[3]["params"]["cursor"], "snap-1.3"); + } + + #[tokio::test] + async fn a_complete_capture_returns_one_page_with_no_cursor() { + let (target, frames) = fake_browser(Fake::Cooperative).await; + let mut session = WayfernSession::open(&target).await.unwrap(); + let page = capture_page_perception(&mut session, &PerceptionRequest::default()) + .await + .unwrap(); + session.close().await; + assert_eq!(page.nodes.len(), 1); + assert_eq!(page.nodes[0].name.as_deref(), Some("Save")); + assert!(!page.truncated); + assert!(page.cursor.is_none()); + assert_eq!(methods(&frames), vec!["Wayfern.capturePagePerception"]); + + // The output keeps the browser's own key spelling. + let wire = serde_json::to_value(&page).unwrap(); + assert_eq!(wire["nodes"][0]["inViewport"], true); + assert_eq!(wire["nodes"][0]["frameId"], "f0"); + assert_eq!(wire["stats"]["totalNodes"], 999); + assert_eq!(wire["snapshotId"], "snap-1"); + assert_eq!(wire["engine"], "wayfern"); + } + + #[tokio::test] + async fn resolving_a_locator_answers_the_match_or_the_candidates() { + let (target, frames) = fake_browser(Fake::Cooperative).await; + let mut session = WayfernSession::open(&target).await.unwrap(); + let locator = LocatorDescription { + role: Some("button".into()), + name: Some("Save".into()), + ..Default::default() + }; + let resolved = resolve_locator( + &mut session, + &locator, + ResolveOptions { + candidate_limit: Some(5), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(resolved.backend_node_id, Some(7)); + assert_eq!(resolved.match_count, 1); + assert_eq!(resolved.matched.signature, "s7"); + assert_eq!(resolved.matched.bounds.width, 30.0); + assert_eq!(resolved.locator, locator); + let wire = serde_json::to_value(&resolved).unwrap(); + assert_eq!(wire["match"]["backendNodeId"], 7); + assert_eq!(wire["matchCount"], 1); + + // The strike point is scrolled to and read from the DOM, in viewport + // pixels, never from the page-coordinate bounds. + let point = viewport_target(&mut session, 7).await.unwrap(); + assert_eq!((point.x, point.y), (140.0, 215.0)); + assert_eq!((point.width, point.height), (80.0, 30.0)); + session.close().await; + { + let sent = frames.lock().unwrap(); + assert_eq!(sent[0]["params"]["locator"]["name"], "Save"); + assert_eq!(sent[0]["params"]["candidateLimit"], 5); + assert!(sent[0]["params"].get("maxNodes").is_none()); + assert_eq!(sent[1]["method"], "DOM.scrollIntoViewIfNeeded"); + assert_eq!(sent[1]["params"]["backendNodeId"], 7); + assert_eq!(sent[2]["method"], "DOM.getContentQuads"); + } + + let (target, _) = fake_browser(Fake::AmbiguousLocator).await; + let mut session = WayfernSession::open(&target).await.unwrap(); + let error = resolve_locator(&mut session, &locator, ResolveOptions::default()) + .await + .expect_err("two matches must be refused"); + session.close().await; + match error { + WayfernError::AmbiguousLocator { + match_count, + candidates, + .. + } => { + assert_eq!(match_count, 2); + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[1]["backendNodeId"], 8); + } + other => panic!("expected an ambiguity, got {other:?}"), + } + } + + #[tokio::test] + async fn extraction_passes_the_browsers_result_through() { + let (target, frames) = fake_browser(Fake::Cooperative).await; + let mut session = WayfernSession::open(&target).await.unwrap(); + let request: ExtractionRequest = serde_json::from_value(serde_json::json!({ + "container": { "role": "listitem" }, + "field_map": [{ "key": "title", "locator": { "role": "link" }, "source": "text" }], + "time_budget_ms": 1000 + })) + .unwrap(); + let extraction = extract_structured(&mut session, &request).await.unwrap(); + session.close().await; + assert_eq!(extraction.row_count, 1); + assert_eq!(extraction.rows[0].values["title"], "One"); + assert_eq!(extraction.stop_reason, "complete"); + let wire = serde_json::to_value(&extraction).unwrap(); + assert_eq!(wire["rowCount"], 1); + assert_eq!(wire["stopReason"], "complete"); + assert_eq!(wire["engine"], "wayfern"); + let sent = frames.lock().unwrap(); + assert_eq!(sent[0]["params"]["fieldMap"][0]["key"], "title"); + assert_eq!(sent[0]["params"]["timeBudgetMs"], 1000); + } + + #[tokio::test] + async fn the_picker_answers_a_pick_a_cancel_and_a_timeout() { + let (target, _) = fake_browser(Fake::PickerPicks).await; + let mut session = WayfernSession::open(&target).await.unwrap(); + let picked = pick_element(&mut session, Duration::from_secs(5), true) + .await + .unwrap(); + session.close().await; + assert_eq!(picked.backend_node_id, 42); + assert_eq!(picked.locator.name.as_deref(), Some("Save")); + assert_eq!(picked.node.signature, "sig-42"); + assert_eq!(picked.match_count, 1); + + let (target, _) = fake_browser(Fake::PickerCancels).await; + let mut session = WayfernSession::open(&target).await.unwrap(); + let error = pick_element(&mut session, Duration::from_secs(5), true) + .await + .expect_err("escape must not look like a pick"); + session.close().await; + assert!( + matches!(&error, WayfernError::PickerCancelled { reason } if reason == "escape"), + "{error:?}" + ); + + // A picker nobody answers is disarmed before the timeout is reported, so + // no highlight outlives the call. + let (target, frames) = fake_browser(Fake::PickerSilent).await; + let mut session = WayfernSession::open(&target).await.unwrap(); + let started = std::time::Instant::now(); + let error = pick_element(&mut session, Duration::from_millis(200), false) + .await + .expect_err("silence must time out"); + session.close().await; + assert!(matches!( + error, + WayfernError::PickerTimedOut { timeout_ms: 200 } + )); + assert!(started.elapsed() < Duration::from_secs(3)); + assert_eq!( + methods(&frames), + vec!["Wayfern.startElementPicker", "Wayfern.stopElementPicker"] + ); + let sent = frames.lock().unwrap(); + assert_eq!(sent[0]["params"]["highlight"], false); + } +} diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index 0beec8f..46f2444 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -3,8 +3,8 @@ use crate::profile::BrowserProfile; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::json; -use std::collections::HashMap; -use std::path::PathBuf; +use std::collections::{HashMap, VecDeque}; +use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::Arc; use std::time::Duration; @@ -37,8 +37,38 @@ pub struct WayfernConfig { pub geoip: Option, // For compatibility with shared config form #[serde(default)] pub block_images: Option, // For compatibility with shared config form + /// LEGACY on/off switch kept for stored configs; `webrtc_mode` wins when + /// both are present, and `Some(true)` alone reads as `block`. #[serde(default)] pub block_webrtc: Option, + /// How WebRTC may reach the network: `auto` (real STUN only where the UDP + /// it needs is carried by the route, else the exit-IP posture), `tcp_only` + /// (one server-reflexive candidate on the proxy's exit IP), or `block` (no + /// ICE candidates at all). `None` is `auto`. + #[serde(default)] + pub webrtc_mode: Option, + /// The user's edits to the profile's persona, as a JSON array of + /// `{id,label,value}`. Everything not edited is derived from the profile's + /// own seed, so this holds edits and nothing else. An empty value removes + /// that row from what the browser offers. + #[serde(default)] + pub persona: Option, + /// A still (.png) or clip (.y4m/.mjpeg) the claimed camera serves, as an + /// absolute path. `None` means the camera serves dark frames; the browser + /// never falls back to the host device. + #[serde(default)] + pub camera_file: Option, + /// `x,y,width,height` in SOURCE pixels, cropped before the frame is scaled. + #[serde(default)] + pub camera_crop: Option, + /// Whether an interactive launch reopens the windows and tabs of the last + /// session. `None` is the default, which is on. Automation, headless, + /// ephemeral and clear-on-close launches never restore, whatever this says, + /// and neither does a browser that cannot take the identity at launch: a + /// restored tab loads before any post-launch `setIdentity`, so it would + /// carry the host device for its whole lifetime. + #[serde(default)] + pub restore_session: Option, #[serde(default)] pub block_webgl: Option, #[serde(default, skip_serializing)] @@ -85,8 +115,8 @@ pub struct WayfernConfig { /// 151, which is the only command that reproduces such a payload exactly. /// /// Written as a full version rather than a major so it can be pinned to an -/// exact build if the identity commands land part-way through the 151 line; -/// missing components compare as zero, so `"151"` means "any 151 or newer". +/// exact build if that is ever needed; missing components compare as zero, so +/// `"151"` means "any 151 or newer". const IDENTITY_API_MIN_VERSION: &str = "151"; /// Whether `version` speaks the identity API. @@ -105,6 +135,624 @@ pub fn supports_identity_api(version: &str) -> bool { crate::api_client::compare_versions(version, IDENTITY_API_MIN_VERSION) != std::cmp::Ordering::Less } +/// First Wayfern version that ships the 152 launch contract: the identity file +/// (`--wayfern-identity-file`), the `--wayfern-token` switch, WebRTC mode and +/// exit IP, persona/icon/camera/Widevine/entitlement-cache switches, the +/// `Vellum` input domain and the perception/locator/extraction commands. +/// Everything gated on it keeps its pre-152 path on any older browser. +const WAYFERN_152_MIN_VERSION: &str = "152"; + +/// Whether `version` speaks the Wayfern 152 launch and automation contract. +/// Same rule as [`supports_identity_api`]: pass `BrowserProfile::version`, read +/// it at the point of use, and an unparsable version takes the older path. +pub fn supports_wayfern_152(version: &str) -> bool { + crate::api_client::compare_versions(version, WAYFERN_152_MIN_VERSION) != std::cmp::Ordering::Less +} + +/// Where the launch-time identity document lives inside the profile directory. +/// Rewritten before every launch; the sync manifest excludes it. +pub const LAUNCH_IDENTITY_FILE: &str = "wayfern-identity.json"; + +/// What kind of launch this is, from the browser's point of view. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LaunchKind { + /// A person opened the profile from the app: the window is theirs, and the + /// session they left is the one they expect to find again. + Interactive, + /// REST, MCP or a batch run drives the browser: it starts clean on the URL + /// the caller named and never reopens what a person left behind. + Automation, +} + +/// Whether this launch reopens the last session, or why it does not. +/// +/// `identity_at_launch` says the device is committed before the first +/// navigation (the 152 identity file), or that there is no device to commit. +/// Anything applied over CDP after the window opens loses the race with a +/// restored tab, which then carries the host device for its whole lifetime, +/// so such a launch starts on a fresh tab instead and the log says why. +pub fn session_restore_verdict( + config: &WayfernConfig, + kind: LaunchKind, + headless: bool, + ephemeral: bool, + clear_on_close: bool, + identity_at_launch: bool, +) -> Result<(), &'static str> { + if kind == LaunchKind::Automation { + return Err("an automation run starts clean"); + } + if headless { + return Err("a headless launch has no session to show"); + } + if ephemeral { + return Err("an ephemeral profile keeps nothing between launches"); + } + if clear_on_close { + return Err("the profile clears its data on close"); + } + if config.randomize_fingerprint_on_launch == Some(true) { + return Err("a device randomized on every launch has no session to continue"); + } + if config.restore_session == Some(false) { + return Err("the profile has session restore switched off"); + } + if !identity_at_launch { + return Err( + "this browser applies the identity after the window opens, so a restored tab would load on the host device", + ); + } + Ok(()) +} + +/// The switches that shape the first window: session restore and the launch +/// identity. Kept apart from the rest of the command line so a test can pin +/// them per launch kind. +pub fn session_switches(restore_session: bool, identity_file: Option<&Path>) -> Vec { + let mut switches = Vec::new(); + if let Some(path) = identity_file { + switches.push(format!("--wayfern-identity-file={}", path.display())); + } + if restore_session { + switches.push("--restore-last-session".to_string()); + } + switches +} + +/// The WebRTC posture a 152 browser is launched with. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebRtcMode { + Auto, + TcpOnly, + Block, +} + +impl WebRtcMode { + pub fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "auto" => Some(Self::Auto), + "tcp_only" | "tcp-only" | "tcponly" => Some(Self::TcpOnly), + "block" | "blocked" | "off" => Some(Self::Block), + _ => None, + } + } + + /// The mode a stored config asks for. `webrtc_mode` wins; the legacy + /// `block_webrtc: true` reads as `block`; anything else is `auto`. An + /// unknown string is `auto` too, logged by the caller, never a launch error. + pub fn from_config(config: &WayfernConfig) -> Self { + if let Some(mode) = config.webrtc_mode.as_deref().and_then(Self::parse) { + return mode; + } + if config.block_webrtc == Some(true) { + return Self::Block; + } + Self::Auto + } + + pub fn switch_value(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::TcpOnly => "tcp_only", + Self::Block => "block", + } + } +} + +/// The WebRTC switches for a launch: the mode always, and the exit IP whenever +/// one is known and the mode can use it. A value the browser will not accept +/// costs only the synthetic server-reflexive candidate that makes a TCP-only +/// route look natural, never a leak. Pre-152 browsers do not read these, and +/// get nothing. +pub fn webrtc_switches(version: &str, mode: WebRtcMode, exit_ip: Option<&str>) -> Vec { + if !supports_wayfern_152(version) { + return Vec::new(); + } + let mut switches = vec![format!("--wayfern-webrtc-mode={}", mode.switch_value())]; + if mode != WebRtcMode::Block { + if let Some(ip) = exit_ip.map(str::trim).filter(|ip| !ip.is_empty()) { + if let Ok(address) = ip.parse::() { + switches.push(format!("--wayfern-webrtc-exit-ip={address}")); + } + } + } + switches +} + +/// Say so when a device claims a screen the host cannot show. +/// +/// One function for both launch paths: an identity-backed profile learns its +/// screen from the running browser, a legacy one carries it on disk, and the +/// sentence is the same either way. +impl WayfernManager { + fn warn_on_screen_over_host(device_json: &str, profile: &BrowserProfile, app_handle: &AppHandle) { + if let Some((claimed_w, claimed_h, host_w, host_h)) = + screen_claim_over_host(Some(device_json), host_screen_size(app_handle)) + { + log::warn!( + "Profile {} claims a {claimed_w}x{claimed_h} screen on a {host_w}x{host_h} display: its window can never fill the screen it reports, which a page can measure", + profile.name + ); + } + } +} + +/// The claimed screen a stored device presents, in CSS pixels. +fn claimed_screen(fingerprint_json: &str) -> Option<(u32, u32)> { + let device = WayfernManager::fingerprint_object(fingerprint_json)?; + let read = |key: &str| device.get(key).and_then(|v| v.as_u64()).map(|v| v as u32); + Some((read("screenWidth")?, read("screenHeight")?)) +} + +/// How much bigger the claimed screen is than the display the browser will +/// actually open on, when it is bigger at all. +/// +/// A device claiming a screen the host cannot show is visible from the page: +/// the window can never grow to the claimed size, so `outerWidth` stays below +/// `screen.width` however the user maximises it. Donut cannot fix the device +/// at launch without silently changing it, so it says so instead, on the +/// launch report and in the log. +pub fn screen_claim_over_host( + fingerprint_json: Option<&str>, + host: Option<(u32, u32)>, +) -> Option<(u32, u32, u32, u32)> { + let (claimed_width, claimed_height) = claimed_screen(fingerprint_json?)?; + let (host_width, host_height) = host?; + if host_width == 0 || host_height == 0 { + return None; + } + (claimed_width > host_width || claimed_height > host_height).then_some(( + claimed_width, + claimed_height, + host_width, + host_height, + )) +} + +/// The primary display's size in CSS pixels, which is what a page reads. +pub fn host_screen_size(app_handle: &AppHandle) -> Option<(u32, u32)> { + let monitor = app_handle.primary_monitor().ok().flatten()?; + let scale = monitor.scale_factor(); + let size = monitor.size().to_logical::(scale); + if size.width < 1.0 || size.height < 1.0 { + return None; + } + Some((size.width as u32, size.height as u32)) +} + +/// Where a 152 browser keeps its entitlement cache: inside donut's own cache +/// root rather than the OS default, so it is removed with the app's data and +/// never shared between an e2e session and the real installation. +pub fn entitlement_cache_switch(version: &str, cache_root: &Path) -> Option { + if !supports_wayfern_152(version) { + return None; + } + let dir = cache_root.join("wayfern-entitlements"); + if let Err(e) = std::fs::create_dir_all(&dir) { + log::warn!( + "Could not create the Wayfern entitlement cache at {}: {e}; the browser keeps its default", + dir.display() + ); + return None; + } + Some(format!("--wayfern-entitlement-cache-dir={}", dir.display())) +} + +/// Fonts for the window badge, loaded from the system once per process. The +/// load walks every font directory, which is far too slow to repeat per launch. +fn badge_fonts() -> std::sync::Arc { + static FONTS: std::sync::OnceLock> = + std::sync::OnceLock::new(); + FONTS + .get_or_init(|| { + let mut db = resvg::usvg::fontdb::Database::new(); + db.load_system_fonts(); + std::sync::Arc::new(db) + }) + .clone() +} + +/// The first letter (or digit) of a profile name, upper-cased, for its badge. +pub fn badge_initial(name: &str) -> String { + name + .chars() + .find(|c| c.is_alphanumeric()) + .map(|c| c.to_uppercase().collect()) + .unwrap_or_default() +} + +/// Whether text on `color` (bare or `#`-prefixed RRGGBB) reads better dark. +fn badge_wants_dark_ink(color: &str) -> bool { + let hex = color.trim().trim_start_matches('#'); + if hex.len() != 6 { + return false; + } + let channel = |i: usize| { + u8::from_str_radix(&hex[i..i + 2], 16) + .map(|v| v as f64 / 255.0) + .unwrap_or(0.0) + }; + // Relative luminance, sRGB weights; 0.6 keeps white ink on every mid tone. + 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4) > 0.6 +} + +/// Render the PNG a 152 browser shows as this profile's window, taskbar and +/// Dock icon: the profile's frame colour with its initial. `None` when the +/// badge cannot be rendered, in which case the browser keeps its stock icon. +pub fn render_profile_icon(name: &str, color: &str) -> Option> { + use resvg::tiny_skia; + let hex = color.trim().trim_start_matches('#'); + if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let initial = badge_initial(name); + let ink = if badge_wants_dark_ink(hex) { + "#1b1b1b" + } else { + "#ffffff" + }; + let escaped = initial + .replace('&', "&") + .replace('<', "<") + .replace('>', ">"); + let svg = format!( + r##" + +{escaped} +"## + ); + let options = resvg::usvg::Options { + fontdb: badge_fonts(), + ..Default::default() + }; + let tree = resvg::usvg::Tree::from_str(&svg, &options).ok()?; + let mut pixmap = tiny_skia::Pixmap::new(256, 256)?; + resvg::render( + &tree, + tiny_skia::Transform::identity(), + &mut pixmap.as_mut(), + ); + pixmap.encode_png().ok() +} + +/// Write the profile's window badge beside its data directory and return the +/// switch that hands it to a 152 browser. Older browsers get nothing. +pub fn profile_icon_switch( + version: &str, + profile_path: &str, + name: &str, + color: &str, +) -> Option { + if !supports_wayfern_152(version) { + return None; + } + let png = render_profile_icon(name, color)?; + let dir = Path::new(profile_path) + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(profile_path)); + let path = dir.join("window-icon.png"); + if let Err(e) = std::fs::write(&path, png) { + log::warn!( + "Could not write the window badge for profile {name} at {}: {e}; the browser keeps its stock icon", + path.display() + ); + return None; + } + let path = path.canonicalize().unwrap_or(path); + Some(format!("--wayfern-profile-icon={}", path.display())) +} + +/// Write the profile's persona beside its data directory and return the switch +/// that hands it to a 152 browser. The document is DERIVED from `seed` with +/// the user's edits applied, so it is stable per profile and unique to it. +pub fn persona_switch( + version: &str, + profile_path: &str, + seed: &str, + edits: Option<&str>, +) -> Option { + if !supports_wayfern_152(version) { + return None; + } + let edits: Vec = edits + .map(str::trim) + .filter(|edits| !edits.is_empty()) + .and_then(|edits| serde_json::from_str(edits).ok()) + .unwrap_or_default(); + let fields = crate::wayfern_persona::with_edits(seed, &edits); + if fields.is_empty() { + return None; + } + let path = Path::new(profile_path).join("wayfern-persona.json"); + let body = serde_json::to_vec(&crate::wayfern_persona::document(&fields)).ok()?; + if let Err(e) = std::fs::write(&path, body) { + log::warn!( + "Could not write the persona at {}: {e}; the browser offers no fill entries", + path.display() + ); + return None; + } + crate::app_dirs::restrict_to_owner(&path); + let path = path.canonicalize().unwrap_or(path); + Some(format!("--wayfern-profile-persona={}", path.display())) +} + +/// The camera switches for a launch. A file that is not there is not passed: +/// the browser would report it and serve dark frames anyway, and the log line +/// here names the profile, which its own does not. +pub fn camera_switches(version: &str, config: &WayfernConfig) -> Vec { + if !supports_wayfern_152(version) { + return Vec::new(); + } + let Some(file) = config + .camera_file + .as_deref() + .map(str::trim) + .filter(|file| !file.is_empty()) + else { + return Vec::new(); + }; + if !Path::new(file).is_file() { + log::warn!("Camera source {file} is missing; the claimed camera serves dark frames"); + return Vec::new(); + } + let mut switches = vec![format!("--wayfern-camera-file={file}")]; + if let Some(crop) = config + .camera_crop + .as_deref() + .map(str::trim) + .filter(|crop| !crop.is_empty()) + { + if valid_camera_crop(crop) { + switches.push(format!("--wayfern-camera-crop={crop}")); + } else { + log::warn!( + "Camera crop {crop:?} is not x,y,width,height in source pixels; using the whole frame" + ); + } + } + switches +} + +/// `x,y,width,height`, all non-negative integers, width and height non-zero. +fn valid_camera_crop(crop: &str) -> bool { + let parts: Vec<&str> = crop.split(',').map(str::trim).collect(); + if parts.len() != 4 { + return false; + } + let Ok(values) = parts + .iter() + .map(|part| part.parse::()) + .collect::, _>>() + else { + return false; + }; + values[2] > 0 && values[3] > 0 +} + +/// The platform directory a component-updater CDM install uses, and the +/// library name inside it. `None` on a platform Widevine does not ship for. +fn widevine_platform() -> Option<(&'static str, &'static str)> { + let arch = match std::env::consts::ARCH { + "x86_64" => "x64", + "aarch64" => "arm64", + _ => return None, + }; + let (os, library) = match std::env::consts::OS { + "macos" => ("mac", "libwidevinecdm.dylib"), + "windows" => ("win", "widevinecdm.dll"), + "linux" => ("linux", "libwidevinecdm.so"), + _ => return None, + }; + Some((Box::leak(format!("{os}_{arch}").into_boxed_str()), library)) +} + +/// The Widevine switch for a launch, when a CDM has been provisioned into +/// `/WidevineCdm` in the component-updater layout. +/// +/// Donut does not download the CDM: its distribution is a licensing decision. +/// What this does is use one that is present, and say so when one is present +/// but unusable — the browser registers nothing from a broken directory, and a +/// silent fallback to the bundled path would hide that. +pub fn widevine_switch(version: &str, data_root: &Path) -> Option { + if !supports_wayfern_152(version) { + return None; + } + let dir = data_root.join("WidevineCdm"); + if !dir.join("manifest.json").is_file() { + return None; + } + match widevine_platform() { + Some((platform, library)) => { + let payload = dir.join("_platform_specific").join(platform).join(library); + if !payload.is_file() { + log::warn!( + "Widevine is provisioned at {} but {} is missing; the browser will register no CDM", + dir.display(), + payload.display() + ); + } + } + None => log::warn!( + "Widevine does not ship for this platform; the CDM directory is passed as provisioned" + ), + } + Some(format!("--wayfern-widevine-cdm-dir={}", dir.display())) +} + +/// The last lines the browser wrote about Wayfern itself. +/// +/// A refusal of the launch identity is logged by the browser and never fatal +/// to it (it keeps the device it would have used anyway), so the launcher has +/// to read the verdict off stderr to turn it into an error the user sees. +#[derive(Clone, Default)] +pub struct BrowserLogTap(Arc>>); + +impl BrowserLogTap { + const CAPACITY: usize = 32; + + pub fn push(&self, line: String) { + let mut lines = self.0.lock().unwrap_or_else(|e| e.into_inner()); + if lines.len() >= Self::CAPACITY { + lines.pop_front(); + } + lines.push_back(line); + } + + pub fn lines(&self) -> Vec { + self + .0 + .lock() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .cloned() + .collect() + } + + /// The reason of the most recent launch-identity refusal, if any. + pub fn identity_refusal(&self) -> Option { + self.lines().iter().rev().find_map(|line| { + line + .split_once("Wayfern launch identity refused: ") + .map(|(_, reason)| reason.trim().to_string()) + }) + } + + /// Whether the browser reported the launch identity as applied. + pub fn identity_applied(&self) -> bool { + self + .lines() + .iter() + .any(|line| line.contains("Wayfern launch identity applied")) + } + + pub fn has_identity_verdict(&self) -> bool { + self.identity_applied() || self.identity_refusal().is_some() + } +} + +/// Keep reading the browser's stderr for its lifetime, keeping only what it +/// says about Wayfern. Reading it all is what keeps the pipe from filling. +fn tap_browser_stderr( + stderr: tokio::process::ChildStderr, + tap: BrowserLogTap, + profile_name: String, +) { + tauri::async_runtime::spawn(async move { + use tokio::io::{AsyncBufReadExt, BufReader}; + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if line.contains("Wayfern") || line.contains("wayfern_") { + log::info!( + "[browser {profile_name}] {}", + crate::log_redaction::text(&line) + ); + tap.push(line); + } + } + }); +} + +/// How a browser process ended up stopping. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StopOutcome { + /// It shut itself down after `Browser.close`, so its session files are + /// complete and the next launch can restore them. + Closed, + /// It exited on a termination request. + Terminated, + /// It had to be killed outright. + Killed, + /// Nothing this function did stopped it. + StillRunning, +} + +impl std::fmt::Display for StopOutcome { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Closed => "closed cleanly", + Self::Terminated => "terminated", + Self::Killed => "killed", + Self::StillRunning => "still running", + }) + } +} + +/// Ask a process to exit: SIGTERM, which Chromium handles as a normal +/// shutdown, or `taskkill` without `/F`, which only reaches a process with a +/// window. The caller escalates when this is not enough. +fn terminate_process(pid: u32) { + #[cfg(unix)] + { + use nix::sys::signal::{kill, Signal}; + use nix::unistd::Pid; + let _ = kill(Pid::from_raw(pid as i32), Signal::SIGTERM); + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + let _ = std::process::Command::new("taskkill") + .args(["/PID", &pid.to_string()]) + .creation_flags(CREATE_NO_WINDOW) + .output(); + } +} + +/// End a process without asking. +fn force_kill_process(pid: u32) { + #[cfg(unix)] + { + use nix::sys::signal::{kill, Signal}; + use nix::unistd::Pid; + let _ = kill(Pid::from_raw(pid as i32), Signal::SIGKILL); + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + let _ = std::process::Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/F"]) + .creation_flags(CREATE_NO_WINDOW) + .output(); + } +} + +/// Wait up to `limit` for `pid` to leave the process table. +async fn wait_for_exit(pid: u32, limit: Duration) -> bool { + let started = std::time::Instant::now(); + loop { + if !crate::proxy_storage::is_process_running(pid) { + return true; + } + if started.elapsed() >= limit { + return false; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + /// Fingerprint fields the browser takes as dedicated parameters rather than as /// overrides. They describe the exit IP, so they travel through their own /// channel instead of being duplicated into `overrides`. @@ -136,6 +784,57 @@ const LOCALE_CARRY_OVER_KEYS: [&str; 7] = [ "accuracy", ]; +/// How the geolocation probe reaches this profile's exit. +/// +/// Every variant either genuinely carries the traffic or refuses. There is no +/// "try it and see" arm on purpose: a probe that does not cross the profile's +/// upstream resolves THIS MACHINE'S address, and its location was then written +/// into the fingerprint as the exit's. +#[derive(Debug, Clone, PartialEq, Eq)] +enum ProbeRoute { + /// `reqwest` proxies this scheme itself. Carries the rewritten URL, so + /// `httpstls` is already the `https` reqwest understands. + Reqwest(String), + /// A temporary local `donut-proxy` worker dials this upstream. Carries the + /// URL the WORKER should dial, which is not always the stored one. + Worker(String), + /// The upstream is VLESS, which no `donut-proxy` worker can speak. An + /// Xray-core sidecar carries the VLESS hop and a `donut-proxy` worker fronts + /// its loopback SOCKS5 endpoint, the same two-stage path `browser_runner` + /// builds for a VLESS launch. Carries the VLESS URI. + Xray(String), + /// Nothing available here carries this upstream. The probe is skipped and + /// the fingerprint keeps no location at all, which is the only honest + /// outcome: a location that is neither the user's nor the exit's is worse + /// than none. + Unroutable, +} + +/// A probe transport built for one fingerprint generation, plus the temporary +/// workers that have to be stopped once the probe is done. +#[derive(Default)] +struct ProbeTransport { + /// The proxy URL to hand `reqwest`, or `None` when nothing could carry the + /// probe and it must be skipped rather than sent unproxied. + proxy: Option, + donut_worker_id: Option, + xray_worker_id: Option, +} + +impl ProbeTransport { + /// Stop everything this transport started. The `donut-proxy` worker goes + /// first because it is the one holding connections open through the Xray + /// sidecar behind it. + async fn shutdown(self) { + if let Some(id) = self.donut_worker_id { + let _ = crate::proxy_runner::stop_proxy_process(&id).await; + } + if let Some(id) = self.xray_worker_id { + let _ = crate::xray_worker_runner::stop_xray_worker(&id).await; + } + } +} + /// A freshly generated device, plus its identity handle when the browser /// supports identities. pub struct GeneratedFingerprint { @@ -171,6 +870,11 @@ struct WayfernInstance { profile_path: Option, url: Option, cdp_port: Option, + /// What the browser said about Wayfern on stderr, for diagnostics. Read + /// through `browser_log_lines`, which the WebRTC and persona launch checks + /// consult; nothing else needs it yet. + #[allow(dead_code)] + log_tap: BrowserLogTap, } struct WayfernManagerInner { @@ -268,9 +972,7 @@ impl WayfernManager { /// Chromium's default untouched. The fingerprint JSON may be the bare object /// or the legacy `{ "fingerprint": {...} }` wrapper. fn window_size_from_fingerprint(fingerprint_json: &str) -> Option<(u32, u32)> { - let parsed: serde_json::Value = serde_json::from_str(fingerprint_json).ok()?; - let fp = parsed.get("fingerprint").unwrap_or(&parsed); - let obj = fp.as_object()?; + let obj = Self::fingerprint_object(fingerprint_json)?; // Accept both numeric and stringified numbers (Wayfern emits numbers, but a // CDP echo or older saved fingerprint may stringify them). @@ -288,14 +990,64 @@ impl WayfernManager { .or_else(|| pair("screenWidth", "screenHeight")) } + /// The fingerprint value a stored `WayfernConfig::fingerprint` string holds: + /// the object itself, or the one nested in the legacy + /// `{ "fingerprint": {...} }` wrapper some old profiles carry. + /// + /// The single place that shape is resolved. Everything that reads a stored + /// fingerprint goes through this or through [`Self::fingerprint_object`], so + /// no two readers can end up disagreeing about which shapes count. + fn unwrap_stored_fingerprint(stored: &serde_json::Value) -> &serde_json::Value { + stored.get("fingerprint").unwrap_or(stored) + } + /// Parse a stored fingerprint JSON into its object, tolerating the legacy /// `{ "fingerprint": {...} }` wrapper some old profiles carry. + /// + /// Shared with `fingerprint_consistency`, which reads the timezone and + /// language it compares against the measured exit through this exact + /// accessor. Two readers with their own idea of the stored shape is how the + /// gate came to report "this profile declares no timezone" for a wrapped + /// fingerprint whose launch presented the timezone nested one level down. pub fn fingerprint_object( fingerprint_json: &str, ) -> Option> { let parsed: serde_json::Value = serde_json::from_str(fingerprint_json).ok()?; - let fp = parsed.get("fingerprint").unwrap_or(&parsed); - fp.as_object().cloned() + Self::unwrap_stored_fingerprint(&parsed) + .as_object() + .cloned() + } + + /// The device this launch hands the browser, derived from what the profile + /// stores. Pure, and the only place that payload is built, so what the + /// browser is actually given can be asserted without one running. + /// + /// It never invents a field. A stored fingerprint that declares no timezone + /// produces a payload with no timezone, and the engine keeps whatever it + /// reports natively. The launcher used to insert `America/New_York` and + /// offset 300 here, which put a US clock behind whatever exit the profile + /// routed through, while `fingerprint_consistency`, reading the same stored + /// fingerprint, told the user its timezone had never been compared. That is + /// the one combination that must never happen: the app cannot claim it + /// compared nothing while shipping a location it made up. + fn launch_fingerprint_payload(fingerprint_json: &str) -> Result { + let stored: serde_json::Value = serde_json::from_str(fingerprint_json) + .map_err(|e| format!("Failed to parse stored fingerprint JSON: {e}"))?; + + // Denormalize for Wayfern CDP (arrays/objects travel as JSON strings). + let mut payload = + Self::denormalize_fingerprint(Self::unwrap_stored_fingerprint(&stored).clone()); + + // Normalize languages: a comma-separated string becomes the array the + // browser expects. + if let Some(obj) = payload.as_object_mut() { + if let Some(serde_json::Value::String(s)) = obj.get("languages").cloned() { + let arr: Vec<&str> = s.split(',').map(|l| l.trim()).collect(); + obj.insert("languages".to_string(), json!(arr)); + } + } + + Ok(payload) } /// A stored JSON object field (`identity_overrides`, `location`), or an @@ -306,9 +1058,7 @@ impl WayfernManager { /// The exit-derived location fields a device object carries, in the shape /// `WayfernConfig::location` stores; `None` when it carries none. - pub fn location_of( - device: &serde_json::Map, - ) -> Option { + pub fn location_of(device: &serde_json::Map) -> Option { let mut location = serde_json::Map::new(); for key in LOCALE_CARRY_OVER_KEYS { if let Some(value) = device.get(key) { @@ -511,7 +1261,7 @@ impl WayfernManager { /// The OS a `navigator.platform` value describes. /// - /// Mirrors `WayfernHandler::IsCrossOSFromPlatform`, including the order of + /// Matches the browser's own platform-to-OS mapping, including the order of /// the tests: `Linux armv8l` and `aarch64` must read as android before the /// plain `Linux` test can claim them. fn os_from_platform(platform: &str) -> Option<&'static str> { @@ -556,10 +1306,10 @@ impl WayfernManager { /// Translate a refused apply into a code the frontend can explain. /// /// CDP carries a message, not a machine-readable code, so matching the text - /// is the only channel the browser has. The literals are the ones - /// `WayfernHandler` emits from its cross-OS gate and its quota branch; if one - /// is ever reworded this degrades to the generic code, which still carries - /// the raw text for support, rather than breaking. + /// is the only channel the browser has. The literals are the ones the browser + /// emits when it refuses a cross-OS claim or a generation; if one is ever + /// reworded this degrades to the generic code, which still carries the raw + /// text for support, rather than breaking. fn apply_failure_error(detail: &str, claimed_os: Option<&str>) -> String { if detail.contains("Cross-OS fingerprinting requires") { return crate::backend_error_with_detail( @@ -567,17 +1317,158 @@ impl WayfernManager { claimed_os.unwrap_or("another operating system"), ); } - // BOTH refusal texts - this maps failures from either release. 151 emits - // "Fingerprint generation limit reached for this account."; the shipped 150 - // browser emits "Too many profiles are being created." A 150 user would - // otherwise fall through to the generic apply-failed message and lose the - // one piece of information that makes the failure actionable. + // BOTH refusal texts, because a profile may be on either browser version. + // Older builds word the generation-limit refusal differently, and matching + // only one wording leaves those users falling through to the generic + // apply-failed message, losing the one piece of information that makes the + // failure actionable. if detail.contains("generation limit reached") || detail.contains("Too many profiles") { return crate::backend_error("WAYFERN_GENERATION_LIMIT_REACHED"); } crate::backend_error_with_detail("WAYFERN_FINGERPRINT_APPLY_FAILED", detail) } + /// The document a 152 browser takes through `--wayfern-identity-file`, or + /// `None` when this profile cannot be described that way: a legacy device + /// payload (only `setFingerprint` reproduces one), no identity, no claimed + /// operating system, or no timezone. The browser requires the timezone + /// because it does not resolve the exit itself; donut holds the proxy and + /// resolved it when the location was written. + pub fn launch_identity_document(config: &WayfernConfig) -> Option { + if config.fingerprint.is_some() { + return None; + } + let identity_id = config + .identity_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty())?; + // The browser requires the operating system in the document. Over CDP an + // omitted `operatingSystem` means the host, so the document says so + // explicitly, and the profile's own claim wins when it has one. + let host_os = crate::profile::types::get_host_os(); + let os = Self::claimed_operating_system(config, None).unwrap_or(host_os.as_str()); + let location = Self::stored_object(config.location.as_deref()); + let geo = Self::geo_params(&location); + let timezone = geo + .get("timezone") + .and_then(|v| v.as_str()) + .filter(|tz| !tz.is_empty())?; + + let mut document = serde_json::Map::new(); + document.insert("identityId".to_string(), json!(identity_id)); + document.insert("operatingSystem".to_string(), json!(os)); + document.insert("timezone".to_string(), json!(timezone)); + if let Some(language) = geo + .get("language") + .and_then(|v| v.as_str()) + .filter(|l| !l.is_empty()) + { + document.insert("language".to_string(), json!(language)); + } + if let (Some(latitude), Some(longitude)) = ( + geo.get("latitude").and_then(|v| v.as_f64()), + geo.get("longitude").and_then(|v| v.as_f64()), + ) { + document.insert("latitude".to_string(), json!(latitude)); + document.insert("longitude".to_string(), json!(longitude)); + } + let overrides = Self::stored_object(config.identity_overrides.as_deref()); + if !overrides.is_empty() { + document.insert( + "overrides".to_string(), + serde_json::Value::Object(overrides), + ); + } + Some(serde_json::Value::Object(document)) + } + + /// Write the launch identity into the profile directory and return the + /// absolute path the browser is given. Private to the user on Unix: the + /// document names the identity and the user's overrides. + fn write_launch_identity( + profile_path: &str, + document: &serde_json::Value, + ) -> Result { + let dir = PathBuf::from(profile_path); + std::fs::create_dir_all(&dir) + .map_err(|e| format!("could not create the profile directory for the identity file: {e}"))?; + let path = dir.join(LAUNCH_IDENTITY_FILE); + let body = serde_json::to_vec(document) + .map_err(|e| format!("could not encode the identity document: {e}"))?; + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&path) + .map_err(|e| format!("could not write the identity file: {e}"))?; + file + .write_all(&body) + .map_err(|e| format!("could not write the identity file: {e}"))?; + } + #[cfg(not(unix))] + { + std::fs::write(&path, &body) + .map_err(|e| format!("could not write the identity file: {e}"))?; + } + let path = path.canonicalize().unwrap_or(path); + Ok(path) + } + + /// Whether the browser started on the identity the launcher handed it. + /// + /// The browser's own stderr verdict is authoritative when present: a + /// refusal names its reason, an "applied" line settles it. Without one, the + /// identity id the browser reports decides, and as a last resort the + /// timezone, language and platform of the running device are compared with + /// the document. + fn launch_identity_verdict( + document: &serde_json::Value, + observed: Option<&serde_json::Value>, + cdp_error: Option<&str>, + tap: &BrowserLogTap, + ) -> Result<&'static str, String> { + if let Some(reason) = tap.identity_refusal() { + return Err(reason); + } + let expected_id = document["identityId"].as_str().unwrap_or_default(); + let observed_id = observed.and_then(|o| o["identityId"].as_str()); + if !expected_id.is_empty() && observed_id == Some(expected_id) { + return Ok("the browser reports the identity"); + } + if tap.identity_applied() { + return Ok("the browser logged the identity as applied"); + } + let Some(observed) = observed else { + return Err(match cdp_error { + Some(error) => format!("Wayfern.getIdentity failed: {error}"), + None => "the browser exposed no page target to verify the identity on".to_string(), + }); + }; + let identity = &observed["identity"]; + let same = |key: &str| identity[key].as_str() == document[key].as_str(); + let platform = identity["platform"].as_str().unwrap_or_default(); + let os_matches = Self::os_from_platform(platform) == document["operatingSystem"].as_str(); + if same("timezone") && (document.get("language").is_none() || same("language")) && os_matches { + return Ok("the running device matches the document's timezone, language and platform"); + } + Err(format!( + "the browser reports identity {} (timezone {}, language {}, platform {}) instead of {expected_id} ({}, {}, {})", + observed_id.unwrap_or("none"), + identity["timezone"].as_str().unwrap_or("unknown"), + identity["language"].as_str().unwrap_or("unknown"), + if platform.is_empty() { "unknown" } else { platform }, + document["timezone"].as_str().unwrap_or("unknown"), + document["language"].as_str().unwrap_or("any"), + document["operatingSystem"].as_str().unwrap_or("unknown"), + )) + } + async fn wait_for_cdp_ready( &self, port: u16, @@ -756,15 +1647,19 @@ impl WayfernManager { true } Err(e) => { - log::warn!("Geolocation failed, using defaults: {e}"); - if let Some(obj) = fingerprint.as_object_mut() { - if !obj.contains_key("timezone") { - obj.insert("timezone".to_string(), json!("America/New_York")); - } - if !obj.contains_key("timezoneOffset") { - obj.insert("timezoneOffset".to_string(), json!(300)); - } - } + // NOTHING is written here, deliberately. A failed probe used to fill in + // America/New_York and offset 300, which made "we could not resolve the + // exit" indistinguishable from "the exit is in New York": the profile + // then presented a US location as its proxy's, in the one field the + // consistency gate and the user both read as authoritative. A location + // that is neither the user's nor the exit's is worse than no location, + // so the fields are left ungenerated. + // + // Returning false is what makes that recoverable: the caller must not + // stamp `geo_proxy_signature`, so the launch-time refresh sees a + // signature mismatch and probes again through the local worker the + // browser is about to use. + log::warn!("Geolocation failed; leaving the fingerprint's location ungenerated: {e}"); false } } @@ -794,9 +1689,46 @@ impl WayfernManager { /// case where reqwest's SOCKS connector can't be trusted with the /// geolocation fetch. Loopback socks URLs are the app's own donut-proxy /// workers, whose single-segment replies don't trigger the connector bug. - fn is_remote_socks_url(url: &str) -> bool { - url.starts_with("socks") - && url::Url::parse(url) + /// Upstreams the geolocation probe must reach through a local donut-proxy + /// worker instead of handing to `reqwest`. + /// + /// Two groups. Remote SOCKS, which reqwest could proxy but which this code has + /// always routed through a worker. And EVERY scheme reqwest cannot proxy, + /// whatever its host, that group is the dangerous one: `Proxy::all` ACCEPTS + /// such a URL, then matches nothing, so the probe went out from the user's + /// REAL address and its geolocation was written into the profile fingerprint. + /// Nothing failed and nothing was logged: exactly the exit-vs-fingerprint + /// mismatch `fingerprint_consistency.rs` exists to catch, manufactured by the + /// fingerprint generator itself. And `probe_url` skips `ss`/`vless` entirely, + /// so the launch-time gate cannot catch it either. + /// + /// The loopback exemption applies ONLY to schemes reqwest can proxy. A + /// loopback SOCKS upstream IS the local worker, so it needs no second one; a + /// loopback `ss://127.0.0.1:8388` is still a scheme reqwest discards, and + /// exempting it re-opened the whole leak. + /// + /// True here means only "reqwest must not be handed this". It does NOT mean a + /// `donut-proxy` worker can carry it, reading it that way is what sent + /// `vless://` to a worker that cannot speak VLESS, so the probe could never + /// succeed. `worker_upstream_url` answers what a worker can actually dial, + /// and `probe_route` puts the two together. + fn needs_local_worker_for_probe(url: &str) -> bool { + // Measured on the REWRITTEN url, because `httpstls://` becomes `https://` + // before reqwest ever sees it and is proxyable from that point on. + let rewritten = crate::proxy_storage::reqwest_upstream_url(url); + let scheme = rewritten + .split("://") + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + + if !crate::proxy_storage::reqwest_can_proxy(&rewritten) { + // No host exemption here: reqwest discards it wherever it points. + return true; + } + + scheme.starts_with("socks") + && url::Url::parse(&rewritten) .ok() .and_then(|u| match u.host() { Some(url::Host::Ipv4(ip)) => Some(!ip.is_loopback()), @@ -815,6 +1747,179 @@ impl WayfernManager { .unwrap_or(false) } + /// The URL a `donut-proxy` worker can actually dial for this upstream, or + /// `None` when no worker can carry it at all. + /// + /// The allow-list is the exact set `proxy_server::dial_upstream` matches on. + /// Everything outside it reaches that function's `_` arm, so every request + /// through the worker dies as "Unsupported upstream proxy scheme", a worker + /// started on such a URL is not a fallback, it is a guaranteed failure. That + /// is how `vless://` came to be routed here: `needs_local_worker_for_probe` + /// answers "reqwest cannot proxy this", which was read as "a worker can", and + /// the probe could then never succeed. VLESS is carried by an Xray-core + /// sidecar instead (see `probe_route`), and anything else honestly has no + /// transport here. + /// + /// `socks5h`/`socks4a` are the "resolve at the exit" spellings of `socks5`/ + /// `socks4`. The worker hands the target hostname to the SOCKS server rather + /// than resolving it locally, which is exactly what the `h` asks for, so they + /// are normalized to the spelling the worker matches instead of rejected. + fn worker_upstream_url(url: &str) -> Option { + let (scheme, rest) = url.split_once("://")?; + let scheme = scheme.to_ascii_lowercase(); + match scheme.as_str() { + "http" | "https" | "httpstls" | "socks4" | "socks5" | "ss" | "shadowsocks" => { + Some(format!("{scheme}://{rest}")) + } + "socks5h" => Some(format!("socks5://{rest}")), + "socks4a" => Some(format!("socks4://{rest}")), + _ => None, + } + } + + /// Decide how the geolocation probe for `url` reaches the exit. + /// + /// Preference order, and the reason for it: probe through something that + /// genuinely carries the traffic, or do not probe at all. Every arm that + /// cannot carry it resolves to `Unroutable`, which the caller turns into a + /// skipped probe and an ungenerated location, never into a probe sent from + /// this machine's own address, and never into a default location presented as + /// the exit's. + fn probe_route(url: &str) -> ProbeRoute { + if !Self::needs_local_worker_for_probe(url) { + let rewritten = crate::proxy_storage::reqwest_upstream_url(url); + // Checked rather than assumed. `needs_local_worker_for_probe` already + // returns true for everything reqwest cannot proxy, but handing reqwest a + // URL it cannot match makes it send the request DIRECT with no error and + // no log line, so the invariant is re-asserted where it matters. + return if crate::proxy_storage::reqwest_can_proxy(&rewritten) { + ProbeRoute::Reqwest(rewritten) + } else { + ProbeRoute::Unroutable + }; + } + + // The worker gets the STORED url, never the reqwest rewrite: to + // `donut-proxy`, `httpstls` means "TLS to the proxy, then CONNECT" while + // `https` means a plaintext CONNECT, so handing it the reqwest spelling + // would silently downgrade that hop. + if let Some(upstream) = Self::worker_upstream_url(url) { + return ProbeRoute::Worker(upstream); + } + + if url + .split_once("://") + .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("vless")) + { + return match crate::xray::parse_vless_uri(url) { + Ok(_) => ProbeRoute::Xray(url.to_string()), + // A `vless://host:port` with no id, flow or security parameters is what + // a stored VLESS proxy collapses to when it is rendered as + // `type://host:port`. Xray cannot dial that, so there is nothing to + // probe through and the launch-time refresh does the location instead - + // by then the upstream is the loopback SOCKS5 endpoint of a real Xray + // worker, which any transport here can carry. + Err(_) => ProbeRoute::Unroutable, + }; + } + + ProbeRoute::Unroutable + } + + /// Whether the probe has to be skipped outright. + /// + /// True when the profile routes its traffic somewhere, the probe would + /// actually leave this machine, and no transport was built to carry it. + /// Probing anyway resolves this machine's own address and writes its location + /// into the fingerprint as the exit's. + /// + /// `probe_leaves_this_machine` is false when the location comes from a pinned + /// geoip IP or geolocation is switched off. `apply_geolocation` never touches + /// the network through the proxy in either case, so a routed profile with a + /// pinned IP must still get its location. + fn must_skip_probe( + routes_traffic: bool, + probe_leaves_this_machine: bool, + have_probe_proxy: bool, + ) -> bool { + routes_traffic && probe_leaves_this_machine && !have_probe_proxy + } + + /// Build the transport the geolocation probe for `url` will use. Callers must + /// `shutdown()` the result once the probe is done, on every path. + async fn build_probe_transport(url: &str) -> ProbeTransport { + match Self::probe_route(url) { + ProbeRoute::Reqwest(proxy) => ProbeTransport { + proxy: Some(proxy), + ..Default::default() + }, + ProbeRoute::Worker(upstream) => Self::front_with_local_worker(upstream, None).await, + ProbeRoute::Xray(uri) => { + // The error is flattened to a String on the spot. `start_xray_worker` + // reports a bare `Box`, which is not `Send`, and holding one + // across the `front_with_local_worker` await below would make this + // whole future non-`Send`, and with it every Tauri command and spawned + // task that reaches fingerprint generation. + let started = crate::xray_worker_runner::start_xray_worker(None, &uri) + .await + .map_err(|error| error.to_string()); + match started { + Ok(worker) => { + let upstream = + crate::proxy_manager::ProxyManager::build_proxy_url(&worker.local_proxy_settings()); + Self::front_with_local_worker(upstream, Some(worker.id)).await + } + Err(e) => { + log::warn!( + "Could not start an Xray-core worker to carry the VLESS geolocation probe ({e}); skipping the probe rather than sending it unproxied" + ); + ProbeTransport::default() + } + } + } + ProbeRoute::Unroutable => { + log::warn!( + "No transport here can carry the geolocation probe through this profile's upstream; skipping the probe rather than resolving this machine's own address" + ); + ProbeTransport::default() + } + } + } + + /// Put a temporary local `donut-proxy` worker in front of `upstream`, the + /// same path the browser itself uses. `xray_worker_id` is threaded through so + /// a sidecar started for a VLESS upstream is still stopped when the worker in + /// front of it fails to start. + async fn front_with_local_worker( + upstream: String, + xray_worker_id: Option, + ) -> ProbeTransport { + match crate::proxy_runner::start_proxy_process(Some(upstream), None).await { + Ok(worker) => ProbeTransport { + proxy: Some(format!( + "http://127.0.0.1:{}", + worker.local_port.unwrap_or(0) + )), + donut_worker_id: Some(worker.id), + xray_worker_id, + }, + Err(e) => { + // NOT the raw upstream. reqwest silently ignores a proxy URL it cannot + // match, so handing it back here sent the probe from this machine's + // real address. `None` means "no proxied probe available", and the + // caller skips the probe entirely rather than making it unproxied. + log::warn!( + "Could not start local proxy worker for geolocation ({e}); skipping the probe rather than sending it unproxied" + ); + ProbeTransport { + proxy: None, + donut_worker_id: None, + xray_worker_id, + } + } + } + } + /// Generate a device for `config` on a headless Wayfern. /// /// On a browser that ships the identity API this mints an identity and @@ -878,6 +1983,18 @@ impl WayfernManager { format!("Failed to spawn headless Wayfern: {e}{hint}") })?; let child_id = child.id(); + // Drain stderr for the browser's lifetime and keep what it says about + // Wayfern: a generation browser that dies before CDP is up leaves its + // reason there and nowhere else. + let generation_log = BrowserLogTap::default(); + let mut child = child; + if let Some(stderr) = child.stderr.take() { + tap_browser_stderr( + stderr, + generation_log.clone(), + format!("generation for {}", profile.name), + ); + } let cleanup = || async { if let Some(id) = child_id { @@ -910,11 +2027,18 @@ impl WayfernManager { .process(sysinfo::Pid::from(id as usize)) .is_some(); - if !is_running { - // Process exited — try to read its stderr - String::from("(process exited before CDP became ready)") + // The tap may still be a line behind the process's exit. + tokio::time::sleep(Duration::from_millis(300)).await; + let said = generation_log.lines(); + let said = if said.is_empty() { + String::from("it said nothing about Wayfern on stderr") } else { - String::from("(process still running but not responding on CDP)") + format!("its last Wayfern lines: {}", said.join(" | ")) + }; + if !is_running { + format!("(process exited before CDP became ready; {said})") + } else { + format!("(process still running but not responding on CDP; {said})") } } else { String::new() @@ -963,9 +2087,8 @@ impl WayfernManager { let use_identity_api = supports_identity_api(&profile.version); // No geolocation override is passed here. Donut resolves the exit's - // location itself, below, through the profile's own proxy — the browser's - // C++ geo service cannot, because an authenticated upstream answers its - // SimpleURLLoader requests with HTTP 407. + // location itself, below, through the profile's own proxy, because the + // browser cannot resolve it through an authenticated upstream. let generate_result = if use_identity_api { self .send_cdp_command(&ws_url, "Wayfern.createIdentity", generate_params) @@ -1005,55 +2128,58 @@ impl WayfernManager { // Normalize the fingerprint: convert JSON string fields to proper types let mut normalized = Self::normalize_fingerprint(fp); - // reqwest's SOCKS connector (hyper-util) corrupts its parse buffer - // when a proxy splits a handshake reply across TCP segments, so a - // socks upstream here can fail even though the proxy is healthy. - // Route the geolocation lookup through a temporary local donut-proxy - // worker — the same path the browser itself uses — and fall back to - // the upstream URL only if the worker can't start. Two exclusions: - // no worker when geolocation won't fetch through the proxy at all - // (disabled, or a fixed geoip IP), and none for loopback socks URLs — - // launch-time callers pass the already-running local worker's - // socks5://127.0.0.1 URL, whose single-segment replies don't trigger - // the bug, so chaining a second worker would only add latency. - let needs_proxied_geo_fetch = !matches!( + // Build a transport that genuinely carries the probe through this + // profile's upstream, or none at all. `probe_route` decides which: + // reqwest where it can proxy the scheme itself, a temporary local + // donut-proxy worker for the schemes that worker speaks (including + // every remote SOCKS one, because reqwest's SOCKS connector corrupts + // its parse buffer when a proxy splits a handshake reply across TCP + // segments), an Xray-core sidecar behind such a worker for VLESS, and + // nothing for an upstream none of them can speak. + // + // No transport is built when the probe would not leave this machine + // anyway: `apply_geolocation` ignores `proxy` entirely when geolocation + // is off or the location comes from a pinned geoip IP. + let probe_leaves_this_machine = !matches!( config.geoip.as_ref(), Some(serde_json::Value::Bool(false)) | Some(serde_json::Value::String(_)) ); - let remote_socks_upstream = config - .proxy - .as_deref() - .filter(|url| Self::is_remote_socks_url(url)); - let (geo_proxy, temp_worker_id) = match remote_socks_upstream { - Some(url) if needs_proxied_geo_fetch => { - match crate::proxy_runner::start_proxy_process(Some(url.to_string()), None) - .await - .map_err(|e| e.to_string()) - { - Ok(worker) => { - let local_url = format!("http://127.0.0.1:{}", worker.local_port.unwrap_or(0)); - (Some(local_url), Some(worker.id)) - } - Err(e) => { - log::warn!( - "Could not start local proxy worker for geolocation ({e}); using the socks upstream directly" - ); - (config.proxy.clone(), None) - } - } - } - _ => (config.proxy.clone(), None), + let transport = match config.proxy.as_deref() { + Some(url) if probe_leaves_this_machine => Self::build_probe_transport(url).await, + _ => ProbeTransport::default(), }; // Apply timezone/geolocation for the proxy this fingerprint is being // generated against. Shared with the launch-time location refresh. - let geolocation_applied = - Self::apply_geolocation(&mut normalized, geo_proxy.as_deref(), config.geoip.as_ref()) - .await; + // A profile that ROUTES ITS TRAFFIC must never probe from the real + // address: `apply_geolocation` treats `None` as "no proxy configured", + // which is right for a direct profile and an IP leak for a routed one. + // + // `config.proxy.is_some()` alone was not that predicate. A WireGuard + // profile carries its route in `vpn_id` and reaches here with + // `config.proxy == None`, so the guard never fired and the host's own + // timezone, latitude/longitude and language were written into the + // fingerprint as authoritative. + let routes_traffic = config.proxy.is_some() || profile.vpn_id.is_some(); + let geolocation_applied = if Self::must_skip_probe( + routes_traffic, + probe_leaves_this_machine, + transport.proxy.is_some(), + ) { + log::warn!( + "Skipping the geolocation probe: this profile has an upstream but no proxied probe could be built, and probing directly would write this machine's own location into the fingerprint" + ); + false + } else { + Self::apply_geolocation( + &mut normalized, + transport.proxy.as_deref(), + config.geoip.as_ref(), + ) + .await + }; - if let Some(worker_id) = temp_worker_id { - let _ = crate::proxy_runner::stop_proxy_process(&worker_id).await; - } + transport.shutdown().await; (normalized, identity_id, geolocation_applied) } @@ -1125,6 +2251,7 @@ impl WayfernManager { extension_paths: &[String], remote_debugging_port: Option, headless: bool, + kind: LaunchKind, ) -> Result> { let executable_path = BrowserRunner::instance() .get_browser_executable_path(profile) @@ -1233,8 +2360,12 @@ impl WayfernManager { "--disable-background-timer-throttling".to_string(), "--crash-server-url=".to_string(), "--disable-updater".to_string(), - "--disable-session-crashed-bubble".to_string(), "--hide-crash-restore-bubble".to_string(), + // Release builds log nothing unless asked. Wayfern reports what it did + // with the launch identity, WebRTC and the rest on stderr, and that is + // the only channel that carries a refusal's reason. + "--enable-logging=stderr".to_string(), + "--log-level=0".to_string(), "--disable-infobars".to_string(), // Prefetch* / NoStatePrefetch: cross-site Speculation-Rules prefetch uses // an isolated NetworkContext that defaults to DIRECT egress (real host IP @@ -1282,11 +2413,11 @@ impl WayfernManager { } // Per-profile window label + distinct frame color so concurrent profile - // windows are easy to tell apart. Wayfern reads these in - // BrowserView::GetWindowTitle() (label) and BrowserFrameView::GetFrameColor() - // (color). The label is the profile name; the color is the user's - // window_color when set, otherwise deterministically derived from the - // profile id so every profile still gets a stable, distinct color. + // windows are easy to tell apart. The browser reads these switches and uses + // them for the window title (label) and the frame colour. The label is the + // profile name; the color is the user's window_color when set, otherwise + // deterministically derived from the profile id so every profile still gets + // a stable, distinct color. if !profile.name.is_empty() { args.push(format!("--wayfern-profile-label={}", profile.name)); } @@ -1345,18 +2476,15 @@ impl WayfernManager { } // A cross-OS claim is authorized from the `wayfernToken` PARAMETER of - // setIdentity/setFingerprint. The browser's gate does not consult the - // WAYFERN_TOKEN env var this launch also sets, so with no token in hand the - // apply is refused and the window would sit there running the HOST device - // under a macOS or Android profile. Refuse before spawning rather than - // opening a window we are about to kill. + // setIdentity/setFingerprint, so with no token in hand the apply is refused + // and the window would sit there running the HOST device under a macOS or + // Android profile. Refuse before spawning rather than opening a window we + // are about to kill. // - // "Cross-OS" is the browser's own test (`WayfernHandler::IsCrossOS` against - // `GetHostOperatingSystem`), so `android` and `ios` count on every desktop. - // - // Not a 151 regression: setFingerprint on 150 has the identical - // parameter-only gate. What changed is that the refusal is no longer - // swallowed as a log line (see the apply loop below). + // "Cross-OS" is the browser's own test against the host OS, so `android` + // and `ios` count on every desktop. Every browser version gates it the same + // way; what changed is that the refusal is no longer swallowed as a log + // line (see the apply loop below). // // Deliberately conservative — this only pre-empts when the claim is // certain. An unrecognised `os`, a platform that maps to nothing, and a @@ -1402,18 +2530,131 @@ impl WayfernManager { args.push("--dns-prefetch-disable".to_string()); } + // A 152 browser takes the identity on its command line and commits it + // before the first navigation, which is what lets a restored tab load on + // the profile's device rather than the host's. Older browsers, legacy + // device payloads and a profile whose location carries no timezone keep + // the post-launch CDP apply below. + let launch_identity = if supports_wayfern_152(&profile.version) { + Self::launch_identity_document(config) + } else { + None + }; + if launch_identity.is_none() + && supports_wayfern_152(&profile.version) + && config.identity_id.is_some() + && config.fingerprint.is_none() + { + log::warn!( + "Profile {} has an identity but no resolved timezone (or no claimed operating system); applying it over CDP after launch instead of at startup", + profile.name + ); + } + let identity_file = match &launch_identity { + Some(document) => Some( + Self::write_launch_identity(profile_path, document) + .map_err(|e| crate::backend_error_with_detail("WAYFERN_IDENTITY_REFUSED", e))?, + ), + None => None, + }; + let identity_at_launch = + identity_file.is_some() || (config.identity_id.is_none() && config.fingerprint.is_none()); + let restore_session = match session_restore_verdict( + config, + kind, + headless, + ephemeral, + profile.clear_on_close, + identity_at_launch, + ) { + Ok(()) => true, + Err(reason) => { + log::info!( + "Session restore is off for profile {}: {reason}", + profile.name + ); + false + } + }; + args.extend(session_switches(restore_session, identity_file.as_deref())); + + // WebRTC posture, plus the exit the launch gate measured for this route + // (cache only: an automation launch never probes). A direct connection + // has nothing cached and needs nothing: its real egress is already what + // every HTTP request shows. + let webrtc_mode = WebRtcMode::from_config(config); + if let Some(unknown) = config + .webrtc_mode + .as_deref() + .filter(|value| WebRtcMode::parse(value).is_none()) + { + log::warn!( + "Profile {} names an unknown WebRTC mode {unknown:?}; launching with auto", + profile.name + ); + } + let exit_ip = crate::fingerprint_consistency::cached_exit_ip(profile); + let webrtc = webrtc_switches(&profile.version, webrtc_mode, exit_ip.as_deref()); + if !webrtc.is_empty() { + log::info!( + "WebRTC for profile {}: mode {}, exit IP {}", + profile.name, + webrtc_mode.switch_value(), + exit_ip.as_deref().unwrap_or("unknown") + ); + } + args.extend(webrtc); + args.extend(entitlement_cache_switch( + &profile.version, + &crate::app_dirs::cache_dir(), + )); + args.extend(profile_icon_switch( + &profile.version, + profile_path, + &profile.name, + profile_color, + )); + // The persona is a property of the profile, so an identity-backed profile + // seeds it from the identity and a legacy one from its id: either way the + // same profile presents the same person on every launch. + let persona_seed = config + .identity_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| profile.id.to_string()); + args.extend(persona_switch( + &profile.version, + profile_path, + &persona_seed, + config.persona.as_deref(), + )); + args.extend(camera_switches(&profile.version, config)); + args.extend(widevine_switch( + &profile.version, + &crate::app_dirs::data_dir(), + )); + if let Some(path) = &identity_file { + log::info!( + "Launch identity for profile {} written to {}", + profile.name, + path.display() + ); + } + let mut command = TokioCommand::new(&executable_path); command .args(&args) .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()); + .stderr(Stdio::piped()); if let Some(ref token) = wayfern_token { command.env("WAYFERN_TOKEN", token); log::info!("Wayfern authorization configured for browser process"); } - let child = command + let mut child = command .spawn() .map_err(|e| -> Box { let hint = if e.raw_os_error() == Some(14001) { @@ -1425,6 +2666,10 @@ impl WayfernManager { format!("Failed to spawn Wayfern: {e}{hint}").into() })?; let process_id = child.id(); + let log_tap = BrowserLogTap::default(); + if let Some(stderr) = child.stderr.take() { + tap_browser_stderr(stderr, log_tap.clone(), profile.name.clone()); + } drop(child); self.wait_for_cdp_ready(port).await?; @@ -1441,7 +2686,65 @@ impl WayfernManager { let identity_only = supports_identity_api(&profile.version) && config.identity_id.is_some() && config.fingerprint.is_none(); - if identity_only { + if let Some(document) = launch_identity.as_ref().filter(|_| identity_file.is_some()) { + // The identity travelled on the command line. The browser never fails + // its own launch over it (a refusal only logs), so the launcher checks, + // and a browser running on the wrong device is closed rather than + // handed to the user with the app still showing the profile's device. + let mut observed: Option = None; + let mut last_error: Option = None; + for target in &page_targets { + if let Some(ws_url) = &target.websocket_debugger_url { + match self + .send_cdp_command(ws_url, "Wayfern.getIdentity", json!({})) + .await + { + Ok(result) => { + observed = Some(result); + break; + } + Err(e) => last_error = Some(e.to_string()), + } + } + } + // The browser wrote its verdict to stderr before it opened the + // debugging port; the tap may still be a line behind the socket. + if !log_tap.has_identity_verdict() { + tokio::time::sleep(Duration::from_millis(300)).await; + } + match Self::launch_identity_verdict( + document, + observed.as_ref(), + last_error.as_deref(), + &log_tap, + ) { + Ok(confirmation) => { + log::info!( + "Launch identity confirmed for profile {}: {confirmation}", + profile.name + ); + // The only place an identity-backed profile's screen is known: the + // device is derived by the browser, so nothing on disk carries it. + if let Some(device) = observed + .as_ref() + .and_then(|value| value.get("identity")) + .map(ToString::to_string) + { + Self::warn_on_screen_over_host(&device, profile, _app_handle); + } + } + Err(reason) => { + log::error!( + "Killing Wayfern (pid {process_id:?}) for profile {}: the launch identity was not applied: {reason}", + profile.name + ); + if let Some(pid) = process_id { + kill_browser_process(pid); + } + return Err(crate::backend_error_with_detail("WAYFERN_IDENTITY_REFUSED", reason).into()); + } + } + } else if identity_only { let identity_id = config.identity_id.clone().unwrap_or_default(); let overrides = Self::stored_object(config.identity_overrides.as_deref()); let location = Self::stored_object(config.location.as_deref()); @@ -1449,11 +2752,10 @@ impl WayfernManager { let mut params = serde_json::Map::new(); params.insert("identityId".to_string(), json!(identity_id)); - // The claimed OS travels explicitly as well as inside the id. A Wayfern - // 152 id carries an epoch and a 16-bit check that a 151 browser's decoder - // does not know; without this parameter 151 would read such an id as - // untagged and rebuild the HOST OS. Both releases let the explicit - // parameter win, so this keeps one stored profile portable across them. + // The claimed OS travels explicitly as well as inside the id, because an + // older browser cannot read an id minted by a newer one and would rebuild + // the HOST OS instead. Every release lets the explicit parameter win, so + // this keeps one stored profile portable across them. if let Some(os) = config.os.as_deref().filter(|os| !os.is_empty()) { params.insert("operatingSystem".to_string(), json!(os)); } @@ -1518,42 +2820,14 @@ impl WayfernManager { "Applying fingerprint to Wayfern browser, fingerprint length: {} chars", fingerprint_json.len() ); + Self::warn_on_screen_over_host(fingerprint_json, profile, _app_handle); - let stored_value: serde_json::Value = serde_json::from_str(fingerprint_json) - .map_err(|e| format!("Failed to parse stored fingerprint JSON: {e}"))?; - - // The stored fingerprint should be the fingerprint object directly (after our fix in generate_fingerprint_config) - // But for backwards compatibility, also handle the wrapped format - let mut fingerprint = if stored_value.get("fingerprint").is_some() { - // Old format: {"fingerprint": {...}} - extract the inner fingerprint - stored_value.get("fingerprint").cloned().unwrap() - } else { - // New format: fingerprint object directly {...} - stored_value.clone() - }; - - // Add default timezone if not present (for profiles created before timezone was added) - if let Some(obj) = fingerprint.as_object_mut() { - if !obj.contains_key("timezone") { - obj.insert("timezone".to_string(), json!("America/New_York")); - log::info!("Added default timezone to fingerprint"); - } - if !obj.contains_key("timezoneOffset") { - obj.insert("timezoneOffset".to_string(), json!(300)); - log::info!("Added default timezoneOffset to fingerprint"); - } - } - - // Denormalize fingerprint for Wayfern CDP (convert arrays/objects to JSON strings) - let mut fingerprint_for_cdp = Self::denormalize_fingerprint(fingerprint); - - // Normalize languages: if it's a comma-separated string, convert to array - if let Some(obj) = fingerprint_for_cdp.as_object_mut() { - if let Some(serde_json::Value::String(s)) = obj.get("languages").cloned() { - let arr: Vec<&str> = s.split(',').map(|l| l.trim()).collect(); - obj.insert("languages".to_string(), json!(arr)); - } - } + // Both stored shapes, the bare object and the legacy + // `{"fingerprint": {...}}` wrapper, are resolved by the same accessor the + // consistency gate reads, and nothing is defaulted in. A profile that + // declares no timezone is launched with none, which is exactly what the + // gate reports to the user. + let fingerprint_for_cdp = Self::launch_fingerprint_payload(fingerprint_json)?; log::info!( "Fingerprint prepared for CDP command, fields: {:?}", @@ -1648,14 +2922,23 @@ impl WayfernManager { // Geolocation is handled internally by the browser binary. if let Some(url) = url { - log::info!("Navigating to URL via CDP"); - if let Some(target) = page_targets.first() { - if let Some(ws_url) = &target.websocket_debugger_url { - if let Err(e) = self - .send_cdp_command(ws_url, "Page.navigate", json!({ "url": url })) - .await - { - log::error!("Failed to navigate to URL: {e}"); + if restore_session { + // The tabs the browser reopened are the user's; the URL gets a tab of + // its own instead of replacing whichever one came first. + log::info!("Opening the launch URL in a new tab beside the restored session"); + if let Err(e) = self.open_url_on_port(port, url).await { + log::error!("Failed to open the launch URL in a new tab: {e}"); + } + } else { + log::info!("Navigating to URL via CDP"); + if let Some(target) = page_targets.first() { + if let Some(ws_url) = &target.websocket_debugger_url { + if let Err(e) = self + .send_cdp_command(ws_url, "Page.navigate", json!({ "url": url })) + .await + { + log::error!("Failed to navigate to URL: {e}"); + } } } } @@ -1690,6 +2973,7 @@ impl WayfernManager { profile_path: Some(profile_path.to_string()), url: url.map(|s| s.to_string()), cdp_port: Some(port), + log_tap, }; let mut inner = self.inner.lock().await; @@ -1708,19 +2992,79 @@ impl WayfernManager { &self, id: &str, ) -> Result<(), Box> { - let mut inner = self.inner.lock().await; + // Taken out of the map first, and the lock dropped: a clean shutdown can + // take seconds, and nothing else should wait on it. + let instance = { + let mut inner = self.inner.lock().await; + inner.instances.remove(id) + }; - if let Some(instance) = inner.instances.remove(id) { + if let Some(instance) = instance { log::info!("Cleaning up Wayfern instance {}", instance.id); if let Some(pid) = instance.process_id { - kill_browser_process(pid); - log::info!("Stopped Wayfern instance {id} (PID: {pid})"); + let outcome = self.stop_browser_process(pid, instance.cdp_port).await; + log::info!("Stopped Wayfern instance {id} (PID: {pid}): {outcome}"); } } Ok(()) } + /// Stop a browser the way its session files need: ask it to close over + /// CDP so Chromium runs its own shutdown (that is what writes the session + /// and commits the cookie jar), then terminate, then kill. Each step gets a + /// bounded wait, so a wedged browser still ends within seconds. + pub async fn stop_browser_process(&self, pid: u32, cdp_port: Option) -> StopOutcome { + if !crate::proxy_storage::is_process_running(pid) { + return StopOutcome::Closed; + } + if let Some(port) = cdp_port { + match tokio::time::timeout(Duration::from_secs(3), self.browser_close(port)).await { + Ok(Ok(())) => {} + Ok(Err(e)) => log::warn!("Browser.close was not accepted on port {port}: {e}"), + Err(_) => log::warn!("Browser.close timed out on port {port}"), + } + if wait_for_exit(pid, Duration::from_secs(5)).await { + return StopOutcome::Closed; + } + log::warn!("Wayfern (PID {pid}) did not exit after Browser.close; terminating it"); + } + terminate_process(pid); + if wait_for_exit(pid, Duration::from_secs(5)).await { + return StopOutcome::Terminated; + } + log::warn!("Wayfern (PID {pid}) ignored the termination request; killing it"); + force_kill_process(pid); + if wait_for_exit(pid, Duration::from_secs(2)).await { + return StopOutcome::Killed; + } + StopOutcome::StillRunning + } + + /// Send `Browser.close` on the browser endpoint. The browser answers with an + /// empty result, or simply drops the socket on its way out; both mean it + /// agreed, and the caller watches the process rather than the reply. + async fn browser_close(&self, port: u16) -> Result<(), Box> { + let version: serde_json::Value = self + .http_client + .get(format!("http://127.0.0.1:{port}/json/version")) + .send() + .await? + .json() + .await?; + let ws_url = version["webSocketDebuggerUrl"] + .as_str() + .ok_or("the browser reported no webSocketDebuggerUrl")?; + match self + .send_cdp_command(ws_url, "Browser.close", json!({})) + .await + { + Ok(_) => Ok(()), + Err(e) if e.to_string().contains("No response received") => Ok(()), + Err(e) => Err(e), + } + } + /// Opens a URL in a new tab for an existing Wayfern instance. pub async fn open_url_in_tab( &self, @@ -1749,8 +3093,16 @@ impl WayfernManager { .and_then(|i| i.cdp_port) .ok_or("Wayfern instance (with CDP port) not found for profile")?; drop(inner); + self.open_url_on_port(port, url).await + } - // Open the URL in a new tab via the CDP HTTP convenience endpoint. + /// Open `url` in a new tab of the browser on `port`, through the CDP HTTP + /// convenience endpoint, which needs no page target to exist yet. + async fn open_url_on_port( + &self, + port: u16, + url: &str, + ) -> Result<(), Box> { let new_tab_url = format!( "http://127.0.0.1:{port}/json/new?{}", urlencoding::encode(url) @@ -1769,6 +3121,29 @@ impl WayfernManager { Ok(()) } + /// What the running browser for `profile_path` has said about Wayfern on + /// stderr so far (launch identity, refusals), newest last. + #[allow(dead_code)] + pub async fn browser_log_lines(&self, profile_path: &str) -> Vec { + let inner = self.inner.lock().await; + let target_path = std::path::Path::new(profile_path) + .canonicalize() + .unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf()); + inner + .instances + .values() + .find(|instance| { + instance.profile_path.as_deref().is_some_and(|path| { + std::path::Path::new(path) + .canonicalize() + .unwrap_or_else(|_| std::path::Path::new(path).to_path_buf()) + == target_path + }) + }) + .map(|instance| instance.log_tap.lines()) + .unwrap_or_default() + } + pub async fn get_cdp_port(&self, profile_path: &str) -> Option { let inner = self.inner.lock().await; let target_path = std::path::Path::new(profile_path) @@ -1861,6 +3236,7 @@ impl WayfernManager { profile_path: Some(found_profile_path.clone()), url: None, cdp_port, + log_tap: BrowserLogTap::default(), }, ); @@ -1972,6 +3348,7 @@ impl WayfernManager { &[], None, false, + LaunchKind::Interactive, ) .await } @@ -2011,20 +3388,9 @@ impl WayfernManager { /// id to stop it by. fn kill_browser_process(pid: u32) { #[cfg(unix)] - { - use nix::sys::signal::{kill, Signal}; - use nix::unistd::Pid; - let _ = kill(Pid::from_raw(pid as i32), Signal::SIGTERM); - } + terminate_process(pid); #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x08000000; - let _ = std::process::Command::new("taskkill") - .args(["/PID", &pid.to_string(), "/F"]) - .creation_flags(CREATE_NO_WINDOW) - .output(); - } + force_kill_process(pid); } lazy_static::lazy_static! { @@ -2074,38 +3440,799 @@ mod tests { #[test] fn remote_socks_url_detection() { // Remote socks upstreams (the hyper-util-affected case) are detected... - assert!(WayfernManager::is_remote_socks_url( + assert!(WayfernManager::needs_local_worker_for_probe( "socks5://user:pass@gw.dataimpulse.com:10000" )); - assert!(WayfernManager::is_remote_socks_url("socks5://1.2.3.4:1080")); - assert!(WayfernManager::is_remote_socks_url("socks4://1.2.3.4:1080")); + assert!(WayfernManager::needs_local_worker_for_probe( + "socks5://1.2.3.4:1080" + )); + assert!(WayfernManager::needs_local_worker_for_probe( + "socks4://1.2.3.4:1080" + )); // ...but the app's own loopback workers are not. socks is a non-special // URL scheme, so the IP literal parses as Host::Domain — the launch-time // randomize path depends on this returning false. - assert!(!WayfernManager::is_remote_socks_url( + assert!(!WayfernManager::needs_local_worker_for_probe( "socks5://127.0.0.1:24001" )); - assert!(!WayfernManager::is_remote_socks_url("socks5://[::1]:24001")); - assert!(!WayfernManager::is_remote_socks_url( + assert!(!WayfernManager::needs_local_worker_for_probe( + "socks5://[::1]:24001" + )); + assert!(!WayfernManager::needs_local_worker_for_probe( "socks5://localhost:24001" )); - // Non-socks schemes and unparsable URLs never need the workaround. - assert!(!WayfernManager::is_remote_socks_url( + // http/https reqwest proxies natively, so they stay on the direct path. + assert!(!WayfernManager::needs_local_worker_for_probe( "http://gw.dataimpulse.com:10000" )); - assert!(!WayfernManager::is_remote_socks_url( + assert!(!WayfernManager::needs_local_worker_for_probe( "https://gw.dataimpulse.com:10000" )); - assert!(!WayfernManager::is_remote_socks_url("socks5://")); - assert!(!WayfernManager::is_remote_socks_url("not a url")); + // A hostless socks URL has nothing to route to; reqwest cannot use it, and + // the worker path ends in a skipped probe rather than an unproxied one. + assert!(!WayfernManager::needs_local_worker_for_probe("socks5://")); + // An unparsable upstream asks for a worker ON PURPOSE. It used to answer + // "no worker needed", which sent it down the arm that hands reqwest the raw + // string, and reqwest answers garbage by silently going DIRECT. The worker + // will fail to start for a malformed URL, and that failure now skips the + // probe entirely, which is the outcome we want: no geolocation beats + // geolocation taken from the user's own address. + assert!(WayfernManager::needs_local_worker_for_probe("not a url")); + + // The schemes reqwest ACCEPTS and then silently does not proxy. Before + // these were routed through a local worker, the fingerprint's geolocation + // probe went out from the user's real address with nothing logged. + // `httpstls` is NOT in this group: `reqwest_upstream_url` rewrites it to + // `https://`, which reqwest proxies natively and over TLS, so the probe + // genuinely goes through the proxy without spawning a worker. The rewrite + // is what makes it safe, which is why the filter measures the REWRITTEN url + // rather than the stored one. + assert!(!WayfernManager::needs_local_worker_for_probe( + "httpstls://user:pass@proxy.example.com:443" + )); + assert!(WayfernManager::needs_local_worker_for_probe( + "ss://method:pass@1.2.3.4:8388" + )); + assert!(WayfernManager::needs_local_worker_for_probe( + "vless://uuid@1.2.3.4:443" + )); + // Loopback is exempt ONLY for schemes reqwest can proxy. `httpstls` is + // rewritten to `https`, so a loopback one genuinely needs no worker. + assert!(!WayfernManager::needs_local_worker_for_probe( + "httpstls://127.0.0.1:8443" + )); + // But a LOOPBACK ss/vless is still a scheme reqwest discards. Exempting + // these re-opened the entire leak for anyone pointing at a locally + // forwarded Shadowsocks or VLESS endpoint. + for loopback in [ + "ss://cipher:pw@127.0.0.1:8388", + "ss://cipher:pw@localhost:8388", + "ss://cipher:pw@[::1]:8388", + "shadowsocks://cipher:pw@127.0.0.1:8388", + "vless://uuid@127.0.0.1:443", + ] { + assert!( + WayfernManager::needs_local_worker_for_probe(loopback), + "{loopback} must still go through a worker" + ); + } + // An ALLOW-list, so a scheme nobody anticipated cannot fall through. + // `proxy_type` is free text via POST /v1/proxies, import_proxies_json and + // the MCP tool, so this is reachable without a code change. Note this only + // says "reqwest must not be handed it", whether anything here can carry it + // is `probe_route`'s answer, tested below. + for unknown in [ + "trojan://gw.example.com:443", + "hysteria2://gw.example.com:443", + "wireguard://gw.example.com:51820", + "SS://cipher:pw@1.2.3.4:8388", + ] { + assert!( + WayfernManager::needs_local_worker_for_probe(unknown), + "{unknown} is not proxyable by reqwest and must not be handed to it" + ); + } + // socks5h is proxyable and remote, so it keeps the socks rule. + assert!(WayfernManager::needs_local_worker_for_probe( + "socks5h://1.2.3.4:1080" + )); + // http/https reqwest proxies natively, so they stay on the direct path. + assert!(!WayfernManager::needs_local_worker_for_probe( + "https://user:pass@proxy.example.com:8443" + )); + } + + /// A complete VLESS + XTLS Vision + REALITY URI, the only shape Donut takes. + fn valid_vless_uri() -> String { + "vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@vpn.example.com:443\ +?security=reality&flow=xtls-rprx-vision&encryption=none&type=tcp&sni=a.com\ +&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&sid=00&fp=chrome" + .to_string() + } + + #[test] + fn worker_upstream_url_mirrors_what_donut_proxy_can_actually_dial() { + // This list is `proxy_server::dial_upstream`'s match arms. Anything outside + // it reaches that function's `_` arm, and every request through the worker + // fails as "Unsupported upstream proxy scheme". + for carried in [ + "http://gw.example.com:8080", + "https://gw.example.com:8080", + "httpstls://user:pass@gw.example.com:443", + "socks4://1.2.3.4:1080", + "socks5://user:pass@gw.example.com:1080", + "ss://aes-256-gcm:pw@1.2.3.4:8388", + "shadowsocks://aes-256-gcm:pw@1.2.3.4:8388", + ] { + assert_eq!( + WayfernManager::worker_upstream_url(carried).as_deref(), + Some(carried), + "{carried} is dialable by a donut-proxy worker and must pass through unchanged" + ); + } + + // VLESS is the whole point of this defect: a worker started on it can never + // complete a single request, so it must not be offered as a transport. + assert_eq!( + WayfernManager::worker_upstream_url(&valid_vless_uri()), + None, + "a donut-proxy worker cannot speak VLESS" + ); + for unspeakable in [ + "vless://uuid@1.2.3.4:443", + "trojan://gw.example.com:443", + "hysteria2://gw.example.com:443", + "wireguard://gw.example.com:51820", + "not a url", + ] { + assert_eq!( + WayfernManager::worker_upstream_url(unspeakable), + None, + "{unspeakable} is not dialable by a donut-proxy worker" + ); + } + + // The "resolve at the exit" spellings mean the same thing to the worker, + // which hands the target hostname to the SOCKS server rather than resolving + // it here, but `dial_upstream` matches the scheme literally, so they have + // to arrive spelled the way it matches. + assert_eq!( + WayfernManager::worker_upstream_url("socks5h://user:pass@gw.example.com:1080").as_deref(), + Some("socks5://user:pass@gw.example.com:1080") + ); + assert_eq!( + WayfernManager::worker_upstream_url("socks4a://1.2.3.4:1080").as_deref(), + Some("socks4://1.2.3.4:1080") + ); + // An uppercase scheme is still the same scheme; `Url::parse` lowercases it + // anyway, so normalizing here keeps the allow-list from rejecting it. + assert_eq!( + WayfernManager::worker_upstream_url("SS://aes-256-gcm:pw@1.2.3.4:8388").as_deref(), + Some("ss://aes-256-gcm:pw@1.2.3.4:8388") + ); + } + + #[test] + fn vless_is_probed_through_xray_or_not_at_all_but_never_through_a_worker() { + // A complete VLESS URI: an Xray-core sidecar carries the hop, exactly as + // browser_runner does for a VLESS launch. + let uri = valid_vless_uri(); + assert_eq!( + WayfernManager::probe_route(&uri), + ProbeRoute::Xray(uri.clone()) + ); + + // A `vless://host:port` is what a stored VLESS proxy collapses to when it + // is rendered as `type://host:port`: no id, no flow, no REALITY key. Xray + // cannot dial it and neither can a worker, so the probe is skipped and the + // location is left ungenerated rather than defaulted. + for lossy in [ + "vless://vpn.example.com:443", + "vless://uuid@vpn.example.com:443", + // Right shape, unsupported transport, still nothing that can carry it. + "vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@a.com:443?security=tls&type=ws", + ] { + assert_eq!( + WayfernManager::probe_route(lossy), + ProbeRoute::Unroutable, + "{lossy} has no transport and must not start a worker" + ); + } + + // And no VLESS shape may EVER resolve to a plain donut-proxy worker: that + // worker answers every request with "Unsupported upstream proxy scheme", + // which used to land as a default America/New_York in the fingerprint. + for any_vless in [ + uri.as_str(), + "vless://vpn.example.com:443", + "vless://uuid@127.0.0.1:443", + "VLESS://vpn.example.com:443", + ] { + assert!( + !matches!( + WayfernManager::probe_route(any_vless), + ProbeRoute::Worker(_) | ProbeRoute::Reqwest(_) + ), + "{any_vless} must never be handed to a donut-proxy worker or to reqwest" + ); + } + } + + #[test] + fn probe_route_sends_each_upstream_to_something_that_carries_it() { + // reqwest proxies these itself; `httpstls` arrives already rewritten to the + // `https` spelling reqwest understands. + assert_eq!( + WayfernManager::probe_route("http://gw.example.com:8080"), + ProbeRoute::Reqwest("http://gw.example.com:8080".into()) + ); + assert_eq!( + WayfernManager::probe_route("httpstls://user:pass@gw.example.com:443"), + ProbeRoute::Reqwest("https://user:pass@gw.example.com:443".into()) + ); + // A loopback socks upstream IS the local worker the browser already uses, + // so it needs no second one. This is the launch-time path. + assert_eq!( + WayfernManager::probe_route("socks5://127.0.0.1:24001"), + ProbeRoute::Reqwest("socks5://127.0.0.1:24001".into()) + ); + + // Remote SOCKS and Shadowsocks go through a worker, which speaks both. + assert_eq!( + WayfernManager::probe_route("socks5://user:pass@gw.example.com:1080"), + ProbeRoute::Worker("socks5://user:pass@gw.example.com:1080".into()) + ); + assert_eq!( + WayfernManager::probe_route("ss://aes-256-gcm:pw@1.2.3.4:8388"), + ProbeRoute::Worker("ss://aes-256-gcm:pw@1.2.3.4:8388".into()) + ); + // The worker matches `socks5`, not `socks5h`, so the route carries the + // spelling it dials rather than the stored one. + assert_eq!( + WayfernManager::probe_route("socks5h://1.2.3.4:1080"), + ProbeRoute::Worker("socks5://1.2.3.4:1080".into()) + ); + + // Schemes nothing here speaks. `proxy_type` is free text through the REST + // API and MCP, so these are reachable without a code change, and each one + // must end in a skipped probe rather than a fabricated location. + for unroutable in [ + "trojan://gw.example.com:443", + "hysteria2://gw.example.com:443", + "wireguard://gw.example.com:51820", + "not a url", + ] { + assert_eq!( + WayfernManager::probe_route(unroutable), + ProbeRoute::Unroutable, + "{unroutable} has no transport that carries it" + ); + } + } + + #[test] + fn a_pinned_geoip_ip_still_resolves_on_a_routed_profile() { + // The probe is skipped only when it would actually leave this machine with + // nothing to carry it. A routed profile whose location comes from a pinned + // geoip IP resolves it locally, so it must not be skipped for want of a + // transport it never needed. + assert!(!WayfernManager::must_skip_probe(true, false, false)); + // Geolocation genuinely going out over a routed profile's upstream, with no + // transport built: this is the case that must never probe. + assert!(WayfernManager::must_skip_probe(true, true, false)); + // Transport built, or nothing routed: probe away. + assert!(!WayfernManager::must_skip_probe(true, true, true)); + assert!(!WayfernManager::must_skip_probe(false, true, false)); + } + + #[tokio::test] + async fn a_failed_geolocation_probe_leaves_the_location_ungenerated() { + // A geoip string skips the network fetch entirely and fails in the local + // MaxMind lookup, so this drives apply_geolocation's Err branch with no + // network, no proxy and no worker. + let mut fingerprint = json!({ "platform": "Win32" }); + let applied = + WayfernManager::apply_geolocation(&mut fingerprint, None, Some(&json!("not-an-ip"))).await; + + assert!(!applied, "a failed lookup must not report success"); + let obj = fingerprint + .as_object() + .expect("fingerprint stays an object"); + for invented in [ + "timezone", + "timezoneOffset", + "latitude", + "longitude", + "language", + "languages", + ] { + assert!( + !obj.contains_key(invented), + "a failed probe must not invent {invented}: {obj:?}" + ); + } + assert_eq!(obj.get("platform"), Some(&json!("Win32"))); + } + + #[tokio::test] + async fn a_failed_geolocation_probe_does_not_overwrite_a_real_location() { + // The other half: a fingerprint that already carries a resolved location + // keeps it verbatim when a later probe fails. + let mut fingerprint = json!({ + "timezone": "Europe/Berlin", + "timezoneOffset": -60, + "latitude": 52.52, + }); + let applied = + WayfernManager::apply_geolocation(&mut fingerprint, None, Some(&json!("not-an-ip"))).await; + + assert!(!applied); + assert_eq!(fingerprint["timezone"], json!("Europe/Berlin")); + assert_eq!(fingerprint["timezoneOffset"], json!(-60)); + assert_eq!(fingerprint["latitude"], json!(52.52)); } fn obj(json: &str) -> serde_json::Map { serde_json::from_str(json).expect("test fixture must be an object") } + fn identity_config(timezone: Option<&str>) -> WayfernConfig { + let mut location = serde_json::Map::new(); + if let Some(tz) = timezone { + location.insert("timezone".into(), json!(tz)); + location.insert("timezoneOffset".into(), json!(60)); + } + location.insert("language".into(), json!("de-DE")); + location.insert("languages".into(), json!(["de-DE", "de"])); + location.insert("latitude".into(), json!(52.52)); + location.insert("longitude".into(), json!(13.405)); + WayfernConfig { + identity_id: Some("3fa85f64-5717-4562-b3fc-2c963f66afa6".into()), + os: Some("windows".into()), + location: Some(serde_json::Value::Object(location).to_string()), + identity_overrides: Some(r#"{"hardwareConcurrency":8}"#.into()), + ..Default::default() + } + } + + #[test] + fn the_launch_identity_document_carries_exactly_what_the_browser_reads() { + let document = + WayfernManager::launch_identity_document(&identity_config(Some("Europe/Berlin"))) + .expect("a complete identity profile yields a document"); + assert_eq!( + document, + json!({ + "identityId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "operatingSystem": "windows", + "timezone": "Europe/Berlin", + "language": "de-DE", + "latitude": 52.52, + "longitude": 13.405, + "overrides": {"hardwareConcurrency": 8} + }) + ); + } + + #[test] + fn the_launch_identity_document_needs_a_timezone_and_an_identity() { + // The browser refuses a document without a timezone, and does not resolve + // one itself, so no document is written at all. + assert!(WayfernManager::launch_identity_document(&identity_config(None)).is_none()); + // No claim means the host, which is what an omitted `operatingSystem` + // means over CDP as well. + let mut no_os = identity_config(Some("Europe/Berlin")); + no_os.os = None; + assert_eq!( + WayfernManager::launch_identity_document(&no_os).unwrap()["operatingSystem"], + json!(crate::profile::types::get_host_os()) + ); + let mut no_identity = identity_config(Some("Europe/Berlin")); + no_identity.identity_id = Some(" ".into()); + assert!(WayfernManager::launch_identity_document(&no_identity).is_none()); + // A legacy device payload is only reproducible through setFingerprint. + let mut legacy = identity_config(Some("Europe/Berlin")); + legacy.fingerprint = Some("{}".into()); + assert!(WayfernManager::launch_identity_document(&legacy).is_none()); + } + + #[test] + fn the_launch_identity_document_omits_what_the_location_lacks() { + let mut config = identity_config(Some("Asia/Tokyo")); + config.location = Some(r#"{"timezone":"Asia/Tokyo","latitude":35.6}"#.into()); + config.identity_overrides = None; + let document = WayfernManager::launch_identity_document(&config).unwrap(); + assert_eq!( + document, + json!({ + "identityId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "operatingSystem": "windows", + "timezone": "Asia/Tokyo" + }), + "a lone latitude, an absent language and empty overrides must not travel" + ); + } + + #[test] + fn session_restore_is_only_for_a_person_on_a_device_committed_at_launch() { + let config = WayfernConfig::default(); + let interactive = |c: &WayfernConfig| { + session_restore_verdict(c, LaunchKind::Interactive, false, false, false, true) + }; + assert_eq!(interactive(&config), Ok(())); + assert!( + session_restore_verdict(&config, LaunchKind::Automation, false, false, false, true).is_err() + ); + assert!( + session_restore_verdict(&config, LaunchKind::Interactive, true, false, false, true).is_err() + ); + assert!( + session_restore_verdict(&config, LaunchKind::Interactive, false, true, false, true).is_err() + ); + assert!( + session_restore_verdict(&config, LaunchKind::Interactive, false, false, true, true).is_err() + ); + assert!( + session_restore_verdict(&config, LaunchKind::Interactive, false, false, false, false) + .is_err(), + "a device applied after the window opens loses the race with a restored tab" + ); + let off = WayfernConfig { + restore_session: Some(false), + ..Default::default() + }; + assert!(interactive(&off).is_err()); + let randomized = WayfernConfig { + randomize_fingerprint_on_launch: Some(true), + ..Default::default() + }; + assert!(interactive(&randomized).is_err()); + let explicit_on = WayfernConfig { + restore_session: Some(true), + ..Default::default() + }; + assert_eq!(interactive(&explicit_on), Ok(())); + } + + #[test] + fn the_webrtc_mode_reads_the_new_field_then_the_legacy_flag() { + assert_eq!( + WebRtcMode::from_config(&WayfernConfig::default()), + WebRtcMode::Auto + ); + let legacy = WayfernConfig { + block_webrtc: Some(true), + ..Default::default() + }; + assert_eq!(WebRtcMode::from_config(&legacy), WebRtcMode::Block); + let both = WayfernConfig { + block_webrtc: Some(true), + webrtc_mode: Some("tcp_only".into()), + ..Default::default() + }; + assert_eq!(WebRtcMode::from_config(&both), WebRtcMode::TcpOnly); + let unknown = WayfernConfig { + webrtc_mode: Some("sideways".into()), + ..Default::default() + }; + assert_eq!(WebRtcMode::from_config(&unknown), WebRtcMode::Auto); + assert_eq!(WebRtcMode::parse("TCP-ONLY"), Some(WebRtcMode::TcpOnly)); + } + + #[test] + fn the_webrtc_switches_carry_the_exit_only_when_it_can_be_used() { + assert!(webrtc_switches("151.0.7922.76", WebRtcMode::Block, Some("8.8.8.8")).is_empty()); + assert_eq!( + webrtc_switches("152.0.7977.64", WebRtcMode::Block, Some("8.8.8.8")), + vec!["--wayfern-webrtc-mode=block".to_string()] + ); + assert_eq!( + webrtc_switches("152.0.7977.64", WebRtcMode::TcpOnly, Some(" 8.8.8.8 ")), + vec![ + "--wayfern-webrtc-mode=tcp_only".to_string(), + "--wayfern-webrtc-exit-ip=8.8.8.8".to_string() + ] + ); + assert_eq!( + webrtc_switches("152.0.7977.64", WebRtcMode::Auto, Some("not an ip")), + vec!["--wayfern-webrtc-mode=auto".to_string()], + "a literal the browser would refuse is not passed at all" + ); + assert_eq!( + webrtc_switches("152.0.7977.64", WebRtcMode::Auto, None), + vec!["--wayfern-webrtc-mode=auto".to_string()] + ); + } + + #[test] + fn the_entitlement_cache_lives_under_the_app_cache_and_only_on_152() { + let root = tempfile::tempdir().unwrap(); + assert_eq!(entitlement_cache_switch("151.0.7922.76", root.path()), None); + let switch = entitlement_cache_switch("152.0.7977.64", root.path()).unwrap(); + let expected = root.path().join("wayfern-entitlements"); + assert_eq!( + switch, + format!("--wayfern-entitlement-cache-dir={}", expected.display()) + ); + assert!( + expected.is_dir(), + "the directory exists before the browser starts" + ); + } + + #[test] + fn the_window_badge_is_a_decodable_png_in_the_profile_colour() { + assert_eq!(badge_initial(" donut shop"), "D"); + assert_eq!(badge_initial("42 things"), "4"); + assert_eq!(badge_initial("!!!"), ""); + assert!(badge_wants_dark_ink("#f5e6a0")); + assert!(!badge_wants_dark_ink("#2b4c7e")); + assert_eq!(render_profile_icon("Any", "not a colour"), None); + + let png = render_profile_icon("Donut", "#2b4c7e").expect("the badge renders"); + let image = image::load_from_memory(&png) + .expect("a decodable PNG") + .into_rgba8(); + assert_eq!((image.width(), image.height()), (256, 256)); + // Inside the rounded square, away from the initial: the profile colour. + assert_eq!(image.get_pixel(40, 128).0, [0x2b, 0x4c, 0x7e, 0xff]); + // The corners stay transparent so the badge reads as a tile, not a sheet. + assert_eq!(image.get_pixel(2, 2).0[3], 0); + // The initial is drawn in white ink somewhere in the middle third when the + // machine has any font at all. Not sampled at the exact centre: that is + // the counter of a "D", which stays the fill colour. + if !badge_fonts().is_empty() { + let ink = (80..176) + .flat_map(|y| (80..176).map(move |x| (x, y))) + .any(|(x, y)| { + let p = image.get_pixel(x, y).0; + p[0] > 0xc0 && p[1] > 0xc0 && p[2] > 0xc0 + }); + assert!(ink, "the initial must be drawn in the middle of the badge"); + } + } + + #[test] + fn the_profile_icon_switch_writes_the_badge_beside_the_data_dir() { + let root = tempfile::tempdir().unwrap(); + let data_dir = root.path().join("profile"); + std::fs::create_dir_all(&data_dir).unwrap(); + let data_dir = data_dir.to_string_lossy().to_string(); + assert_eq!( + profile_icon_switch("151.0.7922.76", &data_dir, "Donut", "ebb5ad"), + None + ); + let switch = profile_icon_switch("152.0.7977.64", &data_dir, "Donut", "ebb5ad").unwrap(); + let expected = root.path().join("window-icon.png").canonicalize().unwrap(); + assert_eq!( + switch, + format!("--wayfern-profile-icon={}", expected.display()) + ); + assert!(image::load_from_memory(&std::fs::read(expected).unwrap()).is_ok()); + } + + #[test] + fn the_persona_document_is_written_per_profile_and_only_on_152() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().to_string_lossy().to_string(); + let seed = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; + assert_eq!(persona_switch("151.0.7922.76", &dir, seed, None), None); + let switch = persona_switch("152.0.7977.64", &dir, seed, None).unwrap(); + let path = root + .path() + .join("wayfern-persona.json") + .canonicalize() + .unwrap(); + assert_eq!( + switch, + format!("--wayfern-profile-persona={}", path.display()) + ); + let document: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!( + document["fields"].as_array().unwrap().len(), + crate::wayfern_persona::FIELD_IDS.len() + ); + + // An edit lands, and a malformed edit blob is ignored rather than fatal. + persona_switch( + "152.0.7977.64", + &dir, + seed, + Some(r#"[{"id":"email","label":"Email","value":"me@example.com"}]"#), + ) + .unwrap(); + let edited: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert!(edited["fields"] + .as_array() + .unwrap() + .iter() + .any(|f| f["id"] == "email" && f["value"] == "me@example.com")); + assert!(persona_switch("152.0.7977.64", &dir, seed, Some("not json")).is_some()); + } + + #[test] + fn the_camera_switches_need_a_file_that_exists_and_a_sane_crop() { + let root = tempfile::tempdir().unwrap(); + let file = root.path().join("frame.png"); + std::fs::write(&file, b"not really a png").unwrap(); + let path = file.to_string_lossy().to_string(); + let config = |file: Option<&str>, crop: Option<&str>| WayfernConfig { + camera_file: file.map(str::to_string), + camera_crop: crop.map(str::to_string), + ..Default::default() + }; + assert!(camera_switches("151.0.7922.76", &config(Some(&path), None)).is_empty()); + assert!(camera_switches("152.0.7977.64", &config(None, Some("0,0,10,10"))).is_empty()); + assert!(camera_switches("152.0.7977.64", &config(Some("/nope/frame.png"), None)).is_empty()); + assert_eq!( + camera_switches("152.0.7977.64", &config(Some(&path), Some("0,0,640,480"))), + vec![ + format!("--wayfern-camera-file={path}"), + "--wayfern-camera-crop=0,0,640,480".to_string() + ] + ); + assert_eq!( + camera_switches("152.0.7977.64", &config(Some(&path), Some("0,0,0,480"))), + vec![format!("--wayfern-camera-file={path}")], + "a crop with no area is dropped, the source is not" + ); + assert!(!valid_camera_crop("1,2,3")); + assert!(!valid_camera_crop("-1,0,10,10")); + assert!(valid_camera_crop(" 1, 2, 30, 40 ")); + } + + #[test] + fn widevine_travels_only_when_a_cdm_has_been_provisioned() { + let root = tempfile::tempdir().unwrap(); + assert_eq!(widevine_switch("152.0.7977.64", root.path()), None); + let dir = root.path().join("WidevineCdm"); + std::fs::create_dir_all(&dir).unwrap(); + assert_eq!( + widevine_switch("152.0.7977.64", root.path()), + None, + "an empty directory is not a provisioned CDM" + ); + std::fs::write(dir.join("manifest.json"), b"{}").unwrap(); + assert_eq!(widevine_switch("151.0.7922.76", root.path()), None); + assert_eq!( + widevine_switch("152.0.7977.64", root.path()), + Some(format!("--wayfern-widevine-cdm-dir={}", dir.display())), + "a provisioned directory is passed even when its payload is missing, so the browser reports it" + ); + } + + #[test] + fn a_screen_claim_is_only_reported_when_it_is_bigger_than_the_display() { + let device = |w: u32, h: u32| format!(r#"{{"screenWidth":{w},"screenHeight":{h}}}"#); + assert_eq!( + screen_claim_over_host(Some(&device(3840, 2160)), Some((2560, 1440))), + Some((3840, 2160, 2560, 1440)) + ); + // Taller but not wider still cannot be shown. + assert_eq!( + screen_claim_over_host(Some(&device(1920, 2160)), Some((2560, 1440))), + Some((1920, 2160, 2560, 1440)) + ); + assert_eq!( + screen_claim_over_host(Some(&device(1920, 1080)), Some((2560, 1440))), + None + ); + assert_eq!( + screen_claim_over_host(Some(&device(2560, 1440)), Some((2560, 1440))), + None, + "an exact fit is not a mismatch" + ); + // Nothing to compare: no device, no host, a device with no screen, or a + // display that reports nothing. + assert_eq!(screen_claim_over_host(None, Some((2560, 1440))), None); + assert_eq!( + screen_claim_over_host(Some(&device(3840, 2160)), None), + None + ); + assert_eq!( + screen_claim_over_host(Some(r#"{"platform":"MacIntel"}"#), Some((2560, 1440))), + None + ); + assert_eq!( + screen_claim_over_host(Some(&device(3840, 2160)), Some((0, 0))), + None + ); + } + + #[test] + fn the_session_switches_follow_the_verdict() { + assert!(session_switches(false, None).is_empty()); + assert_eq!( + session_switches(true, None), + vec!["--restore-last-session".to_string()] + ); + let file = Path::new("/tmp/p/wayfern-identity.json"); + assert_eq!( + session_switches(true, Some(file)), + vec![ + "--wayfern-identity-file=/tmp/p/wayfern-identity.json".to_string(), + "--restore-last-session".to_string() + ] + ); + } + + #[test] + fn the_identity_verdict_trusts_the_browser_log_first() { + let document = json!({ + "identityId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "operatingSystem": "macos", + "timezone": "Europe/Berlin", + "language": "de-DE" + }); + let tap = BrowserLogTap::default(); + tap.push("[1:2:0908/173819.393301:ERROR:wayfern_launch_identity.cc(58)] Wayfern launch identity refused: the identity file carries no `timezone`".into()); + let observed = json!({"identityId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "identity": {}}); + assert_eq!( + WayfernManager::launch_identity_verdict(&document, Some(&observed), None, &tap), + Err("the identity file carries no `timezone`".to_string()), + "a logged refusal wins even over a matching id" + ); + + let tap = BrowserLogTap::default(); + assert!( + WayfernManager::launch_identity_verdict(&document, Some(&observed), None, &tap).is_ok() + ); + + // 152 reports no identityId for a launch identity; the applied line + // settles it. + let tap = BrowserLogTap::default(); + tap.push("[1:2:0908/173819.393301:INFO:wayfern_launch_identity.cc(300)] Wayfern launch identity applied before the first navigation: os=macos timezone=Europe/Berlin language=de-DE version=1".into()); + let anonymous = json!({"identity": {"timezone": "Europe/Berlin", "language": "de-DE", "platform": "MacIntel"}}); + assert!( + WayfernManager::launch_identity_verdict(&document, Some(&anonymous), None, &tap).is_ok() + ); + + // No log line at all: the running device is compared with the document. + let tap = BrowserLogTap::default(); + assert!( + WayfernManager::launch_identity_verdict(&document, Some(&anonymous), None, &tap).is_ok() + ); + let host = json!({"identity": {"timezone": "America/New_York", "language": "en-US", "platform": "MacIntel"}}); + let error = + WayfernManager::launch_identity_verdict(&document, Some(&host), None, &tap).unwrap_err(); + assert!(error.contains("America/New_York"), "{error}"); + assert!(error.contains("Europe/Berlin"), "{error}"); + assert_eq!( + WayfernManager::launch_identity_verdict(&document, None, Some("boom"), &tap), + Err("Wayfern.getIdentity failed: boom".to_string()) + ); + } + + #[test] + fn the_log_tap_keeps_the_newest_lines() { + let tap = BrowserLogTap::default(); + for i in 0..40 { + tap.push(format!("line {i}")); + } + let lines = tap.lines(); + assert_eq!(lines.len(), BrowserLogTap::CAPACITY); + assert_eq!(lines.first().map(String::as_str), Some("line 8")); + assert_eq!(lines.last().map(String::as_str), Some("line 39")); + assert!(!tap.has_identity_verdict()); + } + + #[cfg(unix)] + #[tokio::test] + async fn a_termination_request_ends_a_process_and_the_wait_notices() { + let mut child = std::process::Command::new("sleep") + .arg("30") + .spawn() + .expect("sleep spawns"); + let pid = child.id(); + assert!(!wait_for_exit(pid, Duration::from_millis(200)).await); + terminate_process(pid); + let _ = child.wait(); + assert!(wait_for_exit(pid, Duration::from_secs(5)).await); + } + #[test] fn identity_api_switch_follows_the_chromium_major() { // The profile version is the full Chromium version string. @@ -2117,6 +4244,9 @@ mod tests { // whereas the legacy pair still exists on every version that ever shipped. assert!(!supports_identity_api("")); assert!(!supports_identity_api("not a version")); + assert!(!supports_wayfern_152("151.0.7922.76")); + assert!(supports_wayfern_152("152.0.7977.64")); + assert!(!supports_wayfern_152("garbage")); } #[test] @@ -2211,7 +4341,10 @@ mod tests { }; assert!(!WayfernManager::migrate_identity_config(&mut config)); - assert_eq!(config.fingerprint.as_deref(), Some(r#"{"platform":"Win32"}"#)); + assert_eq!( + config.fingerprint.as_deref(), + Some(r#"{"platform":"Win32"}"#) + ); assert!(config.identity_id.is_none()); assert!(config.identity_overrides.is_none()); } @@ -2350,8 +4483,8 @@ mod tests { #[test] fn platform_strings_map_the_way_the_browser_maps_them() { - // Mirrors WayfernHandler::IsCrossOSFromPlatform, including armv8l/aarch64 - // reading as android rather than linux. + // Mirrors the browser's own platform-to-OS mapping, including armv8l and + // aarch64 reading as android rather than linux. assert_eq!(WayfernManager::os_from_platform("Win32"), Some("windows")); assert_eq!(WayfernManager::os_from_platform("MacIntel"), Some("macos")); assert_eq!(WayfernManager::os_from_platform("iPhone"), Some("ios")); @@ -2372,7 +4505,7 @@ mod tests { #[test] fn a_refused_apply_is_translated_to_a_code_the_frontend_knows() { - // The exact literals WayfernHandler emits. + // The exact literals the browser emits. let cross_os = WayfernManager::apply_failure_error( "CDP error: Cross-OS fingerprinting requires a paid plan. Provide a wayfernToken parameter.", Some("macos"), @@ -2392,6 +4525,54 @@ mod tests { assert!(other.contains("No response received")); } + #[test] + fn the_launch_payload_never_invents_a_location() { + // The regression, and the whole reason the gate is allowed to say "nothing + // was compared": the launcher used to insert `America/New_York` and offset + // 300 into any fingerprint that declared no timezone. The browser then + // presented a US clock behind whatever exit the profile routed through, + // while the app told the user the timezone had never been compared, the + // exact mismatch `fingerprint_consistency` exists to surface, manufactured + // by the launcher and then hidden by it. + for stored in [ + r#"{"platform": "Win32"}"#, + // The legacy wrapper takes the same path. + r#"{"fingerprint": {"platform": "Win32"}}"#, + ] { + let payload = + WayfernManager::launch_fingerprint_payload(stored).expect("a stored fingerprint parses"); + let obj = payload.as_object().expect("stays an object"); + for invented in ["timezone", "timezoneOffset", "latitude", "longitude"] { + assert!( + !obj.contains_key(invented), + "the launch payload invented {invented} for {stored}: {obj:?}" + ); + } + assert_eq!(obj.get("platform"), Some(&json!("Win32"))); + } + } + + #[test] + fn the_launch_payload_unwraps_the_legacy_shape_and_keeps_a_declared_location() { + let payload = WayfernManager::launch_fingerprint_payload( + r#"{"fingerprint": {"timezone": "Europe/Berlin", "timezoneOffset": -60, + "languages": "de-DE, de"}}"#, + ) + .expect("the legacy wrapper parses"); + + assert_eq!(payload["timezone"], json!("Europe/Berlin")); + assert_eq!(payload["timezoneOffset"], json!(-60)); + // A comma-separated ladder still becomes the array the browser expects. + assert_eq!(payload["languages"], json!(["de-DE", "de"])); + // The wrapper itself must never reach the browser. + assert!(payload.get("fingerprint").is_none()); + } + + #[test] + fn the_launch_payload_refuses_unparsable_json() { + assert!(WayfernManager::launch_fingerprint_payload("not json").is_err()); + } + #[test] fn window_size_prefers_outer_window_dimensions() { // Field names + values mirror a real Wayfern fingerprint (camelCase). diff --git a/src-tauri/src/wayfern_persona.rs b/src-tauri/src/wayfern_persona.rs new file mode 100644 index 0000000..a27e9e0 --- /dev/null +++ b/src-tauri/src/wayfern_persona.rs @@ -0,0 +1,393 @@ +//! The person a profile presents as, when a site asks for one. +//! +//! Wayfern shows a "Fill with generated" submenu in any text field, built +//! from a document the launcher writes: `{"fields":[{"label","value"},…]}`. +//! The browser never invents a value, so everything here is donut's. +//! +//! A persona is DERIVED, not stored as prose: the same profile hands the +//! browser the same person on every launch, and two profiles never share one, +//! because every field is a function of the profile's own seed. The user can +//! still edit any field; edits are the only thing that persists. + +use serde::{Deserialize, Serialize}; + +/// A named value the browser offers in its fill submenu. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PersonaField { + pub id: String, + pub label: String, + pub value: String, +} + +/// The browser truncates a longer submenu; keeping the same bound here means +/// what the user edits is what the browser shows. +const MAX_FIELDS: usize = 24; +/// Bounds that match what the fill submenu will render, so a value is never +/// silently shortened. +const MAX_LABEL_CHARS: usize = 64; +const MAX_VALUE_CHARS: usize = 512; + +/// The fields a derived persona carries, in submenu order. +pub const FIELD_IDS: [&str; 9] = [ + "full_name", + "first_name", + "last_name", + "email", + "username", + "phone", + "birth_date", + "street_address", + "postal_code", +]; + +/// FNV-1a with the salt folded into the initial state, then splitmix64, so +/// neighbouring salts do not produce visibly related values. +fn draw(seed: &str, salt: u64) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325u64 ^ salt; + for byte in seed.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + let mut z = hash.wrapping_add(0x9e37_79b9_7f4a_7c15); + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^ (z >> 31) +} + +fn pick<'a>(seed: &str, salt: u64, options: &[&'a str]) -> &'a str { + options[(draw(seed, salt) % options.len() as u64) as usize] +} + +const GIVEN_NAMES: [&str; 32] = [ + "Amelia", "Noah", "Sofia", "Liam", "Mia", "Lucas", "Emma", "Ethan", "Olivia", "Mateo", "Ava", + "Leon", "Zara", "Hugo", "Nora", "Adam", "Iris", "Felix", "Maya", "Oscar", "Lena", "Rafael", + "Clara", "Milan", "Elif", "Jonas", "Nina", "Tobias", "Rosa", "Kai", "Alma", "Viktor", +]; + +const FAMILY_NAMES: [&str; 32] = [ + "Bennett", + "Novak", + "Marchetti", + "Okafor", + "Lindqvist", + "Haddad", + "Vasquez", + "Ferreira", + "Kowalski", + "Dubois", + "Andersen", + "Rahman", + "Moretti", + "Kaminski", + "Bauer", + "Silva", + "Petrov", + "Nakamura", + "Kelly", + "Weiss", + "Salgado", + "Virtanen", + "Costa", + "Yilmaz", + "Horvat", + "Laurent", + "Fischer", + "Blake", + "Reyes", + "Janssen", + "Meyer", + "Sorensen", +]; + +const STREETS: [&str; 16] = [ + "Maple Avenue", + "Linden Street", + "Harbour Road", + "Kestrel Lane", + "Alder Way", + "Foundry Street", + "Willow Crescent", + "Bridgeway", + "Chandler Street", + "Orchard Row", + "Beacon Hill", + "Cypress Walk", + "Quarry Road", + "Sable Street", + "Juniper Court", + "Pier Lane", +]; + +const MAIL_HOSTS: [&str; 6] = [ + "gmail.com", + "outlook.com", + "proton.me", + "yahoo.com", + "icloud.com", + "fastmail.com", +]; + +/// A calendar date `years_back` years or so before now, as `YYYY-MM-DD`. +/// Days-in-month is handled by capping at 28, which every month has. +fn birth_date(seed: &str) -> String { + let year = 1970 + (draw(seed, 61) % 36); // 1970..2005: adult in any locale + let month = 1 + (draw(seed, 62) % 12); + let day = 1 + (draw(seed, 63) % 28); + format!("{year:04}-{month:02}-{day:02}") +} + +/// Digits only, so the value is usable in a field with any formatting rule. +fn phone(seed: &str) -> String { + let area = 200 + (draw(seed, 71) % 700); + let prefix = 200 + (draw(seed, 72) % 700); + let line = draw(seed, 73) % 10_000; + format!("+1{area:03}{prefix:03}{line:04}") +} + +/// Derive the persona a profile presents, in submenu order. +/// +/// `seed` must be stable for the profile and unique to it: the identity id +/// when it has one, otherwise the profile id. Nothing here reads the clock or +/// the host, so the same seed reproduces the same person anywhere. +pub fn derive(seed: &str) -> Vec { + let given = pick(seed, 11, &GIVEN_NAMES); + let family = pick(seed, 12, &FAMILY_NAMES); + let username = format!( + "{}{}{}", + given.to_lowercase(), + family.to_lowercase(), + draw(seed, 21) % 100 + ); + let email = format!("{username}@{}", pick(seed, 22, &MAIL_HOSTS)); + let street = format!( + "{} {}", + 1 + (draw(seed, 31) % 200), + pick(seed, 32, &STREETS) + ); + let postal = format!("{:05}", draw(seed, 33) % 100_000); + + // Ordered by FIELD_IDS, which is what the browser's submenu shows. + let labels = [ + "Full name", + "First name", + "Last name", + "Email", + "Username", + "Phone", + "Date of birth", + "Street address", + "Postal code", + ]; + let values = [ + format!("{given} {family}"), + given.to_string(), + family.to_string(), + email, + username, + phone(seed), + birth_date(seed), + street, + postal, + ]; + FIELD_IDS + .iter() + .zip(labels) + .zip(values) + .map(|((id, label), value)| field(id, label, value)) + .collect() +} + +fn field(id: &str, label: &str, value: String) -> PersonaField { + PersonaField { + id: id.to_string(), + label: label.to_string(), + value, + } +} + +/// Apply the user's edits to a derived persona: an edit replaces the value of +/// the field it names, an unknown id is appended, and a blank value removes +/// the row so the browser never offers an empty entry. +pub fn with_edits(seed: &str, edits: &[PersonaField]) -> Vec { + let mut fields = derive(seed); + for edit in edits { + let value = edit.value.trim(); + match fields.iter().position(|f| f.id == edit.id) { + Some(index) if value.is_empty() => { + fields.remove(index); + } + Some(index) => { + fields[index].value = value.to_string(); + if !edit.label.trim().is_empty() { + fields[index].label = edit.label.trim().to_string(); + } + } + None if value.is_empty() => {} + None => fields.push(field( + &edit.id, + if edit.label.trim().is_empty() { + &edit.id + } else { + edit.label.trim() + }, + value.to_string(), + )), + } + } + fields.truncate(MAX_FIELDS); + for field in &mut fields { + truncate_chars(&mut field.label, MAX_LABEL_CHARS); + truncate_chars(&mut field.value, MAX_VALUE_CHARS); + } + fields +} + +fn truncate_chars(text: &mut String, limit: usize) { + if text.chars().count() > limit { + *text = text.chars().take(limit).collect(); + } +} + +/// The document the browser reads, as it writes it to disk. +pub fn document(fields: &[PersonaField]) -> serde_json::Value { + serde_json::json!({ "fields": fields }) +} + +/// The person this profile presents, as the browser will offer it: derived +/// from the profile's own seed with the user's edits applied. +/// +/// The seed is the identity id when the profile has one and its own id +/// otherwise, which is exactly what the launcher uses, so what this returns is +/// what the next launch writes. `derived_only` asks for the person before any +/// edit, which is what "reset to generated" shows. +#[tauri::command] +pub fn get_profile_persona( + profile_id: String, + derived_only: Option, +) -> Result, String> { + let profile = crate::profile::ProfileManager::instance() + .list_profiles() + .map_err(|e| format!("Failed to list profiles: {e}"))? + .into_iter() + .find(|profile| profile.id.to_string() == profile_id) + .ok_or_else(|| crate::backend_error("PROFILE_NOT_FOUND"))?; + let config = profile.wayfern_config.unwrap_or_default(); + let seed = config + .identity_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| profile.id.to_string()); + if derived_only.unwrap_or(false) { + return Ok(derive(&seed)); + } + let edits: Vec = config + .persona + .as_deref() + .map(str::trim) + .filter(|edits| !edits.is_empty()) + .and_then(|edits| serde_json::from_str(edits).ok()) + .unwrap_or_default(); + Ok(with_edits(&seed, &edits)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_persona_is_stable_for_one_seed_and_different_across_seeds() { + let a = derive("3fa85f64-5717-4562-b3fc-2c963f66afa6"); + assert_eq!(a, derive("3fa85f64-5717-4562-b3fc-2c963f66afa6")); + let b = derive("9c858901-8a57-4791-81fe-4c455b099bc9"); + assert_ne!(a, b); + assert_eq!( + a.iter().map(|f| f.id.as_str()).collect::>(), + FIELD_IDS, + "the submenu order is the launcher's decision and must not drift" + ); + } + + #[test] + fn every_derived_value_is_usable() { + for seed in ["a", "b", "seed-3", "3fa85f64-5717-4562-b3fc-2c963f66afa6"] { + let fields = derive(seed); + let get = |id: &str| { + fields + .iter() + .find(|f| f.id == id) + .map(|f| f.value.clone()) + .unwrap() + }; + assert!(get("email").contains('@')); + assert!(get("email").starts_with(&get("username"))); + assert!( + get("full_name") == format!("{} {}", get("first_name"), get("last_name")), + "the full name must be the two parts it is made of" + ); + let phone = get("phone"); + assert!(phone.starts_with('+') && phone[1..].chars().all(|c| c.is_ascii_digit())); + let birth = get("birth_date"); + assert_eq!(birth.len(), 10); + let day: u32 = birth[8..].parse().unwrap(); + assert!((1..=28).contains(&day), "{birth}"); + assert!(fields.iter().all(|f| !f.value.trim().is_empty())); + } + } + + #[test] + fn an_edit_replaces_one_field_and_a_blank_removes_it() { + let seed = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; + let edited = with_edits( + seed, + &[ + field("email", "", "me@example.com".into()), + field("phone", "", " ".into()), + field("company", "Company", "Donut".into()), + ], + ); + assert_eq!( + edited.iter().find(|f| f.id == "email").unwrap().value, + "me@example.com" + ); + assert!(edited.iter().all(|f| f.id != "phone")); + let extra = edited.iter().find(|f| f.id == "company").unwrap(); + assert_eq!( + (extra.label.as_str(), extra.value.as_str()), + ("Company", "Donut") + ); + // Everything not edited still comes from the seed. + let derived = derive(seed); + assert_eq!( + edited.iter().find(|f| f.id == "full_name").unwrap().value, + derived.iter().find(|f| f.id == "full_name").unwrap().value + ); + } + + #[test] + fn edits_cannot_exceed_the_browsers_own_limits() { + let long = "x".repeat(1000); + let edited = with_edits( + "seed", + &(0..40) + .map(|i| field(&format!("extra{i}"), &long, long.clone())) + .collect::>(), + ); + assert_eq!(edited.len(), MAX_FIELDS); + assert!(edited + .iter() + .all(|f| f.label.chars().count() <= MAX_LABEL_CHARS + && f.value.chars().count() <= MAX_VALUE_CHARS)); + } + + #[test] + fn the_document_is_the_shape_the_browser_parses() { + let document = document(&derive("seed")); + let fields = document["fields"].as_array().unwrap(); + assert_eq!(fields.len(), FIELD_IDS.len()); + assert!(fields + .iter() + .all(|f| f["id"].is_string() && f["label"].is_string() && f["value"].is_string())); + } +} diff --git a/src-tauri/src/wayfern_terms.rs b/src-tauri/src/wayfern_terms.rs index 73fcc86..07895cf 100644 --- a/src-tauri/src/wayfern_terms.rs +++ b/src-tauri/src/wayfern_terms.rs @@ -184,6 +184,10 @@ impl WayfernTermsManager { } log::info!("Wayfern terms and conditions accepted successfully"); + // The frontend only re-reads the marker when it drove the acceptance + // itself. Anything else that accepts (the REST API, a WebDriver session) + // would leave the blocking dialog open, so the change is announced. + let _ = crate::events::emit_empty("wayfern-terms-accepted"); Ok(()) } } diff --git a/src-tauri/src/xray/model.rs b/src-tauri/src/xray/model.rs index 439a4ce..af232bf 100644 --- a/src-tauri/src/xray/model.rs +++ b/src-tauri/src/xray/model.rs @@ -170,11 +170,17 @@ fn validate_endpoint_address(address: &str) -> XrayResult<()> { if address.parse::().is_ok() { return Ok(()); } - Host::parse(address).map_err(|_| XrayError::InvalidField { - field: "address", - reason: "must be a valid hostname or IP address", - })?; - Ok(()) + // Canonicality, not just parseability: `Host::parse` percent-decodes and + // punycodes before it validates, so `caf%C3%A9.example.com` parses fine while + // Xray dials the stored string verbatim and never resolves it. Case is the + // one difference that is safe, since parsing only lowercases. + match Host::parse(address) { + Ok(host) if host.to_string().eq_ignore_ascii_case(address) => Ok(()), + _ => Err(XrayError::InvalidField { + field: "address", + reason: "must be a valid hostname or IP address", + }), + } } fn validate_server_name(server_name: &str) -> XrayResult<()> { @@ -300,6 +306,35 @@ mod tests { } } + #[test] + fn endpoint_rejects_hosts_xray_would_dial_verbatim() { + // Both reach the sidecar unchanged, so accepting them buys a dead tunnel + // with no import-time error. + for address in ["caf%C3%A9.example.com", "café.example.com"] { + let mut config = valid_config(); + config.address = address.to_string(); + assert!( + matches!( + config.validate(), + Err(XrayError::InvalidField { + field: "address", + .. + }) + ), + "{address}" + ); + } + } + + #[test] + fn endpoint_accepts_mixed_case_and_punycode_hosts() { + for address in ["VPN.Example.com", "xn--caf-dma.example.com"] { + let mut config = valid_config(); + config.address = address.to_string(); + assert_eq!(config.validate(), Ok(()), "{address}"); + } + } + #[test] fn id_must_be_a_uuid() { let mut config = valid_config(); diff --git a/src-tauri/src/xray/uri.rs b/src-tauri/src/xray/uri.rs index ea7e290..edbed48 100644 --- a/src-tauri/src/xray/uri.rs +++ b/src-tauri/src/xray/uri.rs @@ -55,7 +55,16 @@ pub fn parse_vless_uri(input: &str) -> XrayResult { })? .to_string(); let address = match url.host().ok_or(XrayError::MissingField("address"))? { - Host::Domain(value) => value.to_string(), + // `vless` is not a special scheme, so `Url` keeps the host exactly as + // written, percent-escapes included, and that string is what the sidecar + // dials. Re-parsing canonicalizes an internationalized host into the + // punycode form that actually resolves. + Host::Domain(value) => Host::parse(value) + .map_err(|_| XrayError::InvalidField { + field: "address", + reason: "must be a valid hostname or IP address", + })? + .to_string(), Host::Ipv4(value) => value.to_string(), Host::Ipv6(value) => value.to_string(), }; @@ -417,6 +426,32 @@ mod tests { assert_eq!(parsed.name.as_deref(), Some("Home server")); } + #[test] + fn an_internationalized_host_is_stored_as_punycode() { + let input = uri(&[]).replace("vpn.example.com", "café.example.com"); + let parsed = parse_vless_uri(&input).unwrap(); + assert_eq!(parsed.config.address, "xn--caf-dma.example.com"); + + // And the canonical form survives an export/import round trip. + let exported = export_vless_uri(&parsed.config, None).unwrap(); + assert_eq!( + parse_vless_uri(&exported).unwrap().config.address, + "xn--caf-dma.example.com" + ); + } + + #[test] + fn rejects_a_host_that_percent_decodes_into_something_undialable() { + let input = uri(&[]).replace("vpn.example.com", "vpn%2Fexample.com"); + assert!(matches!( + parse_vless_uri(&input), + Err(XrayError::InvalidField { + field: "address", + .. + }) + )); + } + #[test] fn applies_only_safe_optional_defaults() { let key = public_key(); diff --git a/src/app/page.tsx b/src/app/page.tsx index 425e1cf..90853eb 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,12 +3,13 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { getCurrent } from "@tauri-apps/plugin-deep-link"; -import { motion } from "motion/react"; +import { useReducedMotion } from "motion/react"; import { useOnborda } from "onborda"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { AboutDialog } from "@/components/about-dialog"; import { AccountPage } from "@/components/account-page"; +import { AgentPage, type AgentTab } from "@/components/agent-page"; import { CloneProfileDialog } from "@/components/clone-profile-dialog"; import { CloseConfirmDialog } from "@/components/close-confirm-dialog"; import { CommandPalette } from "@/components/command-palette"; @@ -34,6 +35,7 @@ import { PreLaunchGateDialog, } from "@/components/pre-launch-gate-dialog"; import { ProfilesDataTable } from "@/components/profile-data-table"; +import { ProfileGroupDragProvider } from "@/components/profile-group-drag"; import { type PasswordDialogMode, ProfilePasswordDialog, @@ -41,6 +43,7 @@ import { import { ProfileSelectorDialog } from "@/components/profile-selector-dialog"; import { ProfileSyncDialog } from "@/components/profile-sync-dialog"; import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog"; +import { ProxyDistributionDialog } from "@/components/proxy-distribution-dialog"; import { ProxyManagementDialog } from "@/components/proxy-management-dialog"; import { type AppPage, RailNav } from "@/components/rail-nav"; import { SettingsDialog } from "@/components/settings-dialog"; @@ -48,7 +51,9 @@ import { ShortcutsPage } from "@/components/shortcuts-page"; import { SyncAllDialog } from "@/components/sync-all-dialog"; import { SyncConfigDialog } from "@/components/sync-config-dialog"; import { SyncFollowerDialog } from "@/components/sync-follower-dialog"; +import { SynchronizerPanel } from "@/components/synchronizer-panel"; import { ThankYouDialog } from "@/components/thank-you-dialog"; +import { TrashPage } from "@/components/trash-page"; import { WayfernConfigDialog } from "@/components/wayfern-config-dialog"; import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog"; import { WelcomeDialog } from "@/components/welcome-dialog"; @@ -58,6 +63,7 @@ import { useCloudAuth } from "@/hooks/use-cloud-auth"; import { useCommercialTrial } from "@/hooks/use-commercial-trial"; import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot"; import { useGroupEvents } from "@/hooks/use-group-events"; +import { useKonamiCode } from "@/hooks/use-konami-code"; import type { PermissionType } from "@/hooks/use-permissions"; import { usePermissions } from "@/hooks/use-permissions"; import { useProfileEvents } from "@/hooks/use-profile-events"; @@ -68,8 +74,12 @@ import { useVersionUpdater } from "@/hooks/use-version-updater"; import { useVpnEvents } from "@/hooks/use-vpn-events"; import { useWayfernTerms } from "@/hooks/use-wayfern-terms"; import { parseBackendError, translateBackendError } from "@/lib/backend-errors"; -import { canUseCookieBot, getEntitlements } from "@/lib/entitlements"; -import { MOTION_EASE_OUT } from "@/lib/motion"; +import { fireSprinkleConfetti } from "@/lib/confetti"; +import { + canUseCookieBot, + canUseRemoteControl, + getEntitlements, +} from "@/lib/entitlements"; import { ONBOARDING_TOUR_CLOSED_EVENT, ONBOARDING_TOUR_FINISHED_EVENT, @@ -128,6 +138,9 @@ function consistencyFromErrorParams( fingerprint_timezone: params?.fingerprintTimezone || null, fingerprint_language: params?.fingerprintLanguage || null, mismatches: (params?.mismatches ?? "").split(",").filter(Boolean), + // Carried through rather than defaulted to empty: a dimension nothing + // compared must not be rebuilt here as a dimension that passed. + unverified: (params?.unverified ?? "").split(",").filter(Boolean), }; } @@ -140,6 +153,7 @@ interface PendingUrl { export default function Home() { const { t } = useTranslation(); + const reducedMotion = useReducedMotion(); // Mount global version update listener/toasts useVersionUpdater(); @@ -149,6 +163,7 @@ export default function Home() { runningProfiles, isLoading: profilesLoading, error: profilesError, + loadProfiles, } = useProfileEvents(); // First-run onboarding tour (Onborda). @@ -262,6 +277,7 @@ export default function Home() { groups: groupsData, isLoading: groupsLoading, error: groupsError, + loadGroups, } = useGroupEvents(); const { @@ -303,7 +319,11 @@ export default function Home() { }, []); // Synchronizer sessions - const { getProfileSyncInfo } = useSyncSessions(); + const { + sessions: syncSessions, + getProfileSyncInfo, + applySession, + } = useSyncSessions(); const [syncLeaderProfile, setSyncLeaderProfile] = useState(null); @@ -367,13 +387,16 @@ export default function Home() { const [extensionManagementInitialTab, setExtensionManagementInitialTab] = useState<"extensions" | "groups">("extensions"); const [integrationsInitialTab, setIntegrationsInitialTab] = useState< - "api" | "mcp" + "api" | "mcp" | "remote" >("api"); const [cookieBotDialogOpen, setCookieBotDialogOpen] = useState(false); const [cookieBotInitialTab, setCookieBotInitialTab] = useState("overview"); + const [agentDialogOpen, setAgentDialogOpen] = useState(false); + const [agentInitialTab, setAgentInitialTab] = useState("run"); const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false); const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); + const [trashPageOpen, setTrashPageOpen] = useState(false); const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false); const [importProfileDialogOpen, setImportProfileDialogOpen] = useState(false); const [proxyManagementDialogOpen, setProxyManagementDialogOpen] = @@ -412,6 +435,10 @@ export default function Home() { const [selectedProfilesForProxy, setSelectedProfilesForProxy] = useState< string[] >([]); + const [proxyDistributionDialogOpen, setProxyDistributionDialogOpen] = + useState(false); + const [selectedProfilesForDistribution, setSelectedProfilesForDistribution] = + useState([]); const [selectedProfiles, setSelectedProfiles] = useState([]); const [searchQuery, setSearchQuery] = useState(""); const [pendingUrls, setPendingUrls] = useState([]); @@ -479,6 +506,9 @@ export default function Home() { // info dialog. ProfilesDataTable consumes it through controlled props. const [profileInfoDialog, setProfileInfoDialog] = useState(null); + const [profileInfoOpenMethod, setProfileInfoOpenMethod] = useState< + "pointer" | "keyboard" + >("pointer"); const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } = usePermissions(); @@ -499,6 +529,8 @@ export default function Home() { setImportProfileDialogOpen(false); setAccountDialogOpen(false); setCookieBotDialogOpen(false); + setAgentDialogOpen(false); + setTrashPageOpen(false); setCurrentPage(page); switch (page) { @@ -520,6 +552,9 @@ export default function Home() { case "cookieBot": setCookieBotDialogOpen(true); break; + case "agent": + setAgentDialogOpen(true); + break; case "integrations": setIntegrationsDialogOpen(true); break; @@ -535,6 +570,9 @@ export default function Home() { case "account": setAccountDialogOpen(true); break; + case "trash": + setTrashPageOpen(true); + break; case "shortcuts": // Plain page render — nothing else to open. break; @@ -598,10 +636,29 @@ export default function Home() { } break; } + case "goAgent": { + // Mod+J: navigate first time; flip run↔history while already there, + // matching how Mod+B flips the Cookie Bot tabs. + if (currentPage === "agent") { + setAgentInitialTab((cur) => (cur === "run" ? "history" : "run")); + } else { + setAgentInitialTab("run"); + handleRailNavigate("agent"); + } + break; + } case "goIntegrations": { - // Mod+I: flip api↔mcp tab when already on integrations. + // Mod+I: cycle the tabs when already on integrations, in the order + // the dialog lists them. The Remote MCP tab only exists for a user + // entitled to remote control, so everyone else flips api and mcp. if (currentPage === "integrations") { - setIntegrationsInitialTab((cur) => (cur === "api" ? "mcp" : "api")); + setIntegrationsInitialTab((cur) => + cur === "api" + ? "mcp" + : cur === "mcp" && canUseRemoteControl(cloudUser) + ? "remote" + : "api", + ); } else { handleRailNavigate("integrations"); } @@ -613,9 +670,12 @@ export default function Home() { case "goSettings": handleRailNavigate("settings"); break; + case "goTrash": + handleRailNavigate("trash"); + break; } }, - [handleRailNavigate, currentPage, proxyManagementInitialTab], + [handleRailNavigate, currentPage, proxyManagementInitialTab, cloudUser], ); // Ordered list the digit shortcuts and palette consume. "__all__" is index 1 @@ -676,6 +736,19 @@ export default function Home() { }; }, [runShortcut, selectGroupByDigit, orderedGroupTargets.length]); + // The classic cheat code pays out in sprinkles. Nothing else changes: it + // touches no profile, setting, or file, so it is safe anywhere in the app. + useKonamiCode( + useCallback(() => { + showSuccessToast(t("easterEgg.konami.title"), { + id: "cheat-code", + description: t("easterEgg.konami.description"), + }); + window.dispatchEvent(new CustomEvent("donut-cheat-code")); + if (!reducedMotion) fireSprinkleConfetti(); + }, [reducedMotion, t]), + ); + // Check for missing binaries and offer to download them const checkMissingBinaries = useCallback(async () => { try { @@ -1066,6 +1139,37 @@ export default function Home() { }; }, [t]); + // The third state. The gate reached the exit but the fingerprint declares no + // value to compare it against, so nothing was verified, which is not a + // mismatch and never blocks, but is not the clean bill of health that + // reporting nothing would imply. + useEffect(() => { + const unlisten = listen( + "fingerprint-consistency-unverified", + (event) => { + const { unverified, exit_timezone, exit_country_code } = event.payload; + if (unverified.length === 0) { + return; + } + showErrorToast(t("consistencyWarning.unverifiedTitle"), { + description: unverified.includes("timezone") + ? t("consistencyWarning.unverifiedTimezoneDetail", { + exit: exit_timezone ?? exit_country_code ?? "?", + }) + : t("consistencyWarning.unverifiedLanguageDetail", { + country: exit_country_code ?? "?", + }), + id: `fingerprint-unverified-${unverified.join("-")}`, + }); + }, + ); + return () => { + void unlisten.then((fn) => { + fn(); + }); + }; + }, [t]); + // Show the queue's head, and how many are waiting behind it. const syncGateUi = useCallback(() => { const queue = gateQueueRef.current; @@ -1269,6 +1373,7 @@ export default function Home() { fingerprint: blocked ? checks.consistency : null, measurementUnreliable: checks.exit_measurement_unreliable, probePending: checks.exit_probe_pending, + unverified: checks.exit_unverified, }, }, opts?.bulkRunId, @@ -1312,6 +1417,7 @@ export default function Home() { measurementUnreliable: localChecks?.exit_measurement_unreliable ?? false, probePending: false, + unverified: localChecks?.exit_unverified ?? [], }, }, opts?.bulkRunId, @@ -1392,9 +1498,10 @@ export default function Home() { console.log("Profile deleted successfully"); } catch (err: unknown) { console.error("Failed to delete profile:", err); - const errorMessage = err instanceof Error ? err.message : String(err); showErrorToast( - t("errors.deleteProfileFailed", { error: errorMessage }), + t("errors.deleteProfileFailed", { + error: translateBackendError(t, err), + }), ); } }, @@ -1458,6 +1565,48 @@ export default function Home() { setGroupAssignmentDialogOpen(true); }, []); + const handleDropProfilesToGroup = useCallback( + async (profileIds: string[], groupId: string | null) => { + const targets = profiles.filter((profile) => + profileIds.includes(profile.id), + ); + if ( + targets.length !== profileIds.length || + targets.some( + (profile) => + profile.process_id != null || runningProfiles.has(profile.id), + ) || + (groupId !== null && !groupsData.some((group) => group.id === groupId)) + ) { + showErrorToast(t("profileMotion.dragUnavailable")); + return false; + } + try { + await invoke("assign_profiles_to_group", { profileIds, groupId }); + setSelectedProfiles((selected) => + selected.filter((id) => !profileIds.includes(id)), + ); + showSuccessToast( + t("groups.assignSuccess", { + count: profileIds.length, + group: + groupsData.find((group) => group.id === groupId)?.name ?? + t("groups.noGroup"), + }), + ); + return true; + } catch (error) { + showErrorToast(translateBackendError(t, error)); + return false; + } finally { + // The backend can reject after writing an earlier member of the batch. + // Refresh both views even on failure so counts and placement stay true. + await Promise.allSettled([loadProfiles(), loadGroups()]); + } + }, + [profiles, runningProfiles, groupsData, loadProfiles, loadGroups, t], + ); + const handleBulkDelete = useCallback(() => { if (selectedProfiles.length === 0) return; setShowBulkDeleteConfirmation(true); @@ -1519,6 +1668,12 @@ export default function Home() { setSelectedProfiles([]); }, [selectedProfiles, handleAssignProfilesToProxy]); + const handleBulkProxyDistribution = useCallback(() => { + if (selectedProfiles.length === 0) return; + setSelectedProfilesForDistribution(selectedProfiles); + setProxyDistributionDialogOpen(true); + }, [selectedProfiles]); + const handleBulkCopyCookies = useCallback(() => { if (selectedProfiles.length === 0) return; const eligibleProfiles = profiles.filter( @@ -1831,6 +1986,8 @@ export default function Home() { let unlistenProgress: (() => void) | undefined; let unlistenCompleted: (() => void) | undefined; let unlistenWayfernBlocked: (() => void) | undefined; + let unlistenMcpLocalDeprecated: (() => void) | undefined; + let unlistenMcpLocalMigrated: (() => void) | undefined; void (async () => { unlistenRequired = await listen( @@ -1903,6 +2060,36 @@ export default function Home() { }); }); + // Local MCP is removed in favour of remote MCP. Something tried to reach + // the removed local server (a client still pointing at the old port, or + // an in-app attempt): tell the user plainly, once, where to go instead. + unlistenMcpLocalDeprecated = await listen("mcp-local-deprecated", () => { + showToast({ + id: "mcp-local-deprecated", + type: "error", + title: t("mcpLocalDeprecated.title"), + description: t("mcpLocalDeprecated.description"), + duration: 15000, + }); + }); + + // Their clients were moved to remote MCP for them (paid accounts). A + // success note so the change is visible rather than silent. + unlistenMcpLocalMigrated = await listen<{ migrated?: number }>( + "mcp-local-migrated", + (event) => { + const migrated = event.payload?.migrated ?? 0; + if (migrated <= 0) return; + showToast({ + id: "mcp-local-migrated", + type: "success", + title: t("mcpLocalMigrated.title"), + description: t("mcpLocalMigrated.description", { count: migrated }), + duration: 12000, + }); + }, + ); + // If the effect was torn down mid-setup, the cleanup below already ran // before these handles existed — unlisten them now so nothing leaks. if (disposed) { @@ -1911,6 +2098,8 @@ export default function Home() { unlistenProgress?.(); unlistenCompleted?.(); unlistenWayfernBlocked?.(); + unlistenMcpLocalDeprecated?.(); + unlistenMcpLocalMigrated?.(); } })(); @@ -1921,6 +2110,8 @@ export default function Home() { unlistenProgress?.(); unlistenCompleted?.(); unlistenWayfernBlocked?.(); + unlistenMcpLocalDeprecated?.(); + unlistenMcpLocalMigrated?.(); }; }, [t]); @@ -2002,566 +2193,610 @@ export default function Home() { : t(`pageTitle.${currentPage}`); return ( -
- - -
- +
+ + +
+ { + setAboutDialogOpen(true); + }} + cookieBotRunning={Object.keys(cookieBotLiveSessions).length > 0} + /> +
+ {currentPage === "profiles" && ( +
+ + { + setSyncLeaderProfile(profile); + }} + onCreateProfile={() => { + setCreateProfileDialogOpen(true); + }} + onImportProfiles={() => { + handleRailNavigate("import"); + }} + /> +
+ )} + + {currentPage === "shortcuts" && ( +
+ +
+ )} + + {settingsDialogOpen && ( + { + setSettingsDialogOpen(false); + setCurrentPage("profiles"); + }} + onIntegrationsOpen={() => { + setSettingsDialogOpen(false); + setIntegrationsDialogOpen(true); + setCurrentPage("integrations"); + }} + subPage={currentPage === "settings"} + /> + )} + + {integrationsDialogOpen && ( + { + setIntegrationsDialogOpen(false); + setCurrentPage("profiles"); + }} + subPage={currentPage === "integrations"} + initialTab={integrationsInitialTab} + /> + )} + + {proxyManagementDialogOpen && ( + { + setProxyManagementDialogOpen(false); + setCurrentPage("profiles"); + }} + subPage={currentPage === "proxies" || currentPage === "vpns"} + initialTab={proxyManagementInitialTab} + /> + )} + + {groupManagementDialogOpen && ( + { + setGroupManagementDialogOpen(false); + setCurrentPage("profiles"); + }} + onGroupManagementComplete={handleGroupManagementComplete} + subPage={currentPage === "groups"} + /> + )} + + {extensionManagementDialogOpen && ( + { + setExtensionManagementDialogOpen(false); + setCurrentPage("profiles"); + }} + limitedMode={false} + subPage={currentPage === "extensions"} + initialTab={extensionManagementInitialTab} + /> + )} + + {importProfileDialogOpen && ( + { + setImportProfileDialogOpen(false); + setCurrentPage("profiles"); + }} + crossOsUnlocked={crossOsUnlocked} + subPage={currentPage === "import"} + /> + )} + + {cookieBotDialogOpen && ( + { + setCookieBotDialogOpen(false); + setCurrentPage("profiles"); + }} + subPage={currentPage === "cookieBot"} + initialTab={cookieBotInitialTab} + profiles={profiles} + cloudUser={cloudUser} + onOpenProfileSync={handleOpenProfileSyncDialog} + onAssignProxy={handleAssignProfilesToProxy} + /> + )} + + {agentDialogOpen && ( + { + setAgentDialogOpen(false); + setCurrentPage("profiles"); + }} + subPage={currentPage === "agent"} + initialTab={agentInitialTab} + profiles={profiles} + cloudUser={cloudUser} + /> + )} + + {accountDialogOpen && ( + { + setAccountDialogOpen(false); + setCurrentPage("profiles"); + }} + subPage={currentPage === "account"} + onOpenSignIn={() => { + setAccountDialogOpen(false); + setCurrentPage("profiles"); + setDeviceCodeDialogOpen(true); + }} + /> + )} + + {trashPageOpen && ( + { + setTrashPageOpen(false); + setCurrentPage("profiles"); + }} + subPage={currentPage === "trash"} + /> + )} +
+
+ + { + setCreateProfileDialogOpen(false); + }} + onCreateProfile={handleCreateProfile} + selectedGroupId={selectedGroupId} + crossOsUnlocked={crossOsUnlocked} + /> + + { + handleRailNavigate("profiles"); + handleSelectGroup(id); + }} + profiles={profiles} + runningProfileIds={runningProfiles} + onLaunchProfile={(profile) => { + void launchProfile(profile); + }} + onKillProfile={(profile) => { + void handleKillProfile(profile); + }} + onShowProfileInfo={(profile) => { + handleRailNavigate("profiles"); + setProfileInfoOpenMethod("keyboard"); + setProfileInfoDialog(profile); + }} + onCreateProfile={() => { + setCreateProfileDialogOpen(true); + }} onOpenAbout={() => { setAboutDialogOpen(true); }} - cookieBotRunning={Object.keys(cookieBotLiveSessions).length > 0} /> -
- {currentPage === "profiles" && ( - - { - setSyncLeaderProfile(profile); - }} - onCreateProfile={() => { - setCreateProfileDialogOpen(true); - }} - onImportProfiles={() => { - handleRailNavigate("import"); - }} - /> - - )} - {currentPage === "shortcuts" && ( - - - - )} - - {settingsDialogOpen && ( - { - setSettingsDialogOpen(false); - setCurrentPage("profiles"); - }} - onIntegrationsOpen={() => { - setSettingsDialogOpen(false); - setIntegrationsDialogOpen(true); - setCurrentPage("integrations"); - }} - subPage={currentPage === "settings"} - /> - )} - - {integrationsDialogOpen && ( - { - setIntegrationsDialogOpen(false); - setCurrentPage("profiles"); - }} - subPage={currentPage === "integrations"} - initialTab={integrationsInitialTab} - /> - )} - - {proxyManagementDialogOpen && ( - { - setProxyManagementDialogOpen(false); - setCurrentPage("profiles"); - }} - subPage={currentPage === "proxies" || currentPage === "vpns"} - initialTab={proxyManagementInitialTab} - /> - )} - - {groupManagementDialogOpen && ( - { - setGroupManagementDialogOpen(false); - setCurrentPage("profiles"); - }} - onGroupManagementComplete={handleGroupManagementComplete} - subPage={currentPage === "groups"} - /> - )} - - {extensionManagementDialogOpen && ( - { - setExtensionManagementDialogOpen(false); - setCurrentPage("profiles"); - }} - limitedMode={false} - subPage={currentPage === "extensions"} - initialTab={extensionManagementInitialTab} - /> - )} - - {importProfileDialogOpen && ( - { - setImportProfileDialogOpen(false); - setCurrentPage("profiles"); - }} - crossOsUnlocked={crossOsUnlocked} - subPage={currentPage === "import"} - /> - )} - - {cookieBotDialogOpen && ( - { - setCookieBotDialogOpen(false); - setCurrentPage("profiles"); - }} - subPage={currentPage === "cookieBot"} - initialTab={cookieBotInitialTab} - profiles={profiles} - cloudUser={cloudUser} - onOpenProfileSync={handleOpenProfileSyncDialog} - onAssignProxy={handleAssignProfilesToProxy} - /> - )} - - {accountDialogOpen && ( - { - setAccountDialogOpen(false); - setCurrentPage("profiles"); - }} - subPage={currentPage === "account"} - onOpenSignIn={() => { - setAccountDialogOpen(false); - setCurrentPage("profiles"); - setDeviceCodeDialogOpen(true); - }} - /> - )} -
-
- - { - setCreateProfileDialogOpen(false); - }} - onCreateProfile={handleCreateProfile} - selectedGroupId={selectedGroupId} - crossOsUnlocked={crossOsUnlocked} - /> - - { - handleRailNavigate("profiles"); - handleSelectGroup(id); - }} - profiles={profiles} - runningProfileIds={runningProfiles} - onLaunchProfile={(profile) => { - void launchProfile(profile); - }} - onKillProfile={(profile) => { - void handleKillProfile(profile); - }} - onShowProfileInfo={(profile) => { - handleRailNavigate("profiles"); - setProfileInfoDialog(profile); - }} - onCreateProfile={() => { - setCreateProfileDialogOpen(true); - }} - onOpenAbout={() => { - setAboutDialogOpen(true); - }} - /> - - { - setAboutDialogOpen(false); - }} - /> - - - - {pendingUrls.map((pendingUrl) => ( - { - setPendingUrls((prev) => - prev.filter((u) => u.id !== pendingUrl.id), - ); + setAboutDialogOpen(false); }} - url={pendingUrl.url} - isUpdating={isUpdating} - runningProfiles={runningProfiles} /> - ))} - { - setPermissionDialogOpen(false); - }} - permissionType={currentPermissionType} - onPermissionGranted={checkNextPermission} - /> + - - setThankYouOpen(false)} - /> + {pendingUrls.map((pendingUrl) => ( + { + setPendingUrls((prev) => + prev.filter((u) => u.id !== pendingUrl.id), + ); + }} + url={pendingUrl.url} + isUpdating={isUpdating} + runningProfiles={runningProfiles} + /> + ))} - { - setCloneProfile(null); - }} - profile={cloneProfile} - /> + { + setPermissionDialogOpen(false); + }} + permissionType={currentPermissionType} + onPermissionGranted={checkNextPermission} + /> - { - pendingLaunchAfterUnlockRef.current = null; - setPasswordDialogProfile(null); - }} - profile={passwordDialogProfile} - mode={passwordDialogMode} - onSuccess={(p) => { - // Resume pending launch after unlock. - if ( - passwordDialogMode === "unlock" && - pendingLaunchAfterUnlockRef.current?.id === p.id - ) { - const target = pendingLaunchAfterUnlockRef.current; + + setThankYouOpen(false)} + /> + + { + setCloneProfile(null); + }} + profile={cloneProfile} + /> + + { pendingLaunchAfterUnlockRef.current = null; - void launchProfile(target); + setPasswordDialogProfile(null); + }} + profile={passwordDialogProfile} + mode={passwordDialogMode} + onSuccess={(p) => { + // Resume pending launch after unlock. + if ( + passwordDialogMode === "unlock" && + pendingLaunchAfterUnlockRef.current?.id === p.id + ) { + const target = pendingLaunchAfterUnlockRef.current; + pendingLaunchAfterUnlockRef.current = null; + void launchProfile(target); + } + // On set/change/remove, the profile's encryption state changed. + // Push that state to the sync server immediately so other devices + // see the new envelope before they next pull. Skip if the profile + // is currently running — its files would be in flux. + if ( + (passwordDialogMode === "set" || + passwordDialogMode === "change" || + passwordDialogMode === "remove") && + !runningProfiles.has(p.id) && + p.sync_mode !== "Disabled" + ) { + void invoke("request_profile_sync", { profileId: p.id }).catch( + (err: unknown) => { + console.error("post-password sync failed", err); + }, + ); + } + }} + /> + + { + setWayfernConfigDialogOpen(false); + }} + profile={currentProfileForWayfernConfig} + onSave={handleSaveWayfernConfig} + isRunning={ + currentProfileForWayfernConfig + ? runningProfiles.has(currentProfileForWayfernConfig.id) + : false } - // On set/change/remove, the profile's encryption state changed. - // Push that state to the sync server immediately so other devices - // see the new envelope before they next pull. Skip if the profile - // is currently running — its files would be in flux. - if ( - (passwordDialogMode === "set" || - passwordDialogMode === "change" || - passwordDialogMode === "remove") && - !runningProfiles.has(p.id) && - p.sync_mode !== "Disabled" - ) { - void invoke("request_profile_sync", { profileId: p.id }).catch( - (err: unknown) => { - console.error("post-password sync failed", err); - }, - ); + crossOsUnlocked={crossOsUnlocked} + /> + + { + setGroupAssignmentDialogOpen(false); + }} + selectedProfiles={selectedProfilesForGroup} + onAssignmentComplete={handleGroupAssignmentComplete} + profiles={profiles} + /> + + { + setExtensionGroupAssignmentDialogOpen(false); + }} + selectedProfiles={selectedProfilesForExtensionGroup} + onAssignmentComplete={handleExtensionGroupAssignmentComplete} + profiles={profiles} + /> + + { + setProxyAssignmentDialogOpen(false); + }} + selectedProfiles={selectedProfilesForProxy} + onAssignmentComplete={handleProxyAssignmentComplete} + profiles={profiles} + storedProxies={storedProxies} + vpnConfigs={vpnConfigs} + /> + + { + setProxyDistributionDialogOpen(false); + }} + selectedProfiles={selectedProfilesForDistribution} + profiles={profiles} + storedProxies={storedProxies} + onDistributionComplete={() => { + // useProfileEvents refreshes the table; only the selection needs + // clearing, and only once the assignments actually landed. + setSelectedProfiles([]); + }} + /> + + { + setCookieCopyDialogOpen(false); + setSelectedProfilesForCookies([]); + }} + selectedProfiles={selectedProfilesForCookies} + profiles={profiles} + runningProfiles={runningProfiles} + onCopyComplete={() => { + setSelectedProfilesForCookies([]); + }} + /> + + { + setCookieManagementDialogOpen(false); + setCurrentProfileForCookieManagement(null); + }} + profile={currentProfileForCookieManagement} + /> + + { + setPendingBulkAction(null); + }} + onConfirm={() => { + if (!pendingBulkAction) return; + if (pendingBulkAction.action === "run") { + void executeBulkRun(pendingBulkAction.profiles); + } else { + void executeBulkStop(pendingBulkAction.profiles); + } + }} + title={ + pendingBulkAction?.action === "stop" + ? t("profiles.bulkStop.confirmTitle", { + count: pendingBulkAction?.profiles.length ?? 0, + }) + : t("profiles.bulkRun.confirmTitle", { + count: pendingBulkAction?.profiles.length ?? 0, + }) } - }} - /> - - { - setWayfernConfigDialogOpen(false); - }} - profile={currentProfileForWayfernConfig} - onSave={handleSaveWayfernConfig} - isRunning={ - currentProfileForWayfernConfig - ? runningProfiles.has(currentProfileForWayfernConfig.id) - : false - } - crossOsUnlocked={crossOsUnlocked} - /> - - { - setGroupAssignmentDialogOpen(false); - }} - selectedProfiles={selectedProfilesForGroup} - onAssignmentComplete={handleGroupAssignmentComplete} - profiles={profiles} - /> - - { - setExtensionGroupAssignmentDialogOpen(false); - }} - selectedProfiles={selectedProfilesForExtensionGroup} - onAssignmentComplete={handleExtensionGroupAssignmentComplete} - profiles={profiles} - /> - - { - setProxyAssignmentDialogOpen(false); - }} - selectedProfiles={selectedProfilesForProxy} - onAssignmentComplete={handleProxyAssignmentComplete} - profiles={profiles} - storedProxies={storedProxies} - vpnConfigs={vpnConfigs} - /> - - { - setCookieCopyDialogOpen(false); - setSelectedProfilesForCookies([]); - }} - selectedProfiles={selectedProfilesForCookies} - profiles={profiles} - runningProfiles={runningProfiles} - onCopyComplete={() => { - setSelectedProfilesForCookies([]); - }} - /> - - { - setCookieManagementDialogOpen(false); - setCurrentProfileForCookieManagement(null); - }} - profile={currentProfileForCookieManagement} - /> - - { - setPendingBulkAction(null); - }} - onConfirm={() => { - if (!pendingBulkAction) return; - if (pendingBulkAction.action === "run") { - void executeBulkRun(pendingBulkAction.profiles); - } else { - void executeBulkStop(pendingBulkAction.profiles); + description={ + pendingBulkAction?.action === "stop" + ? t("profiles.bulkStop.confirmDescription", { + count: pendingBulkAction?.profiles.length ?? 0, + }) + : t("profiles.bulkRun.confirmDescription", { + count: pendingBulkAction?.profiles.length ?? 0, + }) } - }} - title={ - pendingBulkAction?.action === "stop" - ? t("profiles.bulkStop.confirmTitle", { - count: pendingBulkAction?.profiles.length ?? 0, - }) - : t("profiles.bulkRun.confirmTitle", { - count: pendingBulkAction?.profiles.length ?? 0, - }) - } - description={ - pendingBulkAction?.action === "stop" - ? t("profiles.bulkStop.confirmDescription", { - count: pendingBulkAction?.profiles.length ?? 0, - }) - : t("profiles.bulkRun.confirmDescription", { - count: pendingBulkAction?.profiles.length ?? 0, - }) - } - confirmButtonText={ - pendingBulkAction?.action === "stop" - ? t("profiles.bulkStop.confirmButton", { - count: pendingBulkAction?.profiles.length ?? 0, - }) - : t("profiles.bulkRun.confirmButton", { - count: pendingBulkAction?.profiles.length ?? 0, - }) - } - confirmButtonVariant="default" - isLoading={isBulkActing} - /> - { - setShowBulkDeleteConfirmation(false); - }} - onConfirm={confirmBulkDelete} - title={t("profiles.bulkDelete.title")} - description={t("profiles.bulkDelete.description", { - count: selectedProfiles.length, - })} - confirmButtonText={t("profiles.bulkDelete.confirmButton", { - count: selectedProfiles.length, - })} - isLoading={isBulkDeleting} - profileIds={selectedProfiles} - profiles={profiles.map((p) => ({ id: p.id, name: p.name }))} - /> - - { - setSyncConfigDialogOpen(false); - void checkSelfHostedSync(); - if (loginOccurred) { - setSyncAllDialogOpen(true); + confirmButtonText={ + pendingBulkAction?.action === "stop" + ? t("profiles.bulkStop.confirmButton", { + count: pendingBulkAction?.profiles.length ?? 0, + }) + : t("profiles.bulkRun.confirmButton", { + count: pendingBulkAction?.profiles.length ?? 0, + }) } - }} - onLoginStarted={() => { - // Hand the verify step off to its own dialog. We close this one - // first so the verify dialog isn't stacked on top of it (and - // can't end up stacked on top of the profile selector either). - setSyncConfigDialogOpen(false); - setDeviceCodeDialogOpen(true); - }} - /> + confirmButtonVariant="default" + isLoading={isBulkActing} + /> + { + setShowBulkDeleteConfirmation(false); + }} + onConfirm={confirmBulkDelete} + title={t("profiles.bulkDelete.title")} + description={t("profiles.bulkDelete.description", { + count: selectedProfiles.length, + })} + confirmButtonText={t("profiles.bulkDelete.confirmButton", { + count: selectedProfiles.length, + })} + isLoading={isBulkDeleting} + profileIds={selectedProfiles} + profiles={profiles.map((p) => ({ id: p.id, name: p.name }))} + /> - {/* Only render while no profile-selector flow is in progress, so the - verify dialog never lands on top of a deep-link-triggered selector. */} - {pendingUrls.length === 0 && ( - { - setDeviceCodeDialogOpen(false); + setSyncConfigDialogOpen(false); + void checkSelfHostedSync(); if (loginOccurred) { setSyncAllDialogOpen(true); } }} + onLoginStarted={() => { + // Hand the verify step off to its own dialog. We close this one + // first so the verify dialog isn't stacked on top of it (and + // can't end up stacked on top of the profile selector either). + setSyncConfigDialogOpen(false); + setDeviceCodeDialogOpen(true); + }} /> - )} - { - setSyncAllDialogOpen(false); - }} - /> + {/* Only render while no profile-selector flow is in progress, so the + verify dialog never lands on top of a deep-link-triggered selector. */} + {pendingUrls.length === 0 && ( + { + setDeviceCodeDialogOpen(false); + if (loginOccurred) { + setSyncAllDialogOpen(true); + } + }} + /> + )} - { - setProfileSyncDialogOpen(false); - setCurrentProfileForSync(null); - }} - profile={currentProfileForSync} - onSyncConfigOpen={() => { - setSyncConfigDialogOpen(true); - }} - /> + { + setSyncAllDialogOpen(false); + }} + /> - {/* Wayfern Terms and Conditions Dialog - shown if terms not accepted */} - + { + setProfileSyncDialogOpen(false); + setCurrentProfileForSync(null); + }} + profile={currentProfileForSync} + onSyncConfigOpen={() => { + setSyncConfigDialogOpen(true); + }} + /> - {/* Commercial Trial Modal - shown once when trial expires (skip for paid users) */} - + {/* Wayfern Terms and Conditions Dialog - shown if terms not accepted */} + - { - setWindowResizeWarningOpen(false); - windowResizeWarningResolver.current?.(proceed); - windowResizeWarningResolver.current = null; - }} - /> + {/* Commercial Trial Modal - shown once when trial expires (skip for paid users) */} + - { - setSyncLeaderProfile(null); - }} - leaderProfile={syncLeaderProfile} - allProfiles={profiles} - runningProfiles={runningProfiles} - /> -
+ { + setWindowResizeWarningOpen(false); + windowResizeWarningResolver.current?.(proceed); + windowResizeWarningResolver.current = null; + }} + /> + + { + setSyncLeaderProfile(null); + }} + leaderProfile={syncLeaderProfile} + allProfiles={profiles} + runningProfiles={runningProfiles} + /> +
+ ); } diff --git a/src/components/about-dialog.tsx b/src/components/about-dialog.tsx index 3181791..71eb32a 100644 --- a/src/components/about-dialog.tsx +++ b/src/components/about-dialog.tsx @@ -6,6 +6,8 @@ import { useReducedMotion } from "motion/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { LuArrowLeft, LuExternalLink, LuSearch } from "react-icons/lu"; +import { DonutSnack } from "@/components/donut-snack"; +import { ProfileIsolationDemo } from "@/components/profile-isolation-demo"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -34,7 +36,7 @@ interface SystemInfo { portable: boolean; } -type AboutView = "about" | "licenses"; +type AboutView = "about" | "licenses" | "isolation"; // Flywheel: each click adds spin; past this speed the donut escapes the // dialog and bounces around the window (shared physics with the rail egg). @@ -47,6 +49,7 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) { const reducedMotion = useReducedMotion(); const [systemInfo, setSystemInfo] = useState(null); const [logoFlown, setLogoFlown] = useState(false); + const [snackOpen, setSnackOpen] = useState(false); const [view, setView] = useState("about"); const [licenseQuery, setLicenseQuery] = useState(""); @@ -57,6 +60,7 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) { const lastTimeRef = useRef(0); const cancelLaunchRef = useRef<(() => void) | null>(null); const restoreLicensesButtonFocusRef = useRef(false); + const restoreExampleButtonFocusRef = useRef(false); useEffect(() => { if (!isOpen) return; @@ -136,9 +140,11 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) { const handleClose = useCallback(() => { resetLogo(); + setSnackOpen(false); setView("about"); setLicenseQuery(""); restoreLicensesButtonFocusRef.current = false; + restoreExampleButtonFocusRef.current = false; onClose(); }, [onClose, resetLogo]); @@ -159,10 +165,11 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) { }, [resetLogo]); const handleBackToAbout = useCallback(() => { - restoreLicensesButtonFocusRef.current = true; + restoreLicensesButtonFocusRef.current = view === "licenses"; + restoreExampleButtonFocusRef.current = view === "isolation"; setLicenseQuery(""); setView("about"); - }, []); + }, [view]); const filteredLicenses = useMemo(() => { const query = licenseQuery.trim().toLocaleLowerCase(); @@ -190,14 +197,16 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) { >
- + {snackOpen ? ( + + ) : ( + + )}

Donut Browser

@@ -273,6 +304,23 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) { {t("about.licenses")}
+
@@ -281,6 +329,24 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
+ ) : view === "isolation" ? ( + <> + + + {t("isolationDemo.replay")} + + + ) : ( <> diff --git a/src/components/account-page.tsx b/src/components/account-page.tsx index 0bd28ae..f4d0d39 100644 --- a/src/components/account-page.tsx +++ b/src/components/account-page.tsx @@ -34,6 +34,7 @@ import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot"; import { translateBackendError } from "@/lib/backend-errors"; import { canUseCookieBot, + effectivePlanOf, getEntitlements, isTeamOwner, } from "@/lib/entitlements"; @@ -73,6 +74,20 @@ export function AccountPage({ // a refused launch. const remoteHoursVisible = isLoggedIn && canUseCookieBot(user); const showTeamUsage = remoteHoursVisible && isTeamOwner(user); + // A member's own row says "free" because the owner pays. The plan the seat + // is served under is the one the customer expects to read here, and the + // billing period slot names the seat instead, since a seat has no period. + const effectivePlan = effectivePlanOf(user); + const isTeamSeat = user != null && effectivePlan !== user.plan; + const seatRole = + user?.teamRole === "owner" + ? t("sync.team.roleOwner") + : user?.teamRole === "admin" + ? t("sync.team.roleAdmin") + : t("sync.team.roleMember"); + const seatLabel = user?.teamName + ? t("account.teamSeat", { role: seatRole, team: user.teamName }) + : t("account.teamSeatUnnamed", { role: seatRole }); const { quota, isLoading: isQuotaLoading } = useCookieBot( remoteHoursVisible, cookieBotScopeFor(user), @@ -268,8 +283,10 @@ export function AccountPage({

{t("account.plan", { - plan: user.plan, - period: user.planPeriod ?? "—", + plan: effectivePlan, + period: isTeamSeat + ? seatLabel + : (user.planPeriod ?? "—"), })}

@@ -350,7 +367,7 @@ export function AccountPage({ {t("account.fields.plan")}

- {user.plan} + {effectivePlan}

diff --git a/src/components/agent-page.tsx b/src/components/agent-page.tsx new file mode 100644 index 0000000..cb96644 --- /dev/null +++ b/src/components/agent-page.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { AgentRecipes } from "@/components/agent-recipes"; +import { AgentRunForm } from "@/components/agent-run-form"; +import { AgentRunHistory } from "@/components/agent-run-history"; +import { AgentRunPanel } from "@/components/agent-run-view"; +import { + AgentUnavailable, + type AgentUnavailableReason, +} from "@/components/agent-shared"; +import { + AnimatedTabs, + AnimatedTabsContent, + AnimatedTabsList, + AnimatedTabsTrigger, +} from "@/components/ui/animated-tabs"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + type AgentRecipe, + getAgentRecipes, + getAgentRuns, + isRunOver, +} from "@/lib/agent"; +import { parseBackendError } from "@/lib/backend-errors"; +import { canUseAgentAutomation } from "@/lib/entitlements"; +import type { BrowserProfile, CloudUser } from "@/types"; + +export type AgentTab = "run" | "history" | "recipes"; + +interface AgentPageProps { + isOpen: boolean; + onClose: () => void; + subPage?: boolean; + initialTab?: AgentTab; + profiles: BrowserProfile[]; + cloudUser: CloudUser | null; +} + +/** + * Whether a failure means the deployment has no agent at all. + * + * Distinct from every other refusal: a plan that does not include the agent and + * a backend with no model credential are different problems with different + * fixes, and telling a paying customer to upgrade because the server is + * unconfigured is the confusing case this exists to avoid. + */ +function isNotConfigured(error: unknown): boolean { + return parseBackendError(error)?.code === "AGENT_NOT_CONFIGURED"; +} + +export function AgentPage({ + isOpen, + onClose, + subPage, + initialTab = "run", + profiles, + cloudUser, +}: AgentPageProps) { + const { t } = useTranslation(); + const entitled = canUseAgentAutomation(cloudUser); + const signedIn = Boolean(cloudUser); + + const [activeTab, setActiveTab] = useState(initialTab); + const [activeRunId, setActiveRunId] = useState(null); + const [notConfigured, setNotConfigured] = useState(false); + const [recipes, setRecipes] = useState([]); + const [recipesLoading, setRecipesLoading] = useState(false); + const [recipesError, setRecipesError] = useState(null); + // Bumped after any write. It keys the history list, so a started or cancelled + // run remounts it and it reads from the top instead of showing pages that + // predate the change. + const [reloadToken, setReloadToken] = useState(0); + const hasAdopted = useRef(false); + + const unavailableReason: AgentUnavailableReason | null = !signedIn + ? "signIn" + : !entitled + ? "plan" + : notConfigured + ? "notConfigured" + : null; + const available = unavailableReason === null; + + useEffect(() => { + setActiveTab(initialTab); + }, [initialTab]); + + const loadRecipes = useCallback(async () => { + setRecipesLoading(true); + try { + setRecipes(await getAgentRecipes()); + setRecipesError(null); + } catch (error) { + if (isNotConfigured(error)) setNotConfigured(true); + setRecipesError(error); + } finally { + setRecipesLoading(false); + } + }, []); + + useEffect(() => { + if (!isOpen || !available) return; + void loadRecipes(); + }, [isOpen, available, loadRecipes]); + + // A run that is still going when the page opens becomes the one on screen, + // so reopening the app during a run lands on it rather than on an empty form + // with the run only findable through the history tab. One row is read, not a + // page: this is an adoption check, not a listing. + useEffect(() => { + if (!isOpen || !available || hasAdopted.current) return; + hasAdopted.current = true; + void (async () => { + try { + const page = await getAgentRuns({ limit: 1 }); + const newest = page.runs[0]; + if (newest && !isRunOver(newest)) setActiveRunId(newest.id); + } catch (error) { + if (isNotConfigured(error)) setNotConfigured(true); + } + })(); + }, [isOpen, available]); + + useEffect(() => { + if (isOpen) return; + hasAdopted.current = false; + }, [isOpen]); + + const handleLoadError = useCallback((error: unknown) => { + if (isNotConfigured(error)) setNotConfigured(true); + }, []); + + const body = ( +
+ { + setActiveTab(value as AgentTab); + }} + className="flex min-h-0 flex-1 flex-col" + > + {/* The tab strip renders whatever the account is entitled to, because + `AGENT_NOT_CONFIGURED` is a server state discovered by asking: the + chrome has to already be on screen when the answer arrives. Each + panel then carries its own honest explanation instead of a form + that cannot work. */} + + + {t("agent.tabs.run")} + + + {t("agent.tabs.history")} + + + {t("agent.tabs.recipes")} + + + + + {!available ? ( + + ) : activeRunId !== null ? ( + { + setActiveRunId(null); + setReloadToken((token) => token + 1); + }} + onChanged={() => { + setReloadToken((token) => token + 1); + }} + /> + ) : ( + { + setActiveRunId(run.id); + setReloadToken((token) => token + 1); + }} + /> + )} + + + + {available ? ( + + ) : ( + + )} + + + + {available ? ( + { + void loadRecipes(); + }} + /> + ) : ( + + )} + + +
+ ); + + return ( + + + {!subPage && ( + + {t("agent.title")} + {t("agent.description")} + + )} + {body} + + + ); +} diff --git a/src/components/agent-recipe-recorder.tsx b/src/components/agent-recipe-recorder.tsx new file mode 100644 index 0000000..d8e69a5 --- /dev/null +++ b/src/components/agent-recipe-recorder.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { LuCircleDot, LuSquare } from "react-icons/lu"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { RecipeStep } from "@/lib/agent"; +import { translateBackendError } from "@/lib/backend-errors"; +import { showErrorToast } from "@/lib/toast-utils"; +import type { BrowserProfile } from "@/types"; + +interface RecordingStatus { + profile_id: string | null; + steps: RecipeStep[]; + recording: boolean; +} + +/** + * Record a task once in a real browser and keep the steps. + * + * The steps arrive one event at a time while the person works, so the panel + * shows the recipe growing rather than a spinner that ends in a surprise. + */ +export function RecipeRecorder({ + profiles, + onRecorded, +}: { + profiles: BrowserProfile[]; + /** Hand the recorded steps to the editor, where they are reviewed and saved. */ + onRecorded: (steps: RecipeStep[]) => void; +}) { + const { t } = useTranslation(); + const [profileId, setProfileId] = useState(null); + const [steps, setSteps] = useState([]); + const [isRecording, setIsRecording] = useState(false); + const [isBusy, setIsBusy] = useState(false); + const unlisten = useRef([]); + + // Only a running browser can be recorded: the capture is a live CDP session. + const runnable = useMemo( + () => + profiles + .filter((profile) => Boolean(profile.process_id)) + .sort((a, b) => a.name.localeCompare(b.name)), + [profiles], + ); + + // A recording survives this panel being closed and reopened, so the state + // comes from the backend rather than from what this component remembers. + useEffect(() => { + void invoke("get_recipe_recording") + .then((status) => { + setIsRecording(status.recording); + setSteps(status.steps); + if (status.profile_id) setProfileId(status.profile_id); + }) + .catch(() => { + // A backend that cannot answer is simply not recording. + }); + }, []); + + useEffect(() => { + let cancelled = false; + void (async () => { + const step = await listen( + "recipe-recording-step", + (event) => { + setSteps((previous) => [...previous, event.payload]); + }, + ); + const ended = await listen("recipe-recording-ended", () => { + setIsRecording(false); + }); + if (cancelled) { + step(); + ended(); + return; + } + unlisten.current = [step, ended]; + })(); + return () => { + cancelled = true; + for (const off of unlisten.current) off(); + unlisten.current = []; + }; + }, []); + + const start = useCallback(async () => { + if (!profileId) return; + setIsBusy(true); + try { + const status = await invoke("start_recipe_recording", { + profileId, + }); + setSteps(status.steps); + setIsRecording(true); + } catch (error) { + showErrorToast(translateBackendError(t, error)); + } finally { + setIsBusy(false); + } + }, [profileId, t]); + + const stop = useCallback(async () => { + setIsBusy(true); + try { + const status = await invoke("stop_recipe_recording"); + setIsRecording(false); + setSteps(status.steps); + if (status.steps.length > 0) onRecorded(status.steps); + } catch (error) { + showErrorToast(translateBackendError(t, error)); + } finally { + setIsBusy(false); + } + }, [onRecorded, t]); + + return ( +
+

+ {t("agent.recipes.recording.description")} +

+ + {isRecording ? ( +
+ + + {t("agent.recipes.recording.recording")} + + + {t("agent.recipes.recording.steps", { count: steps.length })} + +
+ +
+ ) : ( +
+
+ + +
+ +
+ )} + + {!isRecording && steps.length === 0 && profileId && ( +

+ {t("agent.recipes.recording.empty")} +

+ )} +
+ ); +} diff --git a/src/components/agent-recipe-steps.tsx b/src/components/agent-recipe-steps.tsx new file mode 100644 index 0000000..0cc57af --- /dev/null +++ b/src/components/agent-recipe-steps.tsx @@ -0,0 +1,423 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { LuArrowDown, LuArrowUp, LuTrash2 } from "react-icons/lu"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { RecipeStep, RecipeStepType } from "@/lib/agent"; + +/** The kinds a recipe may hold, in the order the editor offers them. */ +const STEP_TYPES: RecipeStepType[] = [ + "navigate", + "click", + "type", + "waitFor", + "extract", + "pressKey", + "scroll", + "screenshot", + "sleep", +]; + +/** The kinds that name an element, and so carry exactly one target. */ +const TARGETED: RecipeStepType[] = ["click", "type", "waitFor", "extract"]; + +export function isTargeted(type: RecipeStepType): boolean { + return TARGETED.includes(type); +} + +/** A step of `type` with the fields that kind needs, and nothing else. */ +export function emptyStep(type: RecipeStepType): RecipeStep { + switch (type) { + case "navigate": + return { type, url: "" }; + case "click": + return { type, locator: { name: "" } }; + case "type": + return { type, text: "", locator: { name: "" } }; + case "waitFor": + return { type, locator: { name: "" } }; + case "extract": + return { type, name: "", locator: { name: "" } }; + case "pressKey": + return { type, key: "" }; + case "scroll": + return { type, direction: "down" }; + case "screenshot": + return { type }; + case "sleep": + return { type, ms: 1000 }; + } +} + +/** + * Whether a step is complete enough to save. + * + * Deliberately the same shape the Rust command checks and the API enforces: an + * unknown kind, or a targeted step with both a selector and a locator or with + * neither, is refused here rather than sent and rejected. + */ +export function isStepComplete(step: RecipeStep): boolean { + const hasSelector = Boolean(step.selector?.trim()); + const hasLocator = Boolean( + step.locator && + Object.values(step.locator).some((value) => Boolean(value?.trim())), + ); + if (isTargeted(step.type) && hasSelector === hasLocator) return false; + + switch (step.type) { + case "navigate": + return Boolean(step.url?.trim()); + case "type": + return Boolean(step.text?.trim()); + case "extract": + return Boolean(step.name?.trim()); + case "pressKey": + return Boolean(step.key?.trim()); + case "sleep": + return typeof step.ms === "number" && step.ms > 0; + default: + return true; + } +} + +/** Drop the empty fields the editor keeps for its own inputs. */ +export function tidyStep(step: RecipeStep): RecipeStep { + const tidy: RecipeStep = { type: step.type }; + const copyText = (key: "url" | "text" | "name" | "key" | "selector") => { + const value = step[key]?.trim(); + if (value) tidy[key] = value; + }; + copyText("url"); + copyText("text"); + copyText("name"); + copyText("key"); + copyText("selector"); + if (step.locator) { + const locator: Record = {}; + for (const [field, value] of Object.entries(step.locator)) { + if (value?.trim()) locator[field] = value.trim(); + } + if (Object.keys(locator).length > 0) tidy.locator = locator; + } + if (step.type === "scroll") tidy.direction = step.direction ?? "down"; + if (step.type === "sleep") tidy.ms = step.ms ?? 1000; + if (step.type === "screenshot" && step.fullPage) tidy.fullPage = true; + if (step.type === "extract" && step.attr?.trim()) + tidy.attr = step.attr.trim(); + return tidy; +} + +/** + * A recipe's steps, edited as rows rather than as free text. + * + * The API validates a typed step object, so a text box could only produce + * something to be rejected on save. A row per step cannot express an invalid + * one, and it is the same shape the recorder writes. + */ +export function RecipeStepsEditor({ + steps, + onChange, + disabled, +}: { + steps: RecipeStep[]; + onChange: (steps: RecipeStep[]) => void; + disabled?: boolean; +}) { + const { t } = useTranslation(); + + const update = (index: number, next: RecipeStep) => { + onChange(steps.map((step, i) => (i === index ? next : step))); + }; + const move = (index: number, by: number) => { + const target = index + by; + if (target < 0 || target >= steps.length) return; + const next = [...steps]; + [next[index], next[target]] = [next[target], next[index]]; + onChange(next); + }; + + return ( +
+ {steps.map((step, index) => ( +
+
+ +
+ + + +
+ + { + update(index, next); + }} + /> +
+ ))} +
+ ); +} + +function StepFields({ + step, + onChange, + disabled, +}: { + step: RecipeStep; + onChange: (step: RecipeStep) => void; + disabled?: boolean; +}) { + const { t } = useTranslation(); + const set = (patch: Partial) => { + onChange({ ...step, ...patch }); + }; + const setLocator = (field: string, value: string) => { + onChange({ + ...step, + selector: undefined, + locator: { ...step.locator, [field]: value }, + }); + }; + + return ( +
+ {step.type === "navigate" && ( + + { + set({ url: event.target.value }); + }} + /> + + )} + + {step.type === "type" && ( + + { + set({ text: event.target.value }); + }} + /> + + )} + + {step.type === "extract" && ( + <> + + { + set({ name: event.target.value }); + }} + /> + + + { + set({ attr: event.target.value }); + }} + /> + + + )} + + {step.type === "pressKey" && ( + + { + set({ key: event.target.value }); + }} + /> + + )} + + {step.type === "scroll" && ( + + + + )} + + {step.type === "sleep" && ( + + { + set({ ms: Number(event.target.value) }); + }} + /> + + )} + + {step.type === "screenshot" && ( +
+ { + set({ fullPage: checked === true }); + }} + /> + +
+ )} + + {isTargeted(step.type) && ( + <> + + { + setLocator("role", event.target.value); + }} + /> + + + { + setLocator("name", event.target.value); + }} + /> + + + { + onChange({ + ...step, + locator: undefined, + selector: event.target.value, + }); + }} + /> + + + )} +
+ ); +} + +function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/src/components/agent-recipes.tsx b/src/components/agent-recipes.tsx new file mode 100644 index 0000000..177176c --- /dev/null +++ b/src/components/agent-recipes.tsx @@ -0,0 +1,308 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { GoPlus } from "react-icons/go"; +import { RecipeRecorder } from "@/components/agent-recipe-recorder"; +import { + emptyStep, + isStepComplete, + RecipeStepsEditor, + tidyStep, +} from "@/components/agent-recipe-steps"; +import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"; +import { Button } from "@/components/ui/button"; +import { FadingScrollArea } from "@/components/ui/fading-scroll-area"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RippleButton } from "@/components/ui/ripple"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + type AgentRecipe, + createAgentRecipe, + deleteAgentRecipe, + type RecipeStep, + updateAgentRecipe, +} from "@/lib/agent"; +import { translateBackendError } from "@/lib/backend-errors"; +import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; +import type { BrowserProfile } from "@/types"; + +interface AgentRecipesProps { + /** Running profiles are the ones a recording can be made from. */ + profiles: BrowserProfile[]; + recipes: AgentRecipe[]; + isLoading: boolean; + error: unknown; + /** Re-read the library after any write, so every surface agrees at once. */ + onChanged: () => void; +} + +export function AgentRecipes({ + profiles, + recipes, + isLoading, + error, + onChanged, +}: AgentRecipesProps) { + const { t } = useTranslation(); + /** The recipe being edited, `"new"` while composing one, or null. */ + const [editing, setEditing] = useState(null); + /** Steps a recording just produced, waiting to be reviewed and named. */ + const [recorded, setRecorded] = useState(null); + const [pendingRemoval, setPendingRemoval] = useState( + null, + ); + const [isRemoving, setIsRemoving] = useState(false); + + const confirmRemoval = useCallback(async () => { + if (!pendingRemoval) return; + setIsRemoving(true); + try { + await deleteAgentRecipe(pendingRemoval.id); + showSuccessToast(t("agent.recipes.deleted")); + setPendingRemoval(null); + onChanged(); + } catch (removeError) { + showErrorToast(translateBackendError(t, removeError)); + } finally { + setIsRemoving(false); + } + }, [pendingRemoval, onChanged, t]); + + return ( +
+
+

+ {t("agent.recipes.hint")} +

+ { + setEditing("new"); + }} + > + + {t("agent.recipes.new")} + +
+ + {error !== null && ( +

+ {translateBackendError(t, error)} +

+ )} + + { + // A recording is a draft, not a saved recipe: it opens the editor so + // the person names it and sees every step before anything is stored. + setRecorded(steps); + setEditing("new"); + }} + /> + + {editing !== null && ( + { + setEditing(null); + }} + onSaved={() => { + setRecorded(null); + setEditing(null); + onChanged(); + }} + /> + )} + + +
+ {isLoading && recipes.length === 0 ? ( + Array.from({ length: 3 }, (_, index) => ( + + )) + ) : recipes.length === 0 ? ( +

+ {t("agent.recipes.empty")} +

+ ) : ( + recipes.map((recipe) => ( +
+
+ + {recipe.name} + + + {t("agent.recipes.stepCount", { + steps: recipe.steps.length, + })} + +
+ + +
+ )) + )} +
+
+ + { + setPendingRemoval(null); + }} + onConfirm={() => { + void confirmRemoval(); + }} + title={t("agent.recipes.deleteTitle", { + name: pendingRemoval?.name ?? "", + })} + description={t("agent.recipes.deleteDescription")} + confirmButtonText={t("common.buttons.delete")} + isLoading={isRemoving} + /> +
+ ); +} + +function RecipeEditor({ + recipe, + initialSteps, + onCancel, + onSaved, +}: { + recipe: AgentRecipe | null; + /** Steps a recording produced, for a new recipe that starts from one. */ + initialSteps?: RecipeStep[] | null; + onCancel: () => void; + onSaved: () => void; +}) { + const { t } = useTranslation(); + const [name, setName] = useState(recipe?.name ?? ""); + const [steps, setSteps] = useState( + recipe?.steps ?? initialSteps ?? [], + ); + const [isSaving, setIsSaving] = useState(false); + + // Editing a different recipe reuses this component, so the fields follow the + // row the user actually clicked rather than keeping the previous one's text. + useEffect(() => { + setName(recipe?.name ?? ""); + setSteps(recipe?.steps ?? initialSteps ?? []); + }, [recipe, initialSteps]); + + const canSave = + name.trim().length > 0 && + steps.length > 0 && + steps.every(isStepComplete) && + !isSaving; + + const handleSave = useCallback(async () => { + if (!canSave) return; + setIsSaving(true); + try { + const payload = steps.map(tidyStep); + if (recipe) { + await updateAgentRecipe(recipe.id, name.trim(), payload); + showSuccessToast(t("agent.recipes.updated")); + } else { + await createAgentRecipe(name.trim(), payload); + showSuccessToast(t("agent.recipes.created")); + } + onSaved(); + } catch (saveError) { + showErrorToast(translateBackendError(t, saveError)); + } finally { + setIsSaving(false); + } + }, [canSave, recipe, name, steps, onSaved, t]); + + return ( +
{ + event.preventDefault(); + void handleSave(); + }} + > +
+ + { + setName(event.target.value); + }} + /> +
+
+ + +
+

+ {t("agent.recipes.stepsHint")} +

+ +
+
+
+ + + {isSaving ? t("common.buttons.saving") : t("common.buttons.save")} + +
+
+ ); +} diff --git a/src/components/agent-run-form.tsx b/src/components/agent-run-form.tsx new file mode 100644 index 0000000..3cea978 --- /dev/null +++ b/src/components/agent-run-form.tsx @@ -0,0 +1,491 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { LuChevronRight } from "react-icons/lu"; +import { + AnimatedDisclosureChevron, + AnimatedDisclosureContent, +} from "@/components/ui/animated-disclosure"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RippleButton } from "@/components/ui/ripple"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { + AGENT_EFFORTS, + AGENT_GOAL_MAX_CHARS, + AGENT_PLATFORMS, + type AgentBudgets, + type AgentEffort, + type AgentPlatform, + type AgentRecipe, + type AgentRunView, + type AgentTarget, + agentGoalProblem, + parseAllowedHosts, + startAgentRun, +} from "@/lib/agent"; +import { translateBackendError } from "@/lib/backend-errors"; +import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; +import type { BrowserProfile } from "@/types"; + +const TARGETS: readonly AgentTarget[] = ["desktop", "fleet"]; + +/** Minutes, because nobody thinks about a wall-clock budget in milliseconds. */ +const MS_PER_MINUTE = 60_000; + +/** + * Read a positive whole number out of a text field, or null. + * + * Null is "the user did not set a ceiling", which is not the same as zero: an + * unset budget lets the server apply its own default, and a zero would ask for + * a run that stops before it starts. + */ +function positiveInteger(raw: string): number | null { + const trimmed = raw.trim(); + if (trimmed.length === 0) return null; + const value = Number(trimmed); + if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) { + return null; + } + return value; +} + +/** True when the field has something in it that is not a usable ceiling. */ +function isBadBudget(raw: string): boolean { + return raw.trim().length > 0 && positiveInteger(raw) === null; +} + +interface AgentRunFormProps { + profiles: BrowserProfile[]; + recipes: AgentRecipe[]; + /** Pre-selected profile, when the page was opened from one. */ + defaultProfileId?: string | null; + onStarted: (run: AgentRunView) => void; +} + +export function AgentRunForm({ + profiles, + recipes, + defaultProfileId, + onStarted, +}: AgentRunFormProps) { + const { t } = useTranslation(); + + const sortedProfiles = useMemo( + () => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), + [profiles], + ); + + const [profileId, setProfileId] = useState( + defaultProfileId ?? sortedProfiles[0]?.id ?? null, + ); + const [target, setTarget] = useState("desktop"); + const [platform, setPlatform] = useState(null); + const [goal, setGoal] = useState(""); + const [effort, setEffort] = useState("standard"); + const [maxSteps, setMaxSteps] = useState(""); + const [maxMinutes, setMaxMinutes] = useState(""); + const [maxTokens, setMaxTokens] = useState(""); + const [allowedHostsRaw, setAllowedHostsRaw] = useState(""); + const [limitsOpen, setLimitsOpen] = useState(false); + const [isStarting, setIsStarting] = useState(false); + + const selectedProfile = useMemo( + () => sortedProfiles.find((profile) => profile.id === profileId) ?? null, + [sortedProfiles, profileId], + ); + + // A profile deleted from another surface must not leave the form pointing at + // an id the backend would refuse. + useEffect(() => { + if (profileId && sortedProfiles.some((p) => p.id === profileId)) return; + setProfileId(sortedProfiles[0]?.id ?? null); + }, [sortedProfiles, profileId]); + + // A leased host has to be the machine the profile was built for, so the + // platform follows the profile unless the user says otherwise. + const resolvedPlatform: AgentPlatform | null = useMemo(() => { + if (platform) return platform; + const own = selectedProfile?.host_os; + return own && (AGENT_PLATFORMS as readonly string[]).includes(own) + ? (own as AgentPlatform) + : null; + }, [platform, selectedProfile]); + + const goalProblem = agentGoalProblem(goal); + const goalLength = [...goal.trim()].length; + const allowedHosts = useMemo( + () => parseAllowedHosts(allowedHostsRaw), + [allowedHostsRaw], + ); + const budgetProblem = + isBadBudget(maxSteps) || isBadBudget(maxMinutes) || isBadBudget(maxTokens); + const needsPlatform = target === "fleet" && resolvedPlatform === null; + + const canSubmit = + profileId !== null && + goalProblem === null && + !budgetProblem && + !needsPlatform && + !isStarting; + + const handleSubmit = useCallback(async () => { + if (!profileId || goalProblem !== null || budgetProblem || needsPlatform) { + return; + } + const budgets: AgentBudgets = { + maxSteps: positiveInteger(maxSteps), + maxWallMs: (() => { + const minutes = positiveInteger(maxMinutes); + return minutes === null ? null : minutes * MS_PER_MINUTE; + })(), + maxTokens: positiveInteger(maxTokens), + }; + const hasBudget = + budgets.maxSteps !== null || + budgets.maxWallMs !== null || + budgets.maxTokens !== null; + + setIsStarting(true); + try { + const run = await startAgentRun({ + profileId, + target, + goal: goal.trim(), + platform: target === "fleet" ? resolvedPlatform : null, + effort, + budgets: hasBudget ? budgets : null, + allowedHosts: allowedHosts.length > 0 ? allowedHosts : null, + }); + showSuccessToast(t("agent.form.started")); + setGoal(""); + onStarted(run); + } catch (error) { + showErrorToast(translateBackendError(t, error)); + } finally { + setIsStarting(false); + } + }, [ + profileId, + goalProblem, + budgetProblem, + needsPlatform, + maxSteps, + maxMinutes, + maxTokens, + target, + goal, + resolvedPlatform, + effort, + allowedHosts, + onStarted, + t, + ]); + + return ( +
{ + event.preventDefault(); + void handleSubmit(); + }} + > +
+
+ + + {sortedProfiles.length === 0 && ( +

+ {t("agent.form.noProfiles")} +

+ )} +
+ +
+ + +

+ {t( + target === "fleet" + ? "agent.form.targetFleetHint" + : "agent.form.targetDesktopHint", + )} +

+
+
+ + {target === "fleet" && ( +
+ + + {needsPlatform && ( +

+ {t("agent.form.platformRequired")} +

+ )} +
+ )} + +
+
+ + {/* A menu, not a select: inserting a recipe is an action, and the + same recipe has to be insertable twice in a row — which a control + that remembers the value it last held cannot do. */} + {recipes.length > 0 && ( + + + + + + {recipes.map((recipe) => ( + { + // Appended rather than replacing: a saved goal is a + // starting point the user then edits, and silently + // discarding what they already typed is the one thing an + // insert must never do. + setGoal((current) => { + const addition = recipe.steps.join("\n"); + return current.trim().length === 0 + ? addition + : `${current.trimEnd()}\n${addition}`; + }); + }} + > + {recipe.name} + + ))} + + + )} +
+