diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 66b5c9ee63d..0f038327feb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -69,6 +69,48 @@ jobs:
echo "ℹ️ Not a release commit"
fi
+ # Detect shell-code changes on dev/staging pushes. Web-only changes never
+ # need a desktop build (installed shells load the web app live); changes to
+ # the Electron app or the bridge packages trigger a per-env prerelease build
+ # (dev → alpha channel, staging → beta) that the env's update feed
+ # (/api/desktop/update) starts offering automatically.
+ detect-desktop-changes:
+ name: Detect Desktop Changes
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ timeout-minutes: 5
+ if: github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/staging')
+ outputs:
+ changed: ${{ steps.diff.outputs.changed }}
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ with:
+ fetch-depth: 50
+
+ - name: Diff desktop paths
+ id: diff
+ env:
+ BEFORE: ${{ github.event.before }}
+ run: |
+ # Force pushes (dev resets) can reference a BEFORE we don't have;
+ # fall back to the previous commit, and to no build when even that
+ # is unavailable.
+ if [ -z "$BEFORE" ] || ! git cat-file -e "$BEFORE" 2>/dev/null; then
+ BEFORE="$(git rev-parse HEAD^ 2>/dev/null || echo '')"
+ fi
+ if [ -z "$BEFORE" ]; then
+ echo "changed=false" >> "$GITHUB_OUTPUT"
+ echo "ℹ️ No comparable base commit; skipping desktop prerelease"
+ exit 0
+ fi
+ if git diff --name-only "$BEFORE" HEAD | grep -qE '^(apps/desktop/|packages/desktop-bridge/|packages/browser-protocol/)'; then
+ echo "changed=true" >> "$GITHUB_OUTPUT"
+ echo "✅ Desktop shell code changed"
+ else
+ echo "changed=false" >> "$GITHUB_OUTPUT"
+ echo "ℹ️ No desktop shell changes"
+ fi
+
# Run database migrations before images are promoted: the ECR latest/staging
# tag push triggers CodePipeline, so migrating first guarantees the schema is
# in place before the new app version deploys (replaces the removed ECS
@@ -590,3 +632,183 @@ jobs:
env:
GH_PAT: ${{ secrets.GITHUB_TOKEN }}
run: bun run scripts/create-single-release.ts ${{ needs.detect-version.outputs.version }}
+
+ # Desktop release: builds, signs, notarizes, and attaches the macOS app to
+ # the GitHub release created above. Gated on the Apple signing secrets so a
+ # release pipeline run skips cleanly (instead of failing) until the Apple
+ # Developer account is provisioned — the moment the six secrets exist, the
+ # next vX.Y.Z release ships desktop artifacts with no further changes.
+ # Job-level `if:` cannot read the secrets context, hence the probe job.
+ check-desktop-signing:
+ name: Check Desktop Signing Secrets
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ timeout-minutes: 2
+ needs: [detect-version, detect-desktop-changes]
+ # !cancelled(): detect-desktop-changes is skipped on main (and
+ # detect-version tags only on main); either path may need the probe.
+ if: ${{ !cancelled() && (needs.detect-version.outputs.is_release == 'true' || needs.detect-desktop-changes.outputs.changed == 'true') }}
+ outputs:
+ configured: ${{ steps.check.outputs.configured }}
+ steps:
+ - name: Probe Apple signing secrets
+ id: check
+ env:
+ CONFIGURED: ${{ secrets.CSC_LINK != '' && secrets.CSC_KEY_PASSWORD != '' && secrets.APPLE_API_KEY_P8 != '' && secrets.APPLE_API_KEY_ID != '' && secrets.APPLE_API_ISSUER != '' && secrets.APPLE_TEAM_ID != '' }}
+ run: |
+ echo "configured=${CONFIGURED}" >> "$GITHUB_OUTPUT"
+ if [ "$CONFIGURED" != "true" ]; then
+ echo "::warning::Desktop release skipped: Apple signing secrets are not configured (CSC_LINK, CSC_KEY_PASSWORD, APPLE_API_KEY_P8, APPLE_API_KEY_ID, APPLE_API_ISSUER, APPLE_TEAM_ID)."
+ fi
+
+ desktop-release:
+ name: Desktop Release
+ needs: [create-release, check-desktop-signing, detect-version]
+ if: needs.check-desktop-signing.outputs.configured == 'true'
+ permissions:
+ contents: write
+ uses: ./.github/workflows/desktop-release.yml
+ with:
+ version: ${{ needs.detect-version.outputs.version }}
+ publish: true
+ secrets: inherit
+
+ # Per-env desktop prereleases: a dev/staging push that touches shell code
+ # publishes a channel-tagged GitHub prerelease (vX.Y.Z-alpha.N from dev,
+ # vX.Y.Z-beta.N from staging). Each environment's /api/desktop/update feed
+ # offers only its channel, so dev-pointed shells pick up alpha builds,
+ # staging-pointed shells beta builds, and prod-pointed shells stable
+ # releases — independently. Unlike stable releases, prereleases build even
+ # before the Apple signing secrets exist — unsigned, so the update pipeline
+ # is testable end to end; installed shells detect the missing Developer ID
+ # and offer a manual download instead of a Squirrel install.
+ create-desktop-prerelease:
+ name: Create Desktop Prerelease
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ timeout-minutes: 5
+ needs: [detect-desktop-changes, check-desktop-signing]
+ # Requires the signing probe to have actually succeeded (not just "not
+ # cancelled") so a probe failure can't produce a release with no build.
+ if: ${{ !cancelled() && needs.detect-desktop-changes.outputs.changed == 'true' && needs.check-desktop-signing.result == 'success' }}
+ permissions:
+ contents: write
+ outputs:
+ version: ${{ steps.version.outputs.version }}
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+
+ - name: Compute prerelease version and create draft release
+ id: version
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ SIGNED: ${{ needs.check-desktop-signing.outputs.configured }}
+ run: |
+ if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=alpha; APP_NAME="Sim Dev"; else CHANNEL=beta; APP_NAME="Sim Staging"; fi
+ # Prerelease core = next patch after the latest stable release, so
+ # channel builds always outrank the stable they are built on top of
+ # and are always superseded by the next stable. The run-attempt
+ # suffix keeps re-runs of the same workflow from colliding on the
+ # tag while preserving semver ordering.
+ # Fail loudly if the query itself fails: silently falling back to
+ # v0.0.0 would publish a channel build that sorts below the shipped
+ # stable, and installed shells would never see it as an update.
+ if ! LATEST="$(gh release list --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName')"; then
+ echo "::error::Could not query the latest stable release."
+ exit 1
+ fi
+ # An empty release list makes jq print "null", which ${VAR:-default}
+ # does not treat as empty. Anything that is not a bare vX.Y.Z means
+ # "no stable release to build on top of" — start the channel at 0.0.1.
+ if [[ ! "$LATEST" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ LATEST="v0.0.0"
+ fi
+ IFS='.' read -r MAJOR MINOR PATCH <<< "${LATEST#v}"
+ TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))-${CHANNEL}.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}"
+ NOTES="Automated ${CHANNEL}-channel desktop build from ${GITHUB_REF_NAME} @ ${GITHUB_SHA::7}."
+ if [ "$SIGNED" != "true" ]; then
+ NOTES="$NOTES
+
+ ⚠️ Unsigned test build (Apple signing secrets not configured). Gatekeeper will quarantine a downloaded copy: right-click → Open, or clear the flag with \`xattr -dr com.apple.quarantine \"/Applications/${APP_NAME}.app\"\`."
+ fi
+ # Draft until the build uploads its artifacts: drafts are invisible
+ # to the update feed, so a failed or in-flight build can never take
+ # the channel down with an assetless release. Publishing later also
+ # defers tag creation, so failed builds strand no tags.
+ gh release create "$TAG" \
+ --draft \
+ --prerelease \
+ --target "$GITHUB_SHA" \
+ --title "$TAG" \
+ --notes "$NOTES"
+ echo "version=$TAG" >> "$GITHUB_OUTPUT"
+ echo "✅ Created draft prerelease $TAG"
+
+ desktop-prerelease:
+ name: Desktop Prerelease Build
+ needs: [create-desktop-prerelease, check-desktop-signing]
+ permissions:
+ contents: write
+ uses: ./.github/workflows/desktop-release.yml
+ with:
+ version: ${{ needs.create-desktop-prerelease.outputs.version }}
+ publish: true
+ sign: ${{ needs.check-desktop-signing.outputs.configured == 'true' }}
+ secrets: inherit
+
+ # The draft only becomes visible to the update feed once its artifacts are
+ # attached — this is what makes a dev/staging push atomic from the shell's
+ # point of view.
+ publish-desktop-prerelease:
+ name: Publish Desktop Prerelease
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ timeout-minutes: 5
+ needs: [create-desktop-prerelease, desktop-prerelease]
+ permissions:
+ contents: write
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ TAG: ${{ needs.create-desktop-prerelease.outputs.version }}
+ steps:
+ - name: Publish the draft release
+ run: gh release edit "$TAG" --draft=false
+
+ # Keep the release list tidy: per channel, retain the newest 5 prereleases
+ # and delete the rest (with their tags, so dev force-resets don't strand
+ # commits behind stale tags). Leftover drafts (failed or superseded builds)
+ # are always garbage by this point — the current run's release is published.
+ prune-desktop-prereleases:
+ name: Prune Desktop Prereleases
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ timeout-minutes: 5
+ needs: [publish-desktop-prerelease]
+ permissions:
+ contents: write
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ steps:
+ - name: Delete stale prereleases
+ run: |
+ if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=alpha; else CHANNEL=beta; fi
+ gh release list --limit 100 --json tagName,isPrerelease,isDraft,createdAt \
+ --jq "[.[] | select(.isPrerelease and (.isDraft | not) and (.tagName | test(\"-${CHANNEL}\\\\.\")))] | sort_by(.createdAt) | reverse | .[5:] | .[].tagName" |
+ while read -r TAG; do
+ [ -n "$TAG" ] || continue
+ echo "Deleting stale prerelease $TAG"
+ gh release delete "$TAG" --cleanup-tag --yes
+ done
+
+ - name: Delete leftover draft prereleases
+ run: |
+ if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=alpha; else CHANNEL=beta; fi
+ # Drafts have no tag ref, so delete by release id via the API
+ # (gh release delete resolves by tag, which is ambiguous for drafts).
+ gh api "repos/${GH_REPO}/releases?per_page=100" \
+ --jq ".[] | select(.draft and (.tag_name | test(\"-${CHANNEL}\\\\.\"))) | .id" |
+ while read -r ID; do
+ [ -n "$ID" ] || continue
+ echo "Deleting leftover draft release $ID"
+ gh api -X DELETE "repos/${GH_REPO}/releases/${ID}"
+ done
diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml
new file mode 100644
index 00000000000..5a305244d17
--- /dev/null
+++ b/.github/workflows/desktop-e2e.yml
@@ -0,0 +1,85 @@
+name: Desktop E2E
+
+# Smoke coverage of the real Electron shell, plus an advisory canary leg
+# against electron@latest so Chromium-cadence breakage surfaces before an
+# upgrade is attempted (U18/U22).
+#
+# Manual-only for now: the desktop app is tested locally, so the
+# pull_request trigger is disabled until desktop CI is turned back on.
+
+on:
+ workflow_dispatch:
+
+concurrency:
+ group: desktop-e2e-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ e2e:
+ name: E2E (${{ matrix.electron }})
+ runs-on: macos-14
+ strategy:
+ fail-fast: false
+ matrix:
+ electron: [pinned, latest]
+ continue-on-error: ${{ matrix.electron == 'latest' }}
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
+ with:
+ bun-version: 1.3.13
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Switch to electron@latest (canary)
+ if: matrix.electron == 'latest'
+ working-directory: apps/desktop
+ run: bun add -d electron@latest
+
+ - name: Bundle main and preload
+ working-directory: apps/desktop
+ run: bun run build
+
+ - name: Run Playwright _electron smoke suite
+ working-directory: apps/desktop
+ run: bunx playwright test
+
+ - name: Upload test results
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: desktop-e2e-results-${{ matrix.electron }}
+ path: apps/desktop/test-results
+ retention-days: 7
+
+ package-smoke:
+ name: Unsigned package smoke
+ runs-on: macos-14
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
+ with:
+ bun-version: 1.3.13
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Bundle and package unsigned
+ working-directory: apps/desktop
+ env:
+ CSC_IDENTITY_AUTO_DISCOVERY: 'false'
+ run: |
+ bun run build
+ bunx electron-builder --mac dir --publish never
diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml
new file mode 100644
index 00000000000..f0f512b5268
--- /dev/null
+++ b/.github/workflows/desktop-release.yml
@@ -0,0 +1,212 @@
+name: Desktop Release (macOS)
+
+# Builds, signs, notarizes, and uploads the desktop app to an existing GitHub
+# release. Ordering is load-bearing: scripts/create-single-release.ts skips
+# creation when the tag already exists, so this workflow must never create the
+# release itself — it only uploads assets after create-release ran (wired via
+# workflow_call from ci.yml with needs: [create-release]).
+
+on:
+ workflow_call:
+ inputs:
+ version:
+ description: Release tag (vX.Y.Z) to attach desktop artifacts to
+ required: true
+ type: string
+ publish:
+ description: Upload artifacts to the GitHub release
+ required: false
+ type: boolean
+ default: true
+ sign:
+ description: Sign and notarize with the Apple Developer identity. When
+ false (prerelease testing before the signing secrets exist) the build
+ is packaged unsigned; installed shells detect this and offer manual
+ downloads instead of Squirrel installs.
+ required: false
+ type: boolean
+ default: true
+ workflow_dispatch:
+ inputs:
+ version:
+ description: Release tag (vX.Y.Z) to attach desktop artifacts to
+ required: true
+ type: string
+ publish:
+ description: Upload artifacts to the GitHub release
+ required: false
+ type: boolean
+ default: false
+ sign:
+ description: Sign and notarize with the Apple Developer identity
+ required: false
+ type: boolean
+ default: true
+
+permissions:
+ contents: write
+
+jobs:
+ build-sign-notarize:
+ name: Build, Sign, Notarize
+ runs-on: macos-14
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
+ with:
+ bun-version: 1.3.13
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Cache Electron binaries
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/Library/Caches/electron
+ ~/Library/Caches/electron-builder
+ key: electron-cache-${{ runner.os }}-${{ hashFiles('apps/desktop/package.json') }}
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Inject release version
+ env:
+ VERSION: ${{ inputs.version }}
+ run: |
+ SEMVER="${VERSION#v}"
+ if ! [[ "$SEMVER" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.].+)?$ ]]; then
+ echo "Refusing to build: '$VERSION' is not a vX.Y.Z release tag" >&2
+ exit 1
+ fi
+ npm pkg set version="$SEMVER" --prefix apps/desktop
+ INJECTED="$(node -p "require('./apps/desktop/package.json').version")"
+ if [ "$INJECTED" != "$SEMVER" ]; then
+ echo "Version injection mismatch: wanted $SEMVER got $INJECTED" >&2
+ exit 1
+ fi
+
+ # Prerelease versions carry their environment in the tag: -alpha.N is a
+ # dev build, -beta.N a staging build. The channel decides the app's
+ # identity (name/bundle id — a separate app per environment, installable
+ # side by side) and the default origin baked into the bundle, which in
+ # turn selects the update feed the installed app polls.
+ - name: Resolve channel identity
+ id: channel
+ env:
+ VERSION: ${{ inputs.version }}
+ run: |
+ case "$VERSION" in
+ *-alpha.*)
+ NAME='Sim Dev'; APP_ID=ai.sim.desktop.dev; ORIGIN=https://www.dev.sim.ai ;;
+ *-beta.*)
+ NAME='Sim Staging'; APP_ID=ai.sim.desktop.staging; ORIGIN=https://www.staging.sim.ai ;;
+ *)
+ NAME='Sim'; APP_ID=ai.sim.desktop; ORIGIN='' ;;
+ esac
+ {
+ echo "name=$NAME"
+ echo "app_id=$APP_ID"
+ echo "origin=$ORIGIN"
+ } >> "$GITHUB_OUTPUT"
+ echo "Building $NAME ($APP_ID) default origin: ${ORIGIN:-production}"
+
+ - name: Bundle main and preload
+ working-directory: apps/desktop
+ env:
+ SIM_DESKTOP_DEFAULT_ORIGIN: ${{ steps.channel.outputs.origin }}
+ run: bun run build
+
+ - name: Write App Store Connect API key
+ if: ${{ inputs.sign }}
+ env:
+ APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
+ run: |
+ mkdir -p "$RUNNER_TEMP/appstoreconnect"
+ printf '%s' "$APPLE_API_KEY_P8" > "$RUNNER_TEMP/appstoreconnect/AuthKey.p8"
+ chmod 600 "$RUNNER_TEMP/appstoreconnect/AuthKey.p8"
+
+ - name: Package, sign, and notarize
+ if: ${{ inputs.sign }}
+ working-directory: apps/desktop
+ env:
+ CSC_LINK: ${{ secrets.CSC_LINK }}
+ CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
+ # Absolute path — @electron/notarize reads this via Node fs, which
+ # does not expand a leading '~'.
+ APPLE_API_KEY: ${{ runner.temp }}/appstoreconnect/AuthKey.p8
+ APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
+ APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ PRODUCT_NAME: ${{ steps.channel.outputs.name }}
+ APP_ID: ${{ steps.channel.outputs.app_id }}
+ run: >
+ bunx electron-builder --mac --publish never
+ -c.productName="$PRODUCT_NAME" -c.appId="$APP_ID"
+
+ # Unsigned prerelease path: no Developer ID, no notarization. The
+ # binaries end up ad-hoc/linker-signed, which runs locally but gets
+ # quarantined when downloaded — fine for testing the update pipeline.
+ - name: Package unsigned
+ if: ${{ !inputs.sign }}
+ working-directory: apps/desktop
+ env:
+ CSC_IDENTITY_AUTO_DISCOVERY: 'false'
+ PRODUCT_NAME: ${{ steps.channel.outputs.name }}
+ APP_ID: ${{ steps.channel.outputs.app_id }}
+ run: >
+ bunx electron-builder --mac --publish never -c.mac.notarize=false
+ -c.productName="$PRODUCT_NAME" -c.appId="$APP_ID"
+
+ - name: Validate signature and notarization
+ if: ${{ inputs.sign }}
+ run: |
+ DMG="$(ls apps/desktop/release/*.dmg | head -1)"
+ xcrun stapler validate "$DMG"
+ hdiutil attach "$DMG" -mountpoint /tmp/sim-dmg -nobrowse -quiet
+ spctl --assess --type execute --verbose /tmp/sim-dmg/*.app
+ codesign --verify --deep --strict /tmp/sim-dmg/*.app
+ hdiutil detach /tmp/sim-dmg -quiet
+
+ - name: Upload artifacts to the release
+ if: ${{ inputs.publish }}
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ VERSION: ${{ inputs.version }}
+ run: |
+ # electron-builder's GitHub provider always names the manifest
+ # latest-mac.yml (channels are a generic-provider concept), and the
+ # update feed expects exactly that asset name on every release —
+ # normalize defensively in case a config change ever produces a
+ # channel-named manifest.
+ YML="$(find apps/desktop/release -maxdepth 1 -name '*-mac.yml' | head -1)"
+ if [ -z "$YML" ]; then
+ echo "::error::No *-mac.yml updater manifest found in apps/desktop/release"
+ exit 1
+ fi
+ if [ "$(basename "$YML")" != "latest-mac.yml" ]; then
+ mv "$YML" apps/desktop/release/latest-mac.yml
+ fi
+ gh release upload "$VERSION" \
+ apps/desktop/release/*.dmg \
+ apps/desktop/release/*.zip \
+ apps/desktop/release/*.blockmap \
+ apps/desktop/release/latest-mac.yml \
+ --clobber
+
+ - name: Upload artifacts to the workflow run
+ if: ${{ !inputs.publish }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: sim-desktop-${{ inputs.version }}
+ path: |
+ apps/desktop/release/*.dmg
+ apps/desktop/release/*.zip
+ apps/desktop/release/*.blockmap
+ apps/desktop/release/*-mac.yml
+ retention-days: 7
diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml
index 3a934e51eda..195d19dcd38 100644
--- a/.github/workflows/test-build.yml
+++ b/.github/workflows/test-build.yml
@@ -123,6 +123,15 @@ jobs:
- name: API contract boundary audit
run: bun run check:api-validation:strict
+ - name: Desktop bridge contract audit
+ run: bun run check:desktop-bridge
+
+ # Complements the bridge audit above, which compares against a snapshot
+ # this same PR is allowed to regenerate. This one derives every fact from
+ # the source both sides execute, so it has no such blind spot.
+ - name: Desktop IPC contract audit
+ run: bun run check:desktop-ipc
+
- name: Shared utils enforcement audit
run: bun run check:utils
diff --git a/.gitignore b/.gitignore
index a8a0d8e2fde..68ef944d64c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,8 @@ package-lock.json
/apps/**/out/
/apps/**/.next/
/apps/**/build
+# apps/desktop/build holds electron-builder INPUTS (icon, entitlements), not outputs
+!/apps/desktop/build
# production
/build
diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore
new file mode 100644
index 00000000000..22cddc211bb
--- /dev/null
+++ b/apps/desktop/.gitignore
@@ -0,0 +1,5 @@
+dist/
+release/
+build/generated-icon.icns
+playwright-report/
+test-results/
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
new file mode 100644
index 00000000000..8a52ef91c04
--- /dev/null
+++ b/apps/desktop/README.md
@@ -0,0 +1,194 @@
+# Sim Desktop (macOS)
+
+A thin Electron shell around the hosted Sim web app. The renderer loads the configured origin (default `https://sim.ai`) as a normal top-level page in a bundled, pinned Chromium — rendering is identical to Chrome of that version on every machine. No UI is re-implemented and no server stack is bundled.
+
+## Layout
+
+```
+src/main/ # main process (bundled to dist/main.cjs)
+ index.ts # lifecycle + wiring
+ ipc.ts # the single channel table: gate, version floor, handler
+ config.ts # origin + settings store (userData/settings.json)
+ app-routes.ts # Sim routes the shell navigates to (menu + tray share them)
+ atomic-json-file.ts # crash-safe write for the encrypted userData stores
+ navigation.ts # navigation classifier + openExternalSafe
+ windows.ts # window.open policy (full app windows, MCP popup, blank children)
+ window.ts # secure BrowserWindow, permissions, crash/hang recovery
+ security-guards.ts# global web-contents guards, TLS policy
+ csp.ts # Content-Security-Policy fallback header
+ handoff.ts # 127.0.0.1 loopback login handoff + token redeem
+ session-lifecycle.ts # sign-out teardown, 401 watcher, connect intercept
+ load-health.ts # offline/error page, auto-retry, watchdog
+ local-filesystem.ts # session-scoped read-only directory grants + localfs:// broker
+ local-filesystem-grant-store.ts # those grants, encrypted at rest
+ desktop-settings.ts # renderer-facing settings surface
+ downloads.ts # will-download handling
+ context-menu.ts # native right-click + spellcheck
+ telemetry-policy.ts # third-party analytics blocking
+ observability.ts # JSONL event log (userData/logs/desktop-events.log)
+ updater.ts # electron-updater wiring, channels, downgrade/block guards
+ menu.ts # role-based macOS menus
+ tray.ts # tray icon, recent-chat menu, environment marker
+ browser-agent/ # the agent browser: tab lifecycle, panel geometry, CDP driver
+ terminal/ # the agent terminal: PTY sessions, tmux, shell integration
+ browser-credentials/ # saved passwords, OS-auth gated, safeStorage at rest
+ browser-sites/ # imported site directory, safeStorage at rest
+ browser-import/ # one-shot import of profiles, cookies and passwords
+src/preload/ # contextBridge IPC bridge (bundled to dist/preload.cjs)
+static/ # bundled local pages (offline.html)
+e2e/ # Playwright _electron smoke suite
+```
+
+## Local development
+
+```bash
+bun install # workspace root
+cd apps/desktop
+bun run dev # bundle + launch against https://sim.ai
+SIM_DESKTOP_ORIGIN=http://localhost:3000 bun run dev # against local sim
+```
+
+- `bun run test` — vitest unit suite (electron is mocked; runs anywhere).
+- `bun run test:e2e` — Playwright `_electron` smoke suite against a fixture origin (macOS, real Electron window).
+- `bun run type-check` / `lint:check` — standard workspace checks; CI picks these up automatically via `turbo run`.
+- `SIM_DESKTOP_USER_DATA=
` isolates settings/partition state (used by e2e).
+
+Everything is bundled by esbuild into `dist/main.cjs` + `dist/preload.cjs` — including `electron-updater` and the `@sim/*` packages — so the packaged app has **no runtime node_modules** and `electron-builder` needs no lockfile/npmRebuild step (this is the deliberate workaround for Bun ↔ electron-builder friction; there is no `package-lock.json`).
+
+## Auth model (read before touching auth)
+
+- The app loads the hosted origin top-level; better-auth session cookies live in a persistent partition (`persist:sim`, per-origin for self-hosts). Email/password and verified-lenient providers (GitHub) sign in fully in-window.
+- **Google / Microsoft / SSO cannot OAuth inside an embedded browser** (`disallowed_useragent`; UA spoofing is fingerprint-defeated — do not ship it). Navigation to those hosts from an auth surface is intercepted and rerouted through the **system-browser handoff**:
+ 1. App starts a one-shot `127.0.0.1` loopback listener on an ephemeral port and opens `/desktop/auth?state=&port=` in the browser. The state is single-use, in-memory (the app is always running when the callback returns, so nothing is persisted), and constant-time compared.
+ 2. `apps/sim/app/desktop/auth/page.tsx` requires a browser session (redirects through `/login?callbackUrl=…`) and renders a **Continue** gesture gate; only that click mints a one-time token (`POST /api/desktop/auth/handoff`) and sends the browser to the loopback (`http://127.0.0.1:/auth/callback?token=…&state=…`, RFC 8252 §7.3). The gate is the security boundary — state and port are attacker-choosable in a crafted link, so a bare GET must never mint. The loopback is the single hand-back channel: no OS scheme registration, works identically in dev and packaged builds. Interception of loopback is mitigated the way PKCE mitigates it: the token is single-use, short-TTL, and bound to a 128-bit state the app compares in constant time. (RFC 8252's *most*-preferred callback is claimed-`https` / macOS universal links, which bind the OS to a verified app identity — a future hardening step that needs an associated-domains entitlement + `apple-app-site-association` on the origin.)
+ 3. The loopback fires the callback in the main process: the app validates the state, then a renderer in the app partition POSTs the token to `/api/auth/one-time-token/verify` (same-origin ⇒ trustedOrigins/CSRF pass; better-auth sets the session cookie and burns the token) and loads `/workspace`.
+- **The app gets its own session, never the browser's.** The token is minted by `POST /api/desktop/auth/handoff` (`apps/sim/lib/auth/desktop-handoff.ts`), which creates a *new* session row for the user and points the one-time token at that. Better-auth's own `generateOneTimeToken` binds to the *calling* session, so using it directly made the app and the authorizing browser share one row: signing out of either deleted the row the other was still presenting, and — because the session cookie cache is not revalidated against the database — the survivor kept looking signed in while every database-backed session resolution failed (the socket handshake logged `Session not found` and 401-looped). A session per device is what better-auth's own device authorization grant does and what RFC 8252 assumes: sign-out, revocation, and expiry apply to one surface at a time. **Do not "simplify" this back to `generateOneTimeToken`.**
+- Integration connects are **same-window redirects** (`client.oauth2.link`), not popups. Unknown provider hosts stay in-window (lenient default); Google/Microsoft connects get a native dialog offering to finish in the browser — the browser is signed in after the login handoff, tokens land server-side, the app just refreshes.
+- The MCP OAuth popup (`mcp-oauth-*`) is allowed as a same-partition child so `window.opener.postMessage` keeps working.
+- Sign-out **revokes server-side first** (`POST /api/auth/sign-out` from the app-origin renderer — a main-process `session.fetch` would be rejected by better-auth's origin check), then clears cookies/localStorage/IndexedDB/cache/service workers plus any pending handoff state. The revoke is not optional: since the app owns its own session row, clearing the partition alone would strand a live 30-day credential that nothing can revoke. Two signals trigger teardown: the `/login?fromLogout=true` navigation (fast path) **and** deletion of the better-auth session cookie confirmed by a `get-session` probe (robust backstop — catches every sign-out path, not just the settings one, and rotation can't cause a false teardown). API 401s (probe-confirmed) surface a native re-auth prompt.
+
+Deviations from the original plan doc (deliberate):
+- **One hand-back channel, not two.** The plan proposed a `sim://` deep link with a loopback fallback; that was collapsed to loopback-only. The app is always running when the callback returns (it started the loopback), so the custom scheme added complexity and a dev-only failure mode without buying anything — loopback works identically everywhere. No `sim://` scheme is registered.
+- No launch-time session probe; the server's own redirect to `/login` covers the signed-out launch, and the last route is restored otherwise.
+- Browser-initiated `/desktop/auth` visits without a valid `state`+`port` render a friendly error and never mint a token.
+
+## Provider matrix (U5 spike — keep current)
+
+Host lists live in `src/main/navigation.ts` (`SYSTEM_BROWSER_IDP_HOSTS`, `IN_WINDOW_IDP_HOSTS`). Verified so far: Google + Microsoft blocked (by policy, not spike); GitHub assumed lenient. **Before GA, run the spike**: GitHub sign-in, consumer-Microsoft, a sample of integration connects (Notion, Slack, Linear, Atlassian, Box, Dropbox), SSO, and Turnstile-on-signup in a packaged build, then update the lists and this section.
+
+## Web-app coupling contract (audit on web-app changes)
+
+A thin shell over a hosted web app unavoidably knows a few of the web app's conventions. They are listed here so a web-app change that would break the desktop app is auditable in one place. Each is a documented, deliberate coupling — not accidental. The robust long-term de-coupling for all of them is a two-way preload bridge (see "Desktop-only features" below): the web app signals intent (`signalLogout()`, `markAuthSurface()`) instead of the shell inferring it.
+
+| Shell code | Depends on | Breaks if the web app… | Failure mode | Mitigation today |
+|---|---|---|---|---|
+| `session-lifecycle.ts` `isLogoutNavigation` | `/login?fromLogout=true` on sign-out | renames the param/route | fast-path teardown misses | **Cookie backstop** (session-cookie deletion + probe) still tears down — no residue |
+| `session-lifecycle.ts` `isSessionCookieName` | better-auth cookie ends `session_token` | changes the cookie name/prefix | backstop misses (fast path still works for settings sign-out) | better-auth library contract; stable. Revisit on better-auth major |
+| `navigation.ts` `AUTH_SURFACE_PREFIXES` | auth routes `/login /signup /sso /reset-password /verify` | adds/renames an auth route | SSO from the new route gets the connect dialog instead of login | Update the list; unknown hosts from non-auth pages still default sensibly |
+| `navigation.ts` IdP host lists | provider OAuth hostnames + embedded-UA policy | a provider changes hostnames/policy | that provider's sign-in/connect misroutes until a new release | Ships with the app; the U5 spike + upgrade checklist re-verify. Server-delivered config is the future fix |
+| `session-lifecycle.ts` / `handoff.ts` `/workspace` default | `/workspace` is the post-login home | changes the default landing route | post-login/last-route restore lands on a redirect/404 | Web app's own routing usually redirects; low blast radius |
+| `navigation.ts` `mcp-oauth-*` frame name | `hooks/queries/mcp.ts` opens `mcp-oauth-${id}` | renames the popup frame | MCP popup treated as generic → opener lost, flow hangs | String contract; add a shared constant if it churns |
+| `window.ts` theme probe | `document.documentElement.classList.contains('dark')` (next-themes `attribute='class'`) | drops the `dark` class convention | pre-paint background may flash once | Cosmetic only; self-corrects on next load |
+| `handoff.ts` redeem | `POST /api/auth/one-time-token/verify` sets the cookie | better-auth changes the endpoint | handoff sign-in fails | better-auth built-in endpoint; pinned by the `better-auth` version |
+| `apps/sim/lib/auth/desktop-handoff.ts` mint | better-auth stores one-time tokens as `verification` rows keyed `one-time-token:`, unhashed (`storeToken: 'plain'` default) | better-auth renames the namespace or hashes by default | every redeem fails with `Invalid token` | Single constant in one module; the covering test asserts the identifier shape. Revisit on better-auth major |
+
+Overall this is **within normal thin-wrapper coupling** — every item is either backstopped (sign-out), cosmetic (theme), or a stable library/route contract. The only one that genuinely can't self-heal without a release is the IdP host list, which is inherent to the "pin Chromium, ship a binary" model and is managed by the upgrade program.
+
+## Packaging & release
+
+Local unsigned build: `bun run package:dir` (app in `release/mac-universal/`). Signed: `bun run package:mac` with `CSC_LINK`/`CSC_KEY_PASSWORD` exported.
+
+Pre-release share (no Developer ID yet): `SIM_DESKTOP_DEFAULT_ORIGIN=https://www.dev.sim.ai bun run package:share` builds a DMG whose fresh installs default to that origin (baked at build time; official builds leave it unset → prod) and skips per-file signature timestamps. Recipients must clear quarantine once: `xattr -cr /Applications/Sim.app`.
+
+The build also derives the app icon from `SIM_DESKTOP_DEFAULT_ORIGIN`. Every channel uses the exact production icon with its white background and black `sim` mark. Non-production channels add a thin outline using existing platform colors: dev uses orange, staging uses Loop blue, and localhost uses Workflow violet. The macOS menu-bar icon also carries a compact `D`, `S`, or `L` subscript for those environments; production remains unmarked. Packaged `.icns` files live in `build/`; `scripts/build.ts` copies the selected variant to the ignored `build/generated-icon.icns` path consumed by electron-builder. Matching 512px PNGs in `static/` provide the Dock icon for unpackaged runs.
+
+CI (`.github/workflows/desktop-release.yml`, wired into `ci.yml`):
+- Runs only after `create-release` on a `vX.Y.Z:` commit to main — **never before**: `scripts/create-single-release.ts` skips creation if the tag exists, so a desktop job publishing first would eat the changelog. The job builds `--publish never` and uploads assets with `gh release upload --clobber` (idempotent re-runs).
+- **Secrets gate**: `check-desktop-signing` in `ci.yml` probes the six Apple secrets and skips the desktop job with a warning until they exist — releases never fail on a missing Apple account, and the first release after the secrets land ships desktop artifacts automatically. Manual/one-off builds: Actions → "Desktop Release (macOS)" → Run workflow with a `vX.Y.Z` version (`publish: false` uploads artifacts to the run instead of the release).
+- The product semver is **injected** from the release tag into `apps/desktop/package.json` at build time (repo package versions are placeholders). A mismatch guard fails the build.
+- Fuses are flipped at package time (`electronFuses` in `electron-builder.yml`): runAsNode off, NODE_OPTIONS off, inspect args off, ASAR-only + integrity validation, cookie encryption on, `strictlyRequireAllFuses` so new fuses fail loudly on Electron bumps.
+- **Cookie-encryption go/no-go**: on every Electron bump, verify a packaged build keeps its session across relaunch (there are historical cookie-persistence bugs with the `EnableCookieEncryption` fuse). If it reproduces, set `enableCookieEncryption: false` and record it here.
+
+Required repo secrets (owner: whoever holds the Apple Developer account; calendar the expiries — an expired cert/API key breaks every release):
+
+| Secret | Contents |
+|---|---|
+| `CSC_LINK` | base64 of the Developer ID Application `.p12` |
+| `CSC_KEY_PASSWORD` | `.p12` password |
+| `APPLE_API_KEY_P8` | App Store Connect API key file contents (`.p8`) |
+| `APPLE_API_KEY_ID` | API key ID |
+| `APPLE_API_ISSUER` | API issuer ID |
+| `APPLE_TEAM_ID` | Developer team ID |
+
+## Desktop-only features (how to add them cleanly)
+
+Yes — the architecture has a single, clean seam for native features, and nothing about "the renderer is the hosted web app" gets in the way. The rules:
+
+1. **One bridge.** The preload (`src/preload/index.ts`) exposes `window.simDesktop` via `contextBridge` on the main window. This is the *only* channel between web content and native capability. It exposes narrow, typed methods — never raw `ipcRenderer` (Electron security checklist item 20).
+2. **Feature-detect, never assume.** The same web app is served to browsers and to the desktop from one origin, so a desktop feature is progressive enhancement: `if (window.simDesktop) { … }`. In a browser `window.simDesktop` is `undefined` and the feature is simply absent. (`isHosted` already tags these sessions for analytics.)
+3. **Gate in main.** Every channel is validated in `src/main/ipc.ts` by sender frame — app-origin for capability calls, bundled `file:` pages for shell-control calls (checklist item 17). A new native feature adds one gated channel there.
+4. **Single-source the contract.** `apps/sim` cannot import from `apps/desktop` (monorepo rule: `apps/* → packages/*` only). The bridge interface lives in the shared types-only `packages/desktop-bridge` package, which both the preload and web app consume.
+
+Concrete example — a "Reveal in Finder" button:
+
+```ts
+// packages/desktop-bridge/index.ts (shared contract)
+export interface SimDesktopApi { showItemInFolder(path: string): void /* …existing methods… */ }
+
+// apps/desktop/src/preload/index.ts (implement)
+showItemInFolder: (path: string) => ipcRenderer.send('desktop:show-item', path),
+
+// apps/desktop/src/main/ipc.ts (gate)
+ipcMain.on('desktop:show-item', (event, path) => {
+ if (!isAppOriginSender(event, deps.appOrigin()) || typeof path !== 'string') return
+ shell.showItemInFolder(path)
+})
+
+// apps/sim (consume — progressive enhancement)
+const desktop = useDesktop()
+{desktop && desktop.showItemInFolder(file.path)}>Reveal in Finder }
+```
+
+Good fits for the bridge: OS notifications + dock badge on workflow completion, global shortcuts, "reveal in Finder", tray, secure OS-keychain storage. Anything that touches the server/DB still goes through normal APIs — the bridge is only for **native** capability. This same bridge is also the robust way to retire the web-app couplings in the table above: have the web app *tell* the shell (`signalLogout()`, `markAuthSurface()`) instead of the shell inferring from URLs.
+
+### Local filesystem access
+
+Copilot can inspect user-selected local directories through the ordinary VFS tools. Granted folders appear beneath the top-level `user-local/` namespace, and `glob`, `grep`, and `read` are routed to Electron only when their path/pattern is explicitly scoped there. This capability is:
+
+- **Explicit and read-only:** only a user click may open the native folder picker or revoke a grant; model tool calls cannot do either. There are no write/delete/execute/upload operations.
+- **Remembered securely:** grants are encrypted in Electron's private app data with OS-backed `safeStorage` and restored with the same opaque URI after a normal app restart. (A security-scoped bookmark is stored alongside each grant, but it is a no-op in the current Developer ID build — only the macOS App Sandbox consumes it — and is kept purely for forward-compatibility should a sandboxed/MAS build ever ship.) There is no plaintext fallback: when secure storage is unavailable, the returned mount has `remembered: false` and lasts only for that app session.
+- **Revocable:** Desktop settings removes one grant. All grants are removed on explicit sign-out or server-origin change so another Sim account or server cannot inherit them. Normal app quit only releases active OS handles and keeps the encrypted grants.
+- **Opaque:** the model sees canonical paths such as `user-local/Project--/README.md`, never host paths or internal `localfs://` URIs. Electron resolves every request, checks lexical and realpath containment, and refuses symlink escapes.
+- **Desktop-only:** the web app advertises `desktopCapabilities.localFilesystem` only when the Electron bridge is present. Mothership adds the `user-local/` prompt surface and per-call client routing only for that capability, including delegated and resumed work.
+- **Bound to a live Copilot call:** before a native read/search or browser action, Electron asks the authenticated Sim origin for the pending tool-call record. Local requests must exactly match its persisted operation, path, and options; browser actions run with the persisted arguments rather than renderer-supplied ones. Completed, failed, and aborted runs are rejected.
+- **Abort-aware and bounded:** stop/cancel propagates to active native scans and reads. File size, aggregate grep bytes, line, result, traversal-depth, and scan-count limits remain enforced in Electron, and unsafe regular expressions are rejected before execution.
+
+Raw local file bytes are never exposed through the preload bridge and cannot be staged or uploaded by a model. Bounded text read/search results are returned to the active Copilot request; a user must use the normal attachment UI when they want the file itself to leave the device.
+
+## Auto-update, channels, rollout, rollback
+
+- `electron-updater` reads the GitHub Releases feed (`publish` is pinned to `simstudioai/sim`); deltas via `.zip.blockmap`. Install is prompt-based (Restart Now / Later; Later installs on quit) — never forced mid-session.
+- Channels: stable builds (`X.Y.Z`) follow `latest`; `-beta.N` builds follow `beta` (never attach a beta `latest-mac.yml` to a stable tag).
+- Staged rollout: after publishing, edit `stagingPercentage: 10` into the release's `latest-mac.yml`, then raise as crash metrics stay clean.
+- Rollback: a pulled release must be superseded by a **higher** version — users on the broken build will not reinstall an equal one. (A blocked-versions kill-switch was removed as unwired dead code; reintroduce it in `updater.ts` if a remote config source ever exists to feed it.)
+- Ship the DMG and tell users to install to `/Applications` — App Translocation breaks Squirrel.Mac updates from quarantined paths.
+
+## Self-hosting
+
+- Point Settings… (`Cmd+,`) at your instance. HTTPS required (HTTP for localhost only); each origin gets an isolated cookie partition.
+- Deploy the `/desktop/auth` page (ships with `apps/sim`) and include your desktop users' origin in `TRUSTED_ORIGINS` if it differs from `NEXT_PUBLIC_APP_URL`.
+- TLS must be **system-trusted** — the shell hard-rejects certificate errors (no in-app bypass). Install private CA roots in the macOS keychain.
+- `DISABLE_AUTH` instances: the web app serves an anonymous session; the shell needs no special handling, but understand that anyone with the app and your origin has full access.
+- Forks: repoint `publish.owner/repo` in `electron-builder.yml` or strip the updater.
+
+## Known caveats
+
+- Microphone and camera are denied by design (the permission matrix grants only sanitized clipboard writes to the app origin).
+- Default Electron ships H.264/AAC/MP3 — do not swap in the codec-free ffmpeg build.
+- Third-party web analytics (GTM/GA) are blocked at the network layer by default (`blockThirdPartyAnalytics`); first-party PostHog `/ingest` is untouched.
+- `Cmd+F` find-in-page overlay is not implemented (Monaco and tables ship their own finds); revisit if users ask.
+- Sign-in uses only the `127.0.0.1` loopback callback, which needs no OS registration — so it completes identically under `bun run dev` (unpackaged) and in a packaged build. There is no custom URL scheme.
+
+## Electron upgrades
+
+Cadence: Electron ships a major every ~8 weeks and supports the latest 3 — budget a bump every ~4–6 months and adopt security patches within ~2 weeks. Follow `docs/electron-upgrade-checklist.md`; the `desktop-e2e.yml` canary leg (electron@latest) is the early-warning signal.
diff --git a/apps/desktop/build/entitlements.mac.plist b/apps/desktop/build/entitlements.mac.plist
new file mode 100644
index 00000000000..446fe171da8
--- /dev/null
+++ b/apps/desktop/build/entitlements.mac.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+
+
diff --git a/apps/desktop/build/icon-dev.icns b/apps/desktop/build/icon-dev.icns
new file mode 100644
index 00000000000..8a602e51b27
Binary files /dev/null and b/apps/desktop/build/icon-dev.icns differ
diff --git a/apps/desktop/build/icon-local.icns b/apps/desktop/build/icon-local.icns
new file mode 100644
index 00000000000..83593f66398
Binary files /dev/null and b/apps/desktop/build/icon-local.icns differ
diff --git a/apps/desktop/build/icon-staging.icns b/apps/desktop/build/icon-staging.icns
new file mode 100644
index 00000000000..b3c8d53d194
Binary files /dev/null and b/apps/desktop/build/icon-staging.icns differ
diff --git a/apps/desktop/build/icon.icns b/apps/desktop/build/icon.icns
new file mode 100644
index 00000000000..89021815218
Binary files /dev/null and b/apps/desktop/build/icon.icns differ
diff --git a/apps/desktop/docs/electron-upgrade-checklist.md b/apps/desktop/docs/electron-upgrade-checklist.md
new file mode 100644
index 00000000000..8974779d6b1
--- /dev/null
+++ b/apps/desktop/docs/electron-upgrade-checklist.md
@@ -0,0 +1,17 @@
+# Electron upgrade checklist
+
+The rendering-parity guarantee (identical to Chrome of the pinned version) is only durable if upgrades are routine. Run this list for every Electron major bump; the abridged list (steps 1, 2, 6, 8) for patch/security releases.
+
+1. **Read the release notes.** Electron breaking-changes page for the target major, plus its Chromium/Node versions. Note anything touching: session/cookies, permissions, `setWindowOpenHandler`, `will-navigate`/`will-redirect`, preload/sandbox, `net`/loopback, fuses.
+2. **Bump the pin** in `apps/desktop/package.json` (exact version), `bun install`, `bun run type-check && bun run test`.
+3. **Fuses:** the build sets `strictlyRequireAllFuses` — if `electron-builder` fails on a new fuse, decide its state explicitly in `electron-builder.yml` rather than loosening the strict flag.
+4. **Cookie-encryption go/no-go:** packaged build → sign in → quit → relaunch → still signed in. If the session is lost, flip `enableCookieEncryption: false`, file it in the README, and retest.
+5. **Manual spot-checks (packaged build):**
+ - Google sign-in via the system-browser handoff (127.0.0.1 loopback callback → token redeem).
+ - GitHub sign-in in-window; one integration connect (e.g. Notion) in-window; one Google-family connect via the browser dialog.
+ - MCP OAuth popup completes and posts back to the opener.
+ - Workflow canvas (WebGL/ReactFlow), Monaco editing, a table export download.
+ - Offline page appears with networking off; Retry recovers.
+6. **E2E:** `bun run test:e2e` green locally on the new pin; `desktop-e2e.yml` green in CI (the `latest` canary leg should already have hinted at surprises).
+7. **Signing/notarization smoke:** run `desktop-release.yml` via `workflow_dispatch` with `publish: false` against a test tag; `spctl`/`stapler` steps must pass.
+8. **Ship** behind a staged rollout (10% `stagingPercentage`) and watch `update_error` / `renderer_gone` rates in the event logs before raising.
diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts
new file mode 100644
index 00000000000..86d3716430e
--- /dev/null
+++ b/apps/desktop/e2e/smoke.spec.ts
@@ -0,0 +1,114 @@
+import { mkdtempSync } from 'node:fs'
+import type { Server } from 'node:http'
+import { createServer } from 'node:http'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import type { ElectronApplication } from '@playwright/test'
+import { _electron as electron, expect, test } from '@playwright/test'
+
+const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
+
+const PAGES: Record = {
+ '/workspace': `Sim Fixture
+ fixture-app
+ internal
+ external
+ `,
+ '/workspace/two': 'second-route ',
+ '/login': 'fixture-login ',
+}
+
+function startFixtureServer(): Promise<{ server: Server; origin: string }> {
+ return new Promise((resolvePromise) => {
+ const server = createServer((request, response) => {
+ const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname
+ const body = PAGES[path]
+ if (!body) {
+ response.writeHead(404, { 'Content-Type': 'text/html' }).end('not found ')
+ return
+ }
+ response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }).end(body)
+ })
+ server.listen(0, '127.0.0.1', () => {
+ const address = server.address()
+ const port = typeof address === 'object' && address ? address.port : 0
+ resolvePromise({ server, origin: `http://127.0.0.1:${port}` })
+ })
+ })
+}
+
+async function launchApp(origin: string): Promise {
+ return electron.launch({
+ args: ['.'],
+ cwd: DESKTOP_DIR,
+ env: {
+ ...process.env,
+ SIM_DESKTOP_ORIGIN: origin,
+ SIM_DESKTOP_USER_DATA: mkdtempSync(join(tmpdir(), 'sim-desktop-e2e-')),
+ },
+ })
+}
+
+test.describe('desktop shell smoke', () => {
+ let server: Server
+ let origin: string
+ let app: ElectronApplication
+
+ test.beforeAll(async () => {
+ ;({ server, origin } = await startFixtureServer())
+ })
+
+ test.afterAll(async () => {
+ server.close()
+ })
+
+ test.afterEach(async () => {
+ await app?.close().catch(() => {})
+ })
+
+ test('loads the configured origin top-level', async () => {
+ app = await launchApp(origin)
+ const window = await app.firstWindow()
+ await expect(window.locator('#app')).toHaveText('fixture-app')
+ expect(window.url()).toBe(`${origin}/workspace`)
+ })
+
+ test('internal window.open creates an independent full Sim window', async () => {
+ app = await launchApp(origin)
+ const window = await app.firstWindow()
+ const newWindowPromise = app.waitForEvent('window')
+ await window.locator('#internal-blank').click()
+ const secondWindow = await newWindowPromise
+ await expect(secondWindow.locator('#two')).toHaveText('second-route')
+ await expect(window.locator('#app')).toHaveText('fixture-app')
+ expect(app.windows()).toHaveLength(2)
+ })
+
+ test('external window.open goes to the system browser, never a new app window', async () => {
+ app = await launchApp(origin)
+ const window = await app.firstWindow()
+ await app.evaluate(({ shell }) => {
+ const opened: string[] = []
+ ;(globalThis as { __openedExternal?: string[] }).__openedExternal = opened
+ shell.openExternal = async (url: string) => {
+ opened.push(url)
+ }
+ })
+ await window.locator('#external-blank').click()
+ await expect
+ .poll(() =>
+ app.evaluate(() => (globalThis as { __openedExternal?: string[] }).__openedExternal)
+ )
+ .toEqual(['https://docs.sim.ai/x'])
+ expect(app.windows()).toHaveLength(1)
+ await expect(window.locator('#app')).toHaveText('fixture-app')
+ })
+
+ test('unreachable origin shows the bundled offline page', async () => {
+ app = await launchApp('http://127.0.0.1:1')
+ const window = await app.firstWindow()
+ await window.waitForSelector('#retry', { timeout: 30_000 })
+ expect(window.url().startsWith('file:')).toBe(true)
+ })
+})
diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml
new file mode 100644
index 00000000000..595ce865a05
--- /dev/null
+++ b/apps/desktop/electron-builder.yml
@@ -0,0 +1,69 @@
+appId: ai.sim.desktop
+productName: Sim
+copyright: Copyright © 2026 Sim
+
+directories:
+ output: release
+ buildResources: build
+
+files:
+ - dist/**
+ - static/**
+ - package.json
+
+asar: true
+
+# Native modules cannot be dlopen'd from inside an asar.
+# `scripts/ensure-pty-prebuilds.ts` guarantees both arch prebuilds are present
+# before packaging; see mac.x64ArchFiles for how the universal merge treats them.
+asarUnpack:
+ - "**/node_modules/@lydell/node-pty-*/prebuilds/**"
+
+# Space-free regardless of productName ("Sim Dev" etc.): GitHub rewrites
+# asset names containing spaces, which would desync the electron-updater
+# manifest from the uploaded files.
+artifactName: "Sim-${version}-${arch}.${ext}"
+
+electronFuses:
+ runAsNode: false
+ enableCookieEncryption: true
+ enableNodeOptionsEnvironmentVariable: false
+ enableNodeCliInspectArguments: false
+ enableEmbeddedAsarIntegrityValidation: true
+ onlyLoadAppFromAsar: true
+
+mac:
+ category: public.app-category.developer-tools
+ target:
+ - target: dmg
+ arch: [universal]
+ - target: zip
+ arch: [universal]
+ icon: build/generated-icon.icns
+ # node-pty keeps each architecture's binary at its own path, so both halves of
+ # the universal build carry an identical copy of both. @electron/universal
+ # refuses single-arch Mach-O files it wasn't told about, so name them here —
+ # it then takes one copy instead of trying to lipo two same-arch binaries.
+ x64ArchFiles: "**/node-pty-darwin-*/prebuilds/**"
+ # Both halves bundle byte-identical JS, so the merge is pure overhead and
+ # @electron/universal reuses a single app.asar anyway. Skipping it also avoids
+ # @electron/asar 3.x, which calls minimatch through a default export the
+ # workspace-wide minimatch@10 pin no longer provides.
+ mergeASARs: false
+ hardenedRuntime: true
+ gatekeeperAssess: false
+ entitlements: build/entitlements.mac.plist
+ entitlementsInherit: build/entitlements.mac.plist
+ notarize: true
+
+dmg:
+ sign: false
+
+# node-pty ships pure N-API prebuilds, which are ABI-stable across Node and
+# Electron versions, so there is nothing to rebuild against Electron's ABI.
+npmRebuild: false
+
+publish:
+ provider: github
+ owner: simstudioai
+ repo: sim
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
new file mode 100644
index 00000000000..0ac8f76658f
--- /dev/null
+++ b/apps/desktop/package.json
@@ -0,0 +1,58 @@
+{
+ "name": "@sim/desktop",
+ "version": "0.0.0",
+ "private": true,
+ "license": "Apache-2.0",
+ "description": "Sim desktop app for macOS — Electron shell around the hosted web app",
+ "author": "Sim ",
+ "homepage": "https://sim.ai",
+ "type": "module",
+ "main": "dist/main.cjs",
+ "engines": {
+ "bun": ">=1.2.13",
+ "node": ">=20.0.0"
+ },
+ "scripts": {
+ "build": "bun run scripts/ensure-pty-prebuilds.ts && bun run scripts/build.ts",
+ "dev": "bun run scripts/build.ts && electron .",
+ "start": "electron .",
+ "package:dir": "bun run build && electron-builder --mac dir --publish never",
+ "package:mac": "bun run build && electron-builder --mac --publish never",
+ "package:share": "bun run build && electron-builder --mac --publish never -c.mac.timestamp=none",
+ "install:local": "bun run scripts/install-local.ts",
+ "type-check": "tsc --noEmit",
+ "lint": "biome check --write --unsafe .",
+ "lint:check": "biome check .",
+ "format": "biome format --write .",
+ "format:check": "biome format .",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:e2e": "playwright test"
+ },
+ "dependencies": {
+ "@lydell/node-pty": "1.2.0-beta.12",
+ "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
+ "@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
+ "@sim/browser-protocol": "workspace:*",
+ "@sim/desktop-bridge": "workspace:*",
+ "@sim/logger": "workspace:*",
+ "@sim/security": "workspace:*",
+ "@sim/terminal-protocol": "workspace:*",
+ "@sim/utils": "workspace:*",
+ "@xterm/headless": "6.0.0",
+ "electron-updater": "6.8.9",
+ "micromatch": "4.0.8",
+ "safe-regex2": "5.1.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "1.61.1",
+ "@sim/tsconfig": "workspace:*",
+ "@types/micromatch": "4.0.10",
+ "@types/node": "24.2.1",
+ "electron": "43.1.1",
+ "electron-builder": "26.15.3",
+ "esbuild": "0.28.1",
+ "typescript": "^7.0.2",
+ "vitest": "^4.1.0"
+ }
+}
diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts
new file mode 100644
index 00000000000..142b184e05d
--- /dev/null
+++ b/apps/desktop/playwright.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from '@playwright/test'
+
+export default defineConfig({
+ testDir: './e2e',
+ timeout: 90_000,
+ workers: 1,
+ retries: process.env.CI ? 1 : 0,
+ reporter: process.env.CI ? [['github'], ['list']] : [['list']],
+})
diff --git a/apps/desktop/scripts/build.ts b/apps/desktop/scripts/build.ts
new file mode 100644
index 00000000000..18880124faa
--- /dev/null
+++ b/apps/desktop/scripts/build.ts
@@ -0,0 +1,95 @@
+import { copyFileSync } from 'node:fs'
+import { build } from 'esbuild'
+
+const watch = process.argv.includes('--watch')
+
+// Optional build-time default server origin (pre-release shares pointed at a
+// non-prod environment): SIM_DESKTOP_DEFAULT_ORIGIN=https://www.dev.sim.ai.
+// Baked into the bundle so it applies to fresh installs with no settings —
+// unlike the SIM_DESKTOP_ORIGIN env var, which only affects terminal-launched
+// processes. Official builds leave it unset (default https://sim.ai).
+const bakedDefaultOrigin = process.env.SIM_DESKTOP_DEFAULT_ORIGIN ?? ''
+if (
+ bakedDefaultOrigin &&
+ !/^https:\/\/[^\s/]+$/.test(bakedDefaultOrigin) &&
+ !/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(bakedDefaultOrigin)
+) {
+ console.error(
+ `SIM_DESKTOP_DEFAULT_ORIGIN must be a bare https origin or http://localhost (got "${bakedDefaultOrigin}")`
+ )
+ process.exit(1)
+}
+if (bakedDefaultOrigin) {
+ console.log(`• Baking default server origin: ${bakedDefaultOrigin}`)
+}
+
+/** Selects the branded app icon that matches the build's baked environment. */
+function iconForOrigin(origin: string): string {
+ if (!origin) return 'build/icon.icns'
+ const host = new URL(origin).hostname.toLowerCase()
+ if (host === 'localhost' || host === '127.0.0.1') return 'build/icon-local.icns'
+ if (host === 'dev.sim.ai' || host.endsWith('.dev.sim.ai')) return 'build/icon-dev.icns'
+ if (host === 'staging.sim.ai' || host.endsWith('.staging.sim.ai')) {
+ return 'build/icon-staging.icns'
+ }
+ return 'build/icon.icns'
+}
+
+const appIcon = iconForOrigin(bakedDefaultOrigin)
+copyFileSync(appIcon, 'build/generated-icon.icns')
+console.log(`• Selecting desktop icon: ${appIcon}`)
+
+const common = {
+ bundle: true,
+ platform: 'node' as const,
+ format: 'cjs' as const,
+ target: 'node22',
+ sourcemap: true,
+ // node-pty resolves a prebuilt .node binary at runtime, so it must stay
+ // external and be loaded from node_modules rather than inlined here.
+ external: ['electron', '@lydell/node-pty'],
+ tsconfig: 'tsconfig.json',
+ logLevel: 'info' as const,
+ define: {
+ 'process.env.SIM_DESKTOP_DEFAULT_ORIGIN': JSON.stringify(bakedDefaultOrigin),
+ },
+}
+
+async function run(): Promise {
+ if (watch) {
+ const { context } = await import('esbuild')
+ const mainCtx = await context({
+ ...common,
+ entryPoints: ['src/main/index.ts'],
+ outfile: 'dist/main.cjs',
+ })
+ const preloadCtx = await context({
+ ...common,
+ entryPoints: ['src/preload/index.ts'],
+ outfile: 'dist/preload.cjs',
+ })
+ // Separate from the main-window preload: this one is injected into
+ // untrusted pages in the built-in browser and must stay minimal.
+ const browserPreloadCtx = await context({
+ ...common,
+ entryPoints: ['src/preload/browser/index.ts'],
+ outfile: 'dist/browser-preload.cjs',
+ })
+ await Promise.all([mainCtx.watch(), preloadCtx.watch(), browserPreloadCtx.watch()])
+ return
+ }
+ await Promise.all([
+ build({ ...common, entryPoints: ['src/main/index.ts'], outfile: 'dist/main.cjs' }),
+ build({ ...common, entryPoints: ['src/preload/index.ts'], outfile: 'dist/preload.cjs' }),
+ build({
+ ...common,
+ entryPoints: ['src/preload/browser/index.ts'],
+ outfile: 'dist/browser-preload.cjs',
+ }),
+ ])
+}
+
+run().catch((error) => {
+ console.error(error)
+ process.exit(1)
+})
diff --git a/apps/desktop/scripts/ensure-pty-prebuilds.ts b/apps/desktop/scripts/ensure-pty-prebuilds.ts
new file mode 100644
index 00000000000..a41f94cfae8
--- /dev/null
+++ b/apps/desktop/scripts/ensure-pty-prebuilds.ts
@@ -0,0 +1,94 @@
+/**
+ * Fetches the node-pty prebuilt binaries for every architecture the macOS
+ * universal build ships.
+ *
+ * `@lydell/node-pty` selects its native binary at runtime from a per-arch
+ * package (`@lydell/node-pty-darwin-arm64`, `-darwin-x64`), each declaring a
+ * matching `cpu` field. Package managers honour that field, so installing on
+ * an arm64 Mac leaves the x64 binary absent and the x64 half of the universal
+ * app ships without a working PTY. Fetching the tarball directly is the only
+ * way to get both without lying about the host architecture.
+ *
+ * Both binaries live at distinct paths, so `@electron/universal` never has to
+ * lipo them together — it sees byte-identical trees in both halves and keeps
+ * one. That is why this build needs no `x64ArchFiles` rule.
+ */
+import { execFileSync } from 'node:child_process'
+import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const REQUIRED_ARCHES = ['darwin-arm64', 'darwin-x64'] as const
+
+const desktopDir = dirname(dirname(fileURLToPath(import.meta.url)))
+const workspaceRoot = dirname(dirname(desktopDir))
+
+interface DesktopPackageJson {
+ dependencies?: Record
+}
+
+async function pinnedVersion(): Promise {
+ const pkg = (await import(join(desktopDir, 'package.json'), {
+ with: { type: 'json' },
+ })) as { default: DesktopPackageJson }
+ const version = pkg.default.dependencies?.['@lydell/node-pty']
+ if (!version) {
+ throw new Error('@lydell/node-pty is not a dependency of @sim/desktop')
+ }
+ // Exact pin only: a range would let the two halves of a universal build
+ // resolve different binaries.
+ if (!/^\d+\.\d+\.\d+/.test(version)) {
+ throw new Error(`@lydell/node-pty must be pinned to an exact version (got "${version}")`)
+ }
+ return version
+}
+
+/** Where the workspace hoists installed packages. */
+function packageDir(arch: string): string {
+ return join(workspaceRoot, 'node_modules', '@lydell', `node-pty-${arch}`)
+}
+
+async function fetchPrebuild(arch: string, version: string): Promise {
+ const name = `node-pty-${arch}`
+ const url = `https://registry.npmjs.org/@lydell/${name}/-/${name}-${version}.tgz`
+ const response = await fetch(url)
+ if (!response.ok) {
+ throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`)
+ }
+
+ const staging = mkdtempSync(join(tmpdir(), 'sim-pty-prebuild-'))
+ try {
+ const tarball = join(staging, 'package.tgz')
+ writeFileSync(tarball, Buffer.from(await response.arrayBuffer()))
+ execFileSync('tar', ['-xzf', tarball, '-C', staging], { stdio: 'pipe' })
+
+ const target = packageDir(arch)
+ mkdirSync(dirname(target), { recursive: true })
+ rmSync(target, { recursive: true, force: true })
+ renameSync(join(staging, 'package'), target)
+ } finally {
+ rmSync(staging, { recursive: true, force: true })
+ }
+}
+
+async function run(): Promise {
+ const version = await pinnedVersion()
+ for (const arch of REQUIRED_ARCHES) {
+ const dir = packageDir(arch)
+ if (existsSync(join(dir, 'prebuilds', arch, 'pty.node'))) {
+ console.log(`• node-pty prebuild present: ${arch}`)
+ continue
+ }
+ console.log(`• Fetching node-pty prebuild: ${arch}@${version}`)
+ await fetchPrebuild(arch, version)
+ if (!existsSync(join(dir, 'prebuilds', arch, 'pty.node'))) {
+ throw new Error(`Downloaded @lydell/node-pty-${arch} but pty.node is missing`)
+ }
+ }
+}
+
+run().catch((error) => {
+ console.error(error)
+ process.exit(1)
+})
diff --git a/apps/desktop/scripts/install-local.ts b/apps/desktop/scripts/install-local.ts
new file mode 100644
index 00000000000..efd27652a4a
--- /dev/null
+++ b/apps/desktop/scripts/install-local.ts
@@ -0,0 +1,181 @@
+/**
+ * Local dev-install: packages the app from the current checkout and installs
+ * it into /Applications — the "run it like a real Mac app" loop before
+ * official distribution. Signing/notarization are not involved; the locally
+ * built app never carries a quarantine flag, so Gatekeeper doesn't mind.
+ *
+ * bun run install:local # plain Sim.app (origin unchanged)
+ * bun run install:local --local # Sim Local.app → http://localhost:3000
+ * bun run install:local --dev # Sim Dev.app → https://www.dev.sim.ai
+ * bun run install:local --staging # Sim Staging.app → https://www.staging.sim.ai
+ * bun run install:local --prod # Sim.app → https://www.sim.ai
+ * bun run install:local --no-open # build → install only
+ *
+ * Each environment is a separate app (name, bundle id, install path, userData,
+ * single-instance lock, update feed), so all four can be installed and run
+ * side by side. The flag both bakes the default server origin into the build
+ * and writes the app's persisted settings (same as changing the server URL in
+ * Settings). A running installed copy of the SAME channel is quit before
+ * replacing; other channels keep running.
+ */
+import { execFileSync, spawnSync } from 'node:child_process'
+import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { dirname, join } from 'node:path'
+
+interface ChannelIdentity {
+ /** Display + bundle name; also the userData directory name. */
+ name: string
+ appId: string
+ /** Baked default origin + persisted settings origin. Unset = leave as-is. */
+ origin?: string
+}
+
+/** Must stay in sync with APP_NAME_FOR_CHANNEL in src/main/config.ts. */
+const CHANNEL_FLAGS: Record = {
+ '--local': { name: 'Sim Local', appId: 'ai.sim.desktop.local', origin: 'http://localhost:3000' },
+ '--dev': { name: 'Sim Dev', appId: 'ai.sim.desktop.dev', origin: 'https://www.dev.sim.ai' },
+ '--staging': {
+ name: 'Sim Staging',
+ appId: 'ai.sim.desktop.staging',
+ origin: 'https://www.staging.sim.ai',
+ },
+ // Bare sim.ai, matching official prod builds (config.ts) — www.sim.ai
+ // would land in a different cookie partition than a real install.
+ '--prod': { name: 'Sim', appId: 'ai.sim.desktop', origin: 'https://sim.ai' },
+}
+
+const DEFAULT_IDENTITY: ChannelIdentity = { name: 'Sim', appId: 'ai.sim.desktop' }
+
+const channelFlags = process.argv.filter((arg) => arg in CHANNEL_FLAGS)
+if (channelFlags.length > 1) {
+ console.error(`✖ Pass at most one of ${Object.keys(CHANNEL_FLAGS).join(', ')}`)
+ process.exit(1)
+}
+const identity = channelFlags.length === 1 ? CHANNEL_FLAGS[channelFlags[0]] : DEFAULT_IDENTITY
+
+const APP_NAME = `${identity.name}.app`
+const INSTALL_PATH = `/Applications/${APP_NAME}`
+const RELEASE_DIRS = ['release/mac-universal', 'release/mac-arm64', 'release/mac']
+/** Matches the app's userData path (app.setName(...) in src/main/index.ts). */
+const SETTINGS_PATH = join(homedir(), `Library/Application Support/${identity.name}/settings.json`)
+
+function run(command: string, args: string[], env?: Record): void {
+ const result = spawnSync(command, args, {
+ stdio: 'inherit',
+ env: env ? { ...process.env, ...env } : process.env,
+ })
+ if (result.status !== 0) {
+ console.error(`\n✖ ${command} ${args.join(' ')} failed`)
+ process.exit(result.status ?? 1)
+ }
+}
+
+function localBuildStamp(): string {
+ try {
+ const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD']).toString().trim()
+ const dirty = execFileSync('git', ['status', '--porcelain']).toString().trim() ? '+dirty' : ''
+ return `${sha}${dirty}`
+ } catch {
+ return 'unknown'
+ }
+}
+
+function quitInstalledApp(): void {
+ // Match only processes launched from this channel's installed bundle —
+ // never the dev instance running out of node_modules/electron, and never
+ // another channel's install.
+ const running = spawnSync('pgrep', ['-f', `${INSTALL_PATH}/Contents/MacOS/`]).status === 0
+ if (!running) return
+ console.log('• Quitting the running installed app…')
+ spawnSync('osascript', ['-e', `tell application "${identity.name}" to quit`])
+ // Poll briefly; fall back to a hard kill so the install never half-replaces
+ // a live bundle.
+ for (let i = 0; i < 20; i++) {
+ if (spawnSync('pgrep', ['-f', `${INSTALL_PATH}/Contents/MacOS/`]).status !== 0) return
+ execFileSync('sleep', ['0.25'])
+ }
+ spawnSync('pkill', ['-f', `${INSTALL_PATH}/Contents/MacOS/`])
+}
+
+/**
+ * Points the installed app at an environment by writing its persisted
+ * settings — the same field the in-app Settings window edits. Merges into the
+ * existing file so window bounds and other settings survive.
+ */
+function applyOrigin(origin: string): void {
+ let settings: Record = {}
+ try {
+ settings = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')) as Record
+ } catch {
+ // Missing or corrupt settings file — start fresh; the app validates on load.
+ }
+ settings.origin = origin
+ mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
+ writeFileSync(SETTINGS_PATH, `${JSON.stringify(settings, null, 2)}\n`)
+ console.log(`• Server origin set to ${origin}`)
+ if (origin.startsWith('http://localhost')) {
+ console.log(' (make sure the sim dev server is running on :3000)')
+ }
+}
+
+console.log(`• Packaging ${identity.name} from the current checkout…`)
+run(
+ 'bun',
+ ['run', 'build'],
+ identity.origin ? { SIM_DESKTOP_DEFAULT_ORIGIN: identity.origin } : undefined
+)
+// electron-builder only writes the output dir for the CURRENT target/arch
+// (e.g. mac-arm64); other release dirs from older runs (a universal dmg
+// build, an Intel machine) would survive and win the pick below. Remove all
+// candidates first so the only app found is the one just built.
+for (const dir of RELEASE_DIRS) {
+ rmSync(dir, { recursive: true, force: true })
+}
+// Same as package:dir, minus trusted timestamps: codesign's --timestamp does
+// a network round trip to Apple PER FILE (hundreds inside the Electron
+// framework), which turns local signing into a multi-minute stall. Local
+// installs don't need timestamped signatures — only notarized distribution
+// builds do.
+run('bunx', [
+ 'electron-builder',
+ '--mac',
+ 'dir',
+ '--publish',
+ 'never',
+ '-c.mac.timestamp=none',
+ `-c.productName=${identity.name}`,
+ `-c.appId=${identity.appId}`,
+])
+
+const builtApp = RELEASE_DIRS.map((dir) => join(dir, APP_NAME)).find(existsSync)
+if (!builtApp) {
+ console.error(`✖ No built app found under ${RELEASE_DIRS.join(', ')}`)
+ process.exit(1)
+}
+
+// Without a Developer ID identity electron-builder skips signing entirely,
+// and its fuse-flip step invalidates Electron's shipped ad-hoc seal — Apple
+// silicon then SIGKILLs the binary at launch (Code Signature Invalid).
+// Re-seal the whole bundle ad-hoc; ditto below preserves the signature.
+console.log('• Ad-hoc signing the bundle…')
+run('codesign', ['--force', '--deep', '--sign', '-', builtApp])
+
+quitInstalledApp()
+
+console.log(`• Installing ${builtApp} → ${INSTALL_PATH}`)
+rmSync(INSTALL_PATH, { recursive: true, force: true })
+// ditto preserves the code signature and extended attributes, unlike cp.
+run('ditto', [builtApp, INSTALL_PATH])
+
+if (identity.origin) {
+ applyOrigin(identity.origin)
+}
+
+console.log(`✔ Installed ${identity.name} (${localBuildStamp()}) to ${INSTALL_PATH}`)
+
+if (!process.argv.includes('--no-open')) {
+ run('open', [INSTALL_PATH])
+} else {
+ console.log(` Launch it with: open ${INSTALL_PATH}`)
+}
diff --git a/apps/desktop/src/main/app-routes.test.ts b/apps/desktop/src/main/app-routes.test.ts
new file mode 100644
index 00000000000..245019bdc51
--- /dev/null
+++ b/apps/desktop/src/main/app-routes.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest'
+import { newChatRoute, settingsRoute } from '@/main/app-routes'
+
+describe('app routes', () => {
+ it('derives the new-chat route from the last workspace route', () => {
+ expect(newChatRoute('/workspace/ws1/w/wf2')).toBe('/workspace/ws1/home')
+ expect(newChatRoute('/workspace/ws1/home?resource=r1')).toBe('/workspace/ws1/home')
+ expect(newChatRoute('/account')).toBe('/workspace')
+ expect(newChatRoute(undefined)).toBe('/workspace')
+ expect(newChatRoute('//evil.example')).toBe('/workspace')
+ })
+
+ it('derives the settings route from the last workspace route', () => {
+ expect(settingsRoute('/workspace/ws1/w/wf2')).toBe('/workspace/ws1/settings/desktop')
+ expect(settingsRoute('/account')).toBe('/workspace')
+ expect(settingsRoute(undefined)).toBe('/workspace')
+ expect(settingsRoute('//evil.example')).toBe('/workspace')
+ })
+})
diff --git a/apps/desktop/src/main/app-routes.ts b/apps/desktop/src/main/app-routes.ts
new file mode 100644
index 00000000000..6e877edd013
--- /dev/null
+++ b/apps/desktop/src/main/app-routes.ts
@@ -0,0 +1,41 @@
+import { isSafeInternalPath } from '@/main/config'
+
+/**
+ * Routes into the Sim web app that the shell navigates to on the user's
+ * behalf, from a menu item or a tray item.
+ *
+ * They live here rather than in either caller because both the tray and the
+ * application menu offer the same destinations. Keeping them in `tray.ts` made
+ * `index.ts` import tray internals to wire up menu items that have nothing to
+ * do with the tray, and the tray can be absent entirely.
+ */
+
+/** Workspace id from the last visited route, or null when it carries none. */
+function workspaceIdFromRoute(lastRoute: string | undefined): string | null {
+ if (isSafeInternalPath(lastRoute)) {
+ const match = /^\/workspace\/([^/?#]+)/.exec(lastRoute)
+ if (match) {
+ return match[1]
+ }
+ }
+ return null
+}
+
+/**
+ * Route for "New Chat": the home (chat) surface of the workspace the user was
+ * last in, falling back to the workspace picker redirect when the last route
+ * carries no workspace.
+ */
+export function newChatRoute(lastRoute: string | undefined): string {
+ const workspaceId = workspaceIdFromRoute(lastRoute)
+ return workspaceId ? `/workspace/${workspaceId}/home` : '/workspace'
+}
+
+/**
+ * Route for "Settings…": the Sim app's settings surface for the workspace the
+ * user was last in, falling back to the workspace picker redirect.
+ */
+export function settingsRoute(lastRoute: string | undefined): string {
+ const workspaceId = workspaceIdFromRoute(lastRoute)
+ return workspaceId ? `/workspace/${workspaceId}/settings/desktop` : '/workspace'
+}
diff --git a/apps/desktop/src/main/atomic-json-file.ts b/apps/desktop/src/main/atomic-json-file.ts
new file mode 100644
index 00000000000..4561b4c0447
--- /dev/null
+++ b/apps/desktop/src/main/atomic-json-file.ts
@@ -0,0 +1,72 @@
+import { mkdirSync, renameSync, writeFileSync } from 'node:fs'
+import { mkdir, rename, unlink, writeFile } from 'node:fs/promises'
+import { dirname } from 'node:path'
+
+/** Owner-only, matching every store that keeps user data in userData. */
+const FILE_MODE = 0o600
+
+/**
+ * Distinct per call, not just per process.
+ *
+ * The pid keeps a second Sim process from sharing the path — the site
+ * directory used a bare `.tmp` and could be clobbered by exactly that. The
+ * counter covers the other half: these stores are read-modify-write with no
+ * lock, so two overlapping writes to the SAME store in one process (a password
+ * import racing a forget) would otherwise both truncate and write the one
+ * temp file, and the first rename would publish a spliced blob. The vault
+ * treats an unparseable file as empty, so that surfaces as every saved
+ * password silently vanishing.
+ */
+let temporaryFileCounter = 0
+function temporaryPathFor(filePath: string): string {
+ temporaryFileCounter += 1
+ return `${filePath}.${process.pid}.${temporaryFileCounter}.tmp`
+}
+
+/**
+ * Crash-safe JSON writes for the small encrypted stores in userData.
+ *
+ * Every one of them (local-filesystem grants, the credential vault, the site
+ * directory) had written this same temp-file-then-rename sequence by hand, and
+ * they had already drifted: two scoped the temporary file by pid and the third
+ * did not, so two Sim processes writing that store could clobber each other
+ * through a shared `.tmp` path. Owning the sequence once removes the class.
+ */
+export async function writeJsonFileAtomically(filePath: string, value: unknown): Promise {
+ await mkdir(dirname(filePath), { recursive: true })
+ const temporaryPath = temporaryPathFor(filePath)
+ await writeFile(temporaryPath, JSON.stringify(value), { mode: FILE_MODE })
+ await rename(temporaryPath, filePath)
+}
+
+/**
+ * The same sequence for a caller that cannot await.
+ *
+ * Only the settings store needs this: it flushes on `before-quit`, where the
+ * event loop stops before a promise would settle. `indent` because that file
+ * is one users open and edit by hand.
+ */
+export function writeJsonFileAtomicallySync(
+ filePath: string,
+ value: unknown,
+ indent?: number
+): void {
+ mkdirSync(dirname(filePath), { recursive: true })
+ const temporaryPath = temporaryPathFor(filePath)
+ writeFileSync(temporaryPath, JSON.stringify(value, null, indent), { mode: FILE_MODE })
+ renameSync(temporaryPath, filePath)
+}
+
+/**
+ * Deletes a store file, treating "already gone" as success.
+ *
+ * Anything else rethrows: a store that reports a successful `clear()` after an
+ * EACCES tells sign-out teardown the data is gone when it is still on disk.
+ */
+export async function removeFileIfPresent(filePath: string): Promise {
+ try {
+ await unlink(filePath)
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+ }
+}
diff --git a/apps/desktop/src/main/browser-agent/cdp.test.ts b/apps/desktop/src/main/browser-agent/cdp.test.ts
new file mode 100644
index 00000000000..95173dd37c5
--- /dev/null
+++ b/apps/desktop/src/main/browser-agent/cdp.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('electron', () => import('@/test/electron-mock'))
+
+import { WebContentsView } from 'electron'
+import { setColorScheme } from '@/main/browser-agent/cdp'
+
+describe('browser-agent CDP theme', () => {
+ it('emulates explicit light and dark preferences', async () => {
+ const contents = new WebContentsView().webContents
+
+ await setColorScheme(contents, 'dark')
+ await setColorScheme(contents, 'light')
+
+ expect(vi.mocked(contents.debugger.sendCommand).mock.calls).toEqual([
+ [
+ 'Emulation.setEmulatedMedia',
+ { features: [{ name: 'prefers-color-scheme', value: 'dark' }] },
+ ],
+ [
+ 'Emulation.setEmulatedMedia',
+ { features: [{ name: 'prefers-color-scheme', value: 'light' }] },
+ ],
+ ])
+ })
+
+ it('clears the override for the system preference', async () => {
+ const contents = new WebContentsView().webContents
+
+ await setColorScheme(contents, 'system')
+
+ expect(contents.debugger.sendCommand).toHaveBeenCalledWith('Emulation.setEmulatedMedia', {
+ features: [],
+ })
+ })
+})
diff --git a/apps/desktop/src/main/browser-agent/cdp.ts b/apps/desktop/src/main/browser-agent/cdp.ts
new file mode 100644
index 00000000000..b19496ea74b
--- /dev/null
+++ b/apps/desktop/src/main/browser-agent/cdp.ts
@@ -0,0 +1,176 @@
+/**
+ * CDP instrumentation for agent tabs via `webContents.debugger`: auto-handles
+ * the page states that would otherwise wedge automation (JS dialogs, file
+ * choosers), captures screenshots that work even while the view is hidden,
+ * and dispatches TRUSTED input (key events, text insertion). Trusted input
+ * goes through Blink's real input pipeline — unlike synthetic DOM
+ * `KeyboardEvent`s, it triggers default actions (select-all, deletion, caret
+ * movement, character insertion) and is honored by code editors. The user
+ * sees and drives the real embedded page, so there is no screencast.
+ */
+import type { BrowserTheme } from '@sim/browser-protocol'
+import { createLogger } from '@sim/logger'
+import type { WebContents } from 'electron'
+
+const logger = createLogger('BrowserAgentCdp')
+
+const PROTOCOL_VERSION = '1.3'
+
+export interface PageDialog {
+ type: string
+ message: string
+}
+
+export interface CdpCallbacks {
+ /** A JS dialog was auto-handled; the driver surfaces it to the model. */
+ onDialog: (dialog: PageDialog) => void
+ /** A file chooser was suppressed; the driver surfaces it to the model. */
+ onFileChooser: () => void
+}
+
+/** Per-tab callbacks, so a background tab's events reach ITS driver, not the
+ * most-recently-instrumented tab's. */
+const callbacksByContents = new WeakMap()
+/** Contents already instrumented (attach survives for the tab's lifetime). */
+const instrumented = new WeakSet()
+
+async function send(
+ contents: WebContents,
+ method: string,
+ params?: Record
+): Promise {
+ return (await contents.debugger.sendCommand(method, params)) as T
+}
+
+/** Idempotently instruments a tab's WebContents. */
+export async function ensureInstrumented(contents: WebContents, cb: CdpCallbacks): Promise {
+ callbacksByContents.set(contents, cb)
+ if (instrumented.has(contents) && contents.debugger.isAttached()) return
+
+ if (!contents.debugger.isAttached()) {
+ contents.debugger.attach(PROTOCOL_VERSION)
+ }
+ if (!instrumented.has(contents)) {
+ instrumented.add(contents)
+ contents.debugger.on('message', (_event, method, params) => {
+ handleDebuggerEvent(contents, method, params as Record)
+ })
+ }
+
+ await send(contents, 'Page.enable')
+ // Suppress native file choosers: nothing can drive them from the panel,
+ // and an open chooser blocks the page. Recorded and surfaced instead.
+ await send(contents, 'Page.setInterceptFileChooserDialog', { enabled: true }).catch(() => {})
+}
+
+/**
+ * Mirrors Sim's theme into the page's `prefers-color-scheme` media query.
+ * `system` removes the per-tab override so Chromium continues following the OS.
+ */
+export async function setColorScheme(contents: WebContents, theme: BrowserTheme): Promise {
+ await send(contents, 'Emulation.setEmulatedMedia', {
+ features: theme === 'system' ? [] : [{ name: 'prefers-color-scheme', value: theme }],
+ })
+}
+
+function handleDebuggerEvent(
+ contents: WebContents,
+ method: string,
+ params: Record
+): void {
+ const callbacks = callbacksByContents.get(contents)
+ if (method === 'Page.javascriptDialogOpening') {
+ const type = String(params.type ?? 'dialog')
+ const message = String(params.message ?? '').slice(0, 500)
+ // beforeunload is accepted (navigation proceeds); everything else is
+ // dismissed — the model reacts to the recorded message instead of a
+ // dialog that would block the page.
+ void send(contents, 'Page.handleJavaScriptDialog', {
+ accept: type === 'beforeunload',
+ }).catch(() => {})
+ logger.info('Auto-handled page dialog', { type })
+ callbacks?.onDialog({ type, message })
+ return
+ }
+ if (method === 'Page.fileChooserOpened') {
+ logger.info('Suppressed file chooser in agent browser')
+ callbacks?.onFileChooser()
+ }
+}
+
+/**
+ * Longest edge of a captured frame, in pixels. The image is sent to the model
+ * as a base64 image block, so its encoded size is charged against the tool
+ * result budget — an unbounded capture on a retina display is several hundred
+ * kilobytes and buys no legibility the model can use.
+ */
+const MAX_SCREENSHOT_EDGE = 1024
+const SCREENSHOT_QUALITY = 70
+
+interface CdpViewport {
+ clientWidth: number
+ clientHeight: number
+}
+
+/**
+ * Screenshot via CDP (works while the view is hidden), bounded in resolution.
+ *
+ * `clip.scale` is relative to CSS pixels, so passing the CSS viewport with a
+ * scale of 1 already sidesteps the device pixel ratio — an unclipped capture
+ * on a 2x display returns a 2x image. Scaling further down keeps the longest
+ * edge within {@link MAX_SCREENSHOT_EDGE}. Falls back to an unclipped capture
+ * when layout metrics are unavailable.
+ */
+export async function captureScreenshot(contents: WebContents): Promise {
+ const metrics = await send<{
+ cssLayoutViewport?: CdpViewport
+ layoutViewport?: CdpViewport
+ }>(contents, 'Page.getLayoutMetrics').catch(() => null)
+
+ const viewport = metrics?.cssLayoutViewport ?? metrics?.layoutViewport
+ const width = viewport?.clientWidth ?? 0
+ const height = viewport?.clientHeight ?? 0
+ const clip =
+ width > 0 && height > 0
+ ? {
+ x: 0,
+ y: 0,
+ width,
+ height,
+ scale: Math.min(1, MAX_SCREENSHOT_EDGE / Math.max(width, height)),
+ }
+ : undefined
+
+ const result = await send<{ data: string }>(contents, 'Page.captureScreenshot', {
+ format: 'jpeg',
+ quality: SCREENSHOT_QUALITY,
+ ...(clip ? { clip } : {}),
+ })
+ return `data:image/jpeg;base64,${result.data}`
+}
+
+/** One half of a trusted key press (`Input.dispatchKeyEvent` params). */
+export interface CdpKeyEvent {
+ type: 'keyDown' | 'rawKeyDown' | 'keyUp'
+ modifiers: number
+ key: string
+ code: string
+ windowsVirtualKeyCode: number
+ nativeVirtualKeyCode: number
+ text?: string
+ /** Blink editing commands to run with the event (macOS shortcut parity). */
+ commands?: string[]
+}
+
+/** Dispatches one trusted key event through Blink's input pipeline. */
+export async function dispatchKeyEvent(contents: WebContents, event: CdpKeyEvent): Promise {
+ await send(contents, 'Input.dispatchKeyEvent', event as unknown as Record)
+}
+
+/**
+ * Inserts text at the focused element's selection (replacing it) through the
+ * native IME path — works in plain fields and code editors alike.
+ */
+export async function insertText(contents: WebContents, text: string): Promise {
+ await send(contents, 'Input.insertText', { text })
+}
diff --git a/apps/desktop/src/main/browser-agent/context-menu.test.ts b/apps/desktop/src/main/browser-agent/context-menu.test.ts
new file mode 100644
index 00000000000..a190d1eced8
--- /dev/null
+++ b/apps/desktop/src/main/browser-agent/context-menu.test.ts
@@ -0,0 +1,260 @@
+import type { ContextMenuParams, MenuItemConstructorOptions } from 'electron'
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('electron', () => import('@/test/electron-mock'))
+
+import { Menu, WebContentsView } from 'electron'
+import {
+ attachAgentContextMenu,
+ BASE_ZOOM_FACTOR,
+ buildAgentContextMenuTemplate,
+ steppedZoomFactor,
+ zoomPercentOf,
+} from '@/main/browser-agent/context-menu'
+
+const EDIT_FLAGS: ContextMenuParams['editFlags'] = {
+ canUndo: false,
+ canRedo: false,
+ canCut: false,
+ canCopy: false,
+ canPaste: false,
+ canDelete: false,
+ canSelectAll: false,
+ canEditRichly: false,
+}
+
+type Params = Parameters[0]
+type Page = Parameters[1]
+type Handlers = Parameters[2]
+
+function params(overrides: Partial = {}): Params {
+ return { selectionText: '', linkURL: '', isEditable: false, editFlags: EDIT_FLAGS, ...overrides }
+}
+
+function page(overrides: Partial = {}): Page {
+ // A fresh tab sits at the panel's baseline, which the menu reports as 100%.
+ return { canGoBack: true, canGoForward: true, zoomFactor: BASE_ZOOM_FACTOR, ...overrides }
+}
+
+function handlers(): Handlers {
+ return {
+ copy: vi.fn(),
+ paste: vi.fn(),
+ back: vi.fn(),
+ forward: vi.fn(),
+ reload: vi.fn(),
+ openTab: vi.fn(),
+ copyLink: vi.fn(),
+ setZoomFactor: vi.fn(),
+ }
+}
+
+const labels = (template: MenuItemConstructorOptions[]) =>
+ template.filter((item) => item.type !== 'separator').map((item) => item.label)
+
+const item = (template: MenuItemConstructorOptions[], label: string) =>
+ template.find((entry) => entry.label === label)
+
+describe('buildAgentContextMenuTemplate', () => {
+ it('always offers navigation and zoom, whatever was clicked', () => {
+ const template = buildAgentContextMenuTemplate(params(), page(), handlers())
+
+ // Unlike the main window's menu, an empty template is not an option here:
+ // the page has no menu of its own to fall back to.
+ expect(labels(template)).toEqual([
+ 'Back',
+ 'Forward',
+ 'Reload',
+ 'Zoom In',
+ 'Zoom Out',
+ 'Actual Size (100%)',
+ ])
+ })
+
+ it('offers clipboard items only where the click can use them', () => {
+ expect(labels(buildAgentContextMenuTemplate(params(), page(), handlers()))).not.toContain(
+ 'Copy'
+ )
+
+ const withSelection = buildAgentContextMenuTemplate(
+ params({ selectionText: ' hello ' }),
+ page(),
+ handlers()
+ )
+ expect(labels(withSelection)).toContain('Copy')
+
+ const inField = buildAgentContextMenuTemplate(
+ params({ isEditable: true, editFlags: { ...EDIT_FLAGS, canPaste: true } }),
+ page(),
+ handlers()
+ )
+ expect(labels(inField)).toContain('Paste')
+
+ // A read-only field can report canPaste; both signals have to agree.
+ const readOnly = buildAgentContextMenuTemplate(
+ params({ isEditable: false, editFlags: { ...EDIT_FLAGS, canPaste: true } }),
+ page(),
+ handlers()
+ )
+ expect(labels(readOnly)).not.toContain('Paste')
+ })
+
+ it('offers link items for http(s) targets only', () => {
+ const handled = handlers()
+ const template = buildAgentContextMenuTemplate(
+ params({ linkURL: 'https://example.com/docs' }),
+ page(),
+ handled
+ )
+ expect(labels(template)).toContain('Open Link in New Tab')
+
+ item(template, 'Open Link in New Tab')?.click?.({} as never, undefined as never, {} as never)
+ expect(handled.openTab).toHaveBeenCalledWith('https://example.com/docs')
+
+ // The actions open a tab or copy an address; neither means anything for a
+ // script or mail target, so the menu must not offer them.
+ for (const linkURL of ['javascript:alert(1)', 'mailto:a@b.com', 'file:///etc/passwd']) {
+ const other = buildAgentContextMenuTemplate(params({ linkURL }), page(), handlers())
+ expect(labels(other)).not.toContain('Open Link in New Tab')
+ expect(labels(other)).not.toContain('Copy Link Address')
+ }
+ })
+
+ it('disables navigation the page cannot do', () => {
+ const template = buildAgentContextMenuTemplate(
+ params(),
+ page({ canGoBack: false, canGoForward: false }),
+ handlers()
+ )
+
+ expect(item(template, 'Back')?.enabled).toBe(false)
+ expect(item(template, 'Forward')?.enabled).toBe(false)
+ expect(item(template, 'Reload')?.enabled).toBeUndefined()
+ })
+
+ it('reports the current zoom and disables the ends of the ladder', () => {
+ // Two rungs up from the baseline, reported against the baseline rather than
+ // against Chromium's native scale (where this factor would read 110%).
+ const twoUp = steppedZoomFactor(steppedZoomFactor(BASE_ZOOM_FACTOR, 1), 1)
+ const stepped = buildAgentContextMenuTemplate(params(), page({ zoomFactor: twoUp }), handlers())
+ expect(item(stepped, 'Actual Size (121%)')?.enabled).toBe(true)
+
+ const atMax = buildAgentContextMenuTemplate(params(), page({ zoomFactor: 3 }), handlers())
+ expect(item(atMax, 'Zoom In')?.enabled).toBe(false)
+ expect(item(atMax, 'Zoom Out')?.enabled).toBe(true)
+
+ const atMin = buildAgentContextMenuTemplate(params(), page({ zoomFactor: 0.5 }), handlers())
+ expect(item(atMin, 'Zoom Out')?.enabled).toBe(false)
+
+ // Nothing to reset to at 100%.
+ expect(
+ item(buildAgentContextMenuTemplate(params(), page(), handlers()), 'Actual Size (100%)')
+ ?.enabled
+ ).toBe(false)
+ })
+
+ it('resets to exactly the baseline, undoing accumulated drift', () => {
+ const handled = handlers()
+ // Three rungs of float multiplication up, so the factor no longer sits on a
+ // clean value — reset has to restore the baseline exactly, not step back.
+ const drifted = [1, 1, 1].reduce((factor) => steppedZoomFactor(factor, 1), BASE_ZOOM_FACTOR)
+ const template = buildAgentContextMenuTemplate(params(), page({ zoomFactor: drifted }), handled)
+
+ item(template, 'Actual Size (133%)')?.click?.({} as never, undefined as never, {} as never)
+
+ expect(handled.setZoomFactor).toHaveBeenCalledWith(BASE_ZOOM_FACTOR)
+ })
+
+ it('never leaves a separator with nothing above it', () => {
+ for (const p of [
+ params(),
+ params({ selectionText: 'hi' }),
+ params({ linkURL: 'https://example.com' }),
+ params({ isEditable: true, editFlags: { ...EDIT_FLAGS, canPaste: true } }),
+ ]) {
+ const template = buildAgentContextMenuTemplate(p, page(), handlers())
+ expect(template[0].type).not.toBe('separator')
+ expect(template[template.length - 1].type).not.toBe('separator')
+ expect(
+ template.some(
+ (entry, index) => entry.type === 'separator' && template[index - 1]?.type === 'separator'
+ )
+ ).toBe(false)
+ }
+ })
+})
+
+describe('attachAgentContextMenu', () => {
+ type ContextMenuListener = (event: unknown, params: Params) => void
+
+ it('pops a menu built from the page that was right-clicked', () => {
+ const contents = new WebContentsView().webContents
+ vi.mocked(contents.navigationHistory.canGoBack).mockReturnValue(true)
+ attachAgentContextMenu(contents, { openTab: vi.fn() })
+
+ const listeners = vi.mocked(contents.on).mock.calls as unknown as [
+ string,
+ ContextMenuListener,
+ ][]
+ const onContextMenu = listeners.find(([event]) => event === 'context-menu')?.[1]
+ expect(onContextMenu).toBeDefined()
+ onContextMenu?.({}, params())
+
+ const template = vi.mocked(Menu.buildFromTemplate).mock.calls.at(-1)?.[0] as
+ | MenuItemConstructorOptions[]
+ | undefined
+ // The template is read off the live page, not a snapshot of it.
+ expect(item(template ?? [], 'Back')?.enabled).toBe(true)
+ expect(item(template ?? [], 'Forward')?.enabled).toBe(false)
+ })
+})
+
+describe('steppedZoomFactor', () => {
+ it('steps up and down from the current factor', () => {
+ expect(steppedZoomFactor(1, 1)).toBe(1.1)
+ expect(steppedZoomFactor(1, -1)).toBe(1 / 1.1)
+ })
+
+ it('clamps at both ends so a step never runs away', () => {
+ expect(steppedZoomFactor(3, 1)).toBe(3)
+ expect(steppedZoomFactor(0.5, -1)).toBe(0.5)
+ })
+
+ it('treats a nonsense factor as the baseline', () => {
+ // One rung up from the baseline is Chromium's native 1.0.
+ expect(steppedZoomFactor(Number.NaN, 1)).toBe(1)
+ expect(steppedZoomFactor(0, 1)).toBe(1)
+ })
+})
+
+describe('BASE_ZOOM_FACTOR', () => {
+ it('renders a rung below native but reads as 100%', () => {
+ expect(BASE_ZOOM_FACTOR).toBeCloseTo(0.909, 3)
+ expect(zoomPercentOf(BASE_ZOOM_FACTOR)).toBe(100)
+ })
+
+ it('keeps the ladder landing exactly on Chromium native one step up', () => {
+ expect(steppedZoomFactor(BASE_ZOOM_FACTOR, 1)).toBe(1)
+ expect(zoomPercentOf(1)).toBe(110)
+ })
+
+ it('stays inside the ladder, so the page menu can still step both ways', () => {
+ expect(steppedZoomFactor(BASE_ZOOM_FACTOR, 1)).not.toBe(BASE_ZOOM_FACTOR)
+ expect(steppedZoomFactor(BASE_ZOOM_FACTOR, -1)).not.toBe(BASE_ZOOM_FACTOR)
+ })
+})
+
+describe('zoomPercentOf', () => {
+ it('reports every rung relative to the panel baseline, not to native', () => {
+ expect(zoomPercentOf(steppedZoomFactor(BASE_ZOOM_FACTOR, -1))).toBe(91)
+ expect(zoomPercentOf(BASE_ZOOM_FACTOR)).toBe(100)
+ expect(zoomPercentOf(steppedZoomFactor(BASE_ZOOM_FACTOR, 1))).toBe(110)
+ })
+
+ it('still reads 100% after a round trip up and back down', () => {
+ // The ladder is float arithmetic, so the reset item's `!== 100` guard has to
+ // survive a step that does not return bit-identically to the baseline.
+ const roundTripped = steppedZoomFactor(steppedZoomFactor(BASE_ZOOM_FACTOR, 1), -1)
+ expect(zoomPercentOf(roundTripped)).toBe(100)
+ })
+})
diff --git a/apps/desktop/src/main/browser-agent/context-menu.ts b/apps/desktop/src/main/browser-agent/context-menu.ts
new file mode 100644
index 00000000000..52710c1e9a8
--- /dev/null
+++ b/apps/desktop/src/main/browser-agent/context-menu.ts
@@ -0,0 +1,185 @@
+/**
+ * Right-click menu for the embedded browser page.
+ *
+ * Native, deliberately. The page is a native view composited OVER the renderer,
+ * so a menu drawn from the app's own dropdown can only appear by freezing the
+ * page to a captured frame and hiding the view beneath it. That frame has to
+ * pixel-match a live compositor surface, and scrollbars, device scale, and
+ * resize timing all conspire against it — a mismatch shows up as the page
+ * flickering or shifting under the menu. A native menu draws above the view
+ * while the page keeps rendering, so none of that applies.
+ *
+ * It is also what a browser's page menu is everywhere else, and unlike the
+ * terminal's hidden textarea, the roles here act on a real page: `copy` and
+ * `paste` go to the frame that was clicked.
+ */
+import type { ContextMenuParams, MenuItemConstructorOptions, WebContents } from 'electron'
+import { clipboard, Menu } from 'electron'
+
+/**
+ * Page-zoom ladder for the embedded browser, in Chromium's absolute zoom
+ * factors. The ends are the platform's own limits, not a product choice —
+ * Chromium refuses to scale past them, and a rung outside the range would come
+ * back clamped and leave the menu offering a step that never lands.
+ */
+const ZOOM_STEP_RATIO = 1.1
+const MIN_ZOOM_FACTOR = 0.5
+const MAX_ZOOM_FACTOR = 3
+
+/**
+ * What the panel calls 100%.
+ *
+ * The browser lives in a panel that is only ever a fraction of the window, so
+ * it renders a rung below Chromium's native scale and treats THAT as its
+ * baseline: the menu reads 100% there, and every other rung is reported
+ * relative to it. Users get a zoom control that behaves the way one should —
+ * starts at 100%, resets to 100% — over a page that is genuinely rendering at
+ * ~91% of native.
+ *
+ * Defined as one rung below native rather than as a round number so the ladder
+ * still lands exactly on Chromium's 1.0 (the crispest rasterization, one step
+ * up from the baseline) instead of straddling it.
+ */
+export const BASE_ZOOM_FACTOR = 1 / ZOOM_STEP_RATIO
+
+/**
+ * A Chromium zoom factor as a percentage of {@link BASE_ZOOM_FACTOR} — what the
+ * menu shows and what "100%" means everywhere in the browser panel's UI.
+ *
+ * Only display and the reset target convert; the ladder itself stays in
+ * absolute factors, so stepping never round-trips through this and cannot
+ * accumulate float drift away from the rungs.
+ */
+export function zoomPercentOf(factor: number): number {
+ return Math.round((factor / BASE_ZOOM_FACTOR) * 100)
+}
+
+/**
+ * One step along the zoom ladder, clamped to its ends. Returning the current
+ * factor unchanged is how the menu knows an end is reached, so it can disable
+ * the item rather than offer a step that does nothing.
+ */
+export function steppedZoomFactor(current: number, direction: 1 | -1): number {
+ const base = Number.isFinite(current) && current > 0 ? current : BASE_ZOOM_FACTOR
+ const next = direction === 1 ? base * ZOOM_STEP_RATIO : base / ZOOM_STEP_RATIO
+ return Math.min(MAX_ZOOM_FACTOR, Math.max(MIN_ZOOM_FACTOR, next))
+}
+
+/** The parts of a right-click the menu acts on. */
+type AgentContextMenuParams = Pick<
+ ContextMenuParams,
+ 'selectionText' | 'linkURL' | 'isEditable' | 'editFlags'
+>
+
+/** What the page can currently do, read at the moment of the click. */
+interface AgentPageContext {
+ canGoBack: boolean
+ canGoForward: boolean
+ zoomFactor: number
+}
+
+interface AgentContextMenuHandlers {
+ copy(): void
+ paste(): void
+ back(): void
+ forward(): void
+ reload(): void
+ openTab(url: string): void
+ copyLink(url: string): void
+ setZoomFactor(factor: number): void
+}
+
+export interface AgentContextMenuHost {
+ /** Opens a link from the page in another tab of the same browser. */
+ openTab(url: string): void
+}
+
+/**
+ * Builds the page menu. Unlike the main window's menu this is never empty —
+ * navigation and zoom always apply, and only the clipboard and link items
+ * depend on what was clicked.
+ *
+ * Only http(s) links get link items: the actions open a browser tab or copy an
+ * address, and neither means anything for a `javascript:` or `mailto:` target.
+ */
+export function buildAgentContextMenuTemplate(
+ params: AgentContextMenuParams,
+ page: AgentPageContext,
+ handlers: AgentContextMenuHandlers
+): MenuItemConstructorOptions[] {
+ const template: MenuItemConstructorOptions[] = []
+ const linkUrl = /^https?:\/\//i.test(params.linkURL) ? params.linkURL : ''
+
+ if (linkUrl) {
+ template.push(
+ { label: 'Open Link in New Tab', click: () => handlers.openTab(linkUrl) },
+ { label: 'Copy Link Address', click: () => handlers.copyLink(linkUrl) },
+ { type: 'separator' }
+ )
+ }
+
+ if (params.selectionText.trim()) {
+ template.push({ label: 'Copy', click: () => handlers.copy() })
+ }
+ if (params.isEditable && params.editFlags.canPaste) {
+ template.push({ label: 'Paste', click: () => handlers.paste() })
+ }
+ if (template.length > 0 && template[template.length - 1].type !== 'separator') {
+ template.push({ type: 'separator' })
+ }
+
+ template.push(
+ { label: 'Back', enabled: page.canGoBack, click: () => handlers.back() },
+ { label: 'Forward', enabled: page.canGoForward, click: () => handlers.forward() },
+ { label: 'Reload', click: () => handlers.reload() },
+ { type: 'separator' }
+ )
+
+ const zoomIn = steppedZoomFactor(page.zoomFactor, 1)
+ const zoomOut = steppedZoomFactor(page.zoomFactor, -1)
+ const zoomPercent = zoomPercentOf(page.zoomFactor)
+ template.push(
+ {
+ label: 'Zoom In',
+ enabled: zoomIn !== page.zoomFactor,
+ click: () => handlers.setZoomFactor(zoomIn),
+ },
+ {
+ label: 'Zoom Out',
+ enabled: zoomOut !== page.zoomFactor,
+ click: () => handlers.setZoomFactor(zoomOut),
+ },
+ {
+ label: `Actual Size (${zoomPercent}%)`,
+ enabled: zoomPercent !== 100,
+ click: () => handlers.setZoomFactor(BASE_ZOOM_FACTOR),
+ }
+ )
+
+ return template
+}
+
+/** Gives one agent tab its page menu. */
+export function attachAgentContextMenu(contents: WebContents, host: AgentContextMenuHost): void {
+ contents.on('context-menu', (_event, params) => {
+ const template = buildAgentContextMenuTemplate(
+ params,
+ {
+ canGoBack: contents.navigationHistory.canGoBack(),
+ canGoForward: contents.navigationHistory.canGoForward(),
+ zoomFactor: contents.getZoomFactor(),
+ },
+ {
+ copy: () => contents.copy(),
+ paste: () => contents.paste(),
+ back: () => contents.navigationHistory.goBack(),
+ forward: () => contents.navigationHistory.goForward(),
+ reload: () => contents.reload(),
+ openTab: (url) => host.openTab(url),
+ copyLink: (url) => clipboard.writeText(url),
+ setZoomFactor: (factor) => contents.setZoomFactor(factor),
+ }
+ )
+ Menu.buildFromTemplate(template).popup()
+ })
+}
diff --git a/apps/desktop/src/main/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts
new file mode 100644
index 00000000000..cc1059baec6
--- /dev/null
+++ b/apps/desktop/src/main/browser-agent/driver.test.ts
@@ -0,0 +1,313 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('electron', () => import('@/test/electron-mock'))
+
+import { BrowserWindow } from 'electron'
+import * as driverModule from '@/main/browser-agent/driver'
+import * as session from '@/main/browser-agent/session'
+
+type DriverModule = typeof import('@/main/browser-agent/driver')
+
+/**
+ * `initDriver` is a full reset of the driver's and the session's per-session
+ * state, so a clean driver needs no module reload — which is what lets this
+ * file use static imports instead of the `vi.resetModules()` the root
+ * CLAUDE.md forbids. Tests needing real callbacks call `initDriver` again;
+ * calling it twice is exactly the re-init case the reset exists for.
+ */
+function freshDriver(): DriverModule {
+ driverModule.initDriver(
+ {
+ onPageState: vi.fn(),
+ onTabsState: vi.fn(),
+ onSessionStatus: vi.fn(),
+ onFillAvailability: vi.fn(),
+ },
+ () => null
+ )
+ return driverModule
+}
+
+describe('executeTool', () => {
+ let driver: DriverModule
+
+ beforeEach(async () => {
+ driver = freshDriver()
+ })
+
+ it('returns ok:false instead of throwing for tool-level failures', async () => {
+ // No session exists, so any page-dependent tool fails with guidance.
+ const result = await driver.executeTool('browser_click', { elementId: 1 })
+ expect(result.ok).toBe(false)
+ expect(result.error).toMatch(/No page is open yet/)
+ })
+
+ it('validates navigation URLs before touching the session', async () => {
+ const result = await driver.executeTool('browser_navigate', { url: 'file:///etc/passwd' })
+ expect(result).toEqual({
+ ok: false,
+ error: 'URL must be absolute and start with http:// or https://',
+ })
+ })
+
+ it('reports missing required parameters by name', async () => {
+ const result = await driver.executeTool('browser_navigate', {})
+ expect(result.ok).toBe(false)
+ expect(result.error).toMatch(/Missing required parameter "url"/)
+ })
+
+ it('serializes tool calls: a queued failure never rejects the next call', async () => {
+ const first = await driver.executeTool('browser_snapshot', {})
+ expect(first.ok).toBe(false)
+ const second = await driver.executeTool('browser_list_tabs', {})
+ // list_tabs works without a session (empty list).
+ expect(second.ok).toBe(true)
+ expect(second.result).toMatchObject({ tabs: [] })
+ })
+
+ it.each(['', 'about:blank'])(
+ 'fails page tools immediately and releases queued tab listing when the URL is %j',
+ async (url) => {
+ const win = new BrowserWindow()
+ driver.initDriver(
+ {
+ onPageState: vi.fn(),
+ onTabsState: vi.fn(),
+ onSessionStatus: vi.fn(),
+ onFillAvailability: vi.fn(),
+ },
+ () => win
+ )
+ await driver.executeTool('browser_open_tab', {})
+
+ const contents = session.requireTab().view.webContents
+ vi.mocked(contents.getURL).mockReturnValue(url)
+ vi.mocked(contents.executeJavaScript).mockImplementation(() => new Promise(() => {}))
+
+ const snapshot = driver.executeTool('browser_snapshot', {})
+ const listTabs = driver.executeTool('browser_list_tabs', {})
+
+ await expect(snapshot).resolves.toEqual({
+ ok: false,
+ error:
+ 'The active tab is blank. Call browser_navigate before using page inspection or interaction tools.',
+ })
+ await expect(listTabs).resolves.toMatchObject({
+ ok: true,
+ result: {
+ tabs: [{ url }],
+ },
+ })
+ expect(contents.executeJavaScript).not.toHaveBeenCalled()
+ }
+ )
+
+ it('leaves no watchdog timer pending once a tool finishes', async () => {
+ vi.useFakeTimers()
+ try {
+ // Racing against an uncancellable sleep left one timer alive per call for
+ // the full watchdog window — up to two minutes, dozens deep in a run.
+ const before = vi.getTimerCount()
+ await driver.executeTool('browser_list_tabs', {})
+
+ expect(vi.getTimerCount()).toBe(before)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('releases the serialized queue before the renderer timeout when a page call hangs', async () => {
+ vi.useFakeTimers()
+ try {
+ const win = new BrowserWindow()
+ driver.initDriver(
+ {
+ onPageState: vi.fn(),
+ onTabsState: vi.fn(),
+ onSessionStatus: vi.fn(),
+ onFillAvailability: vi.fn(),
+ },
+ () => win
+ )
+ await driver.executeTool('browser_open_tab', {})
+
+ const contents = session.requireTab().view.webContents
+ vi.mocked(contents.executeJavaScript).mockImplementation(() => new Promise(() => {}))
+
+ const hung = driver.executeTool('browser_snapshot', {})
+ const queued = driver.executeTool('browser_list_tabs', {})
+ await vi.advanceTimersByTimeAsync(20_000)
+
+ await expect(hung).resolves.toMatchObject({
+ ok: false,
+ error: expect.stringContaining('did not finish this action in time'),
+ })
+ await expect(queued).resolves.toMatchObject({
+ ok: true,
+ result: { tabs: expect.any(Array) },
+ })
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+})
+
+/**
+ * Trusted CDP input never enters the page, so a focused credential field can
+ * only be ruled out in the driver. These cover that seam; the page-side
+ * detection itself is covered in page-functions.test.ts.
+ */
+describe('credential protection', () => {
+ let driver: DriverModule
+
+ beforeEach(async () => {
+ driver = freshDriver()
+ })
+
+ /** Opens a tab on a real URL so injected page calls are not short-circuited. */
+ async function openPage() {
+ const win = new BrowserWindow()
+ driver.initDriver(
+ {
+ onPageState: vi.fn(),
+ onTabsState: vi.fn(),
+ onSessionStatus: vi.fn(),
+ onFillAvailability: vi.fn(),
+ },
+ () => win
+ )
+ await driver.executeTool('browser_open_tab', {})
+ const contents = session.requireTab().view.webContents
+ vi.mocked(contents.getURL).mockReturnValue('https://example.com/login')
+ return contents
+ }
+
+ /**
+ * Routes injected calls by the function name in the serialized source, so a
+ * test can say what each page probe reports.
+ */
+ function respondWith(
+ contents: Awaited>,
+ replies: Record
+ ): void {
+ vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => {
+ for (const [fnName, value] of Object.entries(replies)) {
+ if (expression.includes(fnName)) return Promise.resolve(value)
+ }
+ return Promise.resolve(undefined)
+ })
+ }
+
+ function cdpCalls(contents: Awaited>, method: string): unknown[][] {
+ return vi
+ .mocked(contents.debugger.sendCommand)
+ .mock.calls.filter(([called]) => called === method)
+ }
+
+ it('refuses a keystroke while a password field holds focus', async () => {
+ const contents = await openPage()
+ respondWith(contents, { activeElementSecrecy: 'secret' })
+
+ const result = await driver.executeTool('browser_press_key', { key: 'a' })
+
+ expect(result.ok).toBe(false)
+ expect(result.error).toMatch(/Refusing to act on a password field/)
+ expect(cdpCalls(contents, 'Input.dispatchKeyEvent')).toHaveLength(0)
+ })
+
+ it('refuses character insertion into a frame it cannot inspect', async () => {
+ const contents = await openPage()
+ respondWith(contents, { activeElementSecrecy: 'opaque' })
+
+ const result = await driver.executeTool('browser_press_key', { key: 'a' })
+
+ expect(result.ok).toBe(false)
+ expect(result.error).toMatch(/cross-origin frame/)
+ expect(cdpCalls(contents, 'Input.dispatchKeyEvent')).toHaveLength(0)
+ })
+
+ it('still allows caret and dismissal keys in a frame it cannot inspect', async () => {
+ const contents = await openPage()
+ respondWith(contents, { activeElementSecrecy: 'opaque', readActiveElementState: {} })
+
+ const result = await driver.executeTool('browser_press_key', { key: 'Escape' })
+
+ expect(result.ok).toBe(true)
+ expect(cdpCalls(contents, 'Input.dispatchKeyEvent').length).toBeGreaterThan(0)
+ })
+
+ it('sends the keystroke when nothing sensitive is focused', async () => {
+ const contents = await openPage()
+ respondWith(contents, { activeElementSecrecy: 'safe', readActiveElementState: {} })
+
+ const result = await driver.executeTool('browser_press_key', { key: 'a' })
+
+ expect(result.ok).toBe(true)
+ expect(cdpCalls(contents, 'Input.dispatchKeyEvent').length).toBeGreaterThan(0)
+ })
+
+ it('aborts a type when focus moves to a password field before the insert', async () => {
+ const contents = await openPage()
+ // The element passed the guard, then the page advanced focus — what a
+ // login form does between the username and password steps.
+ respondWith(contents, {
+ focusElementForTyping: { focused: true, kind: 'input' },
+ activeElementSecrecy: 'secret',
+ })
+
+ const result = await driver.executeTool('browser_type', { elementId: 0, text: 'hunter2' })
+
+ expect(result.ok).toBe(false)
+ expect(result.error).toMatch(/Refusing to act on a password field/)
+ expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(0)
+ })
+
+ it('types normally when focus stays on the vetted element', async () => {
+ const contents = await openPage()
+ respondWith(contents, {
+ focusElementForTyping: { focused: true, kind: 'input' },
+ activeElementSecrecy: 'safe',
+ readActiveElementState: { activeElement: 'input', valueLength: 7 },
+ })
+
+ const result = await driver.executeTool('browser_type', { elementId: 0, text: 'hunter2' })
+
+ expect(result.ok).toBe(true)
+ expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(1)
+ })
+
+ it.each(['Cmd+V', 'Control+V', 'Cmd+C', 'Cmd+X'])(
+ 'refuses the clipboard shortcut %s',
+ async (key) => {
+ const contents = await openPage()
+ respondWith(contents, { activeElementSecrecy: 'safe', readActiveElementState: {} })
+
+ const result = await driver.executeTool('browser_press_key', { key })
+
+ // Paste would move a password copied out of a manager into the page,
+ // where the next snapshot reports it as an ordinary field value.
+ expect(result.ok).toBe(false)
+ expect(result.error).toMatch(/clipboard/i)
+ expect(cdpCalls(contents, 'Input.dispatchKeyEvent')).toHaveLength(0)
+ }
+ )
+
+ it('still allows select-all, which carries no clipboard content', async () => {
+ const contents = await openPage()
+ respondWith(contents, { activeElementSecrecy: 'safe', readActiveElementState: {} })
+
+ const result = await driver.executeTool('browser_press_key', { key: 'Cmd+A' })
+
+ expect(result.ok).toBe(true)
+ })
+
+ it('surfaces the page-side refusal for element-targeted actions', async () => {
+ const contents = await openPage()
+ respondWith(contents, { clickElement: { error: 'password' } })
+
+ const result = await driver.executeTool('browser_click', { elementId: 0 })
+
+ expect(result.ok).toBe(false)
+ expect(result.error).toMatch(/Refusing to act on a password field/)
+ })
+})
diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts
new file mode 100644
index 00000000000..01b1ce501fc
--- /dev/null
+++ b/apps/desktop/src/main/browser-agent/driver.ts
@@ -0,0 +1,913 @@
+/**
+ * Browser-agent driver: executes the copilot's `browser_*` tools against the
+ * agent browser (session.ts) and keeps the renderer's panel header fed with
+ * live page state.
+ *
+ * Perception drives through injected page functions (element registry with a
+ * structural outline). Keyboard actuation (press_key, type) goes through
+ * TRUSTED CDP input events — synthetic DOM KeyboardEvents never trigger
+ * default editing actions (select-all, deletion, character insertion) and are
+ * ignored by code editors, so they exist only as a fallback. Clicks still use
+ * injected functions (element-targeted, no coordinate math). The user needs
+ * no input translation at all — the real page is embedded in the Sim window,
+ * so their clicks and typing are native. Tool calls serialize through a
+ * queue — one real browser can only do one thing at a time — and every call
+ * is bounded by a watchdog so the Sim side always gets a response instead of
+ * waiting out its own timeout against silence.
+ */
+import {
+ BROWSER_DATA_KINDS,
+ type BrowserDataKind,
+ type BrowserKnownSessionsState,
+ type BrowserPageState,
+ type BrowserPanelAction,
+ type BrowserTabsState,
+ type BrowserToolName,
+} from '@sim/browser-protocol'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { sleep } from '@sim/utils/helpers'
+import { isRecordLike } from '@sim/utils/object'
+import type { BrowserWindow, WebContents } from 'electron'
+import * as cdp from '@/main/browser-agent/cdp'
+import { ToolError } from '@/main/browser-agent/errors'
+import {
+ comboInsertsText,
+ comboTouchesClipboard,
+ dispatchKeyCombo,
+ parseKeyCombo,
+} from '@/main/browser-agent/keyboard'
+import { BrowserKnownSessionRegistry } from '@/main/browser-agent/known-sessions'
+import {
+ activeElementSecrecy,
+ clickElement,
+ collectSnapshot,
+ focusElementForTyping,
+ getViewportInfo,
+ hoverElement,
+ pageContainsText,
+ pressKeyOnPage,
+ readActiveElementState,
+ readPageText,
+ scrollPage,
+ selectOptionInElement,
+ typeIntoElement,
+} from '@/main/browser-agent/page-functions'
+import * as session from '@/main/browser-agent/session'
+import { checkAgentUrl } from '@/main/browser-agent/url-guard'
+import { clearCredentials, fillCoordinator, initFillCoordinator } from '@/main/browser-credentials'
+import type { ConfigStore } from '@/main/config'
+
+const logger = createLogger('BrowserAgentDriver')
+
+const NAVIGATION_TIMEOUT_MS = 25_000
+const NAVIGATION_SETTLE_MS = 400
+const DEFAULT_WAIT_FOR_TIMEOUT_MS = 10_000
+const MAX_WAIT_FOR_TIMEOUT_MS = 120_000
+const TAKEOVER_POLL_MS = 1_500
+const TAKEOVER_MAX_MS = 12 * 60 * 60 * 1000
+/**
+ * Hard ceiling on any single tool execution (takeover excepted): whatever
+ * goes wrong, the Sim side always gets a response. Sits above the longest
+ * legitimate tool (browser_wait_for caps at 120s).
+ */
+const DEFAULT_TOOL_WATCHDOG_MS = 20_000
+const NAVIGATION_TOOL_WATCHDOG_MS = 30_000
+const WAIT_FOR_TOOL_WATCHDOG_GRACE_MS = 5_000
+
+export interface DriverCallbacks {
+ onPageState: (state: BrowserPageState) => void
+ onTabsState: (state: BrowserTabsState) => void
+ onSessionStatus: (alive: boolean) => void
+ /** Whether the active tab shows a login form Sim holds a credential for. */
+ onFillAvailability: (available: boolean) => void
+}
+
+let driverCallbacks: DriverCallbacks | null = null
+let knownSessions: BrowserKnownSessionRegistry | null = null
+/** Kept so teardown can force its erasures past the settings write debounce. */
+let configStore: ConfigStore | null = null
+
+/**
+ * Page states auto-handled since the last tool result (dismissed dialogs,
+ * suppressed file choosers, blocked downloads). Attached to the next tool
+ * result so the model reacts to what actually happened on the page.
+ */
+let pendingNotices: string[] = []
+
+function recordNotice(notice: string): void {
+ if (pendingNotices.length < 10) pendingNotices.push(notice)
+}
+
+/**
+ * True while browser_request_takeover waits on the user. The Done chip on the
+ * chat's takeover tool row completes it via the `takeover-done` panel action;
+ * the state lives here (session-level, not in the page) so it survives
+ * navigations and tab switches.
+ */
+let takeoverActive = false
+let takeoverDone = false
+
+function pageStateFor(contents: WebContents, tabId: string): BrowserPageState {
+ return {
+ tabId,
+ url: contents.getURL(),
+ title: contents.getTitle(),
+ loading: contents.isLoading(),
+ canGoBack: contents.navigationHistory.canGoBack(),
+ canGoForward: contents.navigationHistory.canGoForward(),
+ }
+}
+
+function pushPageState(contents: WebContents): void {
+ if (contents.isDestroyed()) return
+ const active = session.activeTab()
+ if (active?.view.webContents !== contents) return
+ driverCallbacks?.onPageState(pageStateFor(contents, active.id))
+}
+
+let lastTabsStateFingerprint: string | null = null
+
+/**
+ * Pushes the tab list to the renderer, skipping a push identical to the last.
+ *
+ * Every tab's load and title events call this, background tabs included, and a
+ * site that rewrites its title on a timer fires them continuously — each an
+ * unconditional broadcast that rebuilt the whole strip. The fingerprint drops
+ * the repeats, matching what the terminal side already does with emitTabs.
+ */
+function pushTabsState(): void {
+ const state = session.getTabsState()
+ const fingerprint = JSON.stringify(state)
+ if (fingerprint === lastTabsStateFingerprint) return
+ lastTabsStateFingerprint = fingerprint
+ driverCallbacks?.onTabsState(state)
+}
+
+/** Instruments a fresh tab: CDP dialog/chooser handling + page-state pushes. */
+function instrumentTab(contents: WebContents): void {
+ void cdp
+ .ensureInstrumented(contents, {
+ onDialog: (dialog) => {
+ recordNotice(
+ `The page showed a ${dialog.type} dialog ("${dialog.message}") which was auto-dismissed.`
+ )
+ },
+ onFileChooser: () => {
+ recordNotice(
+ 'The page opened a file picker; native file uploads are not driven by the agent — ' +
+ 'the user can complete the upload directly in the browser panel if needed.'
+ )
+ },
+ })
+ .then(() => cdp.setColorScheme(contents, session.getBrowserTheme()))
+ .catch((error) => {
+ logger.warn('CDP instrumentation failed', {
+ error: getErrorMessage(error),
+ })
+ })
+ contents.on('did-navigate', () => {
+ knownSessions?.noteTopLevelNavigation(contents.getURL())
+ pushPageState(contents)
+ pushTabsState()
+ })
+ for (const event of [
+ 'did-navigate-in-page',
+ 'page-title-updated',
+ 'did-start-loading',
+ 'did-stop-loading',
+ ] as const) {
+ contents.on(event as 'did-navigate', () => {
+ pushPageState(contents)
+ pushTabsState()
+ })
+ }
+ driverCallbacks?.onSessionStatus(true)
+}
+
+export function initDriver(
+ callbacks: DriverCallbacks,
+ getMainWindow: () => BrowserWindow | null,
+ config?: ConfigStore
+): void {
+ driverCallbacks = callbacks
+ knownSessions = config ? new BrowserKnownSessionRegistry(config) : null
+ configStore = config ?? null
+ // The rest of this module's state is per-session too. Left behind, a new
+ // session inherits the previous one's pending notices, a takeover still
+ // waiting on a user who is gone, and a fingerprint that suppresses its very
+ // first tab push as a duplicate.
+ pendingNotices = []
+ takeoverActive = false
+ takeoverDone = false
+ lastTabsStateFingerprint = null
+ // The serialization chain, too. A takeover from the previous session can sit
+ // unresolved indefinitely, and its `takeoverDone` flag is reset above — so
+ // leaving the old chain head in place would queue the new session's first
+ // tool call behind a promise nothing can ever settle.
+ toolQueue = Promise.resolve()
+ initFillCoordinator({
+ getActiveContents: () => session.activeTab()?.view.webContents ?? null,
+ onAvailabilityChanged: (available) => callbacks.onFillAvailability(available),
+ })
+ session.initSession(
+ {
+ onSessionClosed: () => {
+ driverCallbacks?.onSessionStatus(false)
+ },
+ onTabCreated: instrumentTab,
+ onTabNavigated: (contents) => fillCoordinator()?.noteNavigation(contents),
+ onTabClosed: (contents) => fillCoordinator()?.forget(contents),
+ onActiveTabChanged: (contents) => {
+ pushPageState(contents)
+ // The fill affordance belongs to whichever page is in front.
+ void fillCoordinator()?.refreshAvailability()
+ },
+ onTabsChanged: pushTabsState,
+ onTabThemeChanged: (contents, theme) => {
+ void cdp.setColorScheme(contents, theme).catch((error) => {
+ logger.warn('Could not update browser tab theme', {
+ error: getErrorMessage(error),
+ })
+ })
+ },
+ onDownloadBlocked: (filename) => {
+ recordNotice(
+ `The page tried to download "${filename}"; downloads are not supported in the agent browser, so it was blocked.`
+ )
+ },
+ },
+ getMainWindow,
+ config
+ ? {
+ load: () => config.get('browserPinnedTabUrls'),
+ save: (urls) => config.set('browserPinnedTabUrls', urls),
+ }
+ : undefined
+ )
+}
+
+export async function getKnownSessions(): Promise {
+ if (!knownSessions) return { sessions: [] }
+ const cookieSignals = await session.listAgentCookieSignals()
+ return knownSessions.list(cookieSignals)
+}
+
+/**
+ * Cookies, site storage, cache, and the remembered browsing trail.
+ *
+ * Saved passwords are deliberately NOT touched. "Clear browsing data" is about
+ * signing out of websites, and a user who wanted to erase their password vault
+ * would have to say so separately — silently taking their credentials with it
+ * would be a destructive surprise.
+ */
+export async function clearBrowsingData(
+ kinds: readonly BrowserDataKind[] = BROWSER_DATA_KINDS
+): Promise {
+ // The remembered browsing trail is the local mirror of the cookie jar, so it
+ // goes when cookies do and stays when they do not.
+ if (kinds.includes('cookies')) knownSessions?.clear()
+ await session.clearAgentData(kinds)
+}
+
+/**
+ * Everything the embedded browser holds for the signed-in user, including the
+ * credential vault. This is the Sim sign-out path: a different account signing
+ * in on the same machine must not inherit the previous user's sessions or
+ * passwords.
+ */
+export async function clearBrowserProfile(): Promise {
+ knownSessions?.clear()
+ await session.clearProfileStorage()
+ await clearCredentials()
+ // Last, covering the pinned-tab list `clearProfileStorage` just emptied.
+ // Settings writes coalesce, and an erasure that is still sitting in that
+ // window when the process dies leaves the previous account's data on disk
+ // after sign-out already told the user it was gone.
+ configStore?.flush()
+}
+
+function str(params: Record, key: string): string | undefined {
+ const value = params[key]
+ return typeof value === 'string' && value.length > 0 ? value : undefined
+}
+
+function num(params: Record, key: string): number | undefined {
+ const value = params[key]
+ if (typeof value === 'number' && Number.isFinite(value)) return value
+ if (typeof value === 'string' && value.trim() !== '') {
+ const parsed = Number(value)
+ if (Number.isFinite(parsed)) return parsed
+ }
+ return undefined
+}
+
+/**
+ * Native execution must time out before the renderer gives up (30s default,
+ * 45s navigation, requested wait + 15s). Otherwise the abandoned native
+ * promise keeps owning the serialized queue and every later browser action
+ * times out behind it.
+ */
+export function browserToolWatchdogMs(
+ tool: BrowserToolName,
+ params: Record
+): number | null {
+ if (tool === 'browser_request_takeover') return null
+ if (
+ tool === 'browser_navigate' ||
+ tool === 'browser_open_url' ||
+ tool === 'browser_go_back' ||
+ tool === 'browser_go_forward' ||
+ tool === 'browser_open_tab'
+ ) {
+ return NAVIGATION_TOOL_WATCHDOG_MS
+ }
+ if (tool === 'browser_wait_for') {
+ const requested = Math.min(
+ num(params, 'timeoutMs') ?? DEFAULT_WAIT_FOR_TIMEOUT_MS,
+ MAX_WAIT_FOR_TIMEOUT_MS
+ )
+ return requested + WAIT_FOR_TOOL_WATCHDOG_GRACE_MS
+ }
+ return DEFAULT_TOOL_WATCHDOG_MS
+}
+
+function requireStr(params: Record, key: string): string {
+ const value = str(params, key)
+ if (value === undefined) throw new ToolError(`Missing required parameter "${key}"`)
+ return value
+}
+
+function requireNum(params: Record, key: string): number {
+ const value = num(params, key)
+ if (value === undefined) throw new ToolError(`Missing required numeric parameter "${key}"`)
+ return value
+}
+
+/**
+ * Serializes a self-contained page function and executes it in the page's
+ * main world with JSON-encoded arguments (Electron's executeJavaScript has no
+ * function+args transport like chrome.scripting).
+ */
+async function execInPage(
+ contents: WebContents,
+ fn: (...args: Args) => Result,
+ args: Args
+): Promise {
+ const url = contents.getURL()
+ if (url === '' || url === 'about:blank') {
+ throw new ToolError(
+ 'The active tab is blank. Call browser_navigate before using page inspection or interaction tools.'
+ )
+ }
+ const expression = `(${String(fn)}).apply(null, ${JSON.stringify(args)})`
+ try {
+ return (await contents.executeJavaScript(expression, true)) as Result
+ } catch (error) {
+ const message = getErrorMessage(error)
+ throw new ToolError(
+ `Cannot act on this page (${message}). Browser-internal pages cannot be automated — ` +
+ 'navigate to a regular website first.'
+ )
+ }
+}
+
+/**
+ * Covers focusing, clicking, and typing: the agent has no legitimate reason to
+ * reach a credential field, and takeover is the sanctioned path when a task
+ * needs one.
+ */
+const PASSWORD_REFUSAL =
+ 'Refusing to act on a password field. Call browser_request_takeover so the user ' +
+ 'can enter their credentials themselves.'
+
+/** Maps sentinel `{ error: ... }` results from injected functions to ToolErrors. */
+function unwrapPageResult(result: unknown): unknown {
+ if (isRecordLike(result) && 'error' in result) {
+ const code = (result as { error: string }).error
+ if (code === 'stale') {
+ throw new ToolError(
+ 'That element id is stale (the page changed since the last snapshot). ' +
+ 'Call browser_snapshot again and use a fresh id.'
+ )
+ }
+ if (code === 'password') {
+ throw new ToolError(PASSWORD_REFUSAL)
+ }
+ if (code === 'not-editable') {
+ throw new ToolError('That element is not a text input — pick an editable element.')
+ }
+ if (code === 'not-select') {
+ throw new ToolError('That element is not a dropdown.')
+ }
+ if (code === 'no-option') {
+ const options = (result as { options?: string[] }).options ?? []
+ throw new ToolError(
+ `No option matched that label or value. Available options: ${options.join(', ')}`
+ )
+ }
+ }
+ return result
+}
+
+function waitForLoadComplete(contents: WebContents, timeoutMs: number): Promise {
+ return new Promise((resolve) => {
+ let settled = false
+ const finish = () => {
+ if (settled) return
+ settled = true
+ contents.removeListener('did-stop-loading', finish)
+ contents.removeListener('destroyed', finish)
+ clearTimeout(timer)
+ resolve()
+ }
+ const timer = setTimeout(finish, timeoutMs)
+ contents.on('did-stop-loading', finish)
+ contents.on('destroyed', finish)
+ if (!contents.isLoading()) finish()
+ })
+}
+
+async function navigationResult(contents: WebContents): Promise> {
+ await waitForLoadComplete(contents, NAVIGATION_TIMEOUT_MS)
+ await sleep(NAVIGATION_SETTLE_MS)
+ if (contents.isDestroyed()) throw new ToolError('The tab was closed during navigation.')
+ return { url: contents.getURL(), title: contents.getTitle() }
+}
+
+/**
+ * Bounds a tool call, always clearing the timer once the race settles.
+ *
+ * Not `sleep()` — that timer cannot be cancelled, so racing against it leaves
+ * one pending for the full watchdog window (up to two minutes) after the tool
+ * has already finished, dozens at a time over an agent run.
+ */
+function raceAgainstWatchdog(execution: Promise, watchdogMs: number): Promise {
+ let timer: NodeJS.Timeout | undefined
+ const expiry = new Promise((_resolve, reject) => {
+ timer = setTimeout(
+ () =>
+ reject(
+ new ToolError(
+ 'The browser did not finish this action in time. Take a browser_snapshot to see the current page state.'
+ )
+ ),
+ watchdogMs
+ )
+ })
+ return Promise.race([execution, expiry]).finally(() => clearTimeout(timer))
+}
+
+/**
+ * Post-action readback so the model sees the real effect (selection size,
+ * value length) instead of assuming the key "worked".
+ */
+async function activeElementState(contents: WebContents): Promise> {
+ const state = await execInPage(contents, readActiveElementState, []).catch(() => null)
+ return isRecordLike(state) ? state : {}
+}
+
+/**
+ * Hands control to the user: the page is already natively interactive in the
+ * panel, and the chat's takeover tool row shows the reason with a Done chip.
+ * The tool resolves when that chip sends the `takeover-done` panel action.
+ * Nothing is injected into the page, so nothing covers page content and the
+ * pending state survives navigations.
+ */
+async function runTakeover(purpose: string | undefined): Promise {
+ const tab = session.ensureTab()
+ const contents = tab.view.webContents
+ takeoverActive = true
+ takeoverDone = false
+
+ const startedAt = Date.now()
+ try {
+ while (Date.now() - startedAt < TAKEOVER_MAX_MS) {
+ await sleep(TAKEOVER_POLL_MS)
+ if (!session.hasSession() || contents.isDestroyed()) {
+ throw new ToolError(
+ 'The browser session was closed during takeover. Ask the user what happened, then reopen with browser_navigate.'
+ )
+ }
+ if (takeoverDone) {
+ if (purpose === 'sign_in') {
+ const activeContents = session.activeTab()?.view.webContents
+ if (activeContents && !activeContents.isDestroyed()) {
+ knownSessions?.noteSignInCompleted(activeContents.getURL())
+ }
+ }
+ return { completed: true, elapsedMs: Date.now() - startedAt }
+ }
+ }
+ throw new ToolError('Takeover timed out after 12 hours without the user finishing.')
+ } finally {
+ takeoverActive = false
+ takeoverDone = false
+ }
+}
+
+async function executeToolInner(
+ tool: BrowserToolName,
+ params: Record
+): Promise {
+ switch (tool) {
+ case 'browser_navigate': {
+ const url = requireStr(params, 'url')
+ // Up-front SSRF check for a clean model-facing error. The partition's
+ // onBeforeRequest is the actual enforcement seam (it also catches
+ // page-initiated navigations); this pre-check exists because loadURL's
+ // rejection is swallowed below, so a blocked nav would otherwise surface
+ // as a blank page with no error.
+ const guard = await checkAgentUrl(url)
+ if (!guard.ok) {
+ throw new ToolError(guard.error ?? 'That address was blocked.')
+ }
+ const tab = session.ensureTab()
+ const contents = tab.view.webContents
+ // loadURL rejects on aborts/redirect races that are routine on real
+ // sites; the settled URL/title below is the truth worth reporting.
+ void contents.loadURL(url).catch(() => {})
+ return await navigationResult(contents)
+ }
+
+ case 'browser_open_url': {
+ // Composite navigate + snapshot: one round trip for the copilot's
+ // direct "open this page and look at it" tool, instead of two
+ // checkpoint/resume cycles for browser_navigate then browser_snapshot.
+ const url = requireStr(params, 'url')
+ const guard = await checkAgentUrl(url)
+ if (!guard.ok) {
+ throw new ToolError(guard.error ?? 'That address was blocked.')
+ }
+ const tab = session.ensureTab()
+ const contents = tab.view.webContents
+ void contents.loadURL(url).catch(() => {})
+ const nav = await navigationResult(contents)
+ // A failed snapshot (browser-internal page, injection error) should not
+ // fail the open itself — the page is on screen either way.
+ const snapshot = await execInPage(contents, collectSnapshot, []).catch(() => null)
+ return snapshot === null
+ ? { ...nav, note: 'The page loaded but a snapshot could not be captured.' }
+ : { ...nav, snapshot }
+ }
+
+ case 'browser_go_back':
+ case 'browser_go_forward': {
+ const contents = session.requireTab().view.webContents
+ const history = contents.navigationHistory
+ if (tool === 'browser_go_back') {
+ if (!history.canGoBack()) throw new ToolError('Cannot go back — no earlier history entry.')
+ history.goBack()
+ } else {
+ if (!history.canGoForward()) {
+ throw new ToolError('Cannot go forward — no later history entry.')
+ }
+ history.goForward()
+ }
+ return await navigationResult(contents)
+ }
+
+ case 'browser_open_tab': {
+ const url = str(params, 'url')
+ if (url) {
+ const guard = await checkAgentUrl(url)
+ if (!guard.ok) {
+ throw new ToolError(guard.error ?? 'That address was blocked.')
+ }
+ }
+ const tab = session.addTab()
+ const contents = tab.view.webContents
+ if (url) {
+ void contents.loadURL(url).catch(() => {})
+ const result = await navigationResult(contents)
+ return { tabId: tab.id, ...result }
+ }
+ return { tabId: tab.id, url: '', title: '' }
+ }
+
+ case 'browser_switch_tab': {
+ const tab = session.switchTab(requireStr(params, 'tabId'))
+ const contents = tab.view.webContents
+ return { tabId: tab.id, url: contents.getURL(), title: contents.getTitle() }
+ }
+
+ case 'browser_close_tab': {
+ const tabId = requireStr(params, 'tabId')
+ session.closeTab(tabId)
+ return { closed: tabId }
+ }
+
+ case 'browser_list_tabs': {
+ return session.getTabsState()
+ }
+
+ case 'browser_list_sessions': {
+ return await getKnownSessions()
+ }
+
+ case 'browser_wait_for': {
+ const text = str(params, 'text')
+ const timeoutMs = Math.min(
+ num(params, 'timeoutMs') ?? DEFAULT_WAIT_FOR_TIMEOUT_MS,
+ MAX_WAIT_FOR_TIMEOUT_MS
+ )
+ const startedAt = Date.now()
+ if (!text) {
+ await sleep(timeoutMs)
+ return { waitedMs: timeoutMs }
+ }
+ const contents = session.requireTab().view.webContents
+ while (Date.now() - startedAt < timeoutMs) {
+ const found = await execInPage(contents, pageContainsText, [text]).catch(() => false)
+ if (found) return { found: true, elapsedMs: Date.now() - startedAt }
+ await sleep(300)
+ }
+ return {
+ found: false,
+ elapsedMs: Date.now() - startedAt,
+ note: 'Text did not appear before the timeout. Take a browser_snapshot to see the current page state.',
+ }
+ }
+
+ case 'browser_snapshot': {
+ const contents = session.requireTab().view.webContents
+ return await execInPage(contents, collectSnapshot, [])
+ }
+
+ case 'browser_read_text': {
+ const contents = session.requireTab().view.webContents
+ return unwrapPageResult(await execInPage(contents, readPageText, [num(params, 'elementId')]))
+ }
+
+ case 'browser_screenshot': {
+ const contents = session.requireTab().view.webContents
+ const dataUrl = await cdp.captureScreenshot(contents).catch(() => null)
+ if (dataUrl === null) {
+ throw new ToolError(
+ 'Could not capture the page. Use browser_snapshot or browser_read_text instead.'
+ )
+ }
+ const viewport = await execInPage(contents, getViewportInfo, []).catch(() => null)
+ return { dataUrl, viewport }
+ }
+
+ case 'browser_extract': {
+ const instruction = requireStr(params, 'instruction')
+ const contents = session.requireTab().view.webContents
+ const page = await execInPage(contents, readPageText, [undefined])
+ return { instruction, page }
+ }
+
+ case 'browser_click': {
+ const contents = session.requireTab().view.webContents
+ return unwrapPageResult(
+ await execInPage(contents, clickElement, [requireNum(params, 'elementId')])
+ )
+ }
+
+ case 'browser_type': {
+ const elementId = requireNum(params, 'elementId')
+ const text = requireStr(params, 'text')
+ const submit = params.submit === true
+ const contents = session.requireTab().view.webContents
+
+ // Native path: focus + select current content, then insert through the
+ // IME pipeline so the text REPLACES what's there — the only write path
+ // code editors (CodeMirror/Monaco) honor. The DOM selection set by the
+ // page function covers plain fields; the real select-all keystroke
+ // right after covers editors that track selection in their own model
+ // (their keymaps handle it synchronously, where DOM-selection sync is
+ // async and can lose a race with the insert). Falls back to the
+ // synthetic value-setter when CDP is unavailable.
+ unwrapPageResult(await execInPage(contents, focusElementForTyping, [elementId]))
+ try {
+ await dispatchKeyCombo(
+ contents,
+ parseKeyCombo(process.platform === 'darwin' ? 'Cmd+A' : 'Control+A')
+ )
+ // The guard above vetted the element we asked to focus, but the insert
+ // below goes wherever focus actually is now, a round trip later. Login
+ // forms that auto-advance from username to password move it in exactly
+ // that window, so re-check the real target before sending text.
+ // Deliberately after the select-all rather than before it: this needs
+ // to sit as close to the write as possible. The cost is that a field
+ // which stole focus may end up with its contents selected — it is
+ // never read, and nothing is inserted into it.
+ if ((await execInPage(contents, activeElementSecrecy, []).catch(() => 'safe')) !== 'safe') {
+ throw new ToolError(PASSWORD_REFUSAL)
+ }
+ await cdp.insertText(contents, text)
+ } catch (error) {
+ // A refusal is a decision, not a CDP failure — it must not be retried
+ // through the synthetic path.
+ if (error instanceof ToolError) throw error
+ return unwrapPageResult(
+ await execInPage(contents, typeIntoElement, [elementId, text, submit])
+ )
+ }
+ if (submit) {
+ await dispatchKeyCombo(contents, parseKeyCombo('Enter')).catch(() => {})
+ }
+ const state = await activeElementState(contents)
+ return { typed: true, replacedExisting: true, submitted: submit, ...state }
+ }
+
+ case 'browser_press_key': {
+ const combo = parseKeyCombo(requireStr(params, 'key'))
+ const contents = session.requireTab().view.webContents
+ // Pasting would move the user's clipboard into the page, where the next
+ // snapshot reports it as an ordinary field value — clipboards routinely
+ // hold a password copied out of a password manager. Copy and cut would
+ // overwrite whatever the user had there.
+ if (comboTouchesClipboard(combo)) {
+ throw new ToolError(
+ 'Refusing to use a clipboard shortcut. The clipboard belongs to the user and may ' +
+ 'hold credentials. Use browser_type to enter text.'
+ )
+ }
+ // Trusted CDP key events never enter the page, so they cannot be vetted
+ // from inside it — a focused credential field has to be ruled out here,
+ // before dispatch. Probe failures (blank or uninjectable page) fall
+ // through as safe: such a page has no field to protect.
+ const secrecy = await execInPage(contents, activeElementSecrecy, []).catch(() => 'safe')
+ if (secrecy === 'secret') {
+ throw new ToolError(PASSWORD_REFUSAL)
+ }
+ if (secrecy === 'opaque' && comboInsertsText(combo)) {
+ throw new ToolError(
+ 'Focus is inside a cross-origin frame whose contents cannot be inspected, so this ' +
+ 'keystroke could land in a password field. Call browser_request_takeover if the ' +
+ 'user needs to type here.'
+ )
+ }
+ try {
+ await dispatchKeyCombo(contents, combo)
+ } catch {
+ // CDP unavailable (debugger detached): synthetic DOM fallback. It
+ // cannot trigger default editing actions, so say so in the result.
+ const fallback = await execInPage(contents, pressKeyOnPage, [
+ combo.key,
+ combo.code,
+ combo.keyCode,
+ combo.ctrl,
+ combo.meta,
+ combo.shift,
+ combo.alt,
+ ])
+ return {
+ ...(isRecordLike(fallback) ? fallback : {}),
+ note: 'Delivered as a synthetic page event; editing shortcuts may not take effect.',
+ }
+ }
+ const state = await activeElementState(contents)
+ return { pressed: requireStr(params, 'key'), ...state }
+ }
+
+ case 'browser_scroll': {
+ const contents = session.requireTab().view.webContents
+ return await execInPage(contents, scrollPage, [
+ requireStr(params, 'direction'),
+ num(params, 'amount'),
+ ])
+ }
+
+ case 'browser_select_option': {
+ const contents = session.requireTab().view.webContents
+ return unwrapPageResult(
+ await execInPage(contents, selectOptionInElement, [
+ requireNum(params, 'elementId'),
+ requireStr(params, 'value'),
+ ])
+ )
+ }
+
+ case 'browser_hover': {
+ const contents = session.requireTab().view.webContents
+ return unwrapPageResult(
+ await execInPage(contents, hoverElement, [requireNum(params, 'elementId')])
+ )
+ }
+
+ case 'browser_request_takeover': {
+ // The reason renders in the chat's tool row, not here — but require it
+ // so the model always tells the user why control was handed over.
+ requireStr(params, 'reason')
+ return await runTakeover(str(params, 'purpose'))
+ }
+
+ default: {
+ const exhaustive: never = tool
+ throw new ToolError(`Unknown tool: ${String(exhaustive)}`)
+ }
+ }
+}
+
+/**
+ * Attaches auto-handled page-state notices (dismissed dialogs, suppressed
+ * file choosers, blocked downloads) to the outgoing result so the model
+ * learns what happened without a dedicated tool.
+ */
+function withNotices(result: unknown): unknown {
+ if (pendingNotices.length === 0) return result
+ const notices = pendingNotices
+ pendingNotices = []
+ if (isRecordLike(result)) {
+ return { ...(result as Record), notices }
+ }
+ return { value: result, notices }
+}
+
+/** One real browser can only do one thing at a time — serialize tool calls. */
+let toolQueue: Promise = Promise.resolve()
+
+export async function executeTool(
+ tool: BrowserToolName,
+ params: Record
+): Promise<{ ok: boolean; result?: unknown; error?: string }> {
+ const run = async () => {
+ logger.info('Executing browser tool', { tool })
+ const keepHiddenPageActive = tool !== 'browser_request_takeover'
+ if (keepHiddenPageActive) {
+ session.setAutomationActive(true)
+ }
+ try {
+ const execution = executeToolInner(tool, params)
+ const watchdogMs = browserToolWatchdogMs(tool, params)
+ return withNotices(
+ await (watchdogMs === null ? execution : raceAgainstWatchdog(execution, watchdogMs))
+ )
+ } finally {
+ if (keepHiddenPageActive) {
+ session.setAutomationActive(false)
+ }
+ }
+ }
+
+ const settled = toolQueue.then(run, run)
+ toolQueue = settled.catch(() => {})
+ try {
+ return { ok: true, result: await settled }
+ } catch (error) {
+ const message = getErrorMessage(error)
+ logger.warn('Browser tool failed', { tool, error: message })
+ return { ok: false, error: message }
+ }
+}
+
+/** Browser-chrome commands from the panel header; fire-and-forget. */
+export async function handlePanelAction(action: BrowserPanelAction): Promise {
+ // The Done chip on the chat's takeover tool row: hands control back to the
+ // agent. Meaningful only while a takeover is actually waiting.
+ if (action.action === 'takeover-done') {
+ if (takeoverActive) takeoverDone = true
+ return
+ }
+ // Navigate bootstraps the session: the user can open the panel manually
+ // (before the agent ever touched the browser) and drive it from the URL
+ // bar. The other chrome actions need an existing page.
+ if (action.action === 'navigate') {
+ if (typeof action.url === 'string' && /^https?:\/\//i.test(action.url)) {
+ const contents = session.ensureTab().view.webContents
+ void contents.loadURL(action.url).catch(() => {})
+ }
+ return
+ }
+ if (action.action === 'new-tab') {
+ session.addTab()
+ return
+ }
+ if (action.action === 'duplicate-tab') {
+ if (typeof action.tabId === 'string') {
+ session.duplicateTab(action.tabId)
+ }
+ return
+ }
+ if (action.action === 'switch-tab') {
+ if (typeof action.tabId === 'string') {
+ session.switchTab(action.tabId)
+ }
+ return
+ }
+ if (action.action === 'close-tab') {
+ if (typeof action.tabId === 'string') {
+ session.closeTab(action.tabId)
+ }
+ return
+ }
+ const tab = session.activeTab()
+ if (!tab) return
+ const contents = tab.view.webContents
+ switch (action.action) {
+ case 'reload':
+ contents.reload()
+ return
+ case 'back':
+ if (contents.navigationHistory.canGoBack()) contents.navigationHistory.goBack()
+ return
+ case 'forward':
+ if (contents.navigationHistory.canGoForward()) contents.navigationHistory.goForward()
+ return
+ default:
+ return
+ }
+}
diff --git a/apps/desktop/src/main/browser-agent/errors.ts b/apps/desktop/src/main/browser-agent/errors.ts
new file mode 100644
index 00000000000..33043e8a33c
--- /dev/null
+++ b/apps/desktop/src/main/browser-agent/errors.ts
@@ -0,0 +1,2 @@
+/** A tool-level failure whose message is safe to surface to the model. */
+export class ToolError extends Error {}
diff --git a/apps/desktop/src/main/browser-agent/keyboard.test.ts b/apps/desktop/src/main/browser-agent/keyboard.test.ts
new file mode 100644
index 00000000000..e775ed00948
--- /dev/null
+++ b/apps/desktop/src/main/browser-agent/keyboard.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('electron', () => import('@/test/electron-mock'))
+
+import { buildKeyDispatchPlan, parseKeyCombo } from '@/main/browser-agent/keyboard'
+
+describe('parseKeyCombo', () => {
+ it('parses named keys, letters, and modifier combos', () => {
+ expect(parseKeyCombo('Enter')).toMatchObject({ key: 'Enter', keyCode: 13 })
+ expect(parseKeyCombo('esc')).toMatchObject({ key: 'Escape', keyCode: 27 })
+ expect(parseKeyCombo('a')).toMatchObject({ key: 'a', code: 'KeyA' })
+ expect(parseKeyCombo('Control+A')).toMatchObject({ key: 'a', ctrl: true })
+ expect(parseKeyCombo('Shift+a')).toMatchObject({ key: 'A', shift: true })
+ expect(parseKeyCombo('Cmd+Shift+Z')).toMatchObject({
+ key: 'Z',
+ meta: true,
+ shift: true,
+ })
+ expect(parseKeyCombo('5')).toMatchObject({ key: '5', code: 'Digit5' })
+ })
+
+ it('rejects unknown keys and modifiers', () => {
+ expect(() => parseKeyCombo('Hyper+X')).toThrow(/Unrecognized modifier/)
+ expect(() => parseKeyCombo('NotAKey')).toThrow(/Unrecognized key/)
+ })
+})
+
+describe('buildKeyDispatchPlan', () => {
+ it('carries text for printable keys so Blink inserts the character', () => {
+ const [down, up] = buildKeyDispatchPlan(parseKeyCombo('a'), 'linux')
+ expect(down).toMatchObject({ type: 'keyDown', text: 'a', key: 'a', modifiers: 0 })
+ expect(up).toMatchObject({ type: 'keyUp', key: 'a' })
+ })
+
+ it('sends Enter with a carriage return so defaults fire (form submit)', () => {
+ const [down] = buildKeyDispatchPlan(parseKeyCombo('Enter'), 'linux')
+ expect(down).toMatchObject({ type: 'keyDown', text: '\r', windowsVirtualKeyCode: 13 })
+ })
+
+ it('sends editing keys as rawKeyDown without text', () => {
+ const [down] = buildKeyDispatchPlan(parseKeyCombo('Backspace'), 'linux')
+ expect(down.type).toBe('rawKeyDown')
+ expect(down.text).toBeUndefined()
+ })
+
+ it('maps Cmd shortcuts to Blink editing commands on macOS only', () => {
+ const combo = parseKeyCombo('Cmd+A')
+ const [macDown] = buildKeyDispatchPlan(combo, 'darwin')
+ expect(macDown.commands).toEqual(['selectAll'])
+ expect(macDown.modifiers).toBe(4)
+ const [linuxDown] = buildKeyDispatchPlan(combo, 'linux')
+ expect(linuxDown.commands).toBeUndefined()
+ })
+
+ it('treats Control shortcuts as Cmd on macOS (the model does not know the host OS)', () => {
+ const combo = parseKeyCombo('Control+A')
+ const [macDown] = buildKeyDispatchPlan(combo, 'darwin')
+ expect(macDown.commands).toEqual(['selectAll'])
+ expect(macDown.modifiers).toBe(4) // ctrl normalized away, meta set
+ // On Linux/Windows Ctrl+A is Blink-native; no rewrite.
+ const [linuxDown] = buildKeyDispatchPlan(combo, 'linux')
+ expect(linuxDown.modifiers).toBe(2)
+ expect(linuxDown.commands).toBeUndefined()
+ })
+
+ it('does not rewrite non-editing Control combos on macOS', () => {
+ const [down] = buildKeyDispatchPlan(parseKeyCombo('Control+K'), 'darwin')
+ expect(down.modifiers).toBe(2)
+ expect(down.commands).toBeUndefined()
+ })
+
+ it('maps Cmd+Shift+Z to redo and Cmd+Z to undo on macOS', () => {
+ const [redo] = buildKeyDispatchPlan(parseKeyCombo('Cmd+Shift+Z'), 'darwin')
+ expect(redo.commands).toEqual(['redo'])
+ const [undo] = buildKeyDispatchPlan(parseKeyCombo('Cmd+Z'), 'darwin')
+ expect(undo.commands).toEqual(['undo'])
+ })
+
+ it('encodes the CDP modifier bitmask (Alt=1 Ctrl=2 Meta=4 Shift=8)', () => {
+ const [down] = buildKeyDispatchPlan(parseKeyCombo('Control+Shift+K'), 'linux')
+ expect(down.modifiers).toBe(2 | 8)
+ // Modified letters must not carry text — they are shortcuts, not typing.
+ expect(down.type).toBe('rawKeyDown')
+ expect(down.text).toBeUndefined()
+ })
+})
diff --git a/apps/desktop/src/main/browser-agent/keyboard.ts b/apps/desktop/src/main/browser-agent/keyboard.ts
new file mode 100644
index 00000000000..67c1f883911
--- /dev/null
+++ b/apps/desktop/src/main/browser-agent/keyboard.ts
@@ -0,0 +1,191 @@
+/**
+ * Keyboard machinery for `browser_press_key` and internal key dispatch:
+ * parsing "Cmd+Shift+Z"-style combos and building the trusted CDP
+ * keyDown/keyUp pair. Pure logic except {@link dispatchKeyCombo}.
+ */
+import type { WebContents } from 'electron'
+import * as cdp from '@/main/browser-agent/cdp'
+import { ToolError } from '@/main/browser-agent/errors'
+
+interface KeyDescriptor {
+ key: string
+ code: string
+ keyCode: number
+}
+
+const NAMED_KEYS: Record = {
+ enter: { key: 'Enter', code: 'Enter', keyCode: 13 },
+ escape: { key: 'Escape', code: 'Escape', keyCode: 27 },
+ esc: { key: 'Escape', code: 'Escape', keyCode: 27 },
+ tab: { key: 'Tab', code: 'Tab', keyCode: 9 },
+ backspace: { key: 'Backspace', code: 'Backspace', keyCode: 8 },
+ delete: { key: 'Delete', code: 'Delete', keyCode: 46 },
+ space: { key: ' ', code: 'Space', keyCode: 32 },
+ arrowup: { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 },
+ arrowdown: { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 },
+ arrowleft: { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 },
+ arrowright: { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },
+ up: { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 },
+ down: { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 },
+ left: { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 },
+ right: { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },
+ home: { key: 'Home', code: 'Home', keyCode: 36 },
+ end: { key: 'End', code: 'End', keyCode: 35 },
+ pageup: { key: 'PageUp', code: 'PageUp', keyCode: 33 },
+ pagedown: { key: 'PageDown', code: 'PageDown', keyCode: 34 },
+}
+
+export interface ParsedCombo extends KeyDescriptor {
+ ctrl: boolean
+ meta: boolean
+ shift: boolean
+ alt: boolean
+}
+
+export function parseKeyCombo(combo: string): ParsedCombo {
+ const parts = combo
+ .split('+')
+ .map((part) => part.trim())
+ .filter(Boolean)
+ if (parts.length === 0) throw new ToolError(`Unrecognized key: "${combo}"`)
+ const modifiers = { ctrl: false, meta: false, shift: false, alt: false }
+ const keyPart = parts[parts.length - 1]
+ for (const part of parts.slice(0, -1)) {
+ const lower = part.toLowerCase()
+ if (lower === 'control' || lower === 'ctrl') modifiers.ctrl = true
+ else if (lower === 'meta' || lower === 'cmd' || lower === 'command') modifiers.meta = true
+ else if (lower === 'shift') modifiers.shift = true
+ else if (lower === 'alt' || lower === 'option') modifiers.alt = true
+ else throw new ToolError(`Unrecognized modifier: "${part}"`)
+ }
+ const named = NAMED_KEYS[keyPart.toLowerCase()]
+ if (named) return { ...named, ...modifiers }
+ if (/^[a-zA-Z]$/.test(keyPart)) {
+ const upper = keyPart.toUpperCase()
+ const key = modifiers.shift ? upper : keyPart.toLowerCase()
+ return { key, code: `Key${upper}`, keyCode: upper.charCodeAt(0), ...modifiers }
+ }
+ if (/^[0-9]$/.test(keyPart)) {
+ return { key: keyPart, code: `Digit${keyPart}`, keyCode: keyPart.charCodeAt(0), ...modifiers }
+ }
+ if (keyPart.length === 1) {
+ return { key: keyPart, code: '', keyCode: keyPart.charCodeAt(0), ...modifiers }
+ }
+ throw new ToolError(`Unrecognized key: "${keyPart}"`)
+}
+
+/** CDP `Input` modifier bitmask: Alt=1, Ctrl=2, Meta=4, Shift=8. */
+function cdpModifiers(combo: ParsedCombo): number {
+ return (combo.alt ? 1 : 0) | (combo.ctrl ? 2 : 0) | (combo.meta ? 4 : 0) | (combo.shift ? 8 : 0)
+}
+
+/**
+ * Clipboard shortcuts are deliberately absent. Paste would let an agent move
+ * the user's clipboard — which routinely holds a password copied out of a
+ * password manager — into a page field, where the next snapshot reports it as
+ * an ordinary `value`. Copy and cut would let it overwrite the clipboard. See
+ * {@link comboTouchesClipboard}, which refuses them outright.
+ */
+function editingCommandFor(combo: ParsedCombo): string | null {
+ switch (combo.key.toLowerCase()) {
+ case 'a':
+ return 'selectAll'
+ case 'z':
+ return combo.shift ? 'redo' : 'undo'
+ default:
+ return null
+ }
+}
+
+/**
+ * Whether a combo would drive the system clipboard. Checked before dispatch
+ * rather than by withholding the CDP `commands` array: off macOS these are
+ * Blink-native, so a key event alone still performs them.
+ */
+export function comboTouchesClipboard(combo: ParsedCombo): boolean {
+ if (!combo.ctrl && !combo.meta) return false
+ const key = combo.key.toLowerCase()
+ return key === 'v' || key === 'c' || key === 'x'
+}
+
+/**
+ * On macOS the editing shortcuts are bound in the system menu layer, which
+ * CDP key events never traverse — so Blink must be told the editing command
+ * explicitly (same technique as Puppeteer/Playwright). The model doesn't know
+ * the host OS and often says "Control+A", so on macOS Ctrl is treated as Cmd
+ * for these shortcuts: both must select all, not silently no-op. On other
+ * platforms Ctrl+key is handled inside Blink and needs no help.
+ */
+function normalizeComboForPlatform(combo: ParsedCombo, platform: NodeJS.Platform): ParsedCombo {
+ if (platform !== 'darwin' || !combo.ctrl || combo.meta || editingCommandFor(combo) === null) {
+ return combo
+ }
+ return { ...combo, ctrl: false, meta: true }
+}
+
+function macEditingCommands(combo: ParsedCombo, platform: NodeJS.Platform): string[] {
+ if (platform !== 'darwin' || !combo.meta) return []
+ const command = editingCommandFor(combo)
+ return command ? [command] : []
+}
+
+/**
+ * The characters a combo would insert, or undefined when it only moves the
+ * caret or triggers an editing command. Enter counts: it carries "\r" and
+ * activates defaults such as form submission.
+ */
+function insertedTextFor(combo: ParsedCombo): string | undefined {
+ if (combo.key === 'Enter') return '\r'
+ const printable = combo.key.length === 1 && !combo.ctrl && !combo.meta
+ return printable ? combo.key : undefined
+}
+
+/**
+ * Whether a combo would put characters into whatever the page has focused.
+ * Shares {@link insertedTextFor} with the dispatcher so a guard built on this
+ * cannot drift from what is actually sent.
+ */
+export function comboInsertsText(
+ rawCombo: ParsedCombo,
+ platform: NodeJS.Platform = process.platform
+): boolean {
+ return insertedTextFor(normalizeComboForPlatform(rawCombo, platform)) !== undefined
+}
+
+/**
+ * Builds the trusted keyDown/keyUp pair for a combo. Printable keys without
+ * ctrl/meta carry `text` so Blink inserts the character; Enter carries "\r"
+ * so it activates defaults (form submission, newline). Everything else is a
+ * rawKeyDown, which still drives Blink's default editing actions (Backspace
+ * deletes, arrows move the caret, Ctrl/Cmd+A selects all).
+ */
+export function buildKeyDispatchPlan(
+ rawCombo: ParsedCombo,
+ platform: NodeJS.Platform = process.platform
+): [cdp.CdpKeyEvent, cdp.CdpKeyEvent] {
+ const combo = normalizeComboForPlatform(rawCombo, platform)
+ const modifiers = cdpModifiers(combo)
+ const base = {
+ modifiers,
+ key: combo.key,
+ code: combo.code,
+ windowsVirtualKeyCode: combo.keyCode,
+ nativeVirtualKeyCode: combo.keyCode,
+ }
+ const text = insertedTextFor(combo)
+ const commands = macEditingCommands(combo, platform)
+ const down: cdp.CdpKeyEvent = {
+ ...base,
+ type: text !== undefined ? 'keyDown' : 'rawKeyDown',
+ ...(text !== undefined ? { text } : {}),
+ ...(commands.length > 0 ? { commands } : {}),
+ }
+ return [down, { ...base, type: 'keyUp' }]
+}
+
+/** Presses a combo through the trusted pipeline. Throws on CDP failure. */
+export async function dispatchKeyCombo(contents: WebContents, combo: ParsedCombo): Promise {
+ const [down, up] = buildKeyDispatchPlan(combo)
+ await cdp.dispatchKeyEvent(contents, down)
+ await cdp.dispatchKeyEvent(contents, up)
+}
diff --git a/apps/desktop/src/main/browser-agent/known-sessions.ts b/apps/desktop/src/main/browser-agent/known-sessions.ts
new file mode 100644
index 00000000000..2fd23a97da7
--- /dev/null
+++ b/apps/desktop/src/main/browser-agent/known-sessions.ts
@@ -0,0 +1,212 @@
+import { isIP } from 'node:net'
+import type {
+ BrowserKnownSession,
+ BrowserKnownSessionsState,
+ BrowserSessionEvidence,
+} from '@sim/browser-protocol'
+import type { BrowserKnownSiteSetting, ConfigStore, DesktopSettings } from '@/main/config'
+
+const MAX_STORED_SITES = 100
+const MAX_EXPOSED_SESSIONS = 20
+const SITE_MAX_AGE_MS = 180 * 24 * 60 * 60 * 1000
+const VISIT_WRITE_INTERVAL_MS = 60 * 60 * 1000
+
+export interface BrowserCookieSignal {
+ domain: string
+}
+
+function parseTimestamp(value: unknown): number | null {
+ if (typeof value !== 'string') return null
+ const timestamp = Date.parse(value)
+ return Number.isFinite(timestamp) ? timestamp : null
+}
+
+export function normalizePublicHostname(value: unknown): string | null {
+ if (typeof value !== 'string') return null
+ const candidate = value
+ .trim()
+ .toLowerCase()
+ .replace(/^\.+|\.+$/g, '')
+ if (
+ candidate.length === 0 ||
+ candidate.length > 253 ||
+ !candidate.includes('.') ||
+ isIP(candidate) !== 0 ||
+ !/^[a-z0-9.-]+$/.test(candidate)
+ ) {
+ return null
+ }
+ try {
+ const parsed = new URL(`https://${candidate}`)
+ return parsed.hostname === candidate ? candidate : null
+ } catch {
+ return null
+ }
+}
+
+function hostnameFromUrl(rawUrl: string): string | null {
+ try {
+ const url = new URL(rawUrl)
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
+ return normalizePublicHostname(url.hostname)
+ } catch {
+ return null
+ }
+}
+
+function sanitizeRecords(
+ raw: DesktopSettings['browserKnownSites'],
+ now: number
+): BrowserKnownSiteSetting[] {
+ if (!Array.isArray(raw)) return []
+ const cutoff = now - SITE_MAX_AGE_MS
+ const byHostname = new Map()
+ for (const candidate of raw) {
+ if (typeof candidate !== 'object' || candidate === null) continue
+ const hostname = normalizePublicHostname(candidate.hostname)
+ const lastVisitedAtMs = parseTimestamp(candidate.lastVisitedAt)
+ if (
+ !hostname ||
+ lastVisitedAtMs === null ||
+ lastVisitedAtMs < cutoff ||
+ lastVisitedAtMs > now
+ ) {
+ continue
+ }
+ const signInCompletedAtMs = parseTimestamp(candidate.signInCompletedAt)
+ const normalized: BrowserKnownSiteSetting = {
+ hostname,
+ lastVisitedAt: new Date(lastVisitedAtMs).toISOString(),
+ ...(signInCompletedAtMs !== null &&
+ signInCompletedAtMs >= cutoff &&
+ signInCompletedAtMs <= now
+ ? { signInCompletedAt: new Date(signInCompletedAtMs).toISOString() }
+ : {}),
+ }
+ const existing = byHostname.get(hostname)
+ if (!existing || Date.parse(existing.lastVisitedAt) < lastVisitedAtMs) {
+ byHostname.set(hostname, normalized)
+ } else if (normalized.signInCompletedAt) {
+ const previousSignIn = parseTimestamp(existing.signInCompletedAt) ?? 0
+ if (signInCompletedAtMs !== null && signInCompletedAtMs > previousSignIn) {
+ existing.signInCompletedAt = normalized.signInCompletedAt
+ }
+ }
+ }
+ return [...byHostname.values()]
+ .sort((a, b) => Date.parse(b.lastVisitedAt) - Date.parse(a.lastVisitedAt))
+ .slice(0, MAX_STORED_SITES)
+}
+
+function recordsEqual(
+ left: BrowserKnownSiteSetting[] | undefined,
+ right: BrowserKnownSiteSetting[]
+): boolean {
+ return JSON.stringify(left ?? []) === JSON.stringify(right)
+}
+
+function cookieMatchesHost(cookieDomain: string, hostname: string): boolean {
+ const domain = normalizePublicHostname(cookieDomain)
+ return domain !== null && (hostname === domain || hostname.endsWith(`.${domain}`))
+}
+
+/**
+ * Tracks only top-level hostnames and coarse evidence in the local desktop
+ * settings file. It deliberately cannot retain cookie material or page paths.
+ */
+export class BrowserKnownSessionRegistry {
+ constructor(
+ private readonly config: ConfigStore,
+ private readonly now: () => number = Date.now
+ ) {}
+
+ noteTopLevelNavigation(rawUrl: string): void {
+ const hostname = hostnameFromUrl(rawUrl)
+ if (!hostname) return
+ const now = this.now()
+ const records = sanitizeRecords(this.config.get('browserKnownSites'), now)
+ const existing = records.find((record) => record.hostname === hostname)
+ if (existing && now - Date.parse(existing.lastVisitedAt) < VISIT_WRITE_INTERVAL_MS) {
+ return
+ }
+ const updated: BrowserKnownSiteSetting = {
+ hostname,
+ lastVisitedAt: new Date(now).toISOString(),
+ ...(existing?.signInCompletedAt ? { signInCompletedAt: existing.signInCompletedAt } : {}),
+ }
+ this.config.set(
+ 'browserKnownSites',
+ [updated, ...records.filter((record) => record.hostname !== hostname)].slice(
+ 0,
+ MAX_STORED_SITES
+ )
+ )
+ }
+
+ noteSignInCompleted(rawUrl: string): void {
+ const hostname = hostnameFromUrl(rawUrl)
+ if (!hostname) return
+ const now = this.now()
+ const observedAt = new Date(now).toISOString()
+ const records = sanitizeRecords(this.config.get('browserKnownSites'), now)
+ const updated: BrowserKnownSiteSetting = {
+ hostname,
+ lastVisitedAt: observedAt,
+ signInCompletedAt: observedAt,
+ }
+ this.config.set(
+ 'browserKnownSites',
+ [updated, ...records.filter((record) => record.hostname !== hostname)].slice(
+ 0,
+ MAX_STORED_SITES
+ )
+ )
+ }
+
+ /**
+ * Forgets every remembered site. The record is a browsing trail scoped to
+ * whoever was signed in, so Sim sign-out must not leave it for the next
+ * account.
+ */
+ clear(): void {
+ this.config.set('browserKnownSites', [])
+ // Not left to the debounce. Ordinary writes here can afford to coalesce,
+ // but this one is an erasure the user asked for: if the process dies in
+ // the coalescing window — force quit, crash, OS shutdown — the previous
+ // account's browsing trail is still on disk for whoever signs in next,
+ // and sign-out has already reported success.
+ this.config.flush()
+ }
+
+ list(cookieSignals: BrowserCookieSignal[]): BrowserKnownSessionsState {
+ const now = this.now()
+ const stored = this.config.get('browserKnownSites')
+ const records = sanitizeRecords(stored, now)
+ if (!recordsEqual(stored, records)) {
+ this.config.set('browserKnownSites', records)
+ }
+
+ const sessions: BrowserKnownSession[] = []
+ for (const record of records) {
+ let evidence: BrowserSessionEvidence | null = null
+ let lastObservedAt = record.lastVisitedAt
+ if (record.signInCompletedAt) {
+ evidence = 'sign-in-completed'
+ lastObservedAt = record.signInCompletedAt
+ } else if (
+ cookieSignals.some((cookie) => cookieMatchesHost(cookie.domain, record.hostname))
+ ) {
+ evidence = 'cookies'
+ }
+ if (evidence) {
+ sessions.push({ hostname: record.hostname, evidence, lastObservedAt })
+ }
+ }
+
+ sessions.sort((a, b) => {
+ if (a.evidence !== b.evidence) return a.evidence === 'sign-in-completed' ? -1 : 1
+ return Date.parse(b.lastObservedAt) - Date.parse(a.lastObservedAt)
+ })
+ return { sessions: sessions.slice(0, MAX_EXPOSED_SESSIONS) }
+ }
+}
diff --git a/apps/desktop/src/main/browser-agent/page-functions.test.ts b/apps/desktop/src/main/browser-agent/page-functions.test.ts
new file mode 100644
index 00000000000..dae391901b8
--- /dev/null
+++ b/apps/desktop/src/main/browser-agent/page-functions.test.ts
@@ -0,0 +1,423 @@
+// @vitest-environment jsdom
+
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import {
+ activeElementSecrecy,
+ clickElement,
+ collectSnapshot,
+ focusElementForTyping,
+ getViewportInfo,
+ hoverElement,
+ pageContainsText,
+ pressKeyOnPage,
+ readActiveElementState,
+ readPageText,
+ scrollPage,
+ selectOptionInElement,
+ typeIntoElement,
+} from '@/main/browser-agent/page-functions'
+
+/**
+ * These functions are serialized and run inside an arbitrary page, so they are
+ * exercised here against a real DOM rather than mocks. jsdom omits a few
+ * layout and pointer APIs the functions touch; the shims below supply the
+ * minimum for the code paths under test, and `visible()` makes an element pass
+ * `collectSnapshot`'s zero-size visibility filter.
+ */
+function installDomShims(): void {
+ if (!('PointerEvent' in globalThis)) {
+ // jsdom omits PointerEvent; MouseEvent carries the fields clickElement sets.
+ Object.defineProperty(globalThis, 'PointerEvent', {
+ value: MouseEvent,
+ configurable: true,
+ })
+ }
+ Element.prototype.scrollIntoView = () => {}
+ window.scrollBy = () => {}
+ if (!('innerText' in HTMLElement.prototype)) {
+ Object.defineProperty(HTMLElement.prototype, 'innerText', {
+ get(this: HTMLElement) {
+ return this.textContent ?? ''
+ },
+ configurable: true,
+ })
+ }
+}
+
+/**
+ * Runs a page function the way the driver does — serialized to source and
+ * evaluated with only globals in scope. `new Function` deliberately skips the
+ * module's lexical scope, so anything the function reaches for outside itself
+ * fails here exactly as it would in a real page.
+ */
+function runSerialized(fn: (...args: never[]) => unknown, args: unknown[]): unknown {
+ const expression = `(${String(fn)}).apply(null, ${JSON.stringify(args)})`
+ return new Function(`return ${expression}`)()
+}
+
+function visible(el: T): T {
+ el.getBoundingClientRect = () =>
+ ({ x: 0, y: 0, width: 100, height: 20, top: 0, left: 0, right: 100, bottom: 20 }) as DOMRect
+ return el
+}
+
+/**
+ * Overrides the focus getter instead of calling `focus()`: jsdom's focus
+ * handling varies across element types and frames, and every guard under test
+ * keys off `document.activeElement`, not on real focus.
+ */
+function setActiveElement(doc: Document, el: Element | null): void {
+ Object.defineProperty(doc, 'activeElement', { configurable: true, get: () => el })
+}
+
+/** Registers elements the way `collectSnapshot` does, so ids resolve. */
+function register(...elements: Element[]): void {
+ window.__simAgentElements = elements
+}
+
+function outlineOf(result: unknown): string {
+ return (result as { outline: string }).outline
+}
+
+beforeEach(() => {
+ installDomShims()
+ document.body.innerHTML = ''
+ window.__simAgentElements = []
+})
+
+afterEach(() => {
+ document.body.innerHTML = ''
+})
+
+describe('serialization contract', () => {
+ // The driver ships each of these to the page as `String(fn)`, so a reference
+ // to anything in module scope — a shared helper, an import, a constant —
+ // type-checks and passes every other test in this file, then throws
+ // ReferenceError against a real page. The repeated `isSecretField` helpers
+ // exist because of this constraint; these cases are what enforce it.
+ const cases: Array<[string, (...args: never[]) => unknown, unknown[]]> = [
+ ['collectSnapshot', collectSnapshot, []],
+ ['clickElement', clickElement, [0]],
+ ['focusElementForTyping', focusElementForTyping, [0]],
+ ['typeIntoElement', typeIntoElement, [0, 'text', false]],
+ ['readActiveElementState', readActiveElementState, []],
+ ['activeElementSecrecy', activeElementSecrecy, []],
+ ['pressKeyOnPage', pressKeyOnPage, ['a', 'KeyA', 65, false, false, false, false]],
+ ['scrollPage', scrollPage, ['down', 100]],
+ ['selectOptionInElement', selectOptionInElement, [0, 'value']],
+ ['hoverElement', hoverElement, [0]],
+ ['readPageText', readPageText, []],
+ ['pageContainsText', pageContainsText, ['needle']],
+ ['getViewportInfo', getViewportInfo, []],
+ ]
+
+ it.each(cases)('%s is self-contained', (_name, fn, args) => {
+ document.body.innerHTML = 'V '
+ register(...Array.from(document.body.children).map(visible))
+
+ expect(() => runSerialized(fn, args)).not.toThrow()
+ })
+
+ it('still refuses a password field when run as serialized source', () => {
+ // The guards must survive the trip through String(fn), not just direct
+ // invocation from this module.
+ document.body.innerHTML = ' '
+ register(visible(document.querySelector('input') as HTMLInputElement))
+ setActiveElement(document, document.querySelector('input'))
+
+ expect(runSerialized(clickElement, [0])).toEqual({ error: 'password' })
+ expect(runSerialized(activeElementSecrecy, [])).toBe('secret')
+ expect(runSerialized(readActiveElementState, [])).toMatchObject({ redacted: true })
+ })
+})
+
+describe('secret-field detection', () => {
+ const secretCases: Array<[string, string]> = [
+ ['type=password', ' '],
+ [
+ 'revealed password (type flipped to text)',
+ ' ',
+ ],
+ ['new-password field', ' '],
+ ['uppercase autocomplete token', ' '],
+ ]
+
+ it.each(secretCases)('clickElement refuses a %s', (_label, html) => {
+ document.body.innerHTML = html
+ const input = visible(document.querySelector('input') as HTMLInputElement)
+ register(input)
+
+ expect(clickElement(0)).toEqual({ error: 'password' })
+ })
+
+ it.each(secretCases)('typeIntoElement refuses a %s', (_label, html) => {
+ document.body.innerHTML = html
+ const input = document.querySelector('input') as HTMLInputElement
+ register(input)
+
+ expect(typeIntoElement(0, 'hunter2', false)).toEqual({ error: 'password' })
+ expect(input.value).toBe('')
+ })
+
+ it.each(secretCases)('focusElementForTyping refuses a %s', (_label, html) => {
+ document.body.innerHTML = html
+ register(document.querySelector('input') as HTMLInputElement)
+
+ expect(focusElementForTyping(0)).toEqual({ error: 'password' })
+ })
+
+ it('still allows ordinary fields and controls', () => {
+ document.body.innerHTML =
+ 'Go '
+ const [text, , email] = Array.from(document.body.querySelectorAll('input, button')).map(visible)
+ const button = visible(document.querySelector('button') as HTMLButtonElement)
+ register(text, button, email)
+
+ expect(typeIntoElement(0, 'search terms', false)).toMatchObject({ typed: true })
+ expect(clickElement(1)).toMatchObject({ clicked: true })
+ expect(focusElementForTyping(2)).toMatchObject({ focused: true })
+ })
+
+ it('detects a password field reached through a same-origin iframe', () => {
+ // `instanceof HTMLInputElement` is realm-bound and returns false for nodes
+ // owned by a frame, which is why detection matches on tagName instead.
+ const frame = document.createElement('iframe')
+ document.body.append(frame)
+ const inner = frame.contentDocument as Document
+ inner.body.innerHTML = ' '
+ const nested = inner.querySelector('input') as HTMLInputElement
+
+ expect(nested instanceof HTMLInputElement).toBe(false)
+ register(nested)
+ expect(clickElement(0)).toEqual({ error: 'password' })
+ })
+})
+
+describe('elements inside a same-origin iframe', () => {
+ /**
+ * The snapshot walks into same-origin frames and hands the model ids for
+ * what it finds, so every interaction has to work on them. `instanceof`
+ * against the top frame's constructors is false for those nodes, which used
+ * to make the driver report a real ` ` as "not a text input" —
+ * breaking framed login forms and editors like TinyMCE.
+ */
+ function framedBody(html: string): Document {
+ const frame = document.createElement('iframe')
+ document.body.append(frame)
+ const inner = frame.contentDocument as Document
+ // The frame is its own realm, so the shims installed on the top document's
+ // prototypes do not apply here — the same property that makes `instanceof`
+ // fail across frames.
+ const innerWindow = inner.defaultView as Window & typeof globalThis
+ innerWindow.Element.prototype.scrollIntoView = () => {}
+ inner.body.innerHTML = html
+ return inner
+ }
+
+ it('types into a framed input', () => {
+ const inner = framedBody(' ')
+ const field = inner.querySelector('input') as HTMLInputElement
+ register(field)
+
+ expect(field instanceof HTMLInputElement).toBe(false)
+ expect(typeIntoElement(0, 'hello', false)).toMatchObject({ typed: true })
+ expect(field.value).toBe('hello')
+ })
+
+ it('focuses a framed input for native typing', () => {
+ const inner = framedBody(' ')
+ register(inner.querySelector('input') as HTMLInputElement)
+
+ expect(focusElementForTyping(0)).toMatchObject({ focused: true, kind: 'input' })
+ })
+
+ it('selects an option in a framed select', () => {
+ const inner = framedBody(
+ 'A B '
+ )
+ const select = inner.querySelector('select') as HTMLSelectElement
+ register(select)
+
+ expect(selectOptionInElement(0, 'B')).toMatchObject({ selected: 'B' })
+ expect(select.value).toBe('b')
+ })
+
+ it('focuses a framed element when clicking it', () => {
+ const inner = framedBody('Go ')
+ const button = visible(inner.querySelector('button') as HTMLButtonElement)
+ register(button)
+ let focused = false
+ button.addEventListener('focus', () => {
+ focused = true
+ })
+
+ expect(clickElement(0)).toMatchObject({ clicked: true })
+ expect(focused).toBe(true)
+ })
+
+ it('still refuses a framed password field', () => {
+ const inner = framedBody(' ')
+ register(inner.querySelector('input') as HTMLInputElement)
+
+ expect(typeIntoElement(0, 'hunter2', false)).toEqual({ error: 'password' })
+ expect(focusElementForTyping(0)).toEqual({ error: 'password' })
+ })
+})
+
+describe('collectSnapshot', () => {
+ it('labels a password field and never emits its value', () => {
+ document.body.innerHTML = ' '
+ visible(document.querySelector('input') as HTMLInputElement)
+
+ const outline = outlineOf(collectSnapshot())
+
+ expect(outline).toContain('password-field')
+ expect(outline).not.toContain('hunter2')
+ expect(outline).not.toContain('value=')
+ })
+
+ it('withholds the value of a revealed password field', () => {
+ document.body.innerHTML =
+ ' '
+ visible(document.querySelector('input') as HTMLInputElement)
+
+ const outline = outlineOf(collectSnapshot())
+
+ expect(outline).toContain('password-field')
+ expect(outline).not.toContain('hunter2')
+ })
+
+ it('still reports ordinary input values', () => {
+ document.body.innerHTML = ' '
+ visible(document.querySelector('input') as HTMLInputElement)
+
+ expect(outlineOf(collectSnapshot())).toContain('value="tokyo"')
+ })
+})
+
+describe('readActiveElementState', () => {
+ it('withholds value, length, and selection size for a password field', () => {
+ document.body.innerHTML = ' '
+ setActiveElement(document, document.querySelector('input'))
+
+ expect(readActiveElementState()).toEqual({
+ activeElement: 'password-field',
+ selectedChars: 0,
+ valueLength: 0,
+ valuePreview: '',
+ redacted: true,
+ })
+ })
+
+ it('withholds the value of a revealed password field', () => {
+ document.body.innerHTML =
+ ' '
+ setActiveElement(document, document.querySelector('input'))
+
+ expect(readActiveElementState()).toMatchObject({ redacted: true, valuePreview: '' })
+ })
+
+ it('reports ordinary fields in full', () => {
+ document.body.innerHTML = ' '
+ setActiveElement(document, document.querySelector('input'))
+
+ expect(readActiveElementState()).toMatchObject({
+ activeElement: 'input',
+ valueLength: 5,
+ valuePreview: 'tokyo',
+ })
+ })
+
+ it('descends into a same-origin frame rather than reporting the frame', () => {
+ const frame = document.createElement('iframe')
+ document.body.append(frame)
+ const inner = frame.contentDocument as Document
+ inner.body.innerHTML = ' '
+ setActiveElement(inner, inner.querySelector('input'))
+ setActiveElement(document, frame)
+
+ expect(readActiveElementState()).toMatchObject({
+ activeElement: 'password-field',
+ redacted: true,
+ })
+ })
+})
+
+describe('activeElementSecrecy', () => {
+ it('reports safe for an ordinary field', () => {
+ document.body.innerHTML = ' '
+ setActiveElement(document, document.querySelector('input'))
+
+ expect(activeElementSecrecy()).toBe('safe')
+ })
+
+ it('reports safe when nothing is focused', () => {
+ setActiveElement(document, document.body)
+
+ expect(activeElementSecrecy()).toBe('safe')
+ })
+
+ it('reports secret for a focused password field', () => {
+ document.body.innerHTML = ' '
+ setActiveElement(document, document.querySelector('input'))
+
+ expect(activeElementSecrecy()).toBe('secret')
+ })
+
+ it('reports secret for a password field inside an open shadow root', () => {
+ const host = document.createElement('div')
+ document.body.append(host)
+ const shadow = host.attachShadow({ mode: 'open' })
+ shadow.innerHTML = ' '
+ setActiveElement(shadow as unknown as Document, shadow.querySelector('input'))
+ setActiveElement(document, host)
+
+ expect(activeElementSecrecy()).toBe('secret')
+ })
+
+ it('reports opaque for a cross-origin frame it cannot inspect', () => {
+ const frame = document.createElement('iframe')
+ document.body.append(frame)
+ // A cross-origin frame yields null here; jsdom cannot host one, so the
+ // boundary is reproduced directly.
+ Object.defineProperty(frame, 'contentDocument', { configurable: true, get: () => null })
+ setActiveElement(document, frame)
+
+ expect(activeElementSecrecy()).toBe('opaque')
+ })
+
+ it('descends into a same-origin frame instead of calling it opaque', () => {
+ const frame = document.createElement('iframe')
+ document.body.append(frame)
+ const inner = frame.contentDocument as Document
+ inner.body.innerHTML = ' '
+ setActiveElement(inner, inner.querySelector('input'))
+ setActiveElement(document, frame)
+
+ expect(activeElementSecrecy()).toBe('safe')
+ })
+})
+
+describe('pressKeyOnPage', () => {
+ it('refuses to deliver a keystroke to a focused password field', () => {
+ document.body.innerHTML = ' '
+ setActiveElement(document, document.querySelector('input'))
+
+ expect(pressKeyOnPage('a', 'KeyA', 65, false, false, false, false)).toEqual({
+ error: 'password',
+ })
+ })
+
+ it('delivers keystrokes to ordinary fields', () => {
+ document.body.innerHTML = ' '
+ const input = document.querySelector('input') as HTMLInputElement
+ setActiveElement(document, input)
+ const seen: string[] = []
+ input.addEventListener('keydown', (event) => seen.push(event.key))
+
+ expect(pressKeyOnPage('a', 'KeyA', 65, false, false, false, false)).toMatchObject({
+ pressed: 'a',
+ })
+ expect(seen).toEqual(['a'])
+ })
+})
diff --git a/apps/desktop/src/main/browser-agent/page-functions.ts b/apps/desktop/src/main/browser-agent/page-functions.ts
new file mode 100644
index 00000000000..9b8868b3e8b
--- /dev/null
+++ b/apps/desktop/src/main/browser-agent/page-functions.ts
@@ -0,0 +1,672 @@
+/**
+ * Functions injected into automated pages via `webContents.executeJavaScript`.
+ * The driver serializes each function's source (`String(fn)`) and calls it
+ * with JSON-encoded arguments, so every function here MUST be fully
+ * self-contained: no imports, no closed-over variables, only its own
+ * arguments and page globals. Helpers live INSIDE the function that uses them.
+ *
+ * The element registry (`window.__simAgentElements`) is rebuilt by every
+ * snapshot and naturally cleared by navigation; interaction functions treat a
+ * missing or disconnected entry as a stale id.
+ *
+ * Several functions repeat an identical `isSecretField` helper. That
+ * duplication is required, not accidental: self-containment means a shared
+ * module-level helper would not survive serialization. It matches on
+ * `tagName`/`type`/`autocomplete` rather than `instanceof HTMLInputElement`
+ * because element wrappers are realm-bound — an input reached through a
+ * same-origin iframe belongs to that frame's realm, so `instanceof` against
+ * the top frame's constructor returns false and would skip the check on
+ * exactly the nested login forms that need it most.
+ */
+
+declare global {
+ interface Window {
+ __simAgentElements?: Element[]
+ }
+}
+
+/**
+ * Builds the page snapshot: a structural outline (headings, landmarks) with
+ * interactive elements carrying numeric ids, walking open shadow roots and
+ * same-origin iframes. Rebuilds the element registry as a side effect.
+ */
+export function collectSnapshot(): unknown {
+ const refCap = 300
+ const lineCap = 600
+ // Surrogate-safe truncation: plain slice() cuts by UTF-16 code units and
+ // can split an astral character (emoji, 𝐛𝐨𝐥𝐝 text), leaving a lone high
+ // surrogate that is invalid JSON downstream (Postgres jsonb rejects it).
+ const cut = (s: string, n: number): string => {
+ const out = s.slice(0, n)
+ const last = out.charCodeAt(out.length - 1)
+ return last >= 0xd800 && last <= 0xdbff ? out.slice(0, -1) : out
+ }
+ const interactiveSelector = [
+ 'a[href]',
+ 'button',
+ 'input',
+ 'select',
+ 'textarea',
+ 'summary',
+ '[role="button"]',
+ '[role="link"]',
+ '[role="textbox"]',
+ '[role="searchbox"]',
+ '[role="checkbox"]',
+ '[role="radio"]',
+ '[role="combobox"]',
+ '[role="menuitem"]',
+ '[role="menuitemcheckbox"]',
+ '[role="menuitemradio"]',
+ '[role="tab"]',
+ '[role="switch"]',
+ '[role="option"]',
+ '[role="slider"]',
+ '[onclick]',
+ '[contenteditable="true"]',
+ '[contenteditable=""]',
+ ].join(', ')
+ const landmarkSelector = [
+ 'nav',
+ 'main',
+ 'header',
+ 'footer',
+ 'aside',
+ 'dialog',
+ '[role="navigation"]',
+ '[role="main"]',
+ '[role="banner"]',
+ '[role="contentinfo"]',
+ '[role="complementary"]',
+ '[role="search"]',
+ '[role="dialog"]',
+ '[role="form"]',
+ ].join(', ')
+
+ const registry: Element[] = []
+ window.__simAgentElements = registry
+ const lines: string[] = []
+ let truncated = false
+
+ const isVisible = (el: Element): boolean => {
+ const rect = el.getBoundingClientRect()
+ if (rect.width <= 0 || rect.height <= 0) return false
+ const doc = el.ownerDocument
+ const win = doc.defaultView
+ if (!win) return false
+ const style = win.getComputedStyle(el)
+ return style.visibility !== 'hidden' && style.display !== 'none'
+ }
+
+ const isSecretField = (el: Element | null): boolean => {
+ if (!el || el.tagName !== 'INPUT') return false
+ if (String((el as HTMLInputElement).type || '').toLowerCase() === 'password') return true
+ // A reveal toggle flips the field to type="text" without making its
+ // contents any less secret, and some forms never use type="password" at
+ // all. The autocomplete token is the page's own declaration either way.
+ const hint = String(el.getAttribute('autocomplete') || '').toLowerCase()
+ return hint === 'current-password' || hint === 'new-password'
+ }
+
+ const roleFor = (el: Element): string => {
+ const explicit = el.getAttribute('role')
+ if (explicit) return explicit
+ const tag = el.tagName
+ if (tag === 'A') return 'link'
+ if (tag === 'BUTTON' || tag === 'SUMMARY') return 'button'
+ if (tag === 'SELECT') return 'combobox'
+ if (tag === 'TEXTAREA') return 'textbox'
+ if (tag === 'INPUT') {
+ const type = (el as HTMLInputElement).type
+ if (type === 'checkbox') return 'checkbox'
+ if (type === 'radio') return 'radio'
+ if (type === 'submit' || type === 'button' || type === 'reset') return 'button'
+ return 'textbox'
+ }
+ if ((el as HTMLElement).isContentEditable) return 'textbox'
+ return 'clickable'
+ }
+
+ const nameFor = (el: Element): string => {
+ let name = el.getAttribute('aria-label') || ''
+ if (!name) {
+ const labels = (el as HTMLInputElement).labels
+ if (labels && labels.length > 0) name = labels[0].innerText || ''
+ }
+ if (!name) name = (el as HTMLElement).innerText || ''
+ if (!name) {
+ name =
+ el.getAttribute('placeholder') ||
+ el.getAttribute('title') ||
+ el.getAttribute('alt') ||
+ el.getAttribute('name') ||
+ ''
+ }
+ return cut(name.replace(/\s+/g, ' ').trim(), 120)
+ }
+
+ const push = (line: string): boolean => {
+ if (lines.length >= lineCap) {
+ truncated = true
+ return false
+ }
+ lines.push(line)
+ return true
+ }
+
+ const emitInteractive = (el: Element, indent: string): void => {
+ if (registry.length >= refCap) {
+ truncated = true
+ return
+ }
+ const id = registry.length
+ registry.push(el)
+ let role = roleFor(el)
+ const parts: string[] = []
+ if (isSecretField(el)) {
+ role = 'password-field'
+ } else if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT') {
+ // Tag comparison so fields inside same-origin iframes report their value
+ // like any other. Redaction above is realm-safe and runs first, so
+ // widening this cannot expose a credential field.
+ const value = (el as HTMLInputElement).value
+ if (value) parts.push(`value="${cut(String(value), 120)}"`)
+ }
+ if (el.tagName === 'A') {
+ const href = el.getAttribute('href')
+ if (href) parts.push(`href="${cut(href, 200)}"`)
+ }
+ if ((el as HTMLInputElement).disabled === true) parts.push('disabled')
+ if ((el as HTMLInputElement).checked === true) parts.push('checked')
+ const suffix = parts.length > 0 ? ` ${parts.join(' ')}` : ''
+ push(`${indent}- ${role} "${nameFor(el)}" [ref=${id}]${suffix}`)
+ }
+
+ const headingLevel = (el: Element): number | null => {
+ const match = /^H([1-6])$/.exec(el.tagName)
+ if (match) return Number(match[1])
+ if (el.getAttribute('role') === 'heading') {
+ const level = Number(el.getAttribute('aria-level') || '2')
+ return Number.isFinite(level) ? level : 2
+ }
+ return null
+ }
+
+ const landmarkLabel = (el: Element): string => {
+ const role = el.getAttribute('role')
+ const tag = el.tagName.toLowerCase()
+ const kind =
+ role ||
+ (tag === 'nav'
+ ? 'navigation'
+ : tag === 'header'
+ ? 'banner'
+ : tag === 'footer'
+ ? 'contentinfo'
+ : tag === 'aside'
+ ? 'complementary'
+ : tag)
+ const label = cut((el.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim(), 80)
+ return label ? `${kind} "${label}"` : kind
+ }
+
+ const walk = (root: ParentNode, depth: number): void => {
+ if (truncated && registry.length >= refCap) return
+ for (const el of Array.from(root.children)) {
+ if (registry.length >= refCap && lines.length >= lineCap) return
+ const tag = el.tagName
+ if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEMPLATE') continue
+
+ const indent = ' '.repeat(depth)
+ let childDepth = depth
+
+ if (el.matches(landmarkSelector) && isVisible(el)) {
+ if (!push(`${indent}- ${landmarkLabel(el)}:`)) return
+ childDepth = depth + 1
+ } else {
+ const level = headingLevel(el)
+ if (level !== null && isVisible(el)) {
+ const text = cut(((el as HTMLElement).innerText || '').replace(/\s+/g, ' ').trim(), 160)
+ if (text) push(`${indent}- heading "${text}" (h${level})`)
+ } else if (el.matches(interactiveSelector) && isVisible(el)) {
+ emitInteractive(el, indent)
+ // Interactive containers rarely nest other interactives; still
+ // recurse so e.g. a clickable card exposes its inner links.
+ }
+ }
+
+ if (tag === 'IFRAME' || tag === 'FRAME') {
+ try {
+ const innerDoc = (el as HTMLIFrameElement).contentDocument
+ if (innerDoc?.body && isVisible(el)) {
+ if (!push(`${indent}- iframe:`)) return
+ walk(innerDoc.body, childDepth + 1)
+ }
+ } catch {
+ // Cross-origin iframe — not readable.
+ }
+ continue
+ }
+
+ const shadow = (el as HTMLElement).shadowRoot
+ if (shadow) walk(shadow, childDepth)
+ walk(el, childDepth)
+ }
+ }
+
+ if (document.body) walk(document.body, 0)
+
+ return {
+ url: window.location.href,
+ title: document.title,
+ outline: lines.join('\n'),
+ truncated,
+ scrollY: Math.round(window.scrollY),
+ pageHeight: Math.round(document.documentElement.scrollHeight),
+ viewportHeight: window.innerHeight,
+ }
+}
+
+export function clickElement(id: number): unknown {
+ const isSecretField = (node: Element | null): boolean => {
+ if (!node || node.tagName !== 'INPUT') return false
+ if (String((node as HTMLInputElement).type || '').toLowerCase() === 'password') return true
+ const hint = String(node.getAttribute('autocomplete') || '').toLowerCase()
+ return hint === 'current-password' || hint === 'new-password'
+ }
+
+ const el = (window.__simAgentElements || [])[id]
+ if (!el || !el.isConnected) return { error: 'stale' }
+ // Clicking focuses, and a focused credential field is the one state in
+ // which subsequent keystrokes would land in a password. Refusing the click
+ // keeps that state unreachable rather than relying on every later keyboard
+ // path to re-check.
+ if (isSecretField(el)) return { error: 'password' }
+ el.scrollIntoView({ block: 'center', inline: 'center' })
+ const rect = el.getBoundingClientRect()
+ const opts = {
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ clientX: rect.x + rect.width / 2,
+ clientY: rect.y + rect.height / 2,
+ button: 0,
+ }
+ // Duck-typed rather than `instanceof HTMLElement`: an element reached
+ // through a same-origin iframe belongs to that frame's realm, so the check
+ // is false there and the click would skip focus entirely.
+ const html = el as HTMLElement
+ el.dispatchEvent(new PointerEvent('pointerdown', opts))
+ el.dispatchEvent(new MouseEvent('mousedown', opts))
+ if (typeof html.focus === 'function') html.focus()
+ el.dispatchEvent(new PointerEvent('pointerup', opts))
+ el.dispatchEvent(new MouseEvent('mouseup', opts))
+ if (typeof html.click === 'function') html.click()
+ else el.dispatchEvent(new MouseEvent('click', opts))
+ const label = (el.getAttribute('aria-label') || (el as HTMLElement).innerText || '')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .slice(0, 80)
+ // Drop a trailing lone high surrogate the slice may have created.
+ return { clicked: true, element: label.replace(/[\uD800-\uDBFF]$/, '') }
+}
+
+/**
+ * Prepares an element for NATIVE typing (driver-side CDP `Input.insertText`):
+ * focuses it and selects its current content so the inserted text REPLACES
+ * what's there — including inside code editors (CodeMirror/Monaco), whose
+ * models sync from the DOM selection / native input pipeline.
+ */
+export function focusElementForTyping(id: number): unknown {
+ const isSecretField = (node: Element | null): boolean => {
+ if (!node || node.tagName !== 'INPUT') return false
+ if (String((node as HTMLInputElement).type || '').toLowerCase() === 'password') return true
+ const hint = String(node.getAttribute('autocomplete') || '').toLowerCase()
+ return hint === 'current-password' || hint === 'new-password'
+ }
+
+ const el = (window.__simAgentElements || [])[id]
+ if (!el || !el.isConnected) return { error: 'stale' }
+ el.scrollIntoView({ block: 'center' })
+
+ if (isSecretField(el)) {
+ return { error: 'password' }
+ }
+
+ // Tag comparisons, not `instanceof`: element wrappers are realm-bound, so an
+ // input inside a same-origin iframe — a framed login form, a TinyMCE body —
+ // fails every `instanceof` against the top frame's constructors and would be
+ // reported back as "not a text input".
+ const tag = el.tagName
+ if (tag === 'INPUT' || tag === 'TEXTAREA') {
+ const field = el as HTMLInputElement | HTMLTextAreaElement
+ field.focus()
+ field.select()
+ return { focused: true, kind: tag === 'INPUT' ? 'input' : 'textarea' }
+ }
+
+ // Editors often register a wrapper as the interactive element while the
+ // actual editable surface is a descendant.
+ const editable = (el as HTMLElement).isContentEditable
+ ? (el as HTMLElement)
+ : el.querySelector('[contenteditable="true"], [contenteditable=""]')
+ if (editable) {
+ editable.focus()
+ const selection = editable.ownerDocument.defaultView?.getSelection()
+ if (selection) {
+ const range = editable.ownerDocument.createRange()
+ range.selectNodeContents(editable)
+ selection.removeAllRanges()
+ selection.addRange(range)
+ }
+ return { focused: true, kind: 'contenteditable' }
+ }
+ return { error: 'not-editable' }
+}
+
+/**
+ * Reads back the focused element's state after a native key/type action so
+ * the driver can report what actually happened instead of assuming success.
+ */
+export function readActiveElementState(): unknown {
+ const isSecretField = (node: Element | null): boolean => {
+ if (!node || node.tagName !== 'INPUT') return false
+ if (String((node as HTMLInputElement).type || '').toLowerCase() === 'password') return true
+ const hint = String(node.getAttribute('autocomplete') || '').toLowerCase()
+ return hint === 'current-password' || hint === 'new-password'
+ }
+
+ // Focus inside a frame or an open shadow root surfaces on the outer document
+ // as the host element, so descend to find what is really focused.
+ let active = document.activeElement as HTMLElement | null
+ for (let depth = 0; active && depth < 10; depth++) {
+ const shadow = active.shadowRoot
+ if (shadow?.activeElement) {
+ active = shadow.activeElement as HTMLElement
+ continue
+ }
+ if (active.tagName === 'IFRAME' || active.tagName === 'FRAME') {
+ try {
+ const inner = (active as HTMLIFrameElement).contentDocument
+ if (inner?.activeElement && inner.activeElement !== inner.body) {
+ active = inner.activeElement as HTMLElement
+ continue
+ }
+ } catch {
+ // Cross-origin frame — not inspectable, report the frame itself.
+ }
+ }
+ break
+ }
+
+ if (!active || active === active.ownerDocument.body) {
+ return { activeElement: 'body', selectedChars: 0, valueLength: 0, valuePreview: '' }
+ }
+ // Length and selection size are withheld along with the value: both are
+ // observations of a secret the agent is never allowed to learn.
+ if (isSecretField(active)) {
+ return {
+ activeElement: 'password-field',
+ selectedChars: 0,
+ valueLength: 0,
+ valuePreview: '',
+ redacted: true,
+ }
+ }
+ let value = ''
+ let selectedChars = 0
+ if (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA') {
+ const field = active as HTMLInputElement | HTMLTextAreaElement
+ value = field.value
+ selectedChars = Math.abs((field.selectionEnd ?? 0) - (field.selectionStart ?? 0))
+ } else {
+ value = active.innerText || active.textContent || ''
+ selectedChars = window.getSelection()?.toString().length ?? 0
+ }
+ return {
+ activeElement: active.tagName.toLowerCase(),
+ selectedChars,
+ valueLength: value.length,
+ // Trailing regex drops a lone high surrogate the slice may have created.
+ valuePreview: value
+ .replace(/\s+/g, ' ')
+ .trim()
+ .slice(0, 120)
+ .replace(/[\uD800-\uDBFF]$/, ''),
+ }
+}
+
+/**
+ * Classifies what currently holds focus, for the driver's pre-dispatch check
+ * on trusted CDP key events (which bypass the page entirely and land on
+ * whatever is focused, so this cannot be enforced from inside the page alone):
+ *
+ * - `secret` — a credential field. No keystroke belongs here.
+ * - `opaque` — a cross-origin frame we cannot inspect, so a credential field
+ * cannot be ruled out. Character insertion is refused; caret movement and
+ * Escape are not.
+ * - `safe` — anything we can see and that is not a credential field.
+ */
+export function activeElementSecrecy(): string {
+ const isSecretField = (node: Element | null): boolean => {
+ if (!node || node.tagName !== 'INPUT') return false
+ if (String((node as HTMLInputElement).type || '').toLowerCase() === 'password') return true
+ const hint = String(node.getAttribute('autocomplete') || '').toLowerCase()
+ return hint === 'current-password' || hint === 'new-password'
+ }
+
+ let active = document.activeElement as HTMLElement | null
+ for (let depth = 0; active && depth < 10; depth++) {
+ if (isSecretField(active)) return 'secret'
+ const shadow = active.shadowRoot
+ if (shadow?.activeElement) {
+ active = shadow.activeElement as HTMLElement
+ continue
+ }
+ if (active.tagName === 'IFRAME' || active.tagName === 'FRAME') {
+ let inner: Document | null = null
+ try {
+ inner = (active as HTMLIFrameElement).contentDocument
+ } catch {
+ return 'opaque'
+ }
+ // A same-origin frame yields a document; a cross-origin one yields null.
+ if (!inner) return 'opaque'
+ if (inner.activeElement && inner.activeElement !== inner.body) {
+ active = inner.activeElement as HTMLElement
+ continue
+ }
+ }
+ break
+ }
+ return isSecretField(active) ? 'secret' : 'safe'
+}
+
+export function typeIntoElement(id: number, text: string, submit: boolean): unknown {
+ const isSecretField = (node: Element | null): boolean => {
+ if (!node || node.tagName !== 'INPUT') return false
+ if (String((node as HTMLInputElement).type || '').toLowerCase() === 'password') return true
+ const hint = String(node.getAttribute('autocomplete') || '').toLowerCase()
+ return hint === 'current-password' || hint === 'new-password'
+ }
+
+ const el = (window.__simAgentElements || [])[id]
+ if (!el || !el.isConnected) return { error: 'stale' }
+ el.scrollIntoView({ block: 'center' })
+
+ if (isSecretField(el)) {
+ return { error: 'password' }
+ }
+
+ const tag = el.tagName
+ if (tag === 'INPUT' || tag === 'TEXTAREA') {
+ const field = el as HTMLInputElement | HTMLTextAreaElement
+ field.focus()
+ // The native setter must come from the element's OWN realm. A same-origin
+ // iframe has its own constructors, and calling the top frame's setter on
+ // one of its nodes throws "Illegal invocation".
+ const view = el.ownerDocument.defaultView ?? window
+ const proto =
+ tag === 'INPUT' ? view.HTMLInputElement.prototype : view.HTMLTextAreaElement.prototype
+ const descriptor = Object.getOwnPropertyDescriptor(proto, 'value')
+ if (descriptor?.set) descriptor.set.call(field, text)
+ else field.value = text
+ field.dispatchEvent(new Event('input', { bubbles: true }))
+ field.dispatchEvent(new Event('change', { bubbles: true }))
+ } else if ((el as HTMLElement).isContentEditable) {
+ const editable = el as HTMLElement
+ editable.focus()
+ editable.textContent = text
+ editable.dispatchEvent(
+ new InputEvent('input', { bubbles: true, data: text, inputType: 'insertText' })
+ )
+ } else {
+ return { error: 'not-editable' }
+ }
+
+ if (submit) {
+ const key = {
+ bubbles: true,
+ cancelable: true,
+ key: 'Enter',
+ code: 'Enter',
+ keyCode: 13,
+ which: 13,
+ }
+ const notCancelled = el.dispatchEvent(new KeyboardEvent('keydown', key))
+ el.dispatchEvent(new KeyboardEvent('keyup', key))
+ const form = (el as HTMLInputElement).form ?? (el as HTMLElement).closest?.('form') ?? null
+ if (notCancelled && form) {
+ if (typeof form.requestSubmit === 'function') form.requestSubmit()
+ else form.submit()
+ }
+ }
+ return { typed: true, submitted: submit === true }
+}
+
+export function pressKeyOnPage(
+ key: string,
+ code: string,
+ keyCode: number,
+ ctrl: boolean,
+ meta: boolean,
+ shift: boolean,
+ alt: boolean
+): unknown {
+ const isSecretField = (node: Element | null): boolean => {
+ if (!node || node.tagName !== 'INPUT') return false
+ if (String((node as HTMLInputElement).type || '').toLowerCase() === 'password') return true
+ const hint = String(node.getAttribute('autocomplete') || '').toLowerCase()
+ return hint === 'current-password' || hint === 'new-password'
+ }
+
+ const target = (document.activeElement as HTMLElement | null) ?? document.body
+ // The driver checks focus before taking the trusted CDP path; this covers
+ // the synthetic fallback, which is reached independently when CDP is down.
+ if (isSecretField(target)) return { error: 'password' }
+ const opts = {
+ bubbles: true,
+ cancelable: true,
+ key,
+ code,
+ keyCode,
+ which: keyCode,
+ ctrlKey: ctrl,
+ metaKey: meta,
+ shiftKey: shift,
+ altKey: alt,
+ }
+ target.dispatchEvent(new KeyboardEvent('keydown', opts))
+ target.dispatchEvent(new KeyboardEvent('keyup', opts))
+ return { pressed: key, target: target.tagName.toLowerCase() }
+}
+
+export function scrollPage(direction: string, amount?: number): unknown {
+ const distance = typeof amount === 'number' && amount > 0 ? amount : window.innerHeight * 0.85
+ window.scrollBy({ top: direction === 'up' ? -distance : distance, behavior: 'instant' })
+ const scrollY = Math.round(window.scrollY)
+ const pageHeight = Math.round(document.documentElement.scrollHeight)
+ return {
+ scrollY,
+ pageHeight,
+ atTop: scrollY <= 0,
+ atBottom: scrollY + window.innerHeight >= pageHeight - 2,
+ }
+}
+
+export function selectOptionInElement(id: number, value: string): unknown {
+ const el = (window.__simAgentElements || [])[id]
+ if (!el || !el.isConnected) return { error: 'stale' }
+ if (el.tagName !== 'SELECT') return { error: 'not-select' }
+ const select = el as HTMLSelectElement
+ const wanted = value.trim().toLowerCase()
+ const option = Array.from(select.options).find(
+ (o) => o.value.trim().toLowerCase() === wanted || o.label.trim().toLowerCase() === wanted
+ )
+ if (!option) {
+ return {
+ error: 'no-option',
+ options: Array.from(select.options)
+ .slice(0, 50)
+ .map((o) => o.label.trim()),
+ }
+ }
+ select.value = option.value
+ select.dispatchEvent(new Event('input', { bubbles: true }))
+ select.dispatchEvent(new Event('change', { bubbles: true }))
+ return { selected: option.label.trim() }
+}
+
+export function hoverElement(id: number): unknown {
+ const el = (window.__simAgentElements || [])[id]
+ if (!el || !el.isConnected) return { error: 'stale' }
+ el.scrollIntoView({ block: 'center' })
+ const rect = el.getBoundingClientRect()
+ const opts = {
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ clientX: rect.x + rect.width / 2,
+ clientY: rect.y + rect.height / 2,
+ }
+ el.dispatchEvent(new PointerEvent('pointerover', opts))
+ el.dispatchEvent(new PointerEvent('pointerenter', opts))
+ el.dispatchEvent(new MouseEvent('mouseover', opts))
+ el.dispatchEvent(new MouseEvent('mouseenter', opts))
+ el.dispatchEvent(new PointerEvent('pointermove', opts))
+ el.dispatchEvent(new MouseEvent('mousemove', opts))
+ return { hovered: true }
+}
+
+export function readPageText(id?: number): unknown {
+ const maxChars = 30000
+ let text: string
+ if (typeof id === 'number') {
+ const el = (window.__simAgentElements || [])[id]
+ if (!el || !el.isConnected) return { error: 'stale' }
+ text = (el as HTMLElement).innerText ?? el.textContent ?? ''
+ } else {
+ text = document.body?.innerText ?? ''
+ }
+ const trimmed = text.replace(/\n{3,}/g, '\n\n').trim()
+ return {
+ url: window.location.href,
+ title: document.title,
+ // Trailing regex drops a lone high surrogate the slice may have created.
+ text: trimmed.slice(0, maxChars).replace(/[\uD800-\uDBFF]$/, ''),
+ truncated: trimmed.length > maxChars,
+ }
+}
+
+export function pageContainsText(text: string): boolean {
+ return Boolean(document.body?.innerText.includes(text))
+}
+
+export function getViewportInfo(): unknown {
+ return {
+ url: window.location.href,
+ title: document.title,
+ width: window.innerWidth,
+ height: window.innerHeight,
+ }
+}
diff --git a/apps/desktop/src/main/browser-agent/panel.test.ts b/apps/desktop/src/main/browser-agent/panel.test.ts
new file mode 100644
index 00000000000..e6f154ce076
--- /dev/null
+++ b/apps/desktop/src/main/browser-agent/panel.test.ts
@@ -0,0 +1,155 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('electron', () => import('@/test/electron-mock'))
+
+import { BrowserWindow, WebContentsView } from 'electron'
+import * as panelModule from '@/main/browser-agent/panel'
+
+type PanelModule = typeof import('@/main/browser-agent/panel')
+
+/**
+ * `initPanel` is a full reset of the module's session state, so a clean panel
+ * needs no module reload — which is what lets this file use a static import
+ * instead of the `vi.resetModules()` the root CLAUDE.md forbids.
+ *
+ * The reset happens here rather than being left to `showPanel`, so a test that
+ * never shows a panel still starts from a clean one.
+ */
+function freshPanel(): PanelModule {
+ panelModule.initPanel({
+ getMainWindow: () => null,
+ activeTab: () => null,
+ ensureInitialTab: () => {},
+ onViewDetached: () => {},
+ })
+ return panelModule
+}
+
+const PANEL_RECT = { x: 400, y: 64, width: 600, height: 800 }
+
+/** A panel showing one tab, which is the state occlusion applies to. */
+function showPanel(panel: PanelModule) {
+ const win = new BrowserWindow()
+ const view = new WebContentsView()
+ let active = { id: 'tab-1', view, pinned: false }
+ panel.initPanel({
+ getMainWindow: () => win,
+ activeTab: () => active,
+ ensureInitialTab: () => {},
+ onViewDetached: () => {},
+ })
+ panel.setPanelBounds(PANEL_RECT, win)
+ /** Swaps in another tab's view, as switching tabs does. */
+ const switchTab = (next: WebContentsView) => {
+ active = { id: 'tab-2', view: next, pinned: false }
+ panel.layout()
+ }
+ return { win, view, switchTab }
+}
+
+/** When the view was hidden, in the global mock invocation order. */
+function hiddenAt(view: WebContentsView): number | undefined {
+ const call = vi.mocked(view.setVisible).mock.calls.findIndex(([visible]) => visible === false)
+ return call === -1 ? undefined : vi.mocked(view.setVisible).mock.invocationCallOrder[call]
+}
+
+/** When the replacement frame was pushed to the renderer. */
+function snapshotSentAt(win: BrowserWindow): number | undefined {
+ const call = vi
+ .mocked(win.webContents.send)
+ .mock.calls.findIndex(([channel]) => channel === 'browser-agent:panel-snapshot')
+ return call === -1 ? undefined : vi.mocked(win.webContents.send).mock.invocationCallOrder[call]
+}
+
+describe('panel occlusion', () => {
+ let panel: PanelModule
+
+ beforeEach(() => {
+ panel = freshPanel()
+ })
+
+ it('keeps the page up until its replacement frame exists', async () => {
+ const { win, view } = showPanel(panel)
+
+ panel.setPanelOccluded(true, win)
+
+ // Hiding here is what produced the flash: the renderer paints its snapshot
+ // as soon as it reports occlusion, and the only frame it holds until the
+ // new one lands is the previous overlay's — a different scroll position, or
+ // nothing at all.
+ expect(hiddenAt(view)).toBeUndefined()
+ })
+
+ it('sends the frame before hiding, so the swap shows no seam', async () => {
+ const { win, view } = showPanel(panel)
+
+ panel.setPanelOccluded(true, win)
+ await vi.waitFor(() => expect(hiddenAt(view)).toBeDefined())
+
+ const sent = snapshotSentAt(win)
+ expect(sent).toBeDefined()
+ expect(sent).toBeLessThan(hiddenAt(view) as number)
+ })
+
+ it('stays visible when the overlay closes while the frame is being taken', async () => {
+ const { win, view } = showPanel(panel)
+
+ panel.setPanelOccluded(true, win)
+ panel.setPanelOccluded(false, win)
+ await vi.waitFor(() => expect(snapshotSentAt(win)).toBeDefined())
+
+ // Hiding after the overlay is gone would blank the page with nothing above it.
+ expect(hiddenAt(view)).toBeUndefined()
+ })
+
+ it('photographs a visible page as visible, so no scrollbar flashes into the frame', async () => {
+ const { win, view } = showPanel(panel)
+
+ panel.setPanelOccluded(true, win)
+
+ // Asking to capture a visible page as hidden moves its visibility state,
+ // and Chromium flashes overlay scrollbars across that transition — which
+ // the frame then freezes, so the swap shows a scrollbar the page lacked.
+ expect(view.webContents.capturePage).toHaveBeenCalledWith(undefined, { stayHidden: false })
+ })
+
+ it('keeps an already-hidden page hidden when a tab switch needs a frame', async () => {
+ const { win, view, switchTab } = showPanel(panel)
+ panel.setPanelOccluded(true, win)
+ await vi.waitFor(() => expect(hiddenAt(view)).toBeDefined())
+
+ // Switching tabs under an open overlay re-captures, and that panel really
+ // is hidden — waking it for the shot is what the flag exists to prevent.
+ const next = new WebContentsView()
+ switchTab(next)
+
+ expect(next.webContents.capturePage).toHaveBeenCalledWith(undefined, { stayHidden: true })
+ })
+
+ it('hides anyway when the frame cannot be taken', async () => {
+ const { win, view } = showPanel(panel)
+ vi.mocked(view.webContents.capturePage).mockRejectedValue(new Error('capture failed'))
+
+ panel.setPanelOccluded(true, win)
+
+ // Waiting forever on a failed capture would leave the page over the overlay.
+ await vi.waitFor(() => expect(hiddenAt(view)).toBeDefined())
+ expect(snapshotSentAt(win)).toBeUndefined()
+ })
+
+ it('accepts occlusion again after the panel is hidden and shown', async () => {
+ const { win, view } = showPanel(panel)
+ panel.setPanelOccluded(true, win)
+ await vi.waitFor(() => expect(hiddenAt(view)).toBeDefined())
+
+ // Hiding the panel forgets both halves of the occlusion state; a stale
+ // "already requested" would dedupe the next overlay's report away and leave
+ // the page painting over it.
+ panel.setPanelBounds(null, win)
+ panel.setPanelBounds(PANEL_RECT, win)
+ vi.mocked(view.setVisible).mockClear()
+ panel.setPanelOccluded(true, win)
+
+ await vi.waitFor(() => expect(hiddenAt(view)).toBeDefined())
+ })
+})
diff --git a/apps/desktop/src/main/browser-agent/panel.ts b/apps/desktop/src/main/browser-agent/panel.ts
new file mode 100644
index 00000000000..44b84dee14b
--- /dev/null
+++ b/apps/desktop/src/main/browser-agent/panel.ts
@@ -0,0 +1,518 @@
+/**
+ * Compositing for the embedded browser: where the native view sits inside a
+ * Sim window, when it is visible, and which window owns it.
+ *
+ * The browser is ONE native surface shared by every app window, so exactly one
+ * window may drive it at a time. That, the renderer bounds lease, and the
+ * occlusion snapshot are the intricate parts of the browser and are kept here,
+ * apart from tab bookkeeping.
+ *
+ * Depends on the session only through {@link PanelHost}, injected once at
+ * startup. Tab state changes are pushed in by the session calling {@link layout};
+ * this module never reaches back into it, so the import graph stays one-way.
+ */
+import type {
+ BrowserPanelAnchor,
+ BrowserPanelBounds,
+ BrowserPanelSnapshot,
+} from '@sim/browser-protocol'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { BrowserWindow, WebContentsView } from 'electron'
+import type { AgentTab } from '@/main/browser-agent/session'
+
+const logger = createLogger('BrowserAgentPanel')
+
+/**
+ * The renderer renews the panel rect on a heartbeat. If it goes quiet — the
+ * page crashed, unmounted, or wedged — the lease expires and the native view
+ * is hidden rather than left floating over whatever replaced the panel.
+ */
+const PANEL_LEASE_TTL_MS = 2_500
+const PANEL_LEASE_CHECK_MS = 1_000
+
+/** What the panel needs from the session, supplied once by {@link initPanel}. */
+export interface PanelHost {
+ getMainWindow: () => BrowserWindow | null
+ /** The tab whose view should be composited, or null when there is none. */
+ activeTab: () => AgentTab | null
+ /**
+ * Materializes the initial tab when the panel first becomes visible: a
+ * visible browser resource always represents one open browser window, and
+ * the tab strip, omnibox, and native session must not disagree about that.
+ */
+ ensureInitialTab: () => void
+ /** Lets the session drop focus tracking for a view that is no longer attached. */
+ onViewDetached: (view: WebContentsView | null) => void
+}
+
+let host: PanelHost = {
+ getMainWindow: () => null,
+ activeTab: () => null,
+ ensureInitialTab: () => {},
+ onViewDetached: () => {},
+}
+
+/** Where the panel sits in the window (CSS px); null = panel hidden. */
+let panelBounds: BrowserPanelBounds | null = null
+/** How {@link panelBounds} derives from the viewport, when the renderer said. */
+let panelAnchor: BrowserPanelAnchor | null = null
+/** True while the view is actually hidden for renderer-owned UI above it. */
+let panelOccluded = false
+/**
+ * What the renderer last reported, which leads {@link panelOccluded} while a
+ * frame is being captured. Hiding is what has to wait: the renderer paints its
+ * snapshot the moment it believes the panel is occluded, and the only frame it
+ * has until the new one lands is the one from the previous overlay — a picture
+ * of a different scroll position, or nothing at all. Staying visible until the
+ * replacement is sent means the swap is invisible instead of a flash.
+ */
+let panelOcclusionRequested = false
+let panelLeaseAt = 0
+let leaseTimer: ReturnType | null = null
+let panelSnapshotGeneration = 0
+/** The window currently hosting the active view, for re-parenting checks. */
+let hostedWindow: BrowserWindow | null = null
+/** The app window whose renderer most recently leased the visible panel. */
+let panelOwnerWindow: BrowserWindow | null = null
+/** The view attached to the host window (attach only on change — re-adding an
+ * attached view re-stacks it and can flicker the composite). */
+let attachedView: WebContentsView | null = null
+let lastAppliedBounds = ''
+let lastAppliedVisibility: boolean | null = null
+/** The host window whose `resize` currently drives {@link layout}, if any. */
+let resizeBoundWindow: BrowserWindow | null = null
+/** Captures nothing, so one instance serves every window it is bound to. */
+const onHostResize = () => layout()
+
+export function initPanel(panelHost: PanelHost): void {
+ // A real reset, not a partial setter. Everything below is per-session state,
+ // and this call IS the session boundary — anything left behind is inherited
+ // by the next session: a stale owner window that rejects legitimate panel
+ // updates, a lease timer polling for a panel that no longer exists, a
+ // `lastApplied*` value that dedupes away the first layout of the new one.
+ detachAttachedView()
+ resetOcclusion()
+ if (leaseTimer !== null) {
+ clearInterval(leaseTimer)
+ leaseTimer = null
+ }
+ host = panelHost
+ panelBounds = null
+ panelAnchor = null
+ panelLeaseAt = 0
+ panelOwnerWindow = null
+}
+
+/**
+ * The window owning the visible panel, or null. Self-healing: a destroyed
+ * owner is forgotten here rather than left to reject panel updates from a
+ * window that is legitimately showing the browser.
+ */
+function panelOwner(): BrowserWindow | null {
+ if (panelOwnerWindow?.isDestroyed()) {
+ panelOwnerWindow = null
+ }
+ return panelOwnerWindow
+}
+
+/** The window the panel's native view and pushes belong to. */
+export function panelWindow(): BrowserWindow | null {
+ return panelOwner() ?? host.getMainWindow()
+}
+
+/**
+ * Whether a window may act on the panel. An unowned panel accepts anyone; once
+ * owned, only the owner — so a stale report from a second window cannot hide
+ * or steal the singleton browser surface.
+ */
+export function panelUpdateAllowed(ownerWindow?: BrowserWindow): boolean {
+ if (!ownerWindow) return true
+ const owner = panelOwner()
+ return owner === null || owner === ownerWindow
+}
+
+/**
+ * Whether a window may report panel bounds, given which Sim window has OS
+ * focus. Ownership transfers only to the focused window: when Sim is in the
+ * background nothing is focused, and without this rule every window with the
+ * panel mounted would reclaim it on its next heartbeat, re-parenting the
+ * native view back and forth about once a second.
+ */
+export function canReportPanelBounds(
+ win: BrowserWindow,
+ focusedWindow: BrowserWindow | null
+): boolean {
+ const owner = panelOwner()
+ return owner === null || owner === win || focusedWindow === win
+}
+
+/** True while the renderer is reporting a panel rect. */
+export function isPanelVisible(): boolean {
+ return panelBounds !== null
+}
+
+/**
+ * Re-lays-out on the host window's own `resize` (~68/sec during a live drag, one
+ * per resize step) so the view tracks the frame it is actually in.
+ *
+ * @see layout — the resize is a trigger, never a source of bounds.
+ */
+function bindHostResize(win: BrowserWindow): void {
+ if (resizeBoundWindow === win) return
+ unbindHostResize()
+ win.on('resize', onHostResize)
+ resizeBoundWindow = win
+}
+
+function unbindHostResize(): void {
+ if (resizeBoundWindow && !resizeBoundWindow.isDestroyed()) {
+ resizeBoundWindow.removeListener('resize', onHostResize)
+ }
+ resizeBoundWindow = null
+}
+
+/**
+ * Re-derives the rect for a viewport the renderer has not measured yet, from the
+ * rule it declared plus the rect it measured at `anchor`'s own viewport.
+ * Everything but the width ratio falls out of that pair: the insets are
+ * size-invariant, and the width residual is whatever the ratio leaves over.
+ *
+ * Null when the viewport still matches the measured one, so the measurement
+ * wins wherever it is exact and a wrong ratio can only reach live-resize frames.
+ */
+function evaluateAnchor(
+ anchor: BrowserPanelAnchor | null,
+ measured: BrowserPanelBounds,
+ viewportWidth: number,
+ viewportHeight: number
+): BrowserPanelBounds | null {
+ if (
+ anchor === null ||
+ (viewportWidth === anchor.viewportWidth && viewportHeight === anchor.viewportHeight)
+ ) {
+ return null
+ }
+ const widthOffset = measured.width - anchor.viewportWidth * anchor.widthRatio
+ const rightInset = anchor.viewportWidth - (measured.x + measured.width)
+ const bottom = anchor.viewportHeight - (measured.y + measured.height)
+ const width = viewportWidth * anchor.widthRatio + widthOffset
+ return {
+ x: viewportWidth - rightInset - width,
+ y: measured.y,
+ width,
+ height: viewportHeight - measured.y - bottom,
+ }
+}
+
+/**
+ * Confines a rect to the window's content box, and owns the 1px floor for the
+ * whole path. Pure constraint — it needs no model of where the panel sits.
+ */
+function clampToContent(
+ rect: BrowserPanelBounds,
+ contentWidth: number,
+ contentHeight: number
+): BrowserPanelBounds {
+ const x = Math.min(Math.max(0, rect.x), Math.max(0, contentWidth - 1))
+ const y = Math.min(Math.max(0, rect.y), Math.max(0, contentHeight - 1))
+ return {
+ x,
+ y,
+ width: Math.max(1, Math.min(rect.width, contentWidth - x)),
+ height: Math.max(1, Math.min(rect.height, contentHeight - y)),
+ }
+}
+
+/**
+ * Clears the tracked attachment before touching Electron objects so a stale
+ * host or child view cannot leave layout permanently wedged after teardown.
+ */
+export function detachAttachedView(): void {
+ const view = attachedView
+ const win = hostedWindow
+ attachedView = null
+ hostedWindow = null
+ lastAppliedBounds = ''
+ lastAppliedVisibility = null
+ unbindHostResize()
+ host.onViewDetached(view)
+
+ if (!view || !win) return
+ try {
+ if (win.isDestroyed() || view.webContents.isDestroyed()) return
+ win.contentView.removeChildView(view)
+ } catch (error) {
+ logger.warn('Could not detach embedded browser view', {
+ error: getErrorMessage(error, 'unknown'),
+ })
+ }
+}
+
+/**
+ * Stops the attached view painting without giving up its compositor surface,
+ * so showing it again is immediate. A hidden view takes no input either, which
+ * is what lets renderer UI sit where it used to be.
+ */
+function hideAttachedView(): void {
+ const view = attachedView
+ if (!view || lastAppliedVisibility === false) return
+ lastAppliedVisibility = false
+ // Nothing to re-lay-out while hidden; the showing path rebinds.
+ unbindHostResize()
+ try {
+ if (!view.webContents.isDestroyed()) view.setVisible(false)
+ } catch (error) {
+ logger.warn('Could not hide embedded browser view', {
+ error: getErrorMessage(error, 'unknown'),
+ })
+ }
+}
+
+/**
+ * Detaches only when this exact view is the attached one. Closing a background
+ * tab must not pull the visible tab out of the window.
+ */
+export function detachIfAttached(view: WebContentsView): void {
+ if (attachedView === view) {
+ detachAttachedView()
+ }
+}
+
+/** Forgets both halves of the occlusion state, so a later report is not deduped away. */
+function resetOcclusion(): void {
+ panelOccluded = false
+ panelOcclusionRequested = false
+ panelSnapshotGeneration++
+}
+
+/**
+ * Captures the current browser frame for the renderer to display while the
+ * native view is hidden beneath an overlay.
+ *
+ * The capture is a copy of the compositor surface, so it can never relayout the
+ * page — but asking to capture a VISIBLE page as hidden perturbs its visibility
+ * bookkeeping, and Chromium flashes overlay scrollbars across that transition.
+ * The frame then freezes the flash, and the swap shows a scrollbar the live page
+ * did not have. So the flag tracks what the view actually is: hidden only for
+ * the one caller that captures an already-hidden page (a tab switched while the
+ * panel is occluded), where it stops Chromium promoting it back for the shot.
+ */
+/** Widest the placeholder snapshot needs to be; it sits behind a transient overlay. */
+const SNAPSHOT_MAX_WIDTH = 1024
+const SNAPSHOT_JPEG_QUALITY = 70
+
+/**
+ * Turns a captured frame into a compact JPEG data URL. Resize is a native
+ * operation and JPEG encode runs in native code too, so both are far cheaper
+ * than a full-resolution PNG `toDataURL`, which encodes synchronously on the
+ * event loop.
+ */
+function encodeSnapshot(image: Electron.NativeImage): string {
+ const { width } = image.getSize()
+ const scaled = width > SNAPSHOT_MAX_WIDTH ? image.resize({ width: SNAPSHOT_MAX_WIDTH }) : image
+ const jpeg = scaled.toJPEG(SNAPSHOT_JPEG_QUALITY)
+ return `data:image/jpeg;base64,${jpeg.toString('base64')}`
+}
+
+function capturePanelSnapshot(onSettled?: () => void): void {
+ const active = host.activeTab()
+ const win = panelWindow()
+ if (!active || !win || active.view.webContents.isDestroyed()) {
+ onSettled?.()
+ return
+ }
+
+ const generation = ++panelSnapshotGeneration
+ const tabId = active.id
+ void active.view.webContents
+ .capturePage(undefined, { stayHidden: panelOccluded })
+ .then((image) => {
+ if (generation !== panelSnapshotGeneration || image.isEmpty()) return
+ // Ownership can move while the capture is in flight. This frame is a
+ // picture of the page, so it goes to the window still showing the
+ // browser or nowhere at all.
+ if (panelWindow() !== win || win.isDestroyed()) return
+ // Downscale and JPEG-encode before crossing IPC. capturePage returns a
+ // device-pixel PNG — on a retina half-window that is millions of pixels,
+ // and toDataURL's PNG encode is synchronous on the main process, so a
+ // full-size encode stalls every window's input for the frame. This is a
+ // placeholder shown under a transient overlay, so a downscaled JPEG is
+ // indistinguishable and an order of magnitude cheaper to encode and send.
+ const snapshot: BrowserPanelSnapshot = { dataUrl: encodeSnapshot(image), tabId }
+ win.webContents.send('browser-agent:panel-snapshot', snapshot)
+ })
+ .catch((error) => {
+ logger.warn('Could not capture browser panel snapshot', {
+ error: getErrorMessage(error),
+ })
+ })
+ .finally(() => onSettled?.())
+}
+
+/**
+ * Repositions the active view over the panel rect inside its window
+ * (re-parenting if that window was recreated), and detaches it when the panel
+ * is hidden. CSS pixels scale to DIP by the page's zoom factor. Idempotent:
+ * repeated calls with unchanged inputs perform no view mutations.
+ *
+ * The renderer's measured report is the ONLY writer of bounds. This module
+ * used to also predict a rect on the window's own `resize` event, on the
+ * premise that the panel was right-anchored at a constant width — true only
+ * after a divider drag pins an inline pixel width. The panel's default is
+ * `w-1/2`, so the prediction was wrong by half the frame's window travel and,
+ * because it shared this dedup key, the two writers each invalidated the
+ * other's key and applied a different rect twice per frame. That double
+ * compositor resize was the "swimming" the prediction was meant to prevent.
+ * A divider drag still gets a predicted rect, from the renderer, where the
+ * arithmetic is exact because only the panel's left edge moves.
+ *
+ * The window's `resize` does drive this function (see {@link bindHostResize}),
+ * but only as a trigger — it supplies no rect. The report the renderer already
+ * sent is re-clamped against the new content box, so a shrink can never leave
+ * the view overhanging the frame while that report is one frame stale. The
+ * clamp needs no model of where the panel sits, which is exactly what the
+ * reverted prediction did need. Bounds keep one writer and one dedup key, so
+ * the contention above cannot recur: on a grow the clamp is inert and the key
+ * is unchanged, costing no view mutation at all.
+ */
+export function layout(): void {
+ const win = panelWindow()
+ const active = host.activeTab()
+ const showing = active !== null && panelBounds !== null && win !== null
+ const activeViewChanged = showing && attachedView !== active?.view
+
+ // Detach only when the attached view cannot stay where it is: no tab is
+ // active, a different tab took over, or the hosting window changed.
+ //
+ // A panel that is merely hidden keeps its view attached and invisible, for
+ // the same reason occlusion does (see setPanelOccluded): removing the view
+ // gives up its compositor surface, and rebuilding that on the way back is a
+ // blank repaint that reads as the page having reloaded. Every switch to
+ // another resource and back hides the panel, so that was every switch.
+ if (
+ attachedView !== null &&
+ (active === null || win === null || hostedWindow !== win || attachedView !== active.view)
+ ) {
+ detachAttachedView()
+ }
+ if (!showing || !active || !win || panelBounds === null) {
+ hideAttachedView()
+ return
+ }
+
+ if (attachedView !== active.view) {
+ win.contentView.addChildView(active.view)
+ hostedWindow = win
+ attachedView = active.view
+ if (panelOccluded && activeViewChanged) {
+ capturePanelSnapshot()
+ }
+ }
+ bindHostResize(win)
+ const zoom = win.webContents.getZoomFactor()
+ const [contentWidth, contentHeight] = win.getContentSize()
+ // The anchor is declared in the renderer's CSS pixels, so compare and
+ // evaluate there, then scale the result the same way a measured rect is.
+ const rect =
+ evaluateAnchor(panelAnchor, panelBounds, contentWidth / zoom, contentHeight / zoom) ??
+ panelBounds
+ const bounds = clampToContent(
+ {
+ x: Math.round(rect.x * zoom),
+ y: Math.round(rect.y * zoom),
+ width: Math.round(rect.width * zoom),
+ height: Math.round(rect.height * zoom),
+ },
+ contentWidth,
+ contentHeight
+ )
+ const boundsKey = `${bounds.x}:${bounds.y}:${bounds.width}:${bounds.height}`
+ if (boundsKey !== lastAppliedBounds) {
+ lastAppliedBounds = boundsKey
+ active.view.setBounds(bounds)
+ }
+ const visible = !panelOccluded
+ if (visible !== lastAppliedVisibility) {
+ lastAppliedVisibility = visible
+ active.view.setVisible(visible)
+ }
+}
+
+/**
+ * Renderer-reported panel rect (null = panel hidden/unmounted). When an owner
+ * is supplied, stale reports from another app window cannot steal or hide the
+ * singleton browser surface.
+ */
+export function setPanelBounds(
+ bounds: BrowserPanelBounds | null,
+ ownerWindow?: BrowserWindow,
+ anchor?: BrowserPanelAnchor
+): void {
+ // A closing window releases the panel from its `closed` handler, by which
+ // point Electron has already destroyed it. That release has to be honoured
+ // or the panel stays "visible" with a dead owner, and the next layout
+ // re-parents the native view onto whatever window is active — over a UI
+ // that never asked for it — until the bounds lease expires.
+ if (bounds !== null && ownerWindow?.isDestroyed()) return
+ // Only the owner may hide the panel; a stale report from another window
+ // must not pull the browser out from under the window displaying it.
+ if (bounds === null && !panelUpdateAllowed(ownerWindow)) return
+ if (bounds !== null) {
+ panelOwnerWindow = ownerWindow ?? host.getMainWindow()
+ } else {
+ panelOwnerWindow = null
+ }
+ panelBounds = bounds
+ panelAnchor = bounds === null ? null : (anchor ?? null)
+ if (bounds !== null) {
+ host.ensureInitialTab()
+ }
+ if (bounds === null) {
+ resetOcclusion()
+ }
+ panelLeaseAt = Date.now()
+ if (bounds !== null && leaseTimer === null) {
+ leaseTimer = setInterval(() => {
+ if (panelBounds !== null && Date.now() - panelLeaseAt > PANEL_LEASE_TTL_MS) {
+ logger.info('Panel bounds lease expired; hiding embedded browser view')
+ panelBounds = null
+ panelOwnerWindow = null
+ resetOcclusion()
+ layout()
+ }
+ if (panelBounds === null && leaseTimer !== null) {
+ clearInterval(leaseTimer)
+ leaseTimer = null
+ }
+ }, PANEL_LEASE_CHECK_MS)
+ }
+ layout()
+}
+
+/**
+ * Renderer-reported native-surface occlusion. The view stays attached and
+ * keeps its bounds while hidden, avoiding the flicker and restacking caused by
+ * removing and re-adding it for every tooltip or menu.
+ *
+ * Revealing is immediate; hiding waits for the frame that replaces it (see
+ * {@link panelOcclusionRequested}). A capture that fails or finds nothing to
+ * photograph still hides, so an overlay is never left with the page on top.
+ */
+export function setPanelOccluded(occluded: boolean, ownerWindow?: BrowserWindow): void {
+ if (!panelUpdateAllowed(ownerWindow)) return
+ if (panelOcclusionRequested === occluded) return
+ panelOcclusionRequested = occluded
+ if (!occluded) {
+ panelOccluded = false
+ layout()
+ return
+ }
+ capturePanelSnapshot(() => {
+ // The overlay can close while its frame is being taken; hiding then would
+ // blank the page with nothing above it.
+ if (!panelOcclusionRequested) return
+ panelOccluded = true
+ layout()
+ })
+}
diff --git a/apps/desktop/src/main/browser-agent/registry.ts b/apps/desktop/src/main/browser-agent/registry.ts
new file mode 100644
index 00000000000..eeabc9de405
--- /dev/null
+++ b/apps/desktop/src/main/browser-agent/registry.ts
@@ -0,0 +1,20 @@
+import type { WebContents } from 'electron'
+
+/**
+ * Registry of WebContents that belong to the agent browser (the browser-agent
+ * session's tabs). The global security guards consult this to swap the
+ * app-origin navigation policy for the agent policy (free http/https
+ * browsing) on exactly these contents and nothing else.
+ *
+ * Registration happens right after a view is constructed; the guards check at
+ * navigation time, so the post-construction registration races nothing.
+ */
+const agentContents = new WeakSet()
+
+export function registerAgentWebContents(contents: WebContents): void {
+ agentContents.add(contents)
+}
+
+export function isAgentWebContents(contents: WebContents): boolean {
+ return agentContents.has(contents)
+}
diff --git a/apps/desktop/src/main/browser-agent/session.test.ts b/apps/desktop/src/main/browser-agent/session.test.ts
new file mode 100644
index 00000000000..8ba73fcfc9b
--- /dev/null
+++ b/apps/desktop/src/main/browser-agent/session.test.ts
@@ -0,0 +1,1435 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('electron', () => import('@/test/electron-mock'))
+
+import { MAX_BROWSER_TABS } from '@sim/browser-protocol'
+import { sleep } from '@sim/utils/helpers'
+import { BrowserWindow, session as electronSession } from 'electron'
+import * as panel from '@/main/browser-agent/panel'
+import * as sessionModule from '@/main/browser-agent/session'
+
+type SessionModule = typeof import('@/main/browser-agent/session')
+
+interface MockView {
+ webContents: {
+ session: {
+ setPermissionRequestHandler: ReturnType
+ setPermissionCheckHandler: ReturnType
+ }
+ on: ReturnType
+ setWindowOpenHandler: ReturnType
+ loadURL: ReturnType
+ getURL: ReturnType
+ close: ReturnType
+ focus: ReturnType
+ isFocused: ReturnType
+ isDestroyed: ReturnType
+ setBackgroundThrottling: ReturnType
+ capturePage: ReturnType
+ findInPage: ReturnType
+ stopFindInPage: ReturnType
+ }
+ setBackgroundColor: ReturnType
+ setBounds: ReturnType
+ setVisible: ReturnType
+}
+
+function mainWindowMock() {
+ const win = new BrowserWindow() as unknown as {
+ contentView: {
+ addChildView: ReturnType
+ removeChildView: ReturnType
+ }
+ webContents: { getZoomFactor?: ReturnType }
+ }
+ win.webContents.getZoomFactor = vi.fn(() => 1)
+ return win as unknown as BrowserWindow
+}
+
+/**
+ * `initSession` is a full reset of both this module's and the panel's
+ * per-session state, so a clean session needs no module reload — which is what
+ * lets this file use static imports instead of the `vi.resetModules()` the
+ * root CLAUDE.md forbids.
+ */
+function freshSession(
+ win: BrowserWindow | null | (() => BrowserWindow | null),
+ eventOverrides: Partial = {},
+ persistence?: sessionModule.PinnedTabPersistence
+): SessionModule {
+ const mainWindowProvider = typeof win === 'function' ? win : () => win
+ const session = sessionModule
+ session.initSession(
+ {
+ onSessionClosed: vi.fn(),
+ onTabCreated: vi.fn(),
+ onActiveTabChanged: vi.fn(),
+ onTabsChanged: vi.fn(),
+ onTabThemeChanged: vi.fn(),
+ onDownloadBlocked: vi.fn(),
+ onTabNavigated: vi.fn(),
+ onTabClosed: vi.fn(),
+ ...eventOverrides,
+ },
+ mainWindowProvider,
+ persistence
+ )
+ return session
+}
+
+/** The host `resize` listener panel.ts binds while a view is attached. */
+function hostResizeHandler(win: BrowserWindow): () => void {
+ const calls = (win as unknown as { on: ReturnType }).on.mock.calls
+ const handler = calls.find(([event]) => event === 'resize')?.[1]
+ if (typeof handler !== 'function') throw new Error('no host resize listener bound')
+ return handler as () => void
+}
+
+describe('browser-agent session', () => {
+ let win: BrowserWindow
+ let session: SessionModule
+
+ beforeEach(async () => {
+ win = mainWindowMock()
+ session = freshSession(win)
+ })
+
+ it('creates the first tab lazily, then reuses it', () => {
+ expect(session.hasSession()).toBe(false)
+ const first = session.ensureTab()
+ expect(session.hasSession()).toBe(true)
+ expect(session.ensureTab()).toBe(first)
+ expect(session.listTabs()).toHaveLength(1)
+ expect(session.listTabs()[0]).toMatchObject({ tabId: first.id, active: true })
+ })
+
+ it('starts a second session clean instead of inheriting the first', () => {
+ // `initSession` names itself as the session boundary but used to set three
+ // of its thirteen fields, so everything else leaked into the next session:
+ // its tabs, its theme, its find, its tab-id counter. Nothing re-inits in
+ // production today, which is exactly why the gap stayed invisible — and
+ // why these tests had to reset the whole MODULE to get a clean one.
+ const firstTab = session.ensureTab()
+ session.setBrowserTheme('dark')
+ expect(session.listTabs()).toHaveLength(1)
+
+ const second = freshSession(win)
+
+ expect(second.listTabs()).toHaveLength(0)
+ expect(second.getBrowserTheme()).toBe('system')
+ // Same id as the first session's first tab: the counter restarted, so a
+ // stale id cannot address a tab that outlived the session it came from.
+ expect(second.ensureTab().id).toBe(firstTab.id)
+ })
+
+ it('normalizes browser shortcuts to Command on macOS and Control elsewhere', () => {
+ const input = {
+ type: 'keyDown',
+ key: 'l',
+ isAutoRepeat: false,
+ isComposing: false,
+ shift: false,
+ control: false,
+ alt: false,
+ meta: true,
+ }
+
+ expect(session.browserShortcutForInput(input, 'darwin')).toBe('focus-omnibox')
+ expect(session.browserShortcutForInput(input, 'win32')).toBeNull()
+ expect(session.browserShortcutForInput({ ...input, meta: false, control: true }, 'win32')).toBe(
+ 'focus-omnibox'
+ )
+ expect(session.browserShortcutForInput({ ...input, key: 't' }, 'darwin')).toBe('new-tab')
+ expect(session.browserShortcutForInput({ ...input, key: 'w' }, 'darwin')).toBe('close-tab')
+ expect(session.browserShortcutForInput({ ...input, key: 'f' }, 'darwin')).toBe('find')
+ expect(
+ session.browserShortcutForInput({ ...input, key: 't', shift: true }, 'darwin')
+ ).toBeNull()
+ })
+
+ it('handles browser shortcuts from a focused native tab', () => {
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const first = session.requireTab()
+ const firstContents = (first.view as unknown as MockView).webContents
+ const beforeInput = firstContents.on.mock.calls.find(
+ ([eventName]) => eventName === 'before-input-event'
+ )?.[1] as
+ | ((event: { preventDefault: () => void }, input: Record) => void)
+ | undefined
+ const event = { preventDefault: vi.fn() }
+ const input = {
+ type: 'keyDown',
+ key: 'l',
+ isAutoRepeat: false,
+ isComposing: false,
+ shift: false,
+ control: process.platform !== 'darwin',
+ alt: false,
+ meta: process.platform === 'darwin',
+ }
+
+ beforeInput?.(event, input)
+ expect(event.preventDefault).toHaveBeenCalled()
+ expect(win.webContents.focus).toHaveBeenCalled()
+ expect(win.webContents.send).toHaveBeenLastCalledWith('browser-agent:focus-omnibox', 'select')
+
+ beforeInput?.(event, { ...input, key: 't' })
+ expect(session.listTabs()).toHaveLength(2)
+ expect(win.webContents.send).toHaveBeenLastCalledWith('browser-agent:focus-omnibox', 'clear')
+
+ const second = session.activeTab()
+ expect(second).not.toBeNull()
+ const secondContents = (second?.view as unknown as MockView).webContents
+ const secondBeforeInput = secondContents.on.mock.calls.find(
+ ([eventName]) => eventName === 'before-input-event'
+ )?.[1] as
+ | ((event: { preventDefault: () => void }, input: Record) => void)
+ | undefined
+ secondBeforeInput?.(event, { ...input, key: 'w' })
+ expect(session.listTabs()).toHaveLength(1)
+ expect(firstContents.focus).toHaveBeenCalled()
+
+ beforeInput?.(event, { ...input, key: 'w' })
+ expect(session.listTabs()).toHaveLength(1)
+ expect(session.listTabs()[0].tabId).not.toBe(first.id)
+ expect(win.webContents.send).toHaveBeenLastCalledWith('browser-agent:focus-omnibox', 'clear')
+ })
+
+ it('opens the renderer find bar when the page takes Mod+F', () => {
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const tab = session.requireTab()
+ const contents = (tab.view as unknown as MockView).webContents
+ const beforeInput = contents.on.mock.calls.find(
+ ([eventName]) => eventName === 'before-input-event'
+ )?.[1] as
+ | ((event: { preventDefault: () => void }, input: Record) => void)
+ | undefined
+ const event = { preventDefault: vi.fn() }
+
+ beforeInput?.(event, {
+ type: 'keyDown',
+ key: 'f',
+ isAutoRepeat: false,
+ isComposing: false,
+ shift: false,
+ control: process.platform !== 'darwin',
+ alt: false,
+ meta: process.platform === 'darwin',
+ })
+
+ // The page never sees it — otherwise a site's own Mod+F wins over find.
+ expect(event.preventDefault).toHaveBeenCalled()
+ expect(win.webContents.send).toHaveBeenLastCalledWith('browser-agent:open-find')
+ })
+
+ it('restarts the search while typing and steps without restarting on next/previous', () => {
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const tab = session.requireTab()
+ const contents = (tab.view as unknown as MockView).webContents
+
+ session.findInActiveTab({ query: 'needle', findNext: false, forward: true })
+ expect(contents.findInPage).toHaveBeenLastCalledWith('needle', {
+ forward: true,
+ findNext: false,
+ })
+
+ session.findInActiveTab({ query: 'needle', findNext: true, forward: false })
+ expect(contents.findInPage).toHaveBeenLastCalledWith('needle', {
+ forward: false,
+ findNext: true,
+ })
+
+ // Clearing the box is a stop, not a search for the empty string — and the
+ // bar has to survive it, or deleting the last character closes the bar the
+ // user is still typing in.
+ vi.mocked(win.webContents.send).mockClear()
+ session.findInActiveTab({ query: '', findNext: false, forward: true })
+ expect(contents.stopFindInPage).toHaveBeenCalledWith('clearSelection')
+ expect(contents.findInPage).toHaveBeenCalledTimes(2)
+ expect(win.webContents.send).not.toHaveBeenCalledWith('browser-agent:close-find')
+ })
+
+ it('forwards match counts only for the tab the find is running on', () => {
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const first = session.requireTab()
+ const second = session.addTab()
+ const firstContents = (first.view as unknown as MockView).webContents
+ const secondContents = (second.view as unknown as MockView).webContents
+ const foundOn = (contents: MockView['webContents']) =>
+ contents.on.mock.calls.find(([eventName]) => eventName === 'found-in-page')?.[1] as
+ | ((event: unknown, result: Record) => void)
+ | undefined
+
+ session.switchTab(first.id)
+ session.findInActiveTab({ query: 'needle', findNext: false, forward: true })
+ foundOn(firstContents)?.({}, { activeMatchOrdinal: 2, matches: 7, finalUpdate: true })
+ expect(win.webContents.send).toHaveBeenLastCalledWith('browser-agent:find-result', {
+ activeMatchOrdinal: 2,
+ matches: 7,
+ final: true,
+ })
+
+ // A late result from a tab that is not being searched would relabel the bar
+ // with counts for a page the user is not looking at.
+ vi.mocked(win.webContents.send).mockClear()
+ foundOn(secondContents)?.({}, { activeMatchOrdinal: 1, matches: 3, finalUpdate: true })
+ expect(win.webContents.send).not.toHaveBeenCalledWith(
+ 'browser-agent:find-result',
+ expect.anything()
+ )
+ })
+
+ it('drops the find when its page navigates away, but not on a same-document change', () => {
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const tab = session.requireTab()
+ const contents = (tab.view as unknown as MockView).webContents
+ const navigate = contents.on.mock.calls.find(
+ ([eventName]) => eventName === 'did-start-navigation'
+ )?.[1] as ((details: Record) => void) | undefined
+
+ session.findInActiveTab({ query: 'needle', findNext: false, forward: true })
+ vi.mocked(win.webContents.send).mockClear()
+
+ // A pushState route change keeps the document the matches live in.
+ navigate?.({ isMainFrame: true, isSameDocument: true })
+ expect(win.webContents.send).not.toHaveBeenCalledWith('browser-agent:close-find')
+ // A subframe load likewise leaves the main document alone.
+ navigate?.({ isMainFrame: false, isSameDocument: false })
+ expect(win.webContents.send).not.toHaveBeenCalledWith('browser-agent:close-find')
+
+ navigate?.({ isMainFrame: true, isSameDocument: false })
+ expect(contents.stopFindInPage).toHaveBeenCalledWith('clearSelection')
+ expect(win.webContents.send).toHaveBeenCalledWith('browser-agent:close-find')
+ })
+
+ it('drops the find when the tab it is running on is closed', () => {
+ // Otherwise the searched tab id outlives the tab: the bar stays open
+ // counting matches on a page that no longer exists, and nothing clears it
+ // until the user happens to type a new query.
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ session.requireTab()
+ const second = session.addTab()
+ session.switchTab(second.id)
+ session.findInActiveTab({ query: 'needle', findNext: false, forward: true })
+ vi.mocked(win.webContents.send).mockClear()
+
+ session.closeTab(second.id)
+
+ expect(win.webContents.send).toHaveBeenCalledWith('browser-agent:close-find')
+ })
+
+ it('drops the find when the tab it is running on crashes', () => {
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const first = session.requireTab()
+ session.addTab()
+ session.switchTab(first.id)
+ session.findInActiveTab({ query: 'needle', findNext: false, forward: true })
+ vi.mocked(win.webContents.send).mockClear()
+
+ const contents = (first.view as unknown as MockView).webContents
+ const gone = contents.on.mock.calls.find(
+ ([eventName]) => eventName === 'render-process-gone'
+ )?.[1] as ((event: unknown, details: { reason: string }) => void) | undefined
+ gone?.({}, { reason: 'crashed' })
+
+ expect(win.webContents.send).toHaveBeenCalledWith('browser-agent:close-find')
+ })
+
+ it('drops the find when the user switches to another tab', () => {
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const first = session.requireTab()
+ const second = session.addTab()
+ const firstContents = (first.view as unknown as MockView).webContents
+
+ session.switchTab(first.id)
+ session.findInActiveTab({ query: 'needle', findNext: false, forward: true })
+ vi.mocked(win.webContents.send).mockClear()
+
+ session.switchTab(second.id)
+ expect(firstContents.stopFindInPage).toHaveBeenCalledWith('clearSelection')
+ expect(win.webContents.send).toHaveBeenCalledWith('browser-agent:close-find')
+ })
+
+ it('returns focus to the page only when the user dismissed the bar', () => {
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const tab = session.requireTab()
+ const contents = (tab.view as unknown as MockView).webContents
+
+ // Panel teardown: the bar unmounts under a user who has already moved on,
+ // so pulling focus back into the browser would drag them back to it.
+ session.findInActiveTab({ query: 'needle', findNext: false, forward: true })
+ contents.focus.mockClear()
+ session.stopFindInActiveTab(false)
+ expect(contents.focus).not.toHaveBeenCalled()
+
+ session.findInActiveTab({ query: 'needle', findNext: false, forward: true })
+ contents.focus.mockClear()
+ session.stopFindInActiveTab(true)
+ expect(contents.focus).toHaveBeenCalled()
+ })
+
+ it('returns focus to the page even when no search was running', () => {
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const tab = session.requireTab()
+ const contents = (tab.view as unknown as MockView).webContents
+
+ // Opened and closed without typing. Focus still has to leave the bar: it is
+ // unmounting, and cannot receive the Mod+F that reopens it.
+ contents.focus.mockClear()
+ session.stopFindInActiveTab(true)
+ expect(contents.focus).toHaveBeenCalled()
+
+ // Same once the box is emptied — clearing the query ends the search, so
+ // dismissing afterwards has no searched tab to key focus off either.
+ session.findInActiveTab({ query: 'needle', findNext: false, forward: true })
+ session.findInActiveTab({ query: '', findNext: false, forward: true })
+ contents.focus.mockClear()
+ session.stopFindInActiveTab(true)
+ expect(contents.focus).toHaveBeenCalled()
+ })
+
+ it('closes only the native browser tab targeted by the application menu accelerator', () => {
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const first = session.requireTab()
+ const second = session.addTab()
+ const firstContents = (first.view as unknown as MockView).webContents
+ const secondContents = (second.view as unknown as MockView).webContents
+ const focusListener = secondContents.on.mock.calls.find(
+ ([eventName]) => eventName === 'focus'
+ )?.[1] as (() => void) | undefined
+ const blurListener = secondContents.on.mock.calls.find(
+ ([eventName]) => eventName === 'blur'
+ )?.[1] as (() => void) | undefined
+
+ // Menu accelerators can shift Electron's live focus flag before their
+ // click callback runs. The captured owner must survive that synchronous
+ // blur and remain routable for the current event-loop turn.
+ focusListener?.()
+ blurListener?.()
+
+ expect(session.closeFocusedTab()).toBe(true)
+ expect(session.listTabs()).toHaveLength(1)
+ expect(session.listTabs()[0].tabId).toBe(first.id)
+ expect(firstContents.focus).toHaveBeenCalledOnce()
+
+ // Focus ownership transfers with the close, so a repeated Mod+W closes
+ // the newly active tab even if Electron has not emitted its focus event.
+ expect(session.closeFocusedTab()).toBe(true)
+ expect(session.listTabs()).toHaveLength(1)
+ expect(session.listTabs()[0].tabId).not.toBe(first.id)
+
+ // The replacement is an untouched about:blank tab. It still owns the
+ // browser context, so it must not require a page load or another click.
+ const blankTabId = session.listTabs()[0].tabId
+ expect(session.closeFocusedTab()).toBe(true)
+ expect(session.listTabs()).toHaveLength(1)
+ expect(session.listTabs()[0].tabId).not.toBe(blankTabId)
+
+ session.setPanelFocused(false)
+ expect(session.closeFocusedTab()).toBe(false)
+ expect(session.listTabs()).toHaveLength(1)
+ })
+
+ it('treats renderer browser chrome as browser focus', () => {
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const first = session.requireTab()
+ const second = session.addTab()
+
+ session.setPanelFocused(true)
+ expect(session.closeFocusedTab()).toBe(true)
+ expect(session.listTabs()).toHaveLength(1)
+ expect(session.listTabs()[0].tabId).toBe(first.id)
+ expect(session.listTabs()[0].tabId).not.toBe(second.id)
+
+ session.setPanelFocused(false)
+ expect(session.closeFocusedTab()).toBe(false)
+ })
+
+ it('retains browser focus while a renderer overlay temporarily occludes the page', () => {
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ session.requireTab()
+ session.setPanelFocused(true)
+
+ // Tooltips and browser chrome overlays hide the native surface briefly;
+ // visual occlusion is not a focus change.
+ panel.setPanelOccluded(true)
+ expect(session.closeFocusedTab()).toBe(true)
+ })
+
+ it('unthrottles only the active tab while automation is active', () => {
+ const active = session.ensureTab()
+ const activeContents = (active.view as unknown as MockView).webContents
+ const background = session.addTab()
+ const backgroundContents = (background.view as unknown as MockView).webContents
+ // addTab activated the second tab; put focus back on the first.
+ session.switchTab(active.id)
+ activeContents.setBackgroundThrottling.mockClear()
+ backgroundContents.setBackgroundThrottling.mockClear()
+
+ session.setAutomationActive(true)
+ // The waking is scoped to the active tab; the background tab stays throttled.
+ expect(activeContents.setBackgroundThrottling).toHaveBeenLastCalledWith(false)
+ expect(backgroundContents.setBackgroundThrottling).toHaveBeenLastCalledWith(true)
+
+ session.setAutomationActive(false)
+ expect(activeContents.setBackgroundThrottling).toHaveBeenLastCalledWith(true)
+ })
+
+ it('moves the automation exemption to whichever tab becomes active', () => {
+ const first = session.ensureTab()
+ const second = session.addTab()
+ session.switchTab(first.id)
+ const firstContents = (first.view as unknown as MockView).webContents
+ const secondContents = (second.view as unknown as MockView).webContents
+
+ session.setAutomationActive(true)
+ firstContents.setBackgroundThrottling.mockClear()
+ secondContents.setBackgroundThrottling.mockClear()
+
+ session.switchTab(second.id)
+
+ // The old active tab is re-throttled, the new one exempted — otherwise a
+ // mid-tool switch would strand the wake on a tab the agent left behind.
+ expect(firstContents.setBackgroundThrottling).toHaveBeenLastCalledWith(true)
+ expect(secondContents.setBackgroundThrottling).toHaveBeenLastCalledWith(false)
+ })
+
+ it('updates the native backdrop when Sim changes browser theme', () => {
+ const tab = session.ensureTab()
+ const view = tab.view as unknown as MockView
+
+ session.setBrowserTheme('dark')
+ expect(session.getBrowserTheme()).toBe('dark')
+ expect(view.setBackgroundColor).toHaveBeenLastCalledWith('#0c0c0c')
+
+ session.setBrowserTheme('light')
+ expect(view.setBackgroundColor).toHaveBeenLastCalledWith('#ffffff')
+ })
+
+ it('propagates theme changes to every existing tab', async () => {
+ const onTabThemeChanged = vi.fn()
+ const themedSession = freshSession(win, { onTabThemeChanged })
+ const first = themedSession.ensureTab()
+ const second = themedSession.addTab()
+
+ themedSession.setBrowserTheme('dark')
+
+ expect(onTabThemeChanged.mock.calls).toEqual([
+ [first.view.webContents, 'dark'],
+ [second.view.webContents, 'dark'],
+ ])
+ })
+
+ it('requireTab refuses when no page is open yet', () => {
+ expect(() => session.requireTab()).toThrow(/No page is open yet/)
+ })
+
+ it('opens, switches, and closes tabs with stable ids', () => {
+ const first = session.ensureTab()
+ const second = session.addTab()
+ expect(second.id).not.toBe(first.id)
+ expect(session.activeTab()?.id).toBe(second.id)
+
+ const switched = session.switchTab(first.id)
+ expect(switched.id).toBe(first.id)
+ expect(session.activeTab()?.id).toBe(first.id)
+
+ session.closeTab(first.id)
+ expect(session.listTabs().map((tab) => tab.tabId)).toEqual([second.id])
+ expect(session.activeTab()?.id).toBe(second.id)
+
+ expect(() => session.switchTab('999')).toThrow(/No tab with id 999/)
+ expect(() => session.closeTab('999')).toThrow(/No tab with id 999/)
+ })
+
+ it('selects the neighboring tab when the active tab closes', () => {
+ const first = session.ensureTab()
+ const second = session.addTab()
+ const third = session.addTab()
+
+ session.switchTab(second.id)
+ session.closeTab(second.id)
+ expect(session.activeTab()?.id).toBe(third.id)
+
+ session.closeTab(third.id)
+ expect(session.activeTab()?.id).toBe(first.id)
+ })
+
+ it('reopens the latest closed tab while the browser owns focus', () => {
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ session.ensureTab()
+ const closed = session.addTab()
+ session.setPanelFocused(true)
+ session.closeTab(closed.id)
+
+ expect(session.reopenFocusedTab()).toBe(true)
+ const reopened = session.activeTab()
+ expect(reopened?.id).not.toBe(closed.id)
+ const contents = (reopened?.view as unknown as MockView | undefined)?.webContents
+ expect(contents?.loadURL).toHaveBeenCalledWith('https://example.com/')
+ expect(contents?.focus).toHaveBeenCalled()
+
+ session.setPanelFocused(false)
+ expect(session.reopenFocusedTab()).toBe(false)
+ })
+
+ it('keeps stale reports from another app window from hiding or controlling the browser panel', () => {
+ const otherWindow = mainWindowMock()
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }, win)
+ session.ensureTab()
+ vi.mocked(win.contentView.removeChildView).mockClear()
+
+ panel.setPanelBounds(null, otherWindow)
+ expect(win.contentView.removeChildView).not.toHaveBeenCalled()
+
+ session.setPanelFocused(true, win)
+ expect(session.closeFocusedTab(otherWindow)).toBe(false)
+ expect(session.closeFocusedTab(win)).toBe(true)
+ })
+
+ it('reorders tabs while preserving the pinned-tab boundary', () => {
+ const first = session.ensureTab()
+ const second = session.addTab()
+ const third = session.addTab()
+
+ session.reorderTab(third.id, 0)
+ expect(session.listTabs().map((tab) => tab.tabId)).toEqual([third.id, first.id, second.id])
+
+ session.setTabPinned(first.id, true)
+ session.reorderTab(second.id, 0)
+ expect(session.listTabs().map((tab) => tab.tabId)).toEqual([first.id, second.id, third.id])
+
+ session.reorderTab(first.id, 2)
+ expect(session.listTabs().map((tab) => tab.tabId)).toEqual([first.id, second.id, third.id])
+ expect(() => session.reorderTab('999', 0)).toThrow(/No tab with id 999/)
+ })
+
+ it('moves pinned tabs left and requires unpinning before any close path', async () => {
+ const save = vi.fn()
+ const pinnedSession = freshSession(
+ win,
+ {},
+ {
+ load: () => [],
+ save,
+ }
+ )
+ const first = pinnedSession.ensureTab()
+ const second = pinnedSession.addTab()
+
+ pinnedSession.setTabPinned(second.id, true)
+
+ expect(pinnedSession.listTabs()).toEqual([
+ expect.objectContaining({ tabId: second.id, pinned: true }),
+ expect.objectContaining({ tabId: first.id, pinned: false }),
+ ])
+ expect(save).toHaveBeenLastCalledWith(['https://example.com/'])
+ expect(() => pinnedSession.closeTab(second.id)).toThrow(/Pinned tabs cannot be closed/)
+
+ pinnedSession.setTabPinned(second.id, false)
+ pinnedSession.closeTab(second.id)
+ expect(pinnedSession.listTabs().map((tab) => tab.tabId)).toEqual([first.id])
+ expect(save).toHaveBeenLastCalledWith([])
+ })
+
+ it('restores pinned tabs when the browser resource opens again', async () => {
+ const restoredSession = freshSession(
+ win,
+ {},
+ {
+ load: () => ['https://docs.sim.ai/guide'],
+ save: vi.fn(),
+ }
+ )
+
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+
+ const [restored] = restoredSession.listTabs()
+ expect(restored).toMatchObject({ pinned: true, active: true })
+ const contents = (restoredSession.requireTab().view as unknown as MockView).webContents
+ expect(contents.loadURL).toHaveBeenCalledWith('https://docs.sim.ai/guide')
+ expect(() => restoredSession.closeTab(restored.tabId)).toThrow(/Pinned tabs cannot be closed/)
+
+ const regular = restoredSession.addTab()
+ expect(restoredSession.listTabs()).toEqual([
+ expect.objectContaining({ tabId: restored.tabId, pinned: true }),
+ expect.objectContaining({ tabId: regular.id, pinned: false, active: true }),
+ ])
+ })
+
+ it('limits the browser session to the shared tab cap', () => {
+ session.ensureTab()
+ for (let index = 1; index < MAX_BROWSER_TABS; index++) {
+ session.addTab()
+ }
+
+ expect(session.listTabs()).toHaveLength(MAX_BROWSER_TABS)
+ expect(() => session.addTab()).toThrow(
+ `The browser supports up to ${MAX_BROWSER_TABS} open tabs.`
+ )
+ })
+
+ it('embeds the active view in the MAIN window only while panel bounds are reported', () => {
+ const tab = session.ensureTab()
+ const view = tab.view as unknown as MockView
+ const content = (win as unknown as { contentView: { addChildView: ReturnType } })
+ .contentView
+
+ // No bounds yet: the view is not attached to the window.
+ expect(content.addChildView).not.toHaveBeenCalledWith(tab.view)
+
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ expect(content.addChildView).toHaveBeenCalledWith(tab.view)
+ expect(view.setBounds).toHaveBeenCalledWith({ x: 100, y: 50, width: 800, height: 600 })
+
+ // Panel hidden: the view stops painting but stays attached. Detaching
+ // would give up its compositor surface, and rebuilding that on the way
+ // back is the blank repaint that reads as the page having reloaded —
+ // which is every switch to another resource and back.
+ const removeChildView = (
+ win as unknown as { contentView: { removeChildView: ReturnType } }
+ ).contentView.removeChildView
+ view.setVisible.mockClear()
+ panel.setPanelBounds(null)
+ expect(view.setVisible).toHaveBeenCalledWith(false)
+ expect(removeChildView).not.toHaveBeenCalled()
+
+ // Showing it again reuses the attached view rather than re-adding it.
+ content.addChildView.mockClear()
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ expect(view.setVisible).toHaveBeenLastCalledWith(true)
+ expect(content.addChildView).not.toHaveBeenCalled()
+ })
+
+ it('detaches the previous view when another tab becomes active', () => {
+ const first = session.ensureTab()
+ panel.setPanelBounds({ x: 0, y: 0, width: 800, height: 600 })
+ const content = (
+ win as unknown as {
+ contentView: {
+ addChildView: ReturnType
+ removeChildView: ReturnType
+ }
+ }
+ ).contentView
+ content.addChildView.mockClear()
+ content.removeChildView.mockClear()
+
+ const second = session.addTab()
+
+ // Hiding keeps a view attached, but a tab switch still has to detach:
+ // two native views stacked in the window would composite over each other.
+ expect(content.removeChildView).toHaveBeenCalledWith(first.view)
+ expect(content.addChildView).toHaveBeenCalledWith(second.view)
+ })
+
+ // The measured report is the sole writer of bounds. A main-process
+ // prediction on the window's own `resize` used to race it: it assumed a
+ // constant panel width, which only holds after a divider drag pins one, so
+ // with the default half-width panel it applied a rect that disagreed with
+ // the measurement by half the window's travel — twice per frame, because
+ // the two writers shared a dedup key and kept invalidating each other.
+ it('applies renderer-measured bounds once and invents no rect when the window grows', () => {
+ const tab = session.ensureTab()
+ const view = tab.view as unknown as MockView
+ const mock = win as unknown as {
+ on: ReturnType
+ getContentSize: ReturnType
+ }
+
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ expect(view.setBounds).toHaveBeenCalledWith({ x: 100, y: 50, width: 800, height: 600 })
+
+ // The window's resize is a layout trigger, never a source of bounds. On a
+ // grow the clamp is inert, so the rect is unchanged and nothing is applied
+ // until the renderer measures — this is what keeps the reverted prediction
+ // from creeping back in.
+ const onResize = hostResizeHandler(win)
+
+ view.setBounds.mockClear()
+ mock.getContentSize.mockReturnValue([1380, 950])
+ onResize()
+ expect(view.setBounds).not.toHaveBeenCalled()
+
+ // A repeated identical report stays idempotent.
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ expect(view.setBounds).not.toHaveBeenCalled()
+
+ // The next measured rect is applied exactly once.
+ panel.setPanelBounds({ x: 300, y: 50, width: 900, height: 700 })
+ expect(view.setBounds).toHaveBeenCalledTimes(1)
+ expect(view.setBounds).toHaveBeenCalledWith({ x: 300, y: 50, width: 900, height: 700 })
+ })
+
+ // A shrink outruns the renderer's measurement by a frame; without the clamp
+ // the stale rect is applied verbatim and the view overhangs the new frame.
+ it('confines the view to the content box when the window shrinks', () => {
+ const tab = session.ensureTab()
+ const view = tab.view as unknown as MockView
+ const mock = win as unknown as {
+ on: ReturnType
+ getContentSize: ReturnType
+ }
+
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const onResize = hostResizeHandler(win)
+
+ view.setBounds.mockClear()
+ mock.getContentSize.mockReturnValue([600, 400])
+ onResize()
+
+ expect(view.setBounds).toHaveBeenCalledTimes(1)
+ expect(view.setBounds).toHaveBeenCalledWith({ x: 100, y: 50, width: 500, height: 350 })
+
+ // Re-clamping the same stale rect is idempotent.
+ onResize()
+ expect(view.setBounds).toHaveBeenCalledTimes(1)
+ })
+
+ // The measured rect is a frame stale mid-drag, and for a half-width panel a
+ // window change of D moves the panel's left edge by D/2 — which the clamp
+ // cannot correct because it only truncates. The declared anchor is what moves
+ // x, closing the gap between the divider and the view's left edge.
+ it('re-derives the rect from the declared anchor while the window resizes', () => {
+ const tab = session.ensureTab()
+ const view = tab.view as unknown as MockView
+ const mock = win as unknown as {
+ on: ReturnType
+ getContentSize: ReturnType
+ }
+
+ // Half-width panel, right-flush, measured at a 1000x800 viewport.
+ mock.getContentSize.mockReturnValue([1000, 800])
+ panel.setPanelBounds({ x: 500, y: 40, width: 500, height: 760 }, undefined, {
+ viewportWidth: 1000,
+ viewportHeight: 800,
+ widthRatio: 0.5,
+ })
+ expect(view.setBounds).toHaveBeenLastCalledWith({ x: 500, y: 40, width: 500, height: 760 })
+
+ const onResize = hostResizeHandler(win)
+
+ // Window grows to 1200 wide: half-width means x moves to 600, not 500.
+ view.setBounds.mockClear()
+ mock.getContentSize.mockReturnValue([1200, 800])
+ onResize()
+ expect(view.setBounds).toHaveBeenCalledWith({ x: 600, y: 40, width: 600, height: 760 })
+
+ // Shrinking below the measured size derives it just as well, with no help
+ // from the clamp (600 wide → x 300, width 300, both inside the frame).
+ view.setBounds.mockClear()
+ mock.getContentSize.mockReturnValue([600, 800])
+ onResize()
+ expect(view.setBounds).toHaveBeenCalledWith({ x: 300, y: 40, width: 300, height: 760 })
+ })
+
+ it('prefers the measured rect over the anchor at the measured viewport', () => {
+ const tab = session.ensureTab()
+ const view = tab.view as unknown as MockView
+ const mock = win as unknown as { getContentSize: ReturnType }
+
+ // An anchor that disagrees with the measurement must not win while the
+ // viewport still matches: measurement is authoritative, so a wrong anchor
+ // can only ever affect the frames of a live resize.
+ mock.getContentSize.mockReturnValue([1000, 800])
+ panel.setPanelBounds({ x: 500, y: 40, width: 500, height: 760 }, undefined, {
+ viewportWidth: 1000,
+ viewportHeight: 800,
+ widthRatio: 0,
+ })
+
+ expect(view.setBounds).toHaveBeenLastCalledWith({ x: 500, y: 40, width: 500, height: 760 })
+ })
+
+ it('drops the resize listener while the panel is hidden', () => {
+ session.ensureTab()
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const onResize = hostResizeHandler(win)
+
+ panel.setPanelBounds(null)
+
+ expect(
+ (win as unknown as { removeListener: ReturnType }).removeListener
+ ).toHaveBeenCalledWith('resize', onResize)
+ })
+
+ it('creates one real default tab when the browser panel becomes visible', () => {
+ expect(session.listTabs()).toHaveLength(0)
+
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+
+ expect(session.listTabs()).toHaveLength(1)
+ expect(session.getTabsState().activeTabId).toBe(session.listTabs()[0].tabId)
+
+ const firstTabId = session.listTabs()[0].tabId
+ session.closeTab(firstTabId)
+ expect(session.listTabs()).toHaveLength(1)
+ expect(session.listTabs()[0].tabId).not.toBe(firstTabId)
+ })
+
+ it('clears a stale attachment without touching a destroyed host window', () => {
+ // Production replaces the main window through the provider closure
+ // (`() => getMainWindow()`), never by re-initialising the session — which
+ // is what keeps the live tab across the swap, and the tab surviving is the
+ // whole point of re-parenting it. Driving it the same way here.
+ let host: BrowserWindow = win
+ session = freshSession(() => host)
+ const tab = session.ensureTab()
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const staleContent = (
+ win as unknown as {
+ contentView: {
+ removeChildView: ReturnType
+ }
+ }
+ ).contentView
+ staleContent.removeChildView.mockClear()
+ staleContent.removeChildView.mockImplementation(() => {
+ throw new Error('Object has been destroyed')
+ })
+ vi.mocked(win.isDestroyed).mockReturnValue(true)
+
+ const replacement = mainWindowMock()
+ host = replacement
+
+ expect(() => panel.setPanelBounds(null)).not.toThrow()
+ expect(staleContent.removeChildView).not.toHaveBeenCalled()
+
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const replacementContent = (
+ replacement as unknown as {
+ contentView: {
+ addChildView: ReturnType
+ }
+ }
+ ).contentView
+ expect(replacementContent.addChildView).toHaveBeenCalledWith(tab.view)
+ })
+
+ it('clears a stale attachment without touching a destroyed child view', () => {
+ const tab = session.ensureTab()
+ const view = tab.view as unknown as MockView
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const content = (
+ win as unknown as {
+ contentView: {
+ removeChildView: ReturnType
+ }
+ }
+ ).contentView
+ content.removeChildView.mockClear()
+ view.webContents.isDestroyed.mockReturnValue(true)
+
+ expect(() => panel.setPanelBounds(null)).not.toThrow()
+ expect(content.removeChildView).not.toHaveBeenCalled()
+ })
+
+ it('scales panel bounds by the main window zoom factor', () => {
+ const winZoomed = mainWindowMock()
+ ;(
+ winZoomed as unknown as { webContents: { getZoomFactor: ReturnType } }
+ ).webContents.getZoomFactor = vi.fn(() => 1.5)
+ // Roomy content box so the clamp stays inert and this covers zoom alone.
+ ;(winZoomed as unknown as { getContentSize: ReturnType }).getContentSize = vi.fn(
+ () => [2000, 1400]
+ )
+ const zoomedSession = freshSession(winZoomed)
+
+ const tab = zoomedSession.ensureTab()
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ expect((tab.view as unknown as MockView).setBounds).toHaveBeenCalledWith({
+ x: 150,
+ y: 75,
+ width: 1200,
+ height: 900,
+ })
+ })
+
+ it('keeps an occluded view attached, and hides it only once its frame is sent', async () => {
+ const tab = session.ensureTab()
+ const view = tab.view as unknown as MockView
+ const content = (
+ win as unknown as {
+ contentView: {
+ addChildView: ReturnType
+ removeChildView: ReturnType
+ }
+ }
+ ).contentView
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ content.removeChildView.mockClear()
+ view.setVisible.mockClear()
+
+ panel.setPanelOccluded(true)
+
+ expect(content.removeChildView).not.toHaveBeenCalled()
+ // The page stays up until the frame that replaces it is sent: the renderer
+ // paints its snapshot the moment it reports occlusion, and hiding first
+ // leaves it showing the previous overlay's frame in the gap.
+ expect(view.setVisible).not.toHaveBeenCalledWith(false)
+ await vi.waitFor(() => {
+ expect(win.webContents.send).toHaveBeenCalledWith('browser-agent:panel-snapshot', {
+ dataUrl: 'data:image/jpeg;base64,c2lt',
+ tabId: tab.id,
+ })
+ })
+ await vi.waitFor(() => expect(view.setVisible).toHaveBeenLastCalledWith(false))
+
+ panel.setPanelOccluded(false)
+ expect(view.setVisible).toHaveBeenLastCalledWith(true)
+ })
+
+ it('hardens every tab and keeps http popups inside a new internal tab', () => {
+ const tab = session.ensureTab()
+ const contents = (tab.view as unknown as MockView).webContents
+ expect(contents.session.setPermissionRequestHandler).toHaveBeenCalled()
+ expect(contents.session.setPermissionCheckHandler).toHaveBeenCalled()
+
+ const openHandler = contents.setWindowOpenHandler.mock.calls[0][0] as (details: {
+ url: string
+ }) => { action: string }
+ expect(openHandler({ url: 'https://example.com/popup' })).toEqual({ action: 'deny' })
+ expect(session.listTabs()).toHaveLength(2)
+ const popupContents = (session.activeTab()?.view as unknown as MockView | undefined)
+ ?.webContents
+ expect(popupContents?.loadURL).toHaveBeenCalledWith('https://example.com/popup')
+ expect(contents.loadURL).not.toHaveBeenCalledWith('https://example.com/popup')
+ // Non-http(s) popups are denied without navigating anywhere.
+ contents.loadURL.mockClear()
+ expect(openHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' })
+ expect(contents.loadURL).not.toHaveBeenCalled()
+ })
+
+ it('permission handlers deny every request on the agent partition', () => {
+ const tab = session.ensureTab()
+ const ses = (tab.view as unknown as MockView).webContents.session
+ const requestHandler = ses.setPermissionRequestHandler.mock.calls[0][0] as (
+ wc: unknown,
+ permission: string,
+ callback: (granted: boolean) => void
+ ) => void
+ const callback = vi.fn()
+ requestHandler(null, 'media', callback)
+ expect(callback).toHaveBeenCalledWith(false)
+
+ const checkHandler = ses.setPermissionCheckHandler.mock.calls[0][0] as () => boolean
+ expect(checkHandler()).toBe(false)
+ })
+
+ it('leaves nothing of the signed-out user behind in the browser profile', async () => {
+ const clearStorageData = vi.fn(async () => {})
+ const clearCache = vi.fn(async () => {})
+ vi.mocked(electronSession.fromPartition).mockReturnValue({
+ clearStorageData,
+ clearCache,
+ } as unknown as ReturnType)
+ const save = vi.fn()
+ session = freshSession(win, {}, { load: () => [], save })
+
+ panel.setPanelBounds({ x: 0, y: 0, width: 800, height: 600 })
+ const survivor = (session.ensureTab().view as unknown as MockView).webContents
+ session.closeTab(session.addTab().id)
+ expect(session.reopenClosedTab()).not.toBeNull()
+
+ await session.clearProfileStorage()
+
+ expect(survivor.close).toHaveBeenCalled()
+ expect(session.listTabs()).toHaveLength(0)
+ // Reopen Closed Tab must not resurrect the previous account's browsing.
+ expect(session.reopenClosedTab()).toBeNull()
+ expect(save).toHaveBeenLastCalledWith([])
+ expect(clearStorageData).toHaveBeenCalled()
+ expect(clearCache).toHaveBeenCalled()
+ })
+
+ it('does not rewrite the settings file when the pinned tabs have not changed', async () => {
+ const save = vi.fn()
+ session = freshSession(win, {}, { load: () => [], save })
+ const tab = session.ensureTab()
+ const contents = (tab.view as unknown as MockView).webContents
+ const onNavigate = contents.on.mock.calls.find(
+ ([eventName]) => eventName === 'did-navigate-in-page'
+ )?.[1] as () => void
+ save.mockClear()
+
+ // Any single-page app fires this on every route change, and the settings
+ // store's `===` comparison never matches a freshly built array — so each
+ // one used to mean a synchronous whole-file write on the main thread.
+ onNavigate()
+ onNavigate()
+ onNavigate()
+
+ expect(save).not.toHaveBeenCalled()
+ })
+
+ it('persists once when a tab actually becomes pinned', async () => {
+ const save = vi.fn()
+ session = freshSession(win, {}, { load: () => [], save })
+ const tab = session.ensureTab()
+ ;(tab.view as unknown as MockView).webContents.getURL.mockReturnValue('https://example.com/')
+ save.mockClear()
+
+ session.setTabPinned(tab.id, true)
+
+ expect(save).toHaveBeenCalledTimes(1)
+ expect(save).toHaveBeenLastCalledWith(['https://example.com/'])
+ })
+
+ it('drops a tab whose renderer crashed instead of wedging the session', () => {
+ const first = session.ensureTab()
+ const second = session.addTab()
+ const crashed = (second.view as unknown as MockView).webContents
+ const onGone = crashed.on.mock.calls.find(
+ ([eventName]) => eventName === 'render-process-gone'
+ )?.[1] as (event: unknown, details: { reason: string }) => void
+
+ onGone({}, { reason: 'crashed' })
+
+ // Left in place, activeTab() filters the dead view out while activeTabId
+ // still names it, so requireTab() reports "no page is open" even though
+ // another tab is right there.
+ expect(session.listTabs().map((tab) => tab.tabId)).toEqual([first.id])
+ expect(session.requireTab().id).toBe(first.id)
+ })
+
+ it('reports the session closed when the only tab crashes', async () => {
+ const onSessionClosed = vi.fn()
+ session = freshSession(win, { onSessionClosed })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const onGone = contents.on.mock.calls.find(
+ ([eventName]) => eventName === 'render-process-gone'
+ )?.[1] as (event: unknown, details: { reason: string }) => void
+
+ onGone({}, { reason: 'oom' })
+
+ expect(session.listTabs()).toHaveLength(0)
+ expect(onSessionClosed).toHaveBeenCalled()
+ })
+
+ it('hides the panel when the renderer stops renewing its bounds lease', async () => {
+ vi.useFakeTimers()
+ try {
+ session = freshSession(win)
+ session.ensureTab()
+ panel.setPanelBounds({ x: 0, y: 0, width: 800, height: 600 }, win)
+ const contentView = (
+ win as unknown as { contentView: { removeChildView: ReturnType } }
+ ).contentView
+ contentView.removeChildView.mockClear()
+ const view = session.requireTab().view as unknown as MockView
+ view.setVisible.mockClear()
+
+ // The renderer goes silent — crashed, unmounted, or wedged. Without the
+ // lease the native view keeps floating over whatever replaced the panel.
+ await vi.advanceTimersByTimeAsync(6_000)
+
+ expect(view.setVisible).toHaveBeenCalledWith(false)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('keeps the panel while the renderer keeps renewing the lease', async () => {
+ vi.useFakeTimers()
+ try {
+ session = freshSession(win)
+ session.ensureTab()
+ const bounds = { x: 0, y: 0, width: 800, height: 600 }
+ panel.setPanelBounds(bounds, win)
+ const contentView = (
+ win as unknown as { contentView: { removeChildView: ReturnType } }
+ ).contentView
+ contentView.removeChildView.mockClear()
+
+ // The renderer heartbeats about once a second.
+ for (let beat = 0; beat < 6; beat++) {
+ await vi.advanceTimersByTimeAsync(1_000)
+ panel.setPanelBounds(bounds, win)
+ }
+
+ expect(contentView.removeChildView).not.toHaveBeenCalled()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('hardens every distinct session, not only the first one configured', () => {
+ // Guards against tracking this with one process-wide flag: the second
+ // session would then be left with no permission handlers, no SSRF request
+ // filtering, and no download blocking — silently, and still passing types.
+ const first = (session.ensureTab().view as unknown as MockView).webContents.session
+ const second = (session.addTab().view as unknown as MockView).webContents.session
+ expect(second).not.toBe(first)
+
+ for (const ses of [first, second]) {
+ expect(ses.setPermissionRequestHandler).toHaveBeenCalled()
+ expect(ses.setPermissionCheckHandler).toHaveBeenCalled()
+ }
+ })
+})
+
+/**
+ * The browser is one native surface shared by every app window, so exactly one
+ * window may drive it at a time. These cover who is allowed to take it.
+ */
+describe('browser panel ownership', () => {
+ const BOUNDS = { x: 0, y: 0, width: 800, height: 600 }
+ let win: BrowserWindow
+ let other: BrowserWindow
+ let session: SessionModule
+
+ beforeEach(async () => {
+ win = mainWindowMock()
+ other = mainWindowMock()
+ session = freshSession(win)
+ })
+
+ it('lets any window claim a panel nobody owns yet', () => {
+ expect(panel.canReportPanelBounds(other, null)).toBe(true)
+ })
+
+ it('keeps the owner reporting while Sim sits in the background', () => {
+ panel.setPanelBounds(BOUNDS, win)
+
+ // Nothing is focused, but the owner has not changed.
+ expect(panel.canReportPanelBounds(win, null)).toBe(true)
+ })
+
+ it('refuses a second window claiming the panel while nothing is focused', () => {
+ panel.setPanelBounds(BOUNDS, win)
+
+ // Both windows heartbeat their bounds every second. Allowing an unfocused
+ // claim makes them alternate ownership, re-parenting the native view
+ // between windows roughly once a second for as long as Sim is unfocused.
+ expect(panel.canReportPanelBounds(other, null)).toBe(false)
+ })
+
+ it('transfers ownership to the window the user focused', () => {
+ panel.setPanelBounds(BOUNDS, win)
+
+ expect(panel.canReportPanelBounds(other, other)).toBe(true)
+ })
+
+ it('frees the panel once the owning window is gone', () => {
+ panel.setPanelBounds(BOUNDS, win)
+ vi.mocked(win.isDestroyed).mockReturnValue(true)
+
+ expect(panel.canReportPanelBounds(other, null)).toBe(true)
+ })
+
+ it('releases the panel when the owning window closes', () => {
+ panel.setPanelBounds(BOUNDS, win)
+ const view = session.ensureTab().view as unknown as MockView
+ view.setVisible.mockClear()
+
+ // Electron destroys the window before emitting `closed`, so the release
+ // arrives from an already-destroyed window and must still be honoured.
+ vi.mocked(win.isDestroyed).mockReturnValue(true)
+ panel.setPanelBounds(null, win)
+
+ expect(panel.canReportPanelBounds(other, null)).toBe(true)
+ // Left owned, the next layout would re-parent the browser onto another
+ // window at the closed window's bounds.
+ expect(view.setVisible).not.toHaveBeenCalledWith(true)
+ })
+
+ it('ignores a live non-owner trying to hide the panel', () => {
+ panel.setPanelBounds(BOUNDS, win)
+
+ panel.setPanelBounds(null, other)
+
+ expect(panel.canReportPanelBounds(other, null)).toBe(false)
+ })
+
+ it('ignores panel updates from a window that does not own the panel', () => {
+ panel.setPanelBounds(BOUNDS, win)
+ const view = session.ensureTab().view as unknown as MockView
+ view.webContents.capturePage.mockClear()
+
+ panel.setPanelOccluded(true, other)
+
+ expect(view.webContents.capturePage).not.toHaveBeenCalled()
+ })
+
+ it('accepts panel updates from a live window once the owner is destroyed', () => {
+ panel.setPanelBounds(BOUNDS, win)
+ const view = session.ensureTab().view as unknown as MockView
+ vi.mocked(win.isDestroyed).mockReturnValue(true)
+ view.webContents.capturePage.mockClear()
+
+ panel.setPanelOccluded(true, other)
+
+ // A stale owner must not keep rejecting the window actually on screen.
+ expect(view.webContents.capturePage).toHaveBeenCalled()
+ })
+
+ it('withholds a captured frame from a window that lost ownership mid-capture', async () => {
+ panel.setPanelBounds(BOUNDS, win)
+ session.ensureTab()
+ const send = vi.mocked(win.webContents.send)
+ send.mockClear()
+
+ panel.setPanelOccluded(true, win)
+ panel.setPanelBounds(BOUNDS, other)
+ await sleep(0)
+
+ // The frame is a picture of the page; the window no longer showing the
+ // browser has no business receiving it.
+ expect(
+ send.mock.calls.filter(([channel]) => channel === 'browser-agent:panel-snapshot')
+ ).toEqual([])
+ })
+
+ it('delivers a captured frame to an owner that kept the panel', async () => {
+ panel.setPanelBounds(BOUNDS, win)
+ session.ensureTab()
+ const send = vi.mocked(win.webContents.send)
+ send.mockClear()
+
+ panel.setPanelOccluded(true, win)
+ await sleep(0)
+
+ expect(
+ send.mock.calls.filter(([channel]) => channel === 'browser-agent:panel-snapshot').length
+ ).toBe(1)
+ })
+})
+
+describe('reopening a closed tab', () => {
+ let win: BrowserWindow
+ let session: SessionModule
+
+ beforeEach(async () => {
+ win = mainWindowMock()
+ session = freshSession(win)
+ })
+
+ it('restores an ordinary closed tab', () => {
+ session.ensureTab()
+ const closing = session.addTab()
+ ;(closing.view as unknown as MockView).webContents.getURL.mockReturnValue(
+ 'https://example.com/inbox'
+ )
+ session.closeTab(closing.id)
+
+ const reopened = session.reopenClosedTab()
+
+ expect((reopened?.view as unknown as MockView).webContents.loadURL).toHaveBeenCalledWith(
+ 'https://example.com/inbox'
+ )
+ })
+
+ it('never revives a URL carrying embedded credentials', () => {
+ session.ensureTab()
+ const closing = session.addTab()
+ ;(closing.view as unknown as MockView).webContents.getURL.mockReturnValue(
+ 'https://user:secret@example.com/inbox'
+ )
+ session.closeTab(closing.id)
+
+ const reopened = session.reopenClosedTab()
+
+ // Falls back to a blank tab rather than re-sending the credentials.
+ expect((reopened?.view as unknown as MockView).webContents.loadURL).not.toHaveBeenCalled()
+ })
+
+ it('duplicates a tab by loading the same URL in a new one', () => {
+ session.ensureTab()
+ const source = session.addTab()
+ ;(source.view as unknown as MockView).webContents.getURL.mockReturnValue(
+ 'https://example.com/inbox'
+ )
+
+ const copy = session.duplicateTab(source.id)
+
+ expect(copy?.id).not.toBe(source.id)
+ expect((copy?.view as unknown as MockView).webContents.loadURL).toHaveBeenCalledWith(
+ 'https://example.com/inbox'
+ )
+ })
+
+ it('never copies a URL carrying embedded credentials into a duplicate', () => {
+ session.ensureTab()
+ const source = session.addTab()
+ ;(source.view as unknown as MockView).webContents.getURL.mockReturnValue(
+ 'https://user:pass@example.com/'
+ )
+
+ const copy = session.duplicateTab(source.id)
+
+ // Falls back to a blank tab rather than re-sending the credentials.
+ expect((copy?.view as unknown as MockView).webContents.loadURL).not.toHaveBeenCalled()
+ })
+
+ it('returns null when duplicating a tab that is not open', () => {
+ session.ensureTab()
+ expect(session.duplicateTab('no-such-tab')).toBeNull()
+ })
+
+ it('drops a non-http scheme from the reopen list', () => {
+ session.ensureTab()
+ const closing = session.addTab()
+ ;(closing.view as unknown as MockView).webContents.getURL.mockReturnValue('file:///etc/passwd')
+ session.closeTab(closing.id)
+
+ const reopened = session.reopenClosedTab()
+
+ expect((reopened?.view as unknown as MockView).webContents.loadURL).not.toHaveBeenCalled()
+ })
+})
+
+describe('importAgentCookies', () => {
+ /** Points the mocked partition at a cookie jar and returns its `set` spy. */
+ function withCookieJar(set: ReturnType): SessionModule {
+ // The partition is resolved per call, not captured at module load, so
+ // re-mocking it here is enough — no module reload required.
+ vi.mocked(electronSession.fromPartition).mockReturnValue({
+ cookies: { set },
+ } as unknown as ReturnType)
+ return sessionModule
+ }
+
+ const cookie = (name: string) => ({
+ url: 'https://example.com/',
+ name,
+ value: 'v',
+ path: '/',
+ secure: true,
+ httpOnly: true,
+ sameSite: 'lax' as const,
+ })
+
+ it('writes every cookie into the dedicated browser profile', async () => {
+ const set = vi.fn(async () => {})
+ const session = withCookieJar(set)
+
+ const result = await session.importAgentCookies([cookie('a'), cookie('b')])
+
+ expect(result).toEqual({ imported: 2, failed: 0 })
+ expect(electronSession.fromPartition).toHaveBeenCalledWith('persist:sim-browser-agent')
+ expect(set).toHaveBeenCalledTimes(2)
+ expect(set).toHaveBeenNthCalledWith(1, cookie('a'))
+ })
+
+ it('counts a rejected cookie without losing the rest', async () => {
+ // Chromium refuses cookies whose attributes are inconsistent. That
+ // rejection must cost one cookie, not the whole import.
+ const set = vi.fn(async (details: { name: string }) => {
+ if (details.name === 'bad') throw new Error('Failed to set cookie')
+ })
+ const session = withCookieJar(set)
+
+ const result = await session.importAgentCookies([cookie('a'), cookie('bad'), cookie('c')])
+
+ expect(result).toEqual({ imported: 2, failed: 1 })
+ expect(set).toHaveBeenCalledTimes(3)
+ })
+
+ it('does nothing when there is nothing to import', async () => {
+ const set = vi.fn(async () => {})
+ const session = withCookieJar(set)
+
+ await expect(session.importAgentCookies([])).resolves.toEqual({ imported: 0, failed: 0 })
+ expect(set).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts
new file mode 100644
index 00000000000..1fd2f50b5be
--- /dev/null
+++ b/apps/desktop/src/main/browser-agent/session.ts
@@ -0,0 +1,1121 @@
+import { join } from 'node:path'
+import {
+ type BrowserDataKind,
+ type BrowserFindRequest,
+ type BrowserFindResult,
+ type BrowserOmniboxFocusMode,
+ type BrowserTabState,
+ type BrowserTabsState,
+ type BrowserTheme,
+ MAX_BROWSER_TABS,
+} from '@sim/browser-protocol'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { BrowserWindow, CookiesSetDetails, Input, Session, WebContents } from 'electron'
+import { session as electronSession, nativeTheme, WebContentsView } from 'electron'
+import { attachAgentContextMenu, BASE_ZOOM_FACTOR } from '@/main/browser-agent/context-menu'
+import type { BrowserCookieSignal } from '@/main/browser-agent/known-sessions'
+import {
+ detachAttachedView,
+ detachIfAttached,
+ initPanel,
+ isPanelVisible,
+ layout,
+ panelUpdateAllowed,
+ panelWindow,
+} from '@/main/browser-agent/panel'
+import { registerAgentWebContents } from '@/main/browser-agent/registry'
+import { checkAgentUrl, isBlockedRequestUrl } from '@/main/browser-agent/url-guard'
+
+const logger = createLogger('BrowserAgentSession')
+
+/** Dedicated cookie jar for the agent browser; `persist:` = survives restarts. */
+const AGENT_PARTITION = 'persist:sim-browser-agent'
+
+class SessionError extends Error {}
+
+export interface AgentTab {
+ id: string
+ view: WebContentsView
+ pinned: boolean
+}
+
+export interface PinnedTabPersistence {
+ load: () => unknown
+ save: (urls: string[]) => void
+}
+
+export interface AgentSessionEvents {
+ /** The browser session ended (all tabs gone). */
+ onSessionClosed: () => void
+ /** A newly created tab's WebContents, for the driver to instrument. */
+ onTabCreated: (contents: WebContents) => void
+ /**
+ * A tab navigated, including in-page. Anything bound to the previous
+ * document — notably a pending credential fill — must be invalidated.
+ */
+ onTabNavigated: (contents: WebContents) => void
+ /** A tab's WebContents is going away, so per-tab state can be dropped. */
+ onTabClosed: (contents: WebContents) => void
+ /** The active tab changed (new tab, switch, close). */
+ onActiveTabChanged: (contents: WebContents) => void
+ /** The tab list or active tab changed. */
+ onTabsChanged: () => void
+ /** Sim's appearance preference changed for an existing tab. */
+ onTabThemeChanged: (contents: WebContents, theme: BrowserTheme) => void
+ /** A download was blocked on the agent partition. */
+ onDownloadBlocked: (filename: string, url: string) => void
+}
+
+/**
+ * Bounds reports are a LEASE, not a one-shot: the renderer re-reports the
+ * panel rect continuously while the panel is visible, and the view is hidden
+ * when the lease expires. This is the liveness guard — a renderer that
+ * reloads, crashes, or hard-navigates never gets to send "hide", so the view
+ * must never outlive the reports.
+ */
+const MAX_RECENTLY_CLOSED_TABS = 10
+
+export type BrowserShortcut = 'focus-omnibox' | 'new-tab' | 'close-tab' | 'find'
+
+type BrowserShortcutInput = Pick<
+ Input,
+ 'type' | 'key' | 'isAutoRepeat' | 'isComposing' | 'shift' | 'control' | 'alt' | 'meta'
+>
+
+/**
+ * Resolves browser-level shortcuts using Command on macOS and Control
+ * elsewhere. Modified/composing/repeated keystrokes stay with the page.
+ */
+export function browserShortcutForInput(
+ input: BrowserShortcutInput,
+ platform: NodeJS.Platform = process.platform
+): BrowserShortcut | null {
+ if (
+ input.type !== 'keyDown' ||
+ input.isAutoRepeat ||
+ input.isComposing ||
+ input.shift ||
+ input.alt
+ ) {
+ return null
+ }
+ const primaryModifier = platform === 'darwin' ? input.meta : input.control
+ if (!primaryModifier) return null
+
+ switch (input.key.toLowerCase()) {
+ case 'l':
+ return 'focus-omnibox'
+ case 't':
+ return 'new-tab'
+ case 'w':
+ return 'close-tab'
+ case 'f':
+ return 'find'
+ default:
+ return null
+ }
+}
+
+const tabs: AgentTab[] = []
+const recentlyClosedTabUrls: string[] = []
+let activeTabId: string | null = null
+let nextTabId = 1
+/**
+ * Per-session rather than a single boolean: a process-wide flag would make the
+ * SECOND partition ever configured silently skip every hardening step below —
+ * a failure that type-checks and passes tests.
+ */
+const configuredPartitions = new WeakSet()
+let events: AgentSessionEvents | null = null
+let getMainWindow: () => BrowserWindow | null = () => null
+let pinnedTabPersistence: PinnedTabPersistence | null = null
+let pinnedTabsRestored = false
+/** Serialized form of the last saved pinned-tab list, for change detection. */
+let lastPersistedPinnedTabs: string | null = null
+/** Browser-resource focus, including native pages and renderer-owned chrome. */
+let focusedBrowserTabId: string | null = null
+let focusedBrowserClearTimer: ReturnType | null = null
+/** Raw Sim preference; `system` remains dynamic as the OS theme changes. */
+let browserTheme: BrowserTheme = 'system'
+/** Prevent hidden-page throttling only while an agent action needs the page to make progress. */
+let automationActive = false
+
+/**
+ * Returns the module to the state it had before any session ran.
+ *
+ * {@link initSession} names itself as the session boundary but set three of
+ * these fields and left the rest, so a second call would inherit the first
+ * session's tab id counter, theme, pinned-restore latch and persisted-list
+ * digest — the last of which would then suppress the new session's first save
+ * as an unchanged write. Nothing re-inits in production today, which is
+ * exactly why the gap stayed invisible, and why the tests had to reset the
+ * whole MODULE (`vi.resetModules()`, which the root CLAUDE.md forbids) just to
+ * get a clean one.
+ */
+function resetSessionState(): void {
+ // Tears down live views and clears tabs, the reopen list, the active tab,
+ // the find, and the focused-tab timer. Notifies the OUTGOING handlers, which
+ // is why it runs before the new ones are installed.
+ closeLiveTabs()
+ nextTabId = 1
+ pinnedTabsRestored = false
+ lastPersistedPinnedTabs = null
+ pinnedTabPersistence = null
+ browserTheme = 'system'
+ automationActive = false
+}
+
+export function initSession(
+ handlers: AgentSessionEvents,
+ mainWindowProvider: () => BrowserWindow | null,
+ persistence?: PinnedTabPersistence
+): void {
+ resetSessionState()
+ events = handlers
+ getMainWindow = mainWindowProvider
+ if (persistence) {
+ pinnedTabPersistence = persistence
+ }
+ initPanel({
+ getMainWindow: () => getMainWindow(),
+ activeTab,
+ ensureInitialTab: () => {
+ restorePinnedTabs()
+ if (!hasSession()) {
+ ensureTab()
+ }
+ },
+ onViewDetached: (view) => {
+ clearFocusedBrowserTab(tabs.find((tab) => tab.view === view)?.id)
+ },
+ })
+}
+
+/**
+ * Accepts only what is safe to navigate back to later: http(s), no embedded
+ * credentials, bounded length. Shared by the pinned-tab list and the
+ * closed-tab list, both of which outlive the tab they came from and so must
+ * not be able to revive a `user:pass@host` URL.
+ */
+function sanitizeRestorableUrl(candidate: unknown): string | null {
+ if (typeof candidate !== 'string' || candidate.length > 8_192) return null
+ if (candidate === 'about:blank') return candidate
+ try {
+ const url = new URL(candidate)
+ if ((url.protocol === 'http:' || url.protocol === 'https:') && !url.username && !url.password) {
+ return url.href
+ }
+ } catch {}
+ return null
+}
+
+function sanitizePinnedTabUrls(value: unknown): string[] {
+ if (!Array.isArray(value)) return []
+ const urls: string[] = []
+ for (const candidate of value) {
+ const url = sanitizeRestorableUrl(candidate)
+ if (url !== null) urls.push(url)
+ if (urls.length >= MAX_BROWSER_TABS) break
+ }
+ return urls
+}
+
+function pinnedUrl(tab: AgentTab): string {
+ return tab.view.webContents.getURL() || 'about:blank'
+}
+
+/**
+ * Writes the pinned-tab list only when it actually changed.
+ *
+ * This runs on `did-navigate` and `did-navigate-in-page` for every tab, so any
+ * single-page app fires it on each route change. The settings store compares
+ * with `===`, so a freshly built array never matches and every call would
+ * otherwise mean a synchronous mkdir + write + rename of the whole settings
+ * file on the main thread — including writing `[]` over `[]` when nothing is
+ * pinned at all.
+ */
+function persistPinnedTabs(): void {
+ if (!pinnedTabPersistence || !pinnedTabsRestored) return
+ const urls = tabs
+ .filter((tab) => tab.pinned && !tab.view.webContents.isDestroyed())
+ .map((tab) => pinnedUrl(tab))
+ const fingerprint = JSON.stringify(urls)
+ if (fingerprint === lastPersistedPinnedTabs) return
+ lastPersistedPinnedTabs = fingerprint
+ pinnedTabPersistence.save(urls)
+}
+
+/** Read cookie metadata from the dedicated profile without exposing values. */
+export async function listAgentCookieSignals(): Promise {
+ const cookies = await electronSession.fromPartition(AGENT_PARTITION).cookies.get({})
+ return cookies.flatMap(({ domain }) => (typeof domain === 'string' ? [{ domain }] : []))
+}
+
+/**
+ * Writes imported cookies into the dedicated profile.
+ *
+ * Electron's cookie API is deliberately the only writer: Chromium owns the
+ * destination store's format, and editing that SQLite file directly would
+ * couple Sim to internals it does not control and risk corrupting the profile.
+ * It is also the enforcement point — Chromium rejects a cookie whose
+ * attributes are inconsistent (`SameSite=None` without `Secure`, a domain the
+ * URL cannot set), so a row that would only import under weaker terms fails
+ * here and is counted rather than being quietly relaxed.
+ *
+ * Failures are per-cookie: one rejected cookie must not cost the user the
+ * rest. Nothing about a cookie is logged.
+ */
+export async function importAgentCookies(
+ cookies: CookiesSetDetails[]
+): Promise<{ imported: number; failed: number }> {
+ const jar = electronSession.fromPartition(AGENT_PARTITION).cookies
+ let imported = 0
+ let failed = 0
+ for (const cookie of cookies) {
+ try {
+ await jar.set(cookie)
+ imported += 1
+ } catch {
+ failed += 1
+ }
+ }
+ return { imported, failed }
+}
+
+/**
+ * Default-deny hardening for the agent partition: no permission grants of any
+ * kind, and downloads are cancelled (and surfaced to the driver) rather than
+ * silently dropped on disk.
+ */
+function configureAgentPartition(ses: Session): void {
+ if (configuredPartitions.has(ses)) return
+ configuredPartitions.add(ses)
+ ses.setPermissionRequestHandler((_wc, _permission, callback) => callback(false))
+ ses.setPermissionCheckHandler(() => false)
+ // SSRF choke point for the agent partition. Document navigations (top-level +
+ // iframes) get the full DNS-resolving check — the one seam every navigation
+ // passes through, including page-initiated ones the driver never sees (server
+ // redirects, link clicks, location.href, meta-refresh) — so an internal host
+ // can't slip in that way. Subresources take the cheap synchronous literal-IP
+ // backstop instead of a DNS lookup per asset.
+ ses.webRequest.onBeforeRequest((details, callback) => {
+ if (details.resourceType === 'mainFrame' || details.resourceType === 'subFrame') {
+ void checkAgentUrl(details.url)
+ .then((guard) => {
+ if (!guard.ok) {
+ logger.warn('Blocked agent document navigation to a private host')
+ }
+ callback({ cancel: !guard.ok })
+ })
+ .catch((error) => {
+ // Fail closed: an unexpected rejection must cancel, never leave the
+ // request suspended with no callback.
+ logger.error('Agent SSRF check failed; cancelling request', { error })
+ callback({ cancel: true })
+ })
+ return
+ }
+ callback({ cancel: isBlockedRequestUrl(details.url) })
+ })
+ ses.on('will-download', (_event, item) => {
+ const filename = item.getFilename()
+ const url = item.getURL()
+ logger.info('Blocked download in agent browser', { filename })
+ item.cancel()
+ events?.onDownloadBlocked(filename, url)
+ })
+}
+
+function focusRendererOmnibox(mode: BrowserOmniboxFocusMode): void {
+ const win = panelWindow()
+ if (!win || win.isDestroyed()) return
+ win.webContents.focus()
+ win.webContents.send('browser-agent:focus-omnibox', mode)
+}
+
+/**
+ * Opens the renderer's find bar and moves keyboard focus to it. The bar is
+ * renderer chrome rather than an overlay on the page: a renderer element that
+ * overlapped the native view would trip the occlusion path and hide the very
+ * page being searched.
+ */
+function openRendererFind(): void {
+ const win = panelWindow()
+ if (!win || win.isDestroyed()) return
+ win.webContents.focus()
+ win.webContents.send('browser-agent:open-find')
+}
+
+/**
+ * Tab a find is currently running on. Tracked because the find outlives the
+ * call that started it — Chromium keeps the highlights until it is told to
+ * stop, so leaving a tab (or navigating it) has to clear the find explicitly
+ * or the old matches stay lit under a match count that no longer describes
+ * anything on screen.
+ */
+let findingTabId: string | null = null
+
+/**
+ * Drops a tab's highlights and stops treating it as the tab being searched.
+ * Leaves the renderer's bar alone — emptying the find box and searching a
+ * different tab both end a find while the user is still typing in the bar.
+ */
+function stopFindOnTab(tabId: string | null): void {
+ if (tabId === null) return
+ const tab = tabs.find((entry) => entry.id === tabId)
+ if (tab && !tab.view.webContents.isDestroyed()) {
+ tab.view.webContents.stopFindInPage('clearSelection')
+ }
+ if (findingTabId === tabId) findingTabId = null
+}
+
+/**
+ * Stops the find and dismisses the renderer's bar, for when the page it was
+ * run against is gone — a navigation or a tab switch. Chrome dismisses find on
+ * navigation too, and a count for the previous document is worse than no bar.
+ */
+function dismissFind(tabId: string | null): void {
+ if (tabId === null) return
+ const wasFinding = findingTabId === tabId
+ stopFindOnTab(tabId)
+ if (!wasFinding) return
+ const win = panelWindow()
+ if (win && !win.isDestroyed()) {
+ win.webContents.send('browser-agent:close-find')
+ }
+}
+
+/**
+ * Runs Chromium's own find against the active tab. An empty query stops the
+ * find rather than searching for nothing, matching what emptying Chrome's find
+ * box does — the bar stays open and ready for the next query.
+ */
+export function findInActiveTab(request: BrowserFindRequest): void {
+ const tab = activeTab()
+ if (!tab) return
+ if (request.query === '') {
+ stopFindOnTab(tab.id)
+ return
+ }
+ // A find started on another tab has to go before this one begins, or its
+ // highlights survive on a page the user can no longer see them on.
+ if (findingTabId !== null && findingTabId !== tab.id) stopFindOnTab(findingTabId)
+ findingTabId = tab.id
+ tab.view.webContents.findInPage(request.query, {
+ forward: request.forward,
+ findNext: request.findNext,
+ })
+}
+
+/**
+ * Stops the running find.
+ *
+ * `focusPage` distinguishes the user dismissing the bar — where focus is being
+ * pulled out from under them and Chrome leaves it on the page — from the bar
+ * merely unmounting because the browser panel went away. Only the renderer can
+ * tell those apart: the panel's own teardown reports bounds after its
+ * children's cleanups run, so by the time this is reached the panel still
+ * looks visible either way, and focusing the page on teardown would drag the
+ * user back to a browser they just navigated away from.
+ */
+export function stopFindInActiveTab(focusPage: boolean): void {
+ stopFindOnTab(findingTabId)
+ if (!focusPage) return
+ // Deliberately the ACTIVE tab, not whichever tab was being searched: there is
+ // often no search running at all (the bar was opened and closed without a
+ // query, or the box was emptied first, both of which clear the searched tab).
+ // Keying focus off the search left those cases with focus on the input that
+ // just unmounted, which lands on — from there the page cannot receive
+ // the next Mod+F for the shell to intercept, and the renderer's own handler
+ // is scoped to the panel, so find became unopenable until something else was
+ // clicked.
+ const tab = activeTab()
+ if (tab) tab.view.webContents.focus()
+}
+
+/**
+ * Opens a link from a page in another tab of this browser. Shared by the
+ * window.open interception and the page's right-click menu — both have to stay
+ * inside the browser resource rather than spawn a native window, and both are
+ * reached from an untrusted page, so the scheme is checked here once.
+ */
+function openTabWithUrl(url: string): void {
+ if (!/^https?:\/\//i.test(url)) return
+ try {
+ const tab = addTab()
+ void tab.view.webContents.loadURL(url).catch(() => {})
+ } catch (error) {
+ logger.warn('Could not open a link in a new browser tab', {
+ error: getErrorMessage(error),
+ })
+ }
+}
+
+function createTabView(): WebContentsView {
+ const view = new WebContentsView({
+ webPreferences: {
+ partition: AGENT_PARTITION,
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true,
+ webSecurity: true,
+ webviewTag: false,
+ // A minimal, isolated preload that reports login-form presence and
+ // performs user-authorized credential fills. It exposes nothing to the
+ // page, and runs in the top-level frame only.
+ preload: join(__dirname, 'browser-preload.cjs'),
+ // Throttled by default: a hidden tab should idle. The one exception is
+ // the active tab while a tool waits on it, applied explicitly by
+ // applyActiveTabThrottling — never blanket across every tab.
+ backgroundThrottling: true,
+ spellcheck: false,
+ // The default every origin this tab visits starts at; a per-origin zoom
+ // the user sets from the page menu still wins and still persists.
+ zoomFactor: BASE_ZOOM_FACTOR,
+ },
+ })
+ view.setBackgroundColor(browserBackgroundColor())
+ const contents = view.webContents
+ registerAgentWebContents(contents)
+ configureAgentPartition(contents.session)
+ attachAgentContextMenu(contents, { openTab: openTabWithUrl })
+
+ contents.on('focus', () => {
+ if (focusedBrowserClearTimer !== null) {
+ clearTimeout(focusedBrowserClearTimer)
+ focusedBrowserClearTimer = null
+ }
+ const tab = tabs.find((entry) => entry.view.webContents === contents)
+ focusedBrowserTabId = tab?.id ?? activeTabId
+ })
+ contents.on('blur', () => {
+ const tab = tabs.find((entry) => entry.view.webContents === contents)
+ if (!tab || focusedBrowserTabId !== tab.id) return
+ if (focusedBrowserClearTimer !== null) clearTimeout(focusedBrowserClearTimer)
+ // Electron can emit blur while resolving an application-menu accelerator.
+ // Defer the clear for one event-loop turn so the synchronous menu callback
+ // can still identify which native tab owned the keystroke.
+ focusedBrowserClearTimer = setTimeout(() => {
+ focusedBrowserClearTimer = null
+ if (focusedBrowserTabId === tab.id && !contents.isFocused()) {
+ focusedBrowserTabId = null
+ }
+ }, 0)
+ })
+
+ // Keep popups inside the browser resource: http(s) window.open and
+ // target=_blank requests become a new internal tab, never a native window.
+ contents.setWindowOpenHandler((details) => {
+ openTabWithUrl(details.url)
+ return { action: 'deny' }
+ })
+
+ // Pages may hold navigation hostage with beforeunload dialogs nobody can
+ // see; always let the unload proceed.
+ contents.on('will-prevent-unload', (event) => {
+ event.preventDefault()
+ })
+ // A crashed renderer would otherwise stay in `tabs` forever: `activeTab()`
+ // filters it out and returns null while `activeTabId` still names it, so
+ // `requireTab()` reports "no page is open" even with other tabs open, and
+ // the panel goes blank with no way back.
+ contents.on('render-process-gone', (_event, details) => {
+ const tab = tabs.find((entry) => entry.view === view)
+ if (!tab) return
+ logger.warn('Browser tab renderer exited; dropping the tab', { reason: details.reason })
+ forgetTab(tab)
+ })
+ contents.on('before-input-event', (event, input) => {
+ const shortcut = browserShortcutForInput(input)
+ if (!shortcut) return
+
+ event.preventDefault()
+ if (shortcut === 'focus-omnibox') {
+ focusRendererOmnibox('select')
+ return
+ }
+ if (shortcut === 'find') {
+ openRendererFind()
+ return
+ }
+ if (shortcut === 'new-tab') {
+ if (listTabs().length < MAX_BROWSER_TABS) {
+ addTab()
+ focusRendererOmnibox('clear')
+ }
+ return
+ }
+
+ const tab = tabs.find((entry) => entry.view === view)
+ if (tab) closeTabFromUser(tab.id)
+ })
+ contents.on('found-in-page', (_event, result) => {
+ const tab = tabs.find((entry) => entry.view === view)
+ // Counts from a tab the user has already left would relabel the bar for
+ // whatever page is on screen now.
+ if (!tab || tab.id !== findingTabId) return
+ const win = panelWindow()
+ if (!win || win.isDestroyed()) return
+ const payload: BrowserFindResult = {
+ activeMatchOrdinal: result.activeMatchOrdinal,
+ matches: result.matches,
+ final: result.finalUpdate,
+ }
+ win.webContents.send('browser-agent:find-result', payload)
+ })
+ // A document load replaces what the find was pointing at. Same-document
+ // route changes do not, and Chromium keeps the highlights across them, so
+ // only real navigations dismiss the bar.
+ contents.on('did-start-navigation', (details) => {
+ if (!details.isMainFrame || details.isSameDocument) return
+ const tab = tabs.find((entry) => entry.view === view)
+ if (tab) dismissFind(tab.id)
+ })
+ // A pinned tab persists its latest top-level location, including
+ // user-driven navigations that do not pass through the driver.
+ contents.on('did-navigate', persistPinnedTabs)
+ contents.on('did-navigate-in-page', persistPinnedTabs)
+ // Both document loads and same-document route changes invalidate anything
+ // bound to the previous page: a single-page app can replace a login form
+ // with another site's UI without ever loading a new document.
+ contents.on('did-start-navigation', () => events?.onTabNavigated(contents))
+ contents.on('did-navigate', () => events?.onTabNavigated(contents))
+ contents.on('did-navigate-in-page', () => events?.onTabNavigated(contents))
+ contents.on('destroyed', () => events?.onTabClosed(contents))
+
+ events?.onTabCreated(contents)
+ return view
+}
+
+/** True while any tab exists. */
+export function hasSession(): boolean {
+ return tabs.some((tab) => !tab.view.webContents.isDestroyed())
+}
+
+/**
+ * Keeps the ACTIVE tab responsive during an agent action, then returns it to
+ * normal background throttling.
+ *
+ * Only the active tab, deliberately. The agent drives one tab at a time — the
+ * active one — possibly while the panel is hidden and even that view is
+ * detached, so it is the only tab that must not be throttled mid-tool. Waking
+ * every tab, as this once did, meant an agent run kept all N-1 background
+ * renderers at full speed for the length of the run, which is the browser
+ * side of the multi-tab lag. Nothing depends on a background tab staying
+ * awake: switching to one activates it (and re-applies this) before any tool
+ * touches it, and network loading is not throttled anyway.
+ */
+export function setAutomationActive(active: boolean): void {
+ automationActive = active
+ applyActiveTabThrottling()
+}
+
+/**
+ * Unthrottles the active tab while automation is active, and throttles every
+ * other tab. Call after anything that changes which tab is active, so the
+ * exemption follows the active tab rather than being stranded on the old one.
+ */
+function applyActiveTabThrottling(): void {
+ for (const tab of tabs) {
+ if (tab.view.webContents.isDestroyed()) continue
+ const exempt = automationActive && tab.id === activeTabId
+ tab.view.webContents.setBackgroundThrottling(!exempt)
+ }
+}
+
+function browserBackgroundColor(): string {
+ const dark =
+ browserTheme === 'dark' || (browserTheme === 'system' && nativeTheme.shouldUseDarkColors)
+ return dark ? '#0c0c0c' : '#ffffff'
+}
+
+function updateTabBackgrounds(): void {
+ const color = browserBackgroundColor()
+ for (const tab of tabs) {
+ if (!tab.view.webContents.isDestroyed()) {
+ tab.view.setBackgroundColor(color)
+ }
+ }
+}
+
+/**
+ * Applies Sim's raw appearance preference to every current and future tab.
+ * Page media-query emulation stays in the CDP layer; this module owns the
+ * native view backdrop used before and between page paints.
+ */
+export function setBrowserTheme(theme: BrowserTheme): void {
+ if (browserTheme === theme) return
+ browserTheme = theme
+ updateTabBackgrounds()
+ for (const tab of tabs) {
+ if (!tab.view.webContents.isDestroyed()) {
+ events?.onTabThemeChanged(tab.view.webContents, theme)
+ }
+ }
+}
+
+export function getBrowserTheme(): BrowserTheme {
+ return browserTheme
+}
+
+nativeTheme.on('updated', () => {
+ if (browserTheme === 'system') {
+ updateTabBackgrounds()
+ }
+})
+
+/** The active tab, creating the first tab when none exist. */
+export function ensureTab(): AgentTab {
+ restorePinnedTabs()
+ let active = activeTab()
+ if (!active) {
+ active = addTabInternal()
+ }
+ return active
+}
+
+/** The active tab without creating one. */
+export function requireTab(): AgentTab {
+ restorePinnedTabs()
+ const active = activeTab()
+ if (!active) {
+ throw new SessionError('No page is open yet — call browser_navigate or browser_open_tab first.')
+ }
+ return active
+}
+
+interface AddTabOptions {
+ pinned?: boolean
+ activate?: boolean
+ notify?: boolean
+}
+
+function addTabInternal({
+ pinned = false,
+ activate = true,
+ notify = true,
+}: AddTabOptions = {}): AgentTab {
+ if (tabs.filter((tab) => !tab.view.webContents.isDestroyed()).length >= MAX_BROWSER_TABS) {
+ throw new SessionError(`The browser supports up to ${MAX_BROWSER_TABS} open tabs.`)
+ }
+ const transferBrowserFocus =
+ activate &&
+ (focusedBrowserTabId !== null || tabs.some((tab) => tab.view.webContents.isFocused()))
+ const tab: AgentTab = { id: String(nextTabId++), view: createTabView(), pinned }
+ if (pinned) {
+ const firstRegularTab = tabs.findIndex((entry) => !entry.pinned)
+ tabs.splice(firstRegularTab < 0 ? tabs.length : firstRegularTab, 0, tab)
+ } else {
+ tabs.push(tab)
+ }
+ if (activate || activeTabId === null) {
+ activeTabId = tab.id
+ applyActiveTabThrottling()
+ layout()
+ if (transferBrowserFocus) focusedBrowserTabId = tab.id
+ if (notify) events?.onActiveTabChanged(tab.view.webContents)
+ }
+ if (notify) events?.onTabsChanged()
+ return tab
+}
+
+function restorePinnedTabs(): void {
+ if (pinnedTabsRestored) return
+ pinnedTabsRestored = true
+ const urls = sanitizePinnedTabUrls(pinnedTabPersistence?.load())
+ // Seed the change detector from what is already on disk, so the first
+ // navigation after launch does not rewrite an identical list.
+ lastPersistedPinnedTabs = JSON.stringify(urls)
+ for (const url of urls) {
+ const tab = addTabInternal({ pinned: true, activate: false, notify: false })
+ if (url !== 'about:blank') {
+ void tab.view.webContents.loadURL(url).catch(() => {})
+ }
+ }
+ const active = activeTab()
+ if (active) {
+ layout()
+ events?.onActiveTabChanged(active.view.webContents)
+ events?.onTabsChanged()
+ }
+}
+
+export function addTab(): AgentTab {
+ restorePinnedTabs()
+ return addTabInternal()
+}
+
+/** Restores the most recently closed regular tab for the current app session. */
+export function reopenClosedTab(): AgentTab | null {
+ restorePinnedTabs()
+ if (listTabs().length >= MAX_BROWSER_TABS) return null
+ const url = recentlyClosedTabUrls.shift()
+ if (!url) return null
+
+ const tab = addTabInternal()
+ if (url !== 'about:blank') {
+ // No checkAgentUrl here, unlike the tool-driven navigations: the stored
+ // URL was already sanitized to http(s) on close, and the partition's
+ // onBeforeRequest still runs the full DNS-resolving SSRF check on the
+ // document load. Pre-checking would only buy a nicer error, and there is
+ // no model to report one to — this path is a user keystroke.
+ void tab.view.webContents.loadURL(url).catch(() => {})
+ }
+ return tab
+}
+
+/**
+ * Opens a copy of a tab at the same URL. A duplicate is a fresh load rather
+ * than a clone of the original's session history: the history belongs to the
+ * WebContents, and there is no way to fork it.
+ */
+export function duplicateTab(tabId: string): AgentTab | null {
+ restorePinnedTabs()
+ const source = tabs.find((entry) => entry.id === tabId)
+ if (!source || listTabs().length >= MAX_BROWSER_TABS) return null
+
+ const url = sanitizeRestorableUrl(source.view.webContents.getURL())
+ const tab = addTabInternal()
+ if (url && url !== 'about:blank') {
+ // Sanitized to http(s) without embedded credentials above, and the
+ // partition's onBeforeRequest still runs the full SSRF check on the load —
+ // same reasoning as reopenClosedTab, and this is likewise a user action.
+ void tab.view.webContents.loadURL(url).catch(() => {})
+ }
+ return tab
+}
+
+export function switchTab(tabId: string): AgentTab {
+ restorePinnedTabs()
+ const tab = tabs.find((entry) => entry.id === tabId)
+ if (!tab) throw new SessionError(`No tab with id ${tabId} — call browser_list_tabs.`)
+ // The find belongs to the page it was typed against, not to the browser.
+ if (findingTabId !== null && findingTabId !== tab.id) dismissFind(findingTabId)
+ const transferBrowserFocus =
+ focusedBrowserTabId !== null || tabs.some((entry) => entry.view.webContents.isFocused())
+ activeTabId = tab.id
+ // The automation exemption follows the active tab, so a mid-tool switch
+ // unthrottles the new one and re-throttles the old.
+ applyActiveTabThrottling()
+ layout()
+ if (transferBrowserFocus) focusedBrowserTabId = tab.id
+ events?.onActiveTabChanged(tab.view.webContents)
+ events?.onTabsChanged()
+ return tab
+}
+
+/**
+ * Moves a tab to a final list index while preserving the pinned/regular
+ * boundary. Dragging across that boundary moves to its nearest valid edge.
+ */
+export function reorderTab(tabId: string, targetIndex: number): AgentTab {
+ restorePinnedTabs()
+ if (!Number.isFinite(targetIndex)) {
+ throw new SessionError('Browser tab target index must be a finite number.')
+ }
+ const currentIndex = tabs.findIndex((entry) => entry.id === tabId)
+ if (currentIndex < 0) {
+ throw new SessionError(`No tab with id ${tabId} — call browser_list_tabs.`)
+ }
+ const tab = tabs[currentIndex]
+ const pinnedCount = tabs.filter((entry) => entry.pinned).length
+ const minIndex = tab.pinned ? 0 : pinnedCount
+ const maxIndex = tab.pinned ? pinnedCount - 1 : tabs.length - 1
+ const nextIndex = Math.max(minIndex, Math.min(maxIndex, Math.trunc(targetIndex)))
+ if (nextIndex === currentIndex) return tab
+
+ tabs.splice(currentIndex, 1)
+ tabs.splice(nextIndex, 0, tab)
+ if (tab.pinned) persistPinnedTabs()
+ events?.onTabsChanged()
+ return tab
+}
+
+/**
+ * Drops a tab whose renderer is already gone. Unlike {@link closeTab} this
+ * takes no view down (there is nothing left to close), applies to pinned tabs
+ * too — a crashed pinned tab is no more usable than any other — and does not
+ * offer the page for Reopen Closed Tab, since the user did not close it.
+ */
+function forgetTab(tab: AgentTab): void {
+ const index = tabs.indexOf(tab)
+ if (index < 0) return
+ // Before the splice, while the tab is still resolvable: a find left running
+ // on a tab that is going away keeps `findingTabId` naming a dead tab and
+ // leaves the bar open counting matches on a page nobody can see.
+ dismissFind(tab.id)
+ tabs.splice(index, 1)
+ const transferBrowserFocus = focusedBrowserTabId === tab.id
+ clearFocusedBrowserTab(tab.id)
+ detachIfAttached(tab.view)
+ if (tab.pinned) persistPinnedTabs()
+ if (activeTabId === tab.id) {
+ activeTabId = (tabs[index] ?? tabs[index - 1])?.id ?? null
+ layout()
+ const active = activeTab()
+ if (active) {
+ events?.onActiveTabChanged(active.view.webContents)
+ }
+ }
+ if (!hasSession() && isPanelVisible()) {
+ addTab()
+ if (transferBrowserFocus) focusedBrowserTabId = activeTabId
+ return
+ }
+ if (transferBrowserFocus) focusedBrowserTabId = activeTabId
+ events?.onTabsChanged()
+ if (!hasSession()) {
+ events?.onSessionClosed()
+ }
+}
+
+export function closeTab(tabId: string): void {
+ restorePinnedTabs()
+ const index = tabs.findIndex((entry) => entry.id === tabId)
+ if (index < 0) throw new SessionError(`No tab with id ${tabId} — call browser_list_tabs.`)
+ if (tabs[index].pinned) {
+ throw new SessionError('Pinned tabs cannot be closed. Unpin the tab first.')
+ }
+ // Before the splice, while the tab is still resolvable — see forgetTab.
+ dismissFind(tabId)
+ const [tab] = tabs.splice(index, 1)
+ recentlyClosedTabUrls.unshift(
+ sanitizeRestorableUrl(tab.view.webContents.getURL()) ?? 'about:blank'
+ )
+ if (recentlyClosedTabUrls.length > MAX_RECENTLY_CLOSED_TABS) {
+ recentlyClosedTabUrls.length = MAX_RECENTLY_CLOSED_TABS
+ }
+ const transferBrowserFocus = focusedBrowserTabId === tab.id || tab.view.webContents.isFocused()
+ clearFocusedBrowserTab(tab.id)
+ detachIfAttached(tab.view)
+ tab.view.webContents.close()
+ if (activeTabId === tab.id) {
+ activeTabId = (tabs[index] ?? tabs[index - 1])?.id ?? null
+ layout()
+ const active = activeTab()
+ if (active) {
+ events?.onActiveTabChanged(active.view.webContents)
+ }
+ }
+ // Closing the last tab must not leave a visible browser resource with an
+ // empty strip. Replace it with a fresh New tab, matching normal browser UI.
+ if (!hasSession() && isPanelVisible()) {
+ addTab()
+ if (transferBrowserFocus) focusedBrowserTabId = activeTabId
+ return
+ }
+ if (transferBrowserFocus) focusedBrowserTabId = activeTabId
+ events?.onTabsChanged()
+ if (!hasSession()) {
+ events?.onSessionClosed()
+ }
+}
+
+/**
+ * Pins or unpins a live tab. Pinned tabs form a stable group at the far left,
+ * and their latest URLs are persisted locally for the next browser opening.
+ */
+export function setTabPinned(tabId: string, pinned: boolean): AgentTab {
+ restorePinnedTabs()
+ const index = tabs.findIndex((entry) => entry.id === tabId)
+ if (index < 0) throw new SessionError(`No tab with id ${tabId} — call browser_list_tabs.`)
+ const tab = tabs[index]
+ if (tab.pinned === pinned) return tab
+
+ tabs.splice(index, 1)
+ tab.pinned = pinned
+ if (pinned) {
+ const firstRegularTab = tabs.findIndex((entry) => !entry.pinned)
+ tabs.splice(firstRegularTab < 0 ? tabs.length : firstRegularTab, 0, tab)
+ } else {
+ tabs.push(tab)
+ }
+ persistPinnedTabs()
+ events?.onTabsChanged()
+ return tab
+}
+
+/**
+ * Closes the active tab when the browser resource currently owns the user's
+ * interaction context. Application menu accelerators run before a
+ * WebContentsView's `before-input-event`, so Mod+W must route through this
+ * function instead of Electron's global close role. Returns false when focus
+ * belongs to the rest of the app.
+ */
+export function closeFocusedTab(ownerWindow?: BrowserWindow | null): boolean {
+ if (!panelUpdateAllowed(ownerWindow ?? undefined)) return false
+ const focusedTab = tabs.find(
+ (tab) =>
+ !tab.view.webContents.isDestroyed() &&
+ (tab.id === focusedBrowserTabId || tab.view.webContents.isFocused())
+ )
+ if (!focusedTab) return false
+ closeTabFromUser(focusedTab.id)
+ return true
+}
+
+/** Reopens the latest closed tab only while the browser owns interaction focus. */
+export function reopenFocusedTab(ownerWindow?: BrowserWindow | null): boolean {
+ if (!panelUpdateAllowed(ownerWindow ?? undefined)) return false
+ const browserFocused = tabs.some(
+ (tab) =>
+ !tab.view.webContents.isDestroyed() &&
+ (tab.id === focusedBrowserTabId || tab.view.webContents.isFocused())
+ )
+ if (!browserFocused) return false
+
+ const reopened = reopenClosedTab()
+ if (!reopened) return false
+ reopened.view.webContents.focus()
+ return true
+}
+
+/** Marks renderer-owned browser chrome as focused or releases browser focus. */
+export function setPanelFocused(focused: boolean, ownerWindow?: BrowserWindow): void {
+ if (!panelUpdateAllowed(ownerWindow)) return
+ if (!focused) {
+ clearFocusedBrowserTab()
+ return
+ }
+ if (focusedBrowserClearTimer !== null) {
+ clearTimeout(focusedBrowserClearTimer)
+ focusedBrowserClearTimer = null
+ }
+ focusedBrowserTabId = activeTab()?.id ?? null
+}
+
+function clearFocusedBrowserTab(tabId?: string): void {
+ if (tabId && focusedBrowserTabId !== tabId) return
+ if (focusedBrowserClearTimer !== null) {
+ clearTimeout(focusedBrowserClearTimer)
+ focusedBrowserClearTimer = null
+ }
+ focusedBrowserTabId = null
+}
+
+function closeTabFromUser(tabId: string): void {
+ if (tabs.find((tab) => tab.id === tabId)?.pinned) return
+ const closingLastTab = listTabs().length === 1
+ closeTab(tabId)
+ const active = activeTab()
+ if (closingLastTab || !active || !active.view.webContents.getURL()) {
+ focusRendererOmnibox('clear')
+ return
+ }
+ active.view.webContents.focus()
+}
+
+/** Destroys every live view and forgets which one was active. */
+function closeLiveTabs(): void {
+ detachAttachedView()
+ dismissFind(findingTabId)
+ for (const tab of tabs.splice(0)) {
+ if (!tab.view.webContents.isDestroyed()) {
+ tab.view.webContents.close()
+ }
+ }
+ recentlyClosedTabUrls.length = 0
+ activeTabId = null
+ clearFocusedBrowserTab()
+}
+
+/**
+ * Ends the live session without touching the profile or the pinned-tab list on
+ * disk, so the strip comes back intact next time. Turning the agent browser
+ * off in settings runs this; a sign-out wipe runs {@link clearProfileStorage}.
+ */
+export function closeSession(): void {
+ closeLiveTabs()
+ // Left unrestored so the next opening reads the pinned strip from disk
+ // rather than the emptied in-memory copy. Persistence is gated on the same
+ // flag, so nothing can save over that list in the meantime.
+ pinnedTabsRestored = false
+ events?.onTabsChanged()
+ events?.onSessionClosed()
+ layout()
+}
+
+/**
+ * Wipes the embedded browser's profile: open tabs, the in-memory list behind
+ * Reopen Closed Tab, the persisted pinned tabs, and all site data and cache in
+ * the agent partition. Sim sign-out runs this so the next account signing in
+ * on this machine cannot inherit the previous user's authenticated sessions,
+ * pinned tabs, or browsing trail.
+ */
+export async function clearProfileStorage(): Promise {
+ closeLiveTabs()
+ // Stays true so a later restore cannot re-read the list being erased here.
+ pinnedTabsRestored = true
+ pinnedTabPersistence?.save([])
+ lastPersistedPinnedTabs = '[]'
+ events?.onTabsChanged()
+ layout()
+
+ const ses = electronSession.fromPartition(AGENT_PARTITION)
+ // No `storages` filter: a profile wipe should leave nothing behind, and an
+ // allowlist would silently miss whatever Chromium adds next.
+ await ses.clearStorageData()
+ await ses.clearCache()
+}
+
+/**
+ * Site storage other than cookies. Named explicitly rather than by omission so
+ * a new Chromium storage type is not silently swept into "site data" — the
+ * whole-profile wipe is the one that deliberately takes everything.
+ */
+const SITE_DATA_STORAGES = [
+ 'filesystem',
+ 'indexdb',
+ 'localstorage',
+ 'shadercache',
+ 'websql',
+ 'serviceworkers',
+ 'cachestorage',
+] as const
+
+/**
+ * Erases selected kinds of browsing data without ending the session.
+ *
+ * Unlike {@link clearProfileStorage} this leaves tabs open and the pinned strip
+ * intact: the user asked to clear data, not to close their browser. Saved
+ * passwords live in a separate vault and are never touched here.
+ */
+export async function clearAgentData(kinds: readonly BrowserDataKind[]): Promise {
+ const ses = electronSession.fromPartition(AGENT_PARTITION)
+ const storages: string[] = []
+ if (kinds.includes('cookies')) storages.push('cookies')
+ if (kinds.includes('site-data')) storages.push(...SITE_DATA_STORAGES)
+
+ if (storages.length > 0) {
+ await ses.clearStorageData({ storages } as Parameters[0])
+ }
+ if (kinds.includes('cache')) await ses.clearCache()
+}
+
+export function listTabs(): BrowserTabState[] {
+ restorePinnedTabs()
+ return tabs
+ .filter((tab) => !tab.view.webContents.isDestroyed())
+ .map((tab) => ({
+ tabId: tab.id,
+ title: tab.view.webContents.getTitle(),
+ url: tab.view.webContents.getURL(),
+ loading: tab.view.webContents.isLoading(),
+ active: tab.id === activeTabId,
+ pinned: tab.pinned,
+ }))
+}
+
+export function getTabsState(): BrowserTabsState {
+ return {
+ tabs: listTabs(),
+ activeTabId: activeTab()?.id ?? null,
+ }
+}
+
+export function activeTab(): AgentTab | null {
+ const tab = tabs.find((entry) => entry.id === activeTabId) ?? null
+ if (!tab || tab.view.webContents.isDestroyed()) return null
+ return tab
+}
diff --git a/apps/desktop/src/main/browser-agent/url-guard.test.ts b/apps/desktop/src/main/browser-agent/url-guard.test.ts
new file mode 100644
index 00000000000..b43d45a7da9
--- /dev/null
+++ b/apps/desktop/src/main/browser-agent/url-guard.test.ts
@@ -0,0 +1,122 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockLookup } = vi.hoisted(() => ({ mockLookup: vi.fn() }))
+
+vi.mock('node:dns/promises', () => ({
+ default: { lookup: mockLookup },
+}))
+
+import { checkAgentUrl, isBlockedRequestUrl } from '@/main/browser-agent/url-guard'
+
+describe('checkAgentUrl', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }])
+ })
+
+ it('rejects non-http(s) schemes without resolving', async () => {
+ const result = await checkAgentUrl('file:///etc/passwd')
+ expect(result.ok).toBe(false)
+ expect(mockLookup).not.toHaveBeenCalled()
+ })
+
+ it('rejects malformed URLs', async () => {
+ expect((await checkAgentUrl('not a url')).ok).toBe(false)
+ })
+
+ it('blocks private IP literals without resolving', async () => {
+ expect((await checkAgentUrl('http://169.254.169.254/latest/meta-data')).ok).toBe(false)
+ expect((await checkAgentUrl('http://10.0.0.5/')).ok).toBe(false)
+ expect((await checkAgentUrl('http://192.168.1.1/')).ok).toBe(false)
+ expect((await checkAgentUrl('http://[fd00::1]/')).ok).toBe(false)
+ expect(mockLookup).not.toHaveBeenCalled()
+ })
+
+ it('allows loopback, so a local dev server can be opened', async () => {
+ // The panel exists to browse from this machine, and the agent already has
+ // an unrestricted shell on it. The LAN and the metadata endpoint above are
+ // a different matter and stay blocked.
+ expect((await checkAgentUrl('http://127.0.0.1:3000/')).ok).toBe(true)
+ expect((await checkAgentUrl('http://[::1]:3000/')).ok).toBe(true)
+ expect(mockLookup).not.toHaveBeenCalled()
+ })
+
+ it('allows localhost, which resolves to loopback', async () => {
+ mockLookup.mockResolvedValue([
+ { address: '::1', family: 6 },
+ { address: '127.0.0.1', family: 4 },
+ ])
+ expect((await checkAgentUrl('http://localhost:3000/app')).ok).toBe(true)
+ })
+
+ it('still blocks a host that resolves to the LAN alongside loopback', async () => {
+ mockLookup.mockResolvedValue([
+ { address: '127.0.0.1', family: 4 },
+ { address: '192.168.0.9', family: 4 },
+ ])
+ expect((await checkAgentUrl('http://sneaky.test/')).ok).toBe(false)
+ })
+
+ it('allows public IP literals without resolving', async () => {
+ expect((await checkAgentUrl('https://8.8.8.8/')).ok).toBe(true)
+ expect(mockLookup).not.toHaveBeenCalled()
+ })
+
+ it('allows hostnames that resolve to public addresses', async () => {
+ const result = await checkAgentUrl('https://example.com/page')
+ expect(result.ok).toBe(true)
+ expect(mockLookup).toHaveBeenCalledWith('example.com', { all: true, verbatim: true })
+ })
+
+ it('blocks hostnames that resolve to a private address (DNS rebinding)', async () => {
+ mockLookup.mockResolvedValue([{ address: '10.1.2.3', family: 4 }])
+ expect((await checkAgentUrl('https://rebind.evil.test/')).ok).toBe(false)
+ })
+
+ it('blocks when any resolved address is private', async () => {
+ mockLookup.mockResolvedValue([
+ { address: '93.184.216.34', family: 4 },
+ { address: '192.168.0.9', family: 4 },
+ ])
+ expect((await checkAgentUrl('https://mixed.test/')).ok).toBe(false)
+ })
+
+ it('fails closed when DNS resolution fails', async () => {
+ mockLookup.mockRejectedValue(new Error('ENOTFOUND'))
+ expect((await checkAgentUrl('https://nope.invalid/')).ok).toBe(false)
+ })
+
+ it('fails closed when the DNS lookup exceeds the deadline', async () => {
+ vi.useFakeTimers()
+ try {
+ mockLookup.mockReturnValue(new Promise(() => {})) // never resolves
+ const pending = checkAgentUrl('https://slow.test/')
+ await vi.advanceTimersByTimeAsync(5_000)
+ expect((await pending).ok).toBe(false)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+})
+
+describe('isBlockedRequestUrl', () => {
+ it('blocks literal private/reserved hosts', () => {
+ expect(isBlockedRequestUrl('http://169.254.169.254/latest/meta-data')).toBe(true)
+ expect(isBlockedRequestUrl('http://10.0.0.5/x')).toBe(true)
+ expect(isBlockedRequestUrl('https://[fd00::1]/')).toBe(true)
+ })
+
+ it('allows loopback subresources, so a local page can load its own assets', () => {
+ expect(isBlockedRequestUrl('http://127.0.0.1:8080/app.js')).toBe(false)
+ expect(isBlockedRequestUrl('http://[::1]:8080/app.css')).toBe(false)
+ })
+
+ it('allows public literals and hostnames (classified at nav time)', () => {
+ expect(isBlockedRequestUrl('https://8.8.8.8/')).toBe(false)
+ expect(isBlockedRequestUrl('https://example.com/x')).toBe(false)
+ })
+
+ it('does not throw on malformed input', () => {
+ expect(isBlockedRequestUrl('::::')).toBe(false)
+ })
+})
diff --git a/apps/desktop/src/main/browser-agent/url-guard.ts b/apps/desktop/src/main/browser-agent/url-guard.ts
new file mode 100644
index 00000000000..23f493405e2
--- /dev/null
+++ b/apps/desktop/src/main/browser-agent/url-guard.ts
@@ -0,0 +1,141 @@
+import dns from 'node:dns/promises'
+import { createLogger } from '@sim/logger'
+import {
+ isIpLiteral,
+ isLoopbackIp,
+ isPrivateIp,
+ isPrivateIpHost,
+ unwrapIpv6Brackets,
+} from '@sim/security/ssrf'
+import { getErrorMessage } from '@sim/utils/errors'
+import { parseHttpUrl } from '@/main/navigation'
+
+const logger = createLogger('BrowserAgentUrlGuard')
+
+/** Hard deadline on the SSRF DNS lookup so a slow/hung resolver can't suspend
+ * the check — and the onBeforeRequest callback that awaits it — indefinitely.
+ * A timeout rejects, which fails closed (blocks) via the caller's catch. */
+const DNS_TIMEOUT_MS = 5_000
+
+/** dns.lookup bounded by {@link DNS_TIMEOUT_MS}; the timer is always cleared so a
+ * won race never leaves a dangling rejection. */
+async function resolveHost(host: string) {
+ const lookup = dns.lookup(host, { all: true, verbatim: true })
+ // If the timeout wins the race the lookup stays pending; swallow its eventual
+ // settlement so a late rejection can't surface as an unhandled rejection.
+ lookup.catch(() => {})
+ let timer: NodeJS.Timeout | undefined
+ try {
+ return await Promise.race([
+ lookup,
+ new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error('DNS lookup timed out')), DNS_TIMEOUT_MS)
+ }),
+ ])
+ } finally {
+ clearTimeout(timer)
+ }
+}
+
+export interface UrlGuardResult {
+ ok: boolean
+ error?: string
+}
+
+const OK: UrlGuardResult = { ok: true }
+
+/**
+ * Whether an address is off limits to the embedded browser.
+ *
+ * Loopback is deliberately allowed: it is the user's own machine, and opening
+ * a dev server on localhost is one of the most ordinary things to do in this
+ * panel — the URL bar already assumes `http://` for it. Nothing is given away
+ * by it either, since the desktop app hands the same agent an unrestricted
+ * shell on that machine, so a blocked `http://localhost:3000` is one
+ * `curl http://localhost:3000` away regardless.
+ *
+ * Every other private range stays blocked. Those are a different matter: the
+ * LAN is other people's machines, and `169.254.169.254` is link-local rather
+ * than loopback, so the cloud-metadata endpoint this guard exists for is
+ * unaffected.
+ */
+function isBlockedAddress(ip: string): boolean {
+ return isPrivateIp(ip) && !isLoopbackIp(ip)
+}
+const BLOCKED: UrlGuardResult = {
+ ok: false,
+ error: 'That address points to a private or internal network and was blocked.',
+}
+
+/**
+ * SSRF guard for agent-browser navigation. The embedded browser is a
+ * general-purpose surface driven by model/tool input, so a navigation to a
+ * loopback/RFC1918/link-local host (e.g. the `169.254.169.254` cloud-metadata
+ * endpoint) would let a page's contents be read back through the read/snapshot
+ * tools. This resolves the host the same way `apps/sim` does for outbound
+ * fetches and blocks any that land on a private/reserved address — except
+ * loopback, which is allowed (see {@link isBlockedAddress}).
+ *
+ * IP literals are classified directly; hostnames are DNS-resolved and every
+ * returned address is checked. Resolution failure fails CLOSED (blocks): we
+ * can't confirm the host is public, and Chromium resolves independently, so it
+ * could still reach a private address our lookup missed — matching
+ * `validateUrlWithDNS` in `apps/sim`. The residual DNS-rebinding TOCTOU window
+ * (our lookup vs Chromium's) is only fully closable with egress firewalling;
+ * {@link isBlockedRequestUrl} adds a synchronous per-request literal-IP backstop
+ * for redirects and subresources.
+ */
+export async function checkAgentUrl(rawUrl: string): Promise {
+ const url = parseHttpUrl(rawUrl)
+ if (!url) {
+ return { ok: false, error: 'URL must be absolute and start with http:// or https://' }
+ }
+
+ const host = unwrapIpv6Brackets(url.hostname)
+
+ // IP literal: classify directly, no DNS lookup needed.
+ if (isIpLiteral(host)) {
+ if (isBlockedAddress(host)) {
+ logger.warn('Blocked agent navigation to private IP literal', { host })
+ return BLOCKED
+ }
+ return OK
+ }
+
+ try {
+ const resolved = await resolveHost(host)
+ if (resolved.some(({ address }) => isBlockedAddress(address))) {
+ logger.warn('Blocked agent navigation resolving to private IP', { host })
+ return BLOCKED
+ }
+ } catch (error) {
+ // Fail closed: an unresolved host can't be confirmed public, and Chromium
+ // resolves independently, so it could still reach a private address.
+ logger.warn('Agent navigation host did not resolve; blocking', {
+ host,
+ error: getErrorMessage(error),
+ })
+ return { ok: false, error: 'That address could not be resolved.' }
+ }
+
+ return OK
+}
+
+/**
+ * Synchronous backstop for the agent partition's `onBeforeRequest`: blocks any
+ * request whose host is a **literal** private/reserved IP. This is cheap enough
+ * to run per-request and catches redirects and subresources that target the
+ * metadata endpoint or an internal IP directly, without the cost of a DNS
+ * lookup on every subresource. Hostnames pass here (they are classified at
+ * navigation time by {@link checkAgentUrl}).
+ */
+export function isBlockedRequestUrl(rawUrl: string): boolean {
+ try {
+ // isPrivateIpHost strips IPv6 brackets itself; unwrap again for the
+ // loopback carve-out, which takes a bare address.
+ const host = new URL(rawUrl).hostname
+ return isPrivateIpHost(host) && !isLoopbackIp(unwrapIpv6Brackets(host))
+ } catch {
+ return false
+ }
+}
diff --git a/apps/desktop/src/main/browser-credentials/fill.test.ts b/apps/desktop/src/main/browser-credentials/fill.test.ts
new file mode 100644
index 00000000000..d1822a8700b
--- /dev/null
+++ b/apps/desktop/src/main/browser-credentials/fill.test.ts
@@ -0,0 +1,327 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('electron', () => import('@/test/electron-mock'))
+
+import { sleep } from '@sim/utils/helpers'
+import type { BrowserWindow, WebContents } from 'electron'
+import { Menu } from 'electron'
+import { FillCoordinator } from '@/main/browser-credentials/fill'
+import type { CredentialVault } from '@/main/browser-credentials/vault'
+
+const ORIGIN = 'https://example.com'
+const WINDOW = {} as BrowserWindow
+
+function fakeContents(url = `${ORIGIN}/login`) {
+ return {
+ getURL: vi.fn(() => url),
+ isDestroyed: vi.fn(() => false),
+ send: vi.fn(),
+ }
+}
+
+function fakeVault(overrides: Partial> = {}) {
+ return {
+ isAvailable: vi.fn(() => true),
+ listForOrigin: vi.fn(async () => [
+ {
+ id: 'c1',
+ origin: ORIGIN,
+ username: 'ada',
+ createdAt: '',
+ updatedAt: '',
+ source: 'chrome' as const,
+ },
+ ]),
+ readForFill: vi.fn(async () => ({ username: 'ada', password: 'hunter2' })),
+ ...overrides,
+ }
+}
+
+type Contents = ReturnType
+
+function setup(contents: Contents = fakeContents(), vault = fakeVault()) {
+ const onAvailabilityChanged = vi.fn()
+ let active: Contents | null = contents
+ const coordinator = new FillCoordinator({
+ vault: vault as unknown as CredentialVault,
+ getActiveContents: () => active as unknown as WebContents | null,
+ onAvailabilityChanged,
+ })
+ return {
+ coordinator,
+ contents,
+ vault,
+ onAvailabilityChanged,
+ setActive: (next: Contents | null) => {
+ active = next
+ },
+ }
+}
+
+/** Reports a login form, opens the chooser, and returns its menu template. */
+async function openChooser(context: ReturnType) {
+ context.coordinator.noteFormState(context.contents as unknown as WebContents, {
+ origin: ORIGIN,
+ hasLoginForm: true,
+ })
+ await context.coordinator.showChooser(WINDOW, { x: 10, y: 20 })
+ const template = vi.mocked(Menu.buildFromTemplate).mock.calls.at(-1)?.[0] as
+ | Array<{ label: string; click: () => void }>
+ | undefined
+ return template ?? []
+}
+
+/**
+ * Menu clicks are fire-and-forget, so a test has to wait for the fill's
+ * promise chain to finish on its own.
+ *
+ * Several ticks rather than one: the chain awaits the vault and then
+ * revalidates, and a single macrotask was enough to make this flaky on a
+ * loaded machine — the assertion ran before the chain reached `send`.
+ */
+async function settle(): Promise {
+ for (let tick = 0; tick < 10; tick++) {
+ await sleep(0)
+ }
+}
+
+beforeEach(() => {
+ vi.mocked(Menu.buildFromTemplate).mockClear()
+})
+
+describe('fill availability', () => {
+ it('is available once a login form has a saved match', async () => {
+ const context = setup()
+ context.coordinator.noteFormState(context.contents as unknown as WebContents, {
+ origin: ORIGIN,
+ hasLoginForm: true,
+ })
+
+ await expect(context.coordinator.isFillAvailable()).resolves.toBe(true)
+ })
+
+ it.each([
+ ['there is no login form', { hasLoginForm: false }],
+ ['the page origin cannot hold a credential', { origin: 'about:blank' }],
+ ])('is unavailable when %s', async (_label, report) => {
+ const context = setup()
+ context.coordinator.noteFormState(context.contents as unknown as WebContents, {
+ origin: ORIGIN,
+ hasLoginForm: true,
+ ...report,
+ })
+
+ await expect(context.coordinator.isFillAvailable()).resolves.toBe(false)
+ })
+
+ it('is unavailable with no saved credential for the origin', async () => {
+ const context = setup(fakeContents(), fakeVault({ listForOrigin: vi.fn(async () => []) }))
+ context.coordinator.noteFormState(context.contents as unknown as WebContents, {
+ origin: ORIGIN,
+ hasLoginForm: true,
+ })
+
+ await expect(context.coordinator.isFillAvailable()).resolves.toBe(false)
+ })
+
+ it('is unavailable when secure storage is unavailable', async () => {
+ const context = setup(fakeContents(), fakeVault({ isAvailable: vi.fn(() => false) }))
+ context.coordinator.noteFormState(context.contents as unknown as WebContents, {
+ origin: ORIGIN,
+ hasLoginForm: true,
+ })
+
+ await expect(context.coordinator.isFillAvailable()).resolves.toBe(false)
+ })
+
+ it('drops to unavailable as soon as the page navigates', async () => {
+ const context = setup()
+ context.coordinator.noteFormState(context.contents as unknown as WebContents, {
+ origin: ORIGIN,
+ hasLoginForm: true,
+ })
+
+ context.coordinator.noteNavigation(context.contents as unknown as WebContents)
+
+ await expect(context.coordinator.isFillAvailable()).resolves.toBe(false)
+ })
+
+ it('forgets a closed tab', async () => {
+ const context = setup()
+ context.coordinator.noteFormState(context.contents as unknown as WebContents, {
+ origin: ORIGIN,
+ hasLoginForm: true,
+ })
+
+ context.coordinator.forget(context.contents as unknown as WebContents)
+
+ await expect(context.coordinator.isFillAvailable()).resolves.toBe(false)
+ })
+})
+
+describe('credential chooser', () => {
+ it('lists usernames without reading any password', async () => {
+ const context = setup()
+ const template = await openChooser(context)
+
+ expect(template.map((item) => item.label)).toEqual(['ada'])
+ expect(context.vault.readForFill).not.toHaveBeenCalled()
+ })
+
+ it('refuses to open without a login form or a match', async () => {
+ const context = setup()
+ await expect(context.coordinator.showChooser(WINDOW, { x: 0, y: 0 })).resolves.toBe(false)
+
+ const noMatches = setup(fakeContents(), fakeVault({ listForOrigin: vi.fn(async () => []) }))
+ noMatches.coordinator.noteFormState(noMatches.contents as unknown as WebContents, {
+ origin: ORIGIN,
+ hasLoginForm: true,
+ })
+ await expect(noMatches.coordinator.showChooser(WINDOW, { x: 0, y: 0 })).resolves.toBe(false)
+ })
+})
+
+describe('performing a fill', () => {
+ it('sends the credential to the page the user chose it for', async () => {
+ const context = setup()
+ const template = await openChooser(context)
+
+ template[0].click()
+ await settle()
+
+ expect(context.contents.send).toHaveBeenCalledWith('browser-credentials:fill', {
+ origin: ORIGIN,
+ username: 'ada',
+ password: 'hunter2',
+ })
+ })
+
+ it('fills the email step of a two-step sign-in without sending the password', async () => {
+ const context = setup()
+ context.coordinator.noteFormState(context.contents as unknown as WebContents, {
+ origin: ORIGIN,
+ hasLoginForm: true,
+ hasPasswordField: false,
+ })
+ await context.coordinator.showChooser(WINDOW, { x: 10, y: 20 })
+ const template = vi.mocked(Menu.buildFromTemplate).mock.calls.at(-1)?.[0] as Array<{
+ click: () => void
+ }>
+
+ template[0].click()
+ await settle()
+
+ // The page has nowhere to put a password, so it does not get one.
+ expect(context.contents.send).toHaveBeenCalledWith('browser-credentials:fill', {
+ origin: ORIGIN,
+ username: 'ada',
+ password: undefined,
+ })
+ })
+
+ it('sends the password to a shell that never reported whether a field exists', async () => {
+ const context = setup()
+ // Older preloads omit the flag; assuming a password field keeps them working.
+ context.coordinator.noteFormState(context.contents as unknown as WebContents, {
+ origin: ORIGIN,
+ hasLoginForm: true,
+ })
+ await context.coordinator.showChooser(WINDOW, { x: 10, y: 20 })
+ const template = vi.mocked(Menu.buildFromTemplate).mock.calls.at(-1)?.[0] as Array<{
+ click: () => void
+ }>
+
+ template[0].click()
+ await settle()
+
+ expect(context.contents.send).toHaveBeenCalledWith(
+ 'browser-credentials:fill',
+ expect.objectContaining({ password: 'hunter2' })
+ )
+ })
+
+ it('refuses after the page navigated between choosing and clicking', async () => {
+ const context = setup()
+ const template = await openChooser(context)
+
+ context.coordinator.noteNavigation(context.contents as unknown as WebContents)
+ template[0].click()
+ await settle()
+
+ expect(context.vault.readForFill).not.toHaveBeenCalled()
+ expect(context.contents.send).not.toHaveBeenCalled()
+ })
+
+ it('refuses when the live document is no longer the origin that was reported', async () => {
+ // The preload's report is a claim. If the tab is actually somewhere else
+ // now, the password must not follow it.
+ const context = setup()
+ const template = await openChooser(context)
+ context.contents.getURL.mockReturnValue('https://evil.test/login')
+
+ template[0].click()
+ await settle()
+
+ expect(context.vault.readForFill).not.toHaveBeenCalled()
+ expect(context.contents.send).not.toHaveBeenCalled()
+ })
+
+ it('refuses when the user switched to another tab', async () => {
+ const context = setup()
+ const template = await openChooser(context)
+ context.setActive(fakeContents())
+
+ template[0].click()
+ await settle()
+
+ expect(context.contents.send).not.toHaveBeenCalled()
+ })
+
+ it('refuses when the tab was destroyed', async () => {
+ const context = setup()
+ const template = await openChooser(context)
+ context.contents.isDestroyed.mockReturnValue(true)
+
+ template[0].click()
+ await settle()
+
+ expect(context.contents.send).not.toHaveBeenCalled()
+ })
+
+ it('refuses when the page navigates during the vault read', async () => {
+ // Reading the vault is asynchronous, so the document can change inside it.
+ // Revalidating only before the read would let the password land on the
+ // page that replaced the one the user was looking at.
+ let releaseRead: () => void = () => {}
+ const pending = new Promise((resolve) => {
+ releaseRead = resolve
+ })
+ const vault = fakeVault({
+ readForFill: vi.fn(async () => {
+ await pending
+ return { username: 'ada', password: 'hunter2' }
+ }),
+ })
+ const context = setup(fakeContents(), vault)
+ const template = await openChooser(context)
+
+ template[0].click()
+ await settle()
+ context.coordinator.noteNavigation(context.contents as unknown as WebContents)
+ releaseRead()
+ await settle()
+
+ expect(context.vault.readForFill).toHaveBeenCalled()
+ expect(context.contents.send).not.toHaveBeenCalled()
+ })
+
+ it('refuses when the vault no longer holds the chosen credential', async () => {
+ const context = setup(fakeContents(), fakeVault({ readForFill: vi.fn(async () => null) }))
+ const template = await openChooser(context)
+
+ template[0].click()
+ await settle()
+
+ expect(context.contents.send).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/desktop/src/main/browser-credentials/fill.ts b/apps/desktop/src/main/browser-credentials/fill.ts
new file mode 100644
index 00000000000..ab911504752
--- /dev/null
+++ b/apps/desktop/src/main/browser-credentials/fill.ts
@@ -0,0 +1,197 @@
+import { createLogger } from '@sim/logger'
+import type { BrowserWindow, WebContents } from 'electron'
+import { Menu } from 'electron'
+import { normalizeOrigin } from '@/main/browser-credentials/origin'
+import type { CredentialVault } from '@/main/browser-credentials/vault'
+
+const logger = createLogger('BrowserCredentialFill')
+
+/**
+ * Decides when a credential may be filled, and does the filling.
+ *
+ * The page can navigate at any point between "this page has a login form",
+ * "the user opened the chooser", and "the user picked an account" — and a fill
+ * aimed at the wrong document means a password handed to the wrong site. So
+ * every authorization is bound to a specific tab and a specific navigation
+ * generation, and that binding is revalidated immediately before plaintext
+ * leaves the vault, not just when the chooser opened.
+ *
+ * The chooser is a native menu rather than renderer chrome. That is a security
+ * property, not a styling choice: the selection happens in a surface the main
+ * process owns and the page (and the Sim renderer) cannot synthesize, which is
+ * the main-process-controlled confirmation the design calls for. It also means
+ * no credential id has to cross the preload bridge at all.
+ */
+
+interface FormState {
+ origin: string
+ hasLoginForm: boolean
+ /**
+ * Whether the page currently has somewhere to put a password.
+ *
+ * False on the first step of an identifier-first sign-in, which asks for an
+ * email and only reveals the password field after it is submitted. Those
+ * steps are still worth filling — the username is what they want — so the
+ * password simply is not sent to a page that has nowhere to put it.
+ */
+ hasPasswordField: boolean
+ /** Bumped on every navigation, so a stale authorization cannot be replayed. */
+ generation: number
+}
+
+export interface FillCoordinatorDeps {
+ vault: CredentialVault
+ /** The tab the user is actually looking at. */
+ getActiveContents: () => WebContents | null
+ /** Push the fill affordance's visibility to the Sim renderer. */
+ onAvailabilityChanged: (available: boolean) => void
+}
+
+export interface FormStateReport {
+ origin: string
+ hasLoginForm: boolean
+ /** Absent from shells that predate identifier-first support; assumed true. */
+ hasPasswordField?: boolean
+}
+
+export class FillCoordinator {
+ private readonly states = new WeakMap()
+ private readonly generations = new WeakMap()
+ private lastAvailability = false
+
+ constructor(private readonly deps: FillCoordinatorDeps) {}
+
+ private generationFor(contents: WebContents): number {
+ return this.generations.get(contents) ?? 0
+ }
+
+ /**
+ * Records what the browser preload observed. The report is trusted only as
+ * far as it goes: it can claim a form exists, but the origin it names is
+ * checked against the live URL before any fill.
+ */
+ noteFormState(contents: WebContents, report: FormStateReport): void {
+ const origin = normalizeOrigin(report.origin)
+ if (origin === null) {
+ this.states.delete(contents)
+ } else {
+ this.states.set(contents, {
+ origin,
+ hasLoginForm: report.hasLoginForm,
+ hasPasswordField: report.hasPasswordField ?? true,
+ generation: this.generationFor(contents),
+ })
+ }
+ void this.refreshAvailability()
+ }
+
+ /**
+ * Invalidates everything known about a tab's page. Called on every
+ * navigation, including in-page ones — a single-page app can swap a login
+ * form for a different site's UI without a document load.
+ */
+ noteNavigation(contents: WebContents): void {
+ this.generations.set(contents, this.generationFor(contents) + 1)
+ this.states.delete(contents)
+ void this.refreshAvailability()
+ }
+
+ forget(contents: WebContents): void {
+ this.states.delete(contents)
+ this.generations.delete(contents)
+ void this.refreshAvailability()
+ }
+
+ /** Whether the active tab has a login form with at least one saved match. */
+ async isFillAvailable(): Promise {
+ const contents = this.deps.getActiveContents()
+ if (!contents || contents.isDestroyed()) return false
+ const state = this.states.get(contents)
+ if (!state?.hasLoginForm) return false
+ if (!this.deps.vault.isAvailable()) return false
+ return (await this.deps.vault.listForOrigin(state.origin)).length > 0
+ }
+
+ async refreshAvailability(): Promise {
+ const available = await this.isFillAvailable()
+ if (available === this.lastAvailability) return
+ this.lastAvailability = available
+ this.deps.onAvailabilityChanged(available)
+ }
+
+ /**
+ * Shows the native account chooser near a point in the window.
+ *
+ * Only usernames are listed; no password is read until the user picks one.
+ * The navigation generation is captured here and carried into the fill, so a
+ * page that moves while the menu is open invalidates the choice.
+ */
+ async showChooser(window: BrowserWindow, anchor: { x: number; y: number }): Promise {
+ const contents = this.deps.getActiveContents()
+ if (!contents || contents.isDestroyed()) return false
+ const state = this.states.get(contents)
+ if (!state?.hasLoginForm) return false
+
+ const matches = await this.deps.vault.listForOrigin(state.origin)
+ if (matches.length === 0) return false
+
+ const authorizedGeneration = state.generation
+ const menu = Menu.buildFromTemplate(
+ matches.map((credential) => ({
+ label: credential.username || '(no username)',
+ click: () => {
+ void this.fill(contents, credential.id, authorizedGeneration).catch(() => {})
+ },
+ }))
+ )
+ menu.popup({ window, x: Math.round(anchor.x), y: Math.round(anchor.y) })
+ return true
+ }
+
+ /**
+ * Performs one authorized fill.
+ *
+ * Every precondition is checked again here rather than trusted from when the
+ * chooser opened, and once more after the vault read, because that read is
+ * asynchronous and the page can navigate inside it.
+ */
+ private async fill(
+ contents: WebContents,
+ credentialId: string,
+ authorizedGeneration: number
+ ): Promise {
+ if (!this.isStillAuthorized(contents, authorizedGeneration)) return
+ const state = this.states.get(contents)
+ if (!state) return
+
+ // The origin the preload reported must still be the document's real
+ // origin. This is the check that stops a fill following a page that
+ // navigated to another site.
+ if (normalizeOrigin(contents.getURL()) !== state.origin) return
+
+ const credential = await this.deps.vault.readForFill(credentialId, state.origin)
+ if (credential === null) return
+
+ if (!this.isStillAuthorized(contents, authorizedGeneration)) return
+ if (normalizeOrigin(contents.getURL()) !== state.origin) return
+
+ contents.send('browser-credentials:fill', {
+ origin: state.origin,
+ username: credential.username,
+ // Withheld on an identifier-first step: the page has no password field,
+ // so sending it would put plaintext in a document that cannot use it.
+ password: state.hasPasswordField ? credential.password : undefined,
+ })
+ // Counts and outcomes only — never the origin, username, or password.
+ logger.info('Filled a saved credential at the user\u2019s request')
+ }
+
+ private isStillAuthorized(contents: WebContents, authorizedGeneration: number): boolean {
+ if (contents.isDestroyed()) return false
+ // A fill must land in the tab the user is looking at. Switching tabs
+ // between choosing and filling cancels it.
+ if (this.deps.getActiveContents() !== contents) return false
+ if (this.generationFor(contents) !== authorizedGeneration) return false
+ return this.states.get(contents)?.generation === authorizedGeneration
+ }
+}
diff --git a/apps/desktop/src/main/browser-credentials/index.ts b/apps/desktop/src/main/browser-credentials/index.ts
new file mode 100644
index 00000000000..5b9994fbcb4
--- /dev/null
+++ b/apps/desktop/src/main/browser-credentials/index.ts
@@ -0,0 +1,145 @@
+import { join } from 'node:path'
+import type {
+ BrowserCredentialConflictPolicy,
+ BrowserCredentialMetadata,
+} from '@sim/desktop-bridge'
+import { app, clipboard } from 'electron'
+import { FillCoordinator, type FillCoordinatorDeps } from '@/main/browser-credentials/fill'
+import { authorizeForSecret, revokeSecretAuthorization } from '@/main/browser-credentials/os-auth'
+import { CredentialVault } from '@/main/browser-credentials/vault'
+import { clearSites } from '@/main/browser-sites'
+
+/** How long a copied password may sit on the clipboard before Sim clears it. */
+const CLIPBOARD_CLEAR_MS = 30_000
+
+/**
+ * Composition root for saved passwords: one vault and one fill coordinator for
+ * the whole app. The IPC layer talks to this module and nothing deeper, so the
+ * vault instance is never handed to a renderer-facing surface.
+ */
+
+let vaultInstance: CredentialVault | null = null
+let coordinatorInstance: FillCoordinator | null = null
+
+export function credentialVault(): CredentialVault {
+ if (!vaultInstance) {
+ vaultInstance = new CredentialVault(join(app.getPath('userData'), 'browser-credentials.json'))
+ }
+ return vaultInstance
+}
+
+/** Creates the coordinator once the browser session can report its active tab. */
+export function initFillCoordinator(deps: Omit): FillCoordinator {
+ coordinatorInstance = new FillCoordinator({ ...deps, vault: credentialVault() })
+ return coordinatorInstance
+}
+
+export function fillCoordinator(): FillCoordinator | null {
+ return coordinatorInstance
+}
+
+export function credentialsAvailable(): boolean {
+ return credentialVault().isAvailable()
+}
+
+export function listCredentials(): Promise {
+ return credentialVault().list()
+}
+
+export async function forgetCredential(id: string): Promise {
+ const vault = credentialVault()
+ await vault.delete(id)
+ revokeSecretAuthorization(id)
+ // A forgotten credential can remove the last match for the open page.
+ await coordinatorInstance?.refreshAvailability()
+ return vault.list()
+}
+
+export async function importCredentials(
+ candidates: Parameters[0],
+ policy: BrowserCredentialConflictPolicy
+): ReturnType {
+ const outcome = await credentialVault().importCredentials(candidates, policy)
+ revokeSecretAuthorization()
+ await coordinatorInstance?.refreshAvailability()
+ return outcome
+}
+
+/**
+ * Deletes every saved password at the user's request.
+ *
+ * Distinct from {@link clearCredentials}, which runs as part of sign-out
+ * teardown: this one is a deliberate action from the password manager, so it
+ * resolves the resulting list for the UI to render.
+ */
+export async function forgetAllCredentials(): Promise {
+ await clearCredentials()
+ return []
+}
+
+/**
+ * Reveals one saved password to the settings UI, after the user proves they
+ * are present.
+ *
+ * This is the single path by which password plaintext reaches the Sim
+ * renderer, and it exists only because a password manager the user cannot read
+ * is not a password manager. Everything else about it is deliberately narrow:
+ * one credential per call, a fresh proof of presence, and nothing logged.
+ */
+export async function revealCredential(id: string): Promise {
+ const authorized = await authorizeForSecret({
+ credentialId: id,
+ reason: 'show a saved password',
+ action: 'Show password',
+ })
+ if (!authorized) return null
+ return credentialVault().revealPassword(id)
+}
+
+/**
+ * Copies one saved password to the clipboard without it passing through the
+ * renderer at all, then clears the clipboard again.
+ *
+ * The clipboard is only cleared if it still holds this password — overwriting
+ * whatever the user copied in the meantime would be its own small betrayal.
+ */
+export async function copyCredential(id: string): Promise {
+ const authorized = await authorizeForSecret({
+ credentialId: id,
+ reason: 'copy a saved password',
+ action: 'Copy password',
+ })
+ if (!authorized) return false
+ const password = await credentialVault().revealPassword(id)
+ if (password === null) return false
+
+ clipboard.writeText(password)
+ setTimeout(() => {
+ if (clipboard.readText() === password) clipboard.clear()
+ }, CLIPBOARD_CLEAR_MS).unref?.()
+ return true
+}
+
+/**
+ * Destroys every saved credential. Sim sign-out runs this so the next account
+ * on this machine cannot inherit the previous user's passwords.
+ */
+export async function clearCredentials(): Promise {
+ // Revoked first, and unconditionally. Both deletions below can reject on a
+ // file the OS will not let us unlink, and the sign-out caller only logs
+ // that — so a revoke sequenced after them would be skipped while sign-out
+ // still reported success, leaving the previous user's OS-auth grant live
+ // for whoever signs in next. Dropping the grant early only ever fails safe.
+ revokeSecretAuthorization()
+ try {
+ // Both attempted regardless of each other. Sequencing them in one `try`
+ // made the second conditional on the first, so a vault file the OS would
+ // not let us unlink also left the imported site directory — hostnames and
+ // visit counts from the user's other browser — behind after sign-out.
+ const outcomes = await Promise.allSettled([credentialVault().clear(), clearSites()])
+ const failure = outcomes.find((outcome) => outcome.status === 'rejected')
+ if (failure) throw failure.reason
+ } finally {
+ await coordinatorInstance?.refreshAvailability()
+ }
+}
diff --git a/apps/desktop/src/main/browser-credentials/origin.test.ts b/apps/desktop/src/main/browser-credentials/origin.test.ts
new file mode 100644
index 00000000000..628a5fca8eb
--- /dev/null
+++ b/apps/desktop/src/main/browser-credentials/origin.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest'
+import { normalizeOrigin, normalizeUsername } from '@/main/browser-credentials/origin'
+
+describe('normalizeOrigin', () => {
+ it.each([
+ ['https://example.com/login?next=1', 'https://example.com'],
+ ['https://EXAMPLE.com', 'https://example.com'],
+ ['https://example.com:443/', 'https://example.com'],
+ ['http://example.com:80/', 'http://example.com'],
+ ['https://example.com:8443/', 'https://example.com:8443'],
+ ])('reduces %s to %s', (input, expected) => {
+ expect(normalizeOrigin(input)).toBe(expected)
+ })
+
+ it.each([
+ ['file:///etc/passwd'],
+ ['data:text/html,hi'],
+ ['javascript:alert(1)'],
+ ['about:blank'],
+ ['android://token@com.example/'],
+ ['not a url'],
+ [''],
+ ])('refuses %s, which cannot host a credential', (input) => {
+ expect(normalizeOrigin(input)).toBeNull()
+ })
+})
+
+describe('exact-origin identity', () => {
+ it('reduces only identical sites to the same origin', () => {
+ // Each of these is a different site as far as filling is concerned.
+ // Widening any of them is how a password reaches somewhere it should not.
+ expect(normalizeOrigin('https://example.com')).not.toBe(
+ normalizeOrigin('https://sub.example.com')
+ )
+ expect(normalizeOrigin('https://accounts.google.com')).not.toBe(
+ normalizeOrigin('https://google.com')
+ )
+ expect(normalizeOrigin('https://example.com')).not.toBe(normalizeOrigin('http://example.com'))
+ expect(normalizeOrigin('https://example.com')).not.toBe(
+ normalizeOrigin('https://example.com:8443')
+ )
+ expect(normalizeOrigin('https://example.com/a')).toBe(normalizeOrigin('https://EXAMPLE.com/b'))
+ })
+})
+
+describe('normalizeUsername', () => {
+ it('trims but preserves case', () => {
+ expect(normalizeUsername(' Ada ')).toBe('Ada')
+ expect(normalizeUsername('ada')).not.toBe(normalizeUsername('Ada'))
+ })
+})
diff --git a/apps/desktop/src/main/browser-credentials/origin.ts b/apps/desktop/src/main/browser-credentials/origin.ts
new file mode 100644
index 00000000000..b84c2b992c7
--- /dev/null
+++ b/apps/desktop/src/main/browser-credentials/origin.ts
@@ -0,0 +1,38 @@
+/**
+ * Exact-origin normalization for credential matching.
+ *
+ * Version one matches on `scheme + host + effective port` and nothing looser:
+ * `https://accounts.google.com` is not `https://google.com`, `https://x.com` is
+ * not `http://x.com`, and a subdomain is a different site. Broader affiliation
+ * is a deliberate non-goal — it is the kind of rule that is easy to widen by
+ * accident and hard to explain to a user, and getting it wrong means a
+ * password typed into the wrong site.
+ */
+
+/** Ports that are implied by the scheme and must not change identity. */
+const DEFAULT_PORTS: Record = { 'https:': '443', 'http:': '80' }
+
+/**
+ * The canonical origin for `value`, or null when it cannot host a credential.
+ *
+ * Rejects everything that is not http(s): `file:`, `data:`, `javascript:`, and
+ * opaque origins have no trustworthy identity to bind a password to.
+ */
+export function normalizeOrigin(value: string): string | null {
+ let url: URL
+ try {
+ url = new URL(value)
+ } catch {
+ return null
+ }
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
+ if (!url.hostname) return null
+
+ const port = url.port && url.port !== DEFAULT_PORTS[url.protocol] ? `:${url.port}` : ''
+ return `${url.protocol}//${url.hostname.toLowerCase()}${port}`
+}
+
+/** Usernames compare case-sensitively but ignore surrounding whitespace. */
+export function normalizeUsername(username: string): string {
+ return username.trim()
+}
diff --git a/apps/desktop/src/main/browser-credentials/os-auth.test.ts b/apps/desktop/src/main/browser-credentials/os-auth.test.ts
new file mode 100644
index 00000000000..cc7f433ae63
--- /dev/null
+++ b/apps/desktop/src/main/browser-credentials/os-auth.test.ts
@@ -0,0 +1,165 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const promptTouchID = vi.fn(async () => undefined)
+const canPromptTouchID = vi.fn(() => true)
+const showMessageBox = vi.fn(async () => ({ response: 1 }))
+
+vi.mock('electron', () => ({
+ systemPreferences: {
+ get canPromptTouchID() {
+ return canPromptTouchID
+ },
+ get promptTouchID() {
+ return promptTouchID
+ },
+ },
+ dialog: {
+ get showMessageBox() {
+ return showMessageBox
+ },
+ },
+}))
+
+vi.mock('@sim/logger', () => ({
+ createLogger: () => ({ warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }),
+}))
+
+const { authorizeForSecret, revokeSecretAuthorization } = await import(
+ '@/main/browser-credentials/os-auth'
+)
+
+const GRACE_MS = 30_000
+
+const realPlatform = process.platform
+
+/**
+ * Touch ID is reachable only on darwin, so the suite pins the platform rather
+ * than inheriting the runner's. Without this the biometric expectations below
+ * pass on a Mac and fail on Linux CI, where the gate sends every call to the
+ * fallback dialog instead.
+ */
+function setPlatform(platform: NodeJS.Platform): void {
+ Object.defineProperty(process, 'platform', { value: platform, configurable: true })
+}
+
+function request(credentialId: string) {
+ return { credentialId, reason: 'show a saved password', action: 'Show password' }
+}
+
+describe('authorizeForSecret', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.useRealTimers()
+ revokeSecretAuthorization()
+ setPlatform('darwin')
+ canPromptTouchID.mockReturnValue(true)
+ promptTouchID.mockResolvedValue(undefined)
+ })
+
+ afterEach(() => {
+ setPlatform(realPlatform)
+ })
+
+ it('asks the OS the first time a credential is used', async () => {
+ await expect(authorizeForSecret(request('c1'))).resolves.toBe(true)
+ expect(promptTouchID).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not ask again while the proof of presence is fresh', async () => {
+ await authorizeForSecret(request('c1'))
+ await expect(authorizeForSecret(request('c1'))).resolves.toBe(true)
+
+ // The user proved they were here seconds ago and the plaintext is likely
+ // still on their screen; a second prompt would protect nothing.
+ expect(promptTouchID).toHaveBeenCalledTimes(1)
+ })
+
+ it('confines a grant to the credential it was granted for', async () => {
+ await authorizeForSecret(request('c1'))
+ await expect(authorizeForSecret(request('c2'))).resolves.toBe(true)
+
+ expect(promptTouchID).toHaveBeenCalledTimes(2)
+ })
+
+ it('asks again once the grant lapses', async () => {
+ vi.useFakeTimers()
+ await authorizeForSecret(request('c1'))
+
+ vi.advanceTimersByTime(GRACE_MS)
+ await authorizeForSecret(request('c1'))
+
+ expect(promptTouchID).toHaveBeenCalledTimes(2)
+ })
+
+ it('holds the grant right up to the boundary', async () => {
+ vi.useFakeTimers()
+ await authorizeForSecret(request('c1'))
+
+ vi.advanceTimersByTime(GRACE_MS - 1)
+ await authorizeForSecret(request('c1'))
+
+ expect(promptTouchID).toHaveBeenCalledTimes(1)
+ })
+
+ it('grants nothing when the user declines', async () => {
+ promptTouchID.mockRejectedValueOnce(new Error('cancelled'))
+ await expect(authorizeForSecret(request('c1'))).resolves.toBe(false)
+
+ // A refusal must not be cached as a grant, nor as a standing denial.
+ await expect(authorizeForSecret(request('c1'))).resolves.toBe(true)
+ expect(promptTouchID).toHaveBeenCalledTimes(2)
+ })
+
+ it('asks again after the credential is explicitly revoked', async () => {
+ await authorizeForSecret(request('c1'))
+ revokeSecretAuthorization('c1')
+ await authorizeForSecret(request('c1'))
+
+ expect(promptTouchID).toHaveBeenCalledTimes(2)
+ })
+
+ it('revokes every credential when given no id', async () => {
+ await authorizeForSecret(request('c1'))
+ await authorizeForSecret(request('c2'))
+ revokeSecretAuthorization()
+
+ await authorizeForSecret(request('c1'))
+ await authorizeForSecret(request('c2'))
+ expect(promptTouchID).toHaveBeenCalledTimes(4)
+ })
+
+ it('labels the fallback dialog with the action it is authorizing', async () => {
+ canPromptTouchID.mockReturnValue(false)
+ await authorizeForSecret({
+ credentialId: 'c1',
+ reason: 'copy a saved password',
+ action: 'Copy password',
+ })
+
+ expect(showMessageBox).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Copy password?',
+ buttons: ['Cancel', 'Copy password'],
+ detail: expect.stringContaining('copy a saved password'),
+ })
+ )
+ })
+
+ it('fails closed when the fallback dialog cannot be shown', async () => {
+ canPromptTouchID.mockReturnValue(false)
+ showMessageBox.mockRejectedValueOnce(new Error('no window'))
+
+ await expect(authorizeForSecret(request('c1'))).resolves.toBe(false)
+ })
+
+ it('never reaches for Touch ID off darwin', async () => {
+ // Electron exposes canPromptTouchID on every platform; only the darwin
+ // guard keeps a non-Mac build out of the biometric path.
+ setPlatform('linux')
+
+ await expect(authorizeForSecret(request('c1'))).resolves.toBe(true)
+
+ expect(promptTouchID).not.toHaveBeenCalled()
+ expect(showMessageBox).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/apps/desktop/src/main/browser-credentials/os-auth.ts b/apps/desktop/src/main/browser-credentials/os-auth.ts
new file mode 100644
index 00000000000..c4c3ed830ad
--- /dev/null
+++ b/apps/desktop/src/main/browser-credentials/os-auth.ts
@@ -0,0 +1,112 @@
+import { createLogger } from '@sim/logger'
+import { dialog, systemPreferences } from 'electron'
+
+const logger = createLogger('BrowserCredentialAuth')
+
+/**
+ * How long proving presence for one credential stands before Sim asks again.
+ *
+ * Matches the reveal window in the settings UI, so the prompt comes back at
+ * the same moment an on-screen password re-masks. Within the window the user
+ * has already proven they are at the keyboard, and the plaintext they proved
+ * their way to is typically still on screen — asking a second time to put that
+ * same string on the clipboard is friction that buys nothing, and teaches
+ * people to approve prompts without reading them.
+ */
+const AUTH_GRACE_MS = 30_000
+
+/** Credential id to the moment its proof of presence lapses. */
+const provenUntil = new Map()
+
+export interface SecretAuthRequest {
+ /**
+ * The credential the grant covers. Proving presence for one saved password
+ * says nothing about any other, so grants never widen past the id they were
+ * granted for.
+ */
+ credentialId: string
+ /** Completes "Sim is about to ..." in the prompt. */
+ reason: string
+ /** Confirm-button label and title for the non-biometric fallback. */
+ action: string
+}
+
+function hasFreshProof(credentialId: string): boolean {
+ const expiry = provenUntil.get(credentialId)
+ if (expiry === undefined) return false
+ if (Date.now() >= expiry) {
+ provenUntil.delete(credentialId)
+ return false
+ }
+ return true
+}
+
+/**
+ * Drops standing grants, so a re-import or a delete cannot leave a grant
+ * attached to an id that now means something else.
+ *
+ * Called with no id, it revokes everything.
+ */
+export function revokeSecretAuthorization(credentialId?: string): void {
+ if (credentialId === undefined) provenUntil.clear()
+ else provenUntil.delete(credentialId)
+}
+
+/**
+ * The authorization step in front of revealing or copying a saved password.
+ *
+ * Showing a password is the one place where plaintext leaves the main process
+ * for the Sim renderer, so it is not enough that the page asked nicely — the
+ * person at the keyboard has to prove they are there, in a surface the
+ * renderer cannot drive or fake.
+ *
+ * Touch ID is used when the machine has it. Where it does not (a Mac mini
+ * without a Touch ID keyboard, or a user who has not enrolled), the fallback
+ * is a native modal that the main process owns. That is genuinely weaker than
+ * biometric auth — it proves presence, not identity — so it is a deliberate,
+ * documented trade rather than a silent one, and it still cannot be triggered
+ * without a real click in the page.
+ *
+ * A grant lapses on its own after {@link AUTH_GRACE_MS}; it never removes the
+ * user-gesture requirement the IPC layer enforces, so it shortens the prompt
+ * for a present user rather than opening a path for an absent one.
+ */
+export async function authorizeForSecret({
+ credentialId,
+ reason,
+ action,
+}: SecretAuthRequest): Promise {
+ if (hasFreshProof(credentialId)) return true
+ if (!(await promptForSecret(reason, action))) return false
+ provenUntil.set(credentialId, Date.now() + AUTH_GRACE_MS)
+ return true
+}
+
+async function promptForSecret(reason: string, action: string): Promise {
+ if (process.platform === 'darwin' && systemPreferences.canPromptTouchID?.()) {
+ try {
+ await systemPreferences.promptTouchID(reason)
+ return true
+ } catch {
+ // A cancelled or failed prompt is a refusal, not an error to report.
+ return false
+ }
+ }
+
+ try {
+ const { response } = await dialog.showMessageBox({
+ type: 'warning',
+ buttons: ['Cancel', action],
+ defaultId: 1,
+ cancelId: 0,
+ message: `${action}?`,
+ detail: `Sim is about to ${reason}. Make sure nobody can see your screen.`,
+ noLink: true,
+ })
+ return response === 1
+ } catch (error) {
+ // Fail closed: if the confirmation cannot be shown, nothing is revealed.
+ logger.warn('Could not present the credential confirmation')
+ return false
+ }
+}
diff --git a/apps/desktop/src/main/browser-credentials/vault.test.ts b/apps/desktop/src/main/browser-credentials/vault.test.ts
new file mode 100644
index 00000000000..a763792d22b
--- /dev/null
+++ b/apps/desktop/src/main/browser-credentials/vault.test.ts
@@ -0,0 +1,211 @@
+import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('electron', () => import('@/test/electron-mock'))
+
+import { CredentialVault } from '@/main/browser-credentials/vault'
+
+/**
+ * A stand-in for Electron `safeStorage`. It is deliberately reversible so the
+ * vault's own logic can be exercised; the tests that matter for secrecy assert
+ * that the vault always routes through this provider and refuses to write
+ * anything when it reports itself unavailable.
+ */
+function encryption(available = true) {
+ return {
+ isEncryptionAvailable: vi.fn(() => available),
+ encryptString: vi.fn((value: string) => Buffer.from(`sealed:${value}`, 'utf8')),
+ decryptString: vi.fn((value: Buffer) => value.toString('utf8').replace(/^sealed:/, '')),
+ }
+}
+
+let directory: string
+let vaultPath: string
+
+beforeEach(async () => {
+ directory = await mkdtemp(join(tmpdir(), 'sim-vault-test-'))
+ vaultPath = join(directory, 'browser-credentials.json')
+})
+
+afterEach(async () => {
+ await rm(directory, { recursive: true, force: true })
+})
+
+const CANDIDATES = [
+ { origin: 'https://example.com/login', username: 'ada', password: 'hunter2' },
+ { origin: 'https://other.test', username: 'grace', password: 'correct-horse' },
+]
+
+describe('CredentialVault', () => {
+ it('stores and lists credentials without their passwords', async () => {
+ const vault = new CredentialVault(vaultPath, encryption())
+
+ await expect(vault.importCredentials(CANDIDATES, 'keep-existing')).resolves.toEqual({
+ added: 2,
+ updated: 0,
+ skipped: 0,
+ })
+
+ const listed = await vault.list()
+ expect(listed).toHaveLength(2)
+ expect(listed[0]).toMatchObject({ origin: 'https://example.com', username: 'ada' })
+ expect(listed.every((entry) => !('password' in entry))).toBe(true)
+ })
+
+ it('writes only through the encryption provider, and never plaintext', async () => {
+ const provider = encryption()
+ const vault = new CredentialVault(vaultPath, provider)
+
+ await vault.importCredentials(CANDIDATES, 'keep-existing')
+
+ expect(provider.encryptString).toHaveBeenCalled()
+ const onDisk = await readFile(vaultPath, 'utf8')
+ expect(onDisk).not.toContain('hunter2')
+ expect(onDisk).not.toContain('correct-horse')
+ expect(JSON.parse(onDisk)).toMatchObject({ version: 1, ciphertext: expect.any(String) })
+ })
+
+ it('writes the vault file owner-only', async () => {
+ const vault = new CredentialVault(vaultPath, encryption())
+ await vault.importCredentials(CANDIDATES, 'keep-existing')
+
+ expect((await stat(vaultPath)).mode & 0o077).toBe(0)
+ })
+
+ it('refuses to store anything when secure storage is unavailable', async () => {
+ // No plaintext fallback: a password file Sim cannot encrypt is worse than
+ // a feature the user does not get.
+ const provider = encryption(false)
+ const vault = new CredentialVault(vaultPath, provider)
+
+ expect(vault.isAvailable()).toBe(false)
+ await expect(vault.importCredentials(CANDIDATES, 'keep-existing')).resolves.toEqual({
+ added: 0,
+ updated: 0,
+ skipped: 2,
+ })
+ expect(provider.encryptString).not.toHaveBeenCalled()
+ await expect(readFile(vaultPath, 'utf8')).rejects.toThrow()
+ })
+
+ it('treats exact origin plus username as one credential', async () => {
+ const vault = new CredentialVault(vaultPath, encryption())
+ await vault.importCredentials(CANDIDATES, 'keep-existing')
+
+ // A different subdomain and a different username are both new credentials.
+ await expect(
+ vault.importCredentials(
+ [
+ { origin: 'https://sub.example.com', username: 'ada', password: 'x' },
+ { origin: 'https://example.com', username: 'grace', password: 'y' },
+ ],
+ 'keep-existing'
+ )
+ ).resolves.toMatchObject({ added: 2 })
+ })
+
+ it('keeps the existing password on conflict by default', async () => {
+ const vault = new CredentialVault(vaultPath, encryption())
+ await vault.importCredentials(CANDIDATES, 'keep-existing')
+
+ await expect(
+ vault.importCredentials(
+ [{ origin: 'https://example.com', username: 'ada', password: 'changed' }],
+ 'keep-existing'
+ )
+ ).resolves.toEqual({ added: 0, updated: 0, skipped: 1 })
+
+ const stored = await vault.readForFill(
+ (await vault.list()).find((entry) => entry.username === 'ada')?.id ?? '',
+ 'https://example.com'
+ )
+ expect(stored?.password).toBe('hunter2')
+ })
+
+ it('replaces the password on conflict when asked', async () => {
+ const vault = new CredentialVault(vaultPath, encryption())
+ await vault.importCredentials(CANDIDATES, 'keep-existing')
+
+ await expect(
+ vault.importCredentials(
+ [{ origin: 'https://example.com', username: 'ada', password: 'changed' }],
+ 'replace'
+ )
+ ).resolves.toEqual({ added: 0, updated: 1, skipped: 0 })
+
+ const id = (await vault.list()).find((entry) => entry.username === 'ada')?.id ?? ''
+ expect((await vault.readForFill(id, 'https://example.com'))?.password).toBe('changed')
+ })
+
+ it('skips candidates with no usable origin or an empty password', async () => {
+ const vault = new CredentialVault(vaultPath, encryption())
+
+ await expect(
+ vault.importCredentials(
+ [
+ { origin: 'android://token@com.example/', username: 'a', password: 'p' },
+ { origin: 'https://example.com', username: 'b', password: '' },
+ ],
+ 'keep-existing'
+ )
+ ).resolves.toEqual({ added: 0, updated: 0, skipped: 2 })
+ })
+
+ it('only returns a password for the origin it belongs to', async () => {
+ // The last guard before plaintext exists: an id alone is not enough.
+ const vault = new CredentialVault(vaultPath, encryption())
+ await vault.importCredentials(CANDIDATES, 'keep-existing')
+ const id = (await vault.list()).find((entry) => entry.username === 'ada')?.id ?? ''
+
+ expect(await vault.readForFill(id, 'https://example.com')).toMatchObject({
+ password: 'hunter2',
+ })
+ expect(await vault.readForFill(id, 'https://evil.test')).toBeNull()
+ expect(await vault.readForFill('no-such-id', 'https://example.com')).toBeNull()
+ })
+
+ it('lists matches for one exact origin only', async () => {
+ const vault = new CredentialVault(vaultPath, encryption())
+ await vault.importCredentials(CANDIDATES, 'keep-existing')
+
+ expect(await vault.listForOrigin('https://example.com/login')).toHaveLength(1)
+ expect(await vault.listForOrigin('https://sub.example.com')).toHaveLength(0)
+ expect(await vault.listForOrigin('not-a-url')).toHaveLength(0)
+ })
+
+ it('forgets one credential', async () => {
+ const vault = new CredentialVault(vaultPath, encryption())
+ await vault.importCredentials(CANDIDATES, 'keep-existing')
+ const id = (await vault.list())[0].id
+
+ await expect(vault.delete(id)).resolves.toBe(true)
+ await expect(vault.delete(id)).resolves.toBe(false)
+ expect(await vault.list()).toHaveLength(1)
+ })
+
+ it('destroys everything on clear', async () => {
+ const vault = new CredentialVault(vaultPath, encryption())
+ await vault.importCredentials(CANDIDATES, 'keep-existing')
+
+ await vault.clear()
+
+ expect(await vault.list()).toEqual([])
+ await expect(readFile(vaultPath, 'utf8')).rejects.toThrow()
+ // Clearing an already-empty vault is not an error.
+ await expect(vault.clear()).resolves.toBeUndefined()
+ })
+
+ it('reads a corrupt or undecryptable vault as empty instead of throwing', async () => {
+ const provider = encryption()
+ provider.decryptString = vi.fn(() => {
+ throw new Error('wrong key')
+ })
+ const vault = new CredentialVault(vaultPath, encryption())
+ await vault.importCredentials(CANDIDATES, 'keep-existing')
+
+ const brokenVault = new CredentialVault(vaultPath, provider)
+ await expect(brokenVault.list()).resolves.toEqual([])
+ })
+})
diff --git a/apps/desktop/src/main/browser-credentials/vault.ts b/apps/desktop/src/main/browser-credentials/vault.ts
new file mode 100644
index 00000000000..3d949235aa7
--- /dev/null
+++ b/apps/desktop/src/main/browser-credentials/vault.ts
@@ -0,0 +1,286 @@
+import { readFile } from 'node:fs/promises'
+import type { BrowserCredentialMetadata } from '@sim/desktop-bridge'
+import { generateId } from '@sim/utils/id'
+import { safeStorage } from 'electron'
+import { removeFileIfPresent, writeJsonFileAtomically } from '@/main/atomic-json-file'
+import { normalizeOrigin, normalizeUsername } from '@/main/browser-credentials/origin'
+
+/**
+ * The encrypted credential store.
+ *
+ * The whole record set is encrypted as one blob with Electron `safeStorage`
+ * (Keychain on macOS), written atomically with restrictive permissions. There
+ * is no plaintext fallback: when OS-backed encryption is unavailable the vault
+ * reports itself unavailable and refuses to store anything, because a password
+ * file Sim cannot encrypt is worse than a feature the user does not get.
+ *
+ * Records are decrypted per operation rather than cached. The data is small and
+ * local, and not holding a decrypted password list in memory for the life of
+ * the process is worth far more than the saved microseconds.
+ */
+
+const VAULT_VERSION = 1
+
+export interface CredentialRecord {
+ id: string
+ origin: string
+ username: string
+ password: string
+ /** The site's own icon as a `data:` URL, copied from the source browser. */
+ icon?: string
+ createdAt: string
+ updatedAt: string
+ source: 'chrome' | 'manual'
+}
+
+/** How an import should treat a credential that already exists. */
+export type ConflictPolicy = 'keep-existing' | 'replace'
+
+export interface ImportCandidate {
+ origin: string
+ username: string
+ password: string
+ icon?: string
+}
+
+export interface ImportOutcome {
+ added: number
+ updated: number
+ skipped: number
+}
+
+interface EncryptedVaultEnvelope {
+ version: typeof VAULT_VERSION
+ ciphertext: string
+}
+
+interface EncryptionProvider {
+ isEncryptionAvailable(): boolean
+ encryptString(value: string): Buffer
+ decryptString(value: Buffer): string
+}
+
+function isCredentialRecord(value: unknown): value is CredentialRecord {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
+ const record = value as Record
+ return (
+ typeof record.id === 'string' &&
+ typeof record.origin === 'string' &&
+ typeof record.username === 'string' &&
+ typeof record.password === 'string' &&
+ typeof record.createdAt === 'string' &&
+ typeof record.updatedAt === 'string' &&
+ (record.source === 'chrome' || record.source === 'manual')
+ )
+}
+
+function toMetadata(record: CredentialRecord): BrowserCredentialMetadata {
+ return {
+ id: record.id,
+ origin: record.origin,
+ username: record.username,
+ createdAt: record.createdAt,
+ updatedAt: record.updatedAt,
+ source: record.source,
+ ...(record.icon ? { icon: record.icon } : {}),
+ }
+}
+
+export class CredentialVault {
+ constructor(
+ private readonly filePath: string,
+ private readonly encryption: EncryptionProvider = safeStorage,
+ private readonly now: () => Date = () => new Date()
+ ) {}
+
+ /**
+ * Whether credentials can be stored at all. The UI must hide or disable
+ * password features when this is false rather than degrading to plaintext.
+ */
+ isAvailable(): boolean {
+ // Guarded: on a Linux box with no keyring this throws rather than
+ // returning false, and an unguarded call propagated out of a password
+ // import. The site directory has always defended against it; this did not.
+ try {
+ return this.encryption.isEncryptionAvailable()
+ } catch {
+ return false
+ }
+ }
+
+ /**
+ * Decrypted records. Private on purpose — nothing outside this class should
+ * hold a list of passwords, and callers get metadata instead.
+ */
+ private async read(): Promise {
+ if (!this.isAvailable()) return []
+ try {
+ const raw = JSON.parse(await readFile(this.filePath, 'utf8')) as
+ | Partial
+ | undefined
+ if (raw?.version !== VAULT_VERSION || typeof raw.ciphertext !== 'string') return []
+ const parsed = JSON.parse(
+ this.encryption.decryptString(Buffer.from(raw.ciphertext, 'base64'))
+ ) as unknown
+ return Array.isArray(parsed) ? parsed.filter(isCredentialRecord) : []
+ } catch {
+ // A missing, corrupt, or undecryptable vault reads as empty rather than
+ // throwing: the browser must stay usable, and a failed write is where
+ // the user is told something is wrong.
+ return []
+ }
+ }
+
+ private async write(records: CredentialRecord[]): Promise {
+ if (!this.isAvailable()) return false
+ const envelope: EncryptedVaultEnvelope = {
+ version: VAULT_VERSION,
+ ciphertext: this.encryption.encryptString(JSON.stringify(records)).toString('base64'),
+ }
+ await writeJsonFileAtomically(this.filePath, envelope)
+ return true
+ }
+
+ /** Every stored credential, without passwords, newest activity first. */
+ async list(): Promise {
+ const records = await this.read()
+ return records
+ .map(toMetadata)
+ .sort(
+ (left, right) =>
+ left.origin.localeCompare(right.origin) || left.username.localeCompare(right.username)
+ )
+ }
+
+ /** Credentials whose origin exactly matches `origin`, without passwords. */
+ async listForOrigin(origin: string): Promise {
+ const normalized = normalizeOrigin(origin)
+ if (normalized === null) return []
+ return (await this.read())
+ .filter((record) => record.origin === normalized)
+ .map(toMetadata)
+ .sort((left, right) => left.username.localeCompare(right.username))
+ }
+
+ /**
+ * The username and password for one credential.
+ *
+ * Main-process only, and only for an authorized fill. These values must
+ * never be returned through the preload bridge, written to a log, put on a
+ * span, or persisted anywhere outside this vault.
+ */
+ async readForFill(
+ id: string,
+ origin: string
+ ): Promise<{ username: string; password: string } | null> {
+ const normalized = normalizeOrigin(origin)
+ if (normalized === null) return null
+ // The origin is re-checked here, at the last moment before plaintext is
+ // produced, so a stale or swapped id cannot pull a password for a site
+ // other than the one the fill was authorized against.
+ const record = (await this.read()).find(
+ (candidate) => candidate.id === id && candidate.origin === normalized
+ )
+ return record ? { username: record.username, password: record.password } : null
+ }
+
+ /**
+ * The password for one credential, by id alone.
+ *
+ * Unlike {@link readForFill} there is no origin to check against, because
+ * the password manager lists credentials rather than matching a page. That
+ * makes this the weakest-guarded read in the vault, so its only caller must
+ * be the reveal path, behind OS authentication.
+ */
+ async revealPassword(id: string): Promise {
+ return (await this.read()).find((record) => record.id === id)?.password ?? null
+ }
+
+ async delete(id: string): Promise {
+ const records = await this.read()
+ const remaining = records.filter((record) => record.id !== id)
+ if (remaining.length === records.length) return false
+ return this.write(remaining)
+ }
+
+ /**
+ * Merges imported credentials.
+ *
+ * Identity is exact origin plus username, per the import design: the same
+ * login on the same site is the same credential, and everything else is a
+ * new one. A conflicting entry is kept or replaced according to `policy` —
+ * never silently duplicated.
+ */
+ async importCredentials(
+ candidates: ImportCandidate[],
+ policy: ConflictPolicy
+ ): Promise {
+ if (!this.isAvailable()) return { added: 0, updated: 0, skipped: candidates.length }
+
+ const records = await this.read()
+ const byIdentity = new Map(
+ records.map((record) => [`${record.origin}\u0000${record.username}`, record])
+ )
+ const timestamp = this.now().toISOString()
+ const outcome: ImportOutcome = { added: 0, updated: 0, skipped: 0 }
+ let iconsAdded = false
+
+ for (const candidate of candidates) {
+ const origin = normalizeOrigin(candidate.origin)
+ const username = normalizeUsername(candidate.username)
+ if (origin === null || candidate.password.length === 0) {
+ outcome.skipped += 1
+ continue
+ }
+
+ const identity = `${origin}\u0000${username}`
+ const existing = byIdentity.get(identity)
+ if (existing) {
+ if (policy === 'keep-existing' || existing.password === candidate.password) {
+ // A re-import still refreshes a missing icon; that is not a
+ // credential change, so it does not count as an update.
+ if (candidate.icon && !existing.icon) {
+ existing.icon = candidate.icon
+ iconsAdded = true
+ }
+ outcome.skipped += 1
+ continue
+ }
+ existing.password = candidate.password
+ existing.updatedAt = timestamp
+ existing.source = 'chrome'
+ if (candidate.icon) existing.icon = candidate.icon
+ outcome.updated += 1
+ continue
+ }
+
+ const record: CredentialRecord = {
+ id: generateId(),
+ origin,
+ username,
+ password: candidate.password,
+ ...(candidate.icon ? { icon: candidate.icon } : {}),
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ source: 'chrome',
+ }
+ byIdentity.set(identity, record)
+ records.push(record)
+ outcome.added += 1
+ }
+
+ if (outcome.added === 0 && outcome.updated === 0 && !iconsAdded) return outcome
+ if (!(await this.write(records))) {
+ return { added: 0, updated: 0, skipped: candidates.length }
+ }
+ return outcome
+ }
+
+ /**
+ * Destroys the vault. Sign-out runs this so the next Sim account on this
+ * machine cannot inherit the previous user's passwords.
+ */
+ async clear(): Promise {
+ await removeFileIfPresent(this.filePath)
+ }
+}
diff --git a/apps/desktop/src/main/browser-import/browser-sources.ts b/apps/desktop/src/main/browser-import/browser-sources.ts
new file mode 100644
index 00000000000..800aecfc61a
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/browser-sources.ts
@@ -0,0 +1,99 @@
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+
+/**
+ * The Chromium-family browsers Sim can import from on macOS.
+ *
+ * Every one of these shares Chromium's profile layout and its `OSCrypt`
+ * encryption, so a single reader handles all of them. What actually differs
+ * between browsers is only two things: where the user-data directory lives,
+ * and which login-Keychain item holds the Safe Storage password. That is what
+ * this table is.
+ *
+ * Safari is deliberately absent and cannot be added here. It stores cookies in
+ * an undocumented binary format inside a TCC-protected container (reading it
+ * would require Full Disk Access, a far broader grant than a Keychain prompt),
+ * and its passwords are iCloud Keychain items with per-item ACLs rather than a
+ * database — there is no key to derive and nothing to decrypt. The supported
+ * path for Safari is a user-driven CSV export.
+ */
+export interface BrowserSource {
+ /** Stable, opaque identifier used to namespace profile ids. */
+ id: string
+ /** Product name as the user knows it. */
+ label: string
+ /** User-data directory, relative to the home directory. */
+ userDataSegments: readonly string[]
+ /** The login-Keychain generic-password item holding the Safe Storage key. */
+ keychain: { service: string; account: string }
+}
+
+export const BROWSER_SOURCES: readonly BrowserSource[] = [
+ {
+ id: 'chrome',
+ label: 'Chrome',
+ userDataSegments: ['Library', 'Application Support', 'Google', 'Chrome'],
+ keychain: { service: 'Chrome Safe Storage', account: 'Chrome' },
+ },
+ {
+ id: 'arc',
+ label: 'Arc',
+ userDataSegments: ['Library', 'Application Support', 'Arc', 'User Data'],
+ keychain: { service: 'Arc Safe Storage', account: 'Arc' },
+ },
+ {
+ id: 'dia',
+ label: 'Dia',
+ userDataSegments: ['Library', 'Application Support', 'Dia', 'User Data'],
+ keychain: { service: 'Dia Safe Storage', account: 'Dia' },
+ },
+ {
+ id: 'brave',
+ label: 'Brave',
+ userDataSegments: ['Library', 'Application Support', 'BraveSoftware', 'Brave-Browser'],
+ keychain: { service: 'Brave Safe Storage', account: 'Brave' },
+ },
+ {
+ id: 'edge',
+ label: 'Microsoft Edge',
+ userDataSegments: ['Library', 'Application Support', 'Microsoft Edge'],
+ keychain: { service: 'Microsoft Edge Safe Storage', account: 'Microsoft Edge' },
+ },
+ {
+ id: 'vivaldi',
+ label: 'Vivaldi',
+ userDataSegments: ['Library', 'Application Support', 'Vivaldi'],
+ keychain: { service: 'Vivaldi Safe Storage', account: 'Vivaldi' },
+ },
+ {
+ id: 'chromium',
+ label: 'Chromium',
+ userDataSegments: ['Library', 'Application Support', 'Chromium'],
+ keychain: { service: 'Chromium Safe Storage', account: 'Chromium' },
+ },
+] as const
+
+export function userDataDirFor(source: BrowserSource, home: string = homedir()): string {
+ return join(home, ...source.userDataSegments)
+}
+
+/**
+ * Splits a bridge profile id back into its browser and profile directory.
+ *
+ * Ids are namespaced (`arc:Profile 1`) because profile directory names repeat
+ * across browsers — every one of them has a `Default`. Returns null for
+ * anything malformed; the caller then resolves against discovered profiles
+ * anyway, so a bad id can never become a path.
+ */
+export function parseProfileId(profileId: string): { sourceId: string; directory: string } | null {
+ const separator = profileId.indexOf(':')
+ if (separator <= 0 || separator === profileId.length - 1) return null
+ return {
+ sourceId: profileId.slice(0, separator),
+ directory: profileId.slice(separator + 1),
+ }
+}
+
+export function formatProfileId(sourceId: string, directory: string): string {
+ return `${sourceId}:${directory}`
+}
diff --git a/apps/desktop/src/main/browser-import/chromium-cookies.test.ts b/apps/desktop/src/main/browser-import/chromium-cookies.test.ts
new file mode 100644
index 00000000000..2ef3d603e31
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/chromium-cookies.test.ts
@@ -0,0 +1,249 @@
+import { createCipheriv, createHash } from 'node:crypto'
+import { mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { sleep } from '@sim/utils/helpers'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { readBrowserCookies } from '@/main/browser-import/chromium-cookies'
+import { deriveEncryptionKey } from '@/main/browser-import/chromium-crypto'
+
+/**
+ * Exercises the reader against a synthetic Chrome profile. Fixtures are built
+ * here on purpose: a test must never read a developer's real Chrome profile or
+ * touch their Keychain.
+ */
+const sqliteAvailable = await import('node:sqlite').then(
+ () => true,
+ () => false
+)
+
+const KEY = deriveEncryptionKey('test-safe-storage-password')
+const NOW_SECONDS = 1_800_000_000
+
+async function stagingDirectories(): Promise> {
+ const entries = await readdir(tmpdir())
+ return new Set(entries.filter((entry) => entry.startsWith('sim-chrome-import-')))
+}
+
+/**
+ * Staging copies still present that were not there at `before`.
+ *
+ * Every reader in this folder stages through the shared `os.tmpdir()` under one
+ * prefix, and Vitest runs their suites in parallel workers, so a single snapshot
+ * can catch a sibling's copy mid-read and read as a leak. A leak of our own
+ * never clears, so this settles instead of sampling once.
+ */
+async function stagingLeftBehindSince(before: ReadonlySet): Promise {
+ let leftover: string[] = []
+ for (let attempt = 0; attempt < 20; attempt++) {
+ leftover = [...(await stagingDirectories())].filter((entry) => !before.has(entry))
+ if (leftover.length === 0) return leftover
+ await sleep(25)
+ }
+ return leftover
+}
+
+function chromeTime(unixSeconds: number): bigint {
+ return BigInt(unixSeconds + 11_644_473_600) * 1_000_000n
+}
+
+function encryptV10(plaintext: Buffer): Uint8Array {
+ const cipher = createCipheriv('aes-128-cbc', KEY, Buffer.alloc(16, 0x20))
+ return Buffer.concat([Buffer.from('v10'), cipher.update(plaintext), cipher.final()])
+}
+
+interface FixtureCookie {
+ hostKey: string
+ name: string
+ encryptedValue?: Uint8Array
+ value?: string
+ expiresUtc?: bigint
+ hasExpires?: number
+ isPersistent?: number
+ isSecure?: number
+ sameSite?: number
+ path?: string
+}
+
+let directory: string
+
+beforeEach(async () => {
+ directory = await mkdtemp(join(tmpdir(), 'sim-chrome-cookies-test-'))
+})
+
+afterEach(async () => {
+ await rm(directory, { recursive: true, force: true })
+})
+
+async function writeCookieDatabase(cookies: FixtureCookie[]): Promise {
+ const { DatabaseSync } = await import('node:sqlite')
+ const path = join(directory, 'Cookies')
+ const database = new DatabaseSync(path)
+ database.exec(`
+ CREATE TABLE cookies (
+ creation_utc INTEGER NOT NULL, host_key TEXT NOT NULL, name TEXT NOT NULL,
+ value TEXT NOT NULL, path TEXT NOT NULL, expires_utc INTEGER NOT NULL,
+ is_secure INTEGER NOT NULL, is_httponly INTEGER NOT NULL,
+ has_expires INTEGER NOT NULL, is_persistent INTEGER NOT NULL,
+ samesite INTEGER NOT NULL, encrypted_value BLOB
+ )
+ `)
+ const insert = database.prepare(`
+ INSERT INTO cookies (creation_utc, host_key, name, value, path, expires_utc,
+ is_secure, is_httponly, has_expires, is_persistent, samesite, encrypted_value)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `)
+ for (const cookie of cookies) {
+ insert.run(
+ chromeTime(NOW_SECONDS - 100),
+ cookie.hostKey,
+ cookie.name,
+ cookie.value ?? '',
+ cookie.path ?? '/',
+ cookie.expiresUtc ?? chromeTime(NOW_SECONDS + 3600),
+ cookie.isSecure ?? 1,
+ 1,
+ cookie.hasExpires ?? 1,
+ cookie.isPersistent ?? 1,
+ cookie.sameSite ?? 1,
+ cookie.encryptedValue ?? null
+ )
+ }
+ database.close()
+ return path
+}
+
+describe.skipIf(!sqliteAvailable)('readBrowserCookies', () => {
+ it('decrypts a v10 cookie and preserves its attributes', async () => {
+ const path = await writeCookieDatabase([
+ {
+ hostKey: '.example.com',
+ name: 'session',
+ encryptedValue: encryptV10(Buffer.from('token-123')),
+ },
+ ])
+
+ const result = await readBrowserCookies(path, KEY, NOW_SECONDS)
+
+ expect(result.rowsSeen).toBe(1)
+ expect(result.cookies).toHaveLength(1)
+ const [cookie] = result.cookies
+ expect(cookie).toMatchObject({
+ url: 'https://example.com/',
+ name: 'session',
+ value: 'token-123',
+ domain: '.example.com',
+ secure: true,
+ httpOnly: true,
+ sameSite: 'lax',
+ })
+ expect(cookie.expirationDate).toBeCloseTo(NOW_SECONDS + 3600, 3)
+ })
+
+ it('reads Chrome timestamps that overflow a safe integer', async () => {
+ const path = await writeCookieDatabase([
+ {
+ hostKey: 'example.com',
+ name: 'a',
+ encryptedValue: encryptV10(Buffer.from('v')),
+ expiresUtc: chromeTime(NOW_SECONDS + 86_400),
+ },
+ ])
+
+ const [cookie] = (await readBrowserCookies(path, KEY, NOW_SECONDS)).cookies
+ expect(cookie.expirationDate).toBeCloseTo(NOW_SECONDS + 86_400, 3)
+ })
+
+ it('removes a verified domain-bound prefix', async () => {
+ const plaintext = Buffer.concat([
+ createHash('sha256').update('.example.com').digest(),
+ Buffer.from('bound'),
+ ])
+ const path = await writeCookieDatabase([
+ { hostKey: '.example.com', name: 'a', encryptedValue: encryptV10(plaintext) },
+ ])
+
+ expect((await readBrowserCookies(path, KEY, NOW_SECONDS)).cookies[0].value).toBe('bound')
+ })
+
+ it('falls back to the plain value column for unencrypted rows', async () => {
+ const path = await writeCookieDatabase([
+ { hostKey: 'example.com', name: 'a', value: 'legacy-plaintext' },
+ ])
+
+ expect((await readBrowserCookies(path, KEY, NOW_SECONDS)).cookies[0].value).toBe(
+ 'legacy-plaintext'
+ )
+ })
+
+ it('counts unreadable and expired rows instead of failing the import', async () => {
+ const path = await writeCookieDatabase([
+ { hostKey: 'example.com', name: 'good', encryptedValue: encryptV10(Buffer.from('v')) },
+ { hostKey: 'example.com', name: 'bad', encryptedValue: Buffer.from('v20-not-supported') },
+ {
+ hostKey: 'example.com',
+ name: 'old',
+ encryptedValue: encryptV10(Buffer.from('v')),
+ expiresUtc: chromeTime(NOW_SECONDS - 10),
+ },
+ ])
+
+ const result = await readBrowserCookies(path, KEY, NOW_SECONDS)
+
+ expect(result.rowsSeen).toBe(3)
+ expect(result.cookies.map(({ name }) => name)).toEqual(['good'])
+ expect(result.skipped['decrypt-failed']).toBe(1)
+ expect(result.skipped.expired).toBe(1)
+ })
+
+ it('reports an unrecognised schema rather than guessing', async () => {
+ const path = join(directory, 'Cookies')
+ const { DatabaseSync } = await import('node:sqlite')
+ const database = new DatabaseSync(path)
+ database.exec('CREATE TABLE something_else (a TEXT)')
+ database.close()
+
+ await expect(readBrowserCookies(path, KEY, NOW_SECONDS)).rejects.toMatchObject({
+ code: 'unsupported-schema',
+ })
+ })
+
+ it('reports an unreadable source rather than throwing raw', async () => {
+ await expect(
+ readBrowserCookies(join(directory, 'absent', 'Cookies'), KEY, NOW_SECONDS)
+ ).rejects.toMatchObject({ code: 'profile-unreadable' })
+ })
+
+ it('leaves the source database untouched', async () => {
+ const path = await writeCookieDatabase([
+ { hostKey: 'example.com', name: 'a', encryptedValue: encryptV10(Buffer.from('v')) },
+ ])
+ const before = await stat(path)
+
+ await readBrowserCookies(path, KEY, NOW_SECONDS)
+
+ const after = await stat(path)
+ expect(after.size).toBe(before.size)
+ expect(after.mtimeMs).toBe(before.mtimeMs)
+ })
+
+ it('deletes its decrypted working copy', async () => {
+ const before = await stagingDirectories()
+ const path = await writeCookieDatabase([
+ { hostKey: 'example.com', name: 'a', encryptedValue: encryptV10(Buffer.from('v')) },
+ ])
+
+ await readBrowserCookies(path, KEY, NOW_SECONDS)
+
+ expect(await stagingLeftBehindSince(before)).toEqual([])
+ })
+
+ it('cleans up even when the read fails', async () => {
+ const before = await stagingDirectories()
+ await writeFile(join(directory, 'Cookies'), 'not a database')
+
+ await expect(readBrowserCookies(join(directory, 'Cookies'), KEY, NOW_SECONDS)).rejects.toThrow()
+
+ expect(await stagingLeftBehindSince(before)).toEqual([])
+ })
+})
diff --git a/apps/desktop/src/main/browser-import/chromium-cookies.ts b/apps/desktop/src/main/browser-import/chromium-cookies.ts
new file mode 100644
index 00000000000..74e79b6cd0b
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/chromium-cookies.ts
@@ -0,0 +1,86 @@
+import { decryptChromiumValue } from '@/main/browser-import/chromium-crypto'
+import { translateCookieRow } from '@/main/browser-import/cookie-translate'
+import { queryBrowserDatabase } from '@/main/browser-import/sqlite-source'
+import {
+ type ChromiumCookieRow,
+ type CookieSkipCounts,
+ emptySkipCounts,
+ type ImportableCookie,
+ toNumber,
+ toText,
+} from '@/main/browser-import/types'
+
+/**
+ * Decrypts a Chrome profile's cookies. The source database is copied and read
+ * read-only by {@link queryBrowserDatabase}; nothing here writes to Chrome.
+ */
+
+const MAX_COOKIE_ROWS = 50_000
+
+const COOKIE_QUERY = `
+ SELECT host_key, name, path, expires_utc, is_secure, is_httponly,
+ has_expires, is_persistent, samesite, encrypted_value, value
+ FROM cookies
+ LIMIT ${MAX_COOKIE_ROWS}
+`
+
+export interface ReadCookiesResult {
+ cookies: ImportableCookie[]
+ skipped: CookieSkipCounts
+ /** Rows examined, so the caller can tell "no cookies" from "none survived". */
+ rowsSeen: number
+}
+
+function toRow(raw: Record): ChromiumCookieRow {
+ return {
+ hostKey: toText(raw.host_key),
+ name: toText(raw.name),
+ path: toText(raw.path),
+ expiresUtc: toNumber(raw.expires_utc),
+ isSecure: toNumber(raw.is_secure) !== 0,
+ isHttpOnly: toNumber(raw.is_httponly) !== 0,
+ hasExpires: toNumber(raw.has_expires) !== 0,
+ isPersistent: toNumber(raw.is_persistent) !== 0,
+ sameSite: toNumber(raw.samesite),
+ }
+}
+
+/**
+ * Rows that fail to decrypt or cannot be aimed at a single host are counted,
+ * not thrown — one unreadable cookie should not cost the user the other
+ * thousand.
+ */
+export async function readBrowserCookies(
+ cookiesPath: string,
+ key: Buffer,
+ nowSeconds: number = Date.now() / 1000
+): Promise {
+ const rows = await queryBrowserDatabase(cookiesPath, 'Cookies', COOKIE_QUERY)
+ const cookies: ImportableCookie[] = []
+ const skipped = emptySkipCounts()
+
+ for (const raw of rows) {
+ const row = toRow(raw)
+ const encrypted = raw.encrypted_value
+ let value: string | null
+ if (encrypted instanceof Uint8Array && encrypted.length > 0) {
+ value = decryptChromiumValue(Buffer.from(encrypted), key, { domain: row.hostKey })
+ } else {
+ // Pre-encryption rows keep their value in the plain `value` column.
+ value = toText(raw.value)
+ }
+ if (value === null) {
+ skipped['decrypt-failed'] += 1
+ continue
+ }
+
+ const outcome = translateCookieRow(row, value, nowSeconds)
+ if (outcome.ok) {
+ cookies.push(outcome.cookie)
+ } else {
+ skipped[outcome.reason] += 1
+ }
+ }
+
+ return { cookies, skipped, rowsSeen: rows.length }
+}
diff --git a/apps/desktop/src/main/browser-import/chromium-crypto.test.ts b/apps/desktop/src/main/browser-import/chromium-crypto.test.ts
new file mode 100644
index 00000000000..7fcf4dbd109
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/chromium-crypto.test.ts
@@ -0,0 +1,88 @@
+import { createCipheriv, createHash } from 'node:crypto'
+import { describe, expect, it, vi } from 'vitest'
+import {
+ decryptChromiumValue,
+ deriveEncryptionKey,
+ readSafeStoragePassword,
+} from '@/main/browser-import/chromium-crypto'
+
+/** Produces a blob in the same shape Chrome writes on macOS. */
+function encryptV10(plaintext: Buffer, key: Buffer): Buffer {
+ const cipher = createCipheriv('aes-128-cbc', key, Buffer.alloc(16, 0x20))
+ return Buffer.concat([Buffer.from('v10'), cipher.update(plaintext), cipher.final()])
+}
+
+const KEY = deriveEncryptionKey('test-safe-storage-password')
+
+describe('deriveEncryptionKey', () => {
+ it('derives a deterministic AES-128 key', () => {
+ expect(KEY).toHaveLength(16)
+ expect(deriveEncryptionKey('test-safe-storage-password').equals(KEY)).toBe(true)
+ expect(deriveEncryptionKey('a-different-password').equals(KEY)).toBe(false)
+ })
+})
+
+describe('decryptChromiumValue', () => {
+ it('round-trips a v10 value', () => {
+ const encrypted = encryptV10(Buffer.from('session-token-abc'), KEY)
+ expect(decryptChromiumValue(encrypted, KEY)).toBe('session-token-abc')
+ })
+
+ it('strips a domain hash prefix once it verifies against the host', () => {
+ const domain = '.example.com'
+ const plaintext = Buffer.concat([
+ createHash('sha256').update(domain).digest(),
+ Buffer.from('bound-value'),
+ ])
+ expect(decryptChromiumValue(encryptV10(plaintext, KEY), KEY, { domain })).toBe('bound-value')
+ })
+
+ it('leaves values from profiles without domain binding intact', () => {
+ // Cutting a fixed 32 bytes would silently corrupt every value written by
+ // an older Chrome, so the prefix is only removed when it is really a hash.
+ const encrypted = encryptV10(Buffer.from('a'.repeat(40)), KEY)
+ expect(decryptChromiumValue(encrypted, KEY, { domain: '.example.com' })).toBe('a'.repeat(40))
+ })
+
+ it('rejects a blob that is not a supported scheme', () => {
+ expect(decryptChromiumValue(Buffer.from('v20somethingelse'), KEY)).toBeNull()
+ expect(decryptChromiumValue(Buffer.alloc(0), KEY)).toBeNull()
+ expect(decryptChromiumValue(Buffer.from('v10'), KEY)).toBeNull()
+ })
+
+ it('rejects a body that is not a whole number of AES blocks', () => {
+ expect(
+ decryptChromiumValue(Buffer.concat([Buffer.from('v10'), Buffer.alloc(7)]), KEY)
+ ).toBeNull()
+ })
+
+ it('does not return plaintext when decrypted with the wrong key', () => {
+ const encrypted = encryptV10(Buffer.from('session-token-abc'), KEY)
+ expect(decryptChromiumValue(encrypted, deriveEncryptionKey('wrong'))).not.toBe(
+ 'session-token-abc'
+ )
+ })
+})
+
+describe('readSafeStoragePassword', () => {
+ const ARC_ITEM = { service: 'Arc Safe Storage', account: 'Arc' }
+
+ it('reads the item belonging to the browser being imported', async () => {
+ // Each browser names its own item. Asking for the wrong one would prompt
+ // the user about a browser they did not choose.
+ const read = vi.fn().mockResolvedValue('arc-password')
+
+ await expect(readSafeStoragePassword(ARC_ITEM, read)).resolves.toBe('arc-password')
+ expect(read).toHaveBeenCalledExactlyOnceWith('Arc Safe Storage', 'Arc')
+ })
+
+ it('fails closed when the item is denied or missing', async () => {
+ const read = vi.fn().mockRejectedValue(new Error('User denied access'))
+ await expect(readSafeStoragePassword(ARC_ITEM, read)).resolves.toBeNull()
+ })
+
+ it('treats an empty password as unavailable', async () => {
+ const read = vi.fn().mockResolvedValue(' ')
+ await expect(readSafeStoragePassword(ARC_ITEM, read)).resolves.toBeNull()
+ })
+})
diff --git a/apps/desktop/src/main/browser-import/chromium-crypto.ts b/apps/desktop/src/main/browser-import/chromium-crypto.ts
new file mode 100644
index 00000000000..53f18920668
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/chromium-crypto.ts
@@ -0,0 +1,157 @@
+import { execFile } from 'node:child_process'
+import { createDecipheriv, createHash, pbkdf2Sync } from 'node:crypto'
+import { promisify } from 'node:util'
+
+/**
+ * Chrome's macOS value encryption (Chromium's `OSCrypt`, scheme `v10`).
+ *
+ * The key is derived from a random password Chrome stores in the login
+ * Keychain. Reading it goes through the documented `security` tool, so macOS
+ * applies its own Keychain ACL — the user gets the standard "allow access"
+ * prompt for a binary that is not on the item's ACL, and a denial is a hard
+ * failure here. Nothing in this module weakens or works around that check.
+ *
+ * On Windows this scheme does not apply: Chrome's App-Bound Encryption is
+ * designed to stop other applications decrypting profile data, and the
+ * importer deliberately does not attempt it.
+ */
+
+const execFileAsync = promisify(execFile)
+
+/** Fixed Chromium `OSCrypt` parameters for macOS. */
+const KEY_SALT = 'saltysalt'
+const KEY_ITERATIONS = 1003
+const KEY_LENGTH = 16
+/** Chromium uses 16 spaces as the IV rather than a per-value nonce. */
+const INITIALIZATION_VECTOR = Buffer.alloc(16, 0x20)
+const V10_PREFIX = Buffer.from('v10')
+const AES_BLOCK_SIZE = 16
+/** Length of the SHA-256(domain) modern Chrome prepends to cookie plaintext. */
+const DOMAIN_HASH_LENGTH = 32
+
+/**
+ * The user could sit on the Keychain prompt for a while, so this is a
+ * safety net against a wedged child process, not a UI deadline.
+ */
+const KEYCHAIN_TIMEOUT_MS = 120_000
+
+export type KeychainPasswordReader = (service: string, account: string) => Promise
+
+const readKeychainItem: KeychainPasswordReader = async (service, account) => {
+ const { stdout } = await execFileAsync(
+ '/usr/bin/security',
+ ['find-generic-password', '-w', '-s', service, '-a', account],
+ { timeout: KEYCHAIN_TIMEOUT_MS, maxBuffer: 64 * 1024 }
+ )
+ return stdout.trim()
+}
+
+/**
+ * The Safe Storage password for one browser's Keychain item, or null when the
+ * item does not exist or the user refused access.
+ *
+ * Each browser names its own item (`Arc Safe Storage`, `Brave Safe Storage`,
+ * and so on), so the item is passed in rather than guessed at: asking for the
+ * wrong one would prompt the user about a browser they did not choose.
+ *
+ * The return value is a secret. It must never be logged, persisted, or sent
+ * anywhere. Callers should derive a key and drop the reference immediately.
+ */
+export async function readSafeStoragePassword(
+ item: { service: string; account: string },
+ read: KeychainPasswordReader = readKeychainItem
+): Promise {
+ try {
+ const password = (await read(item.service, item.account)).trim()
+ return password.length > 0 ? password : null
+ } catch {
+ // Item absent, or access denied. Fail closed.
+ return null
+ }
+}
+
+/** Derives the AES-128 key. The result is key material — zero it after use. */
+export function deriveEncryptionKey(safeStoragePassword: string): Buffer {
+ return pbkdf2Sync(safeStoragePassword, KEY_SALT, KEY_ITERATIONS, KEY_LENGTH, 'sha1')
+}
+
+/**
+ * Strips Chromium's PKCS#7 padding, tolerating values that carry none.
+ *
+ * A wrong key usually surfaces here or as mojibake rather than as a thrown
+ * error, which is why the caller treats an implausible result as a failed
+ * decrypt rather than trusting it.
+ */
+function stripPkcs7Padding(plaintext: Buffer): Buffer {
+ const padding = plaintext.at(-1)
+ if (padding === undefined || padding === 0 || padding > AES_BLOCK_SIZE) return plaintext
+ if (padding > plaintext.length) return plaintext
+ return plaintext.subarray(0, plaintext.length - padding)
+}
+
+/**
+ * Removes the SHA-256(domain) prefix modern Chrome binds to cookie plaintext,
+ * but only after confirming the prefix really is that hash.
+ *
+ * Verifying rather than blindly dropping 32 bytes is what makes this work
+ * across Chrome versions: profiles written before domain binding have no
+ * prefix, and cutting one off them would silently corrupt every value.
+ */
+function stripVerifiedDomainHash(plaintext: Buffer, hostKey: string): Buffer {
+ if (plaintext.length < DOMAIN_HASH_LENGTH) return plaintext
+ const prefix = plaintext.subarray(0, DOMAIN_HASH_LENGTH)
+ const bareHost = hostKey.replace(/^\./, '')
+ for (const candidate of [hostKey, bareHost, `.${bareHost}`]) {
+ if (createHash('sha256').update(candidate).digest().equals(prefix)) {
+ return plaintext.subarray(DOMAIN_HASH_LENGTH)
+ }
+ }
+ return plaintext
+}
+
+export interface DecryptOptions {
+ /**
+ * The row's `host_key`. Supply it for cookies so a domain-bound prefix can
+ * be recognised and removed; omit it for values that carry no prefix.
+ */
+ domain?: string
+}
+
+/**
+ * Decrypts one `v10` value, or returns null when the blob is not a supported
+ * scheme or does not decrypt to usable text.
+ *
+ * Never throws: a single unreadable row must not abort an import, and the
+ * caller counts failures instead. A null is also how a `v20`/app-bound value
+ * surfaces, which is the signal that this profile needs a different path
+ * rather than a weaker one.
+ */
+export function decryptChromiumValue(
+ encrypted: Buffer,
+ key: Buffer,
+ { domain }: DecryptOptions = {}
+): string | null {
+ const body = encrypted.subarray(V10_PREFIX.length)
+ if (
+ encrypted.length <= V10_PREFIX.length ||
+ !encrypted.subarray(0, V10_PREFIX.length).equals(V10_PREFIX) ||
+ body.length % AES_BLOCK_SIZE !== 0
+ ) {
+ return null
+ }
+ try {
+ const decipher = createDecipheriv('aes-128-cbc', key, INITIALIZATION_VECTOR)
+ decipher.setAutoPadding(false)
+ let plaintext = stripPkcs7Padding(Buffer.concat([decipher.update(body), decipher.final()]))
+ if (domain !== undefined) {
+ plaintext = stripVerifiedDomainHash(plaintext, domain)
+ }
+ const value = plaintext.toString('utf8')
+ // A wrong key decrypts to bytes that are not a cookie value. Rejecting
+ // replacement characters and control bytes keeps that garbage out of the
+ // browser profile instead of storing it as a live cookie.
+ return value.includes('\uFFFD') || /[\u0000-\u001F\u007F]/.test(value) ? null : value
+ } catch {
+ return null
+ }
+}
diff --git a/apps/desktop/src/main/browser-import/chromium-favicons.test.ts b/apps/desktop/src/main/browser-import/chromium-favicons.test.ts
new file mode 100644
index 00000000000..f2ec2679a31
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/chromium-favicons.test.ts
@@ -0,0 +1,117 @@
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { readBrowserFavicons } from '@/main/browser-import/chromium-favicons'
+
+const sqliteAvailable = await import('node:sqlite').then(
+ () => true,
+ () => false
+)
+
+/** A minimal but genuinely valid 1x1 PNG. */
+const PNG = Buffer.from(
+ '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c6300010000050001' +
+ '0d0a2db40000000049454e44ae426082',
+ 'hex'
+)
+
+interface FixtureIcon {
+ pageUrl: string
+ width?: number
+ data?: Uint8Array
+}
+
+let directory: string
+
+beforeEach(async () => {
+ directory = await mkdtemp(join(tmpdir(), 'sim-favicons-test-'))
+})
+
+afterEach(async () => {
+ await rm(directory, { recursive: true, force: true })
+})
+
+async function writeFaviconDatabase(icons: FixtureIcon[]): Promise {
+ const { DatabaseSync } = await import('node:sqlite')
+ const path = join(directory, 'Favicons')
+ const database = new DatabaseSync(path)
+ database.exec(`
+ CREATE TABLE icon_mapping (id INTEGER PRIMARY KEY, page_url TEXT, icon_id INTEGER);
+ CREATE TABLE favicon_bitmaps (id INTEGER PRIMARY KEY, icon_id INTEGER, image_data BLOB, width INTEGER);
+ `)
+ const mapping = database.prepare('INSERT INTO icon_mapping (page_url, icon_id) VALUES (?, ?)')
+ const bitmap = database.prepare(
+ 'INSERT INTO favicon_bitmaps (icon_id, image_data, width) VALUES (?, ?, ?)'
+ )
+ icons.forEach((icon, index) => {
+ mapping.run(icon.pageUrl, index + 1)
+ bitmap.run(index + 1, icon.data ?? PNG, icon.width ?? 32)
+ })
+ database.close()
+ return path
+}
+
+describe.skipIf(!sqliteAvailable)('readBrowserFavicons', () => {
+ it('returns an icon keyed by origin as a data URL', async () => {
+ const path = await writeFaviconDatabase([{ pageUrl: 'https://example.com/login' }])
+
+ const icons = await readBrowserFavicons(path, new Set(['https://example.com']))
+
+ expect(icons.get('https://example.com')).toMatch(/^data:image\/png;base64,/)
+ })
+
+ it('reads only the origins being imported', async () => {
+ // A password import must not drag the browser's whole history along.
+ const path = await writeFaviconDatabase([
+ { pageUrl: 'https://wanted.test/' },
+ { pageUrl: 'https://unrelated.test/' },
+ ])
+
+ const icons = await readBrowserFavicons(path, new Set(['https://wanted.test']))
+
+ expect([...icons.keys()]).toEqual(['https://wanted.test'])
+ })
+
+ it('does nothing when no origins are requested', async () => {
+ const path = await writeFaviconDatabase([{ pageUrl: 'https://example.com/' }])
+
+ await expect(readBrowserFavicons(path, new Set())).resolves.toEqual(new Map())
+ })
+
+ it('prefers the bitmap closest to the size actually displayed', async () => {
+ const large = Buffer.concat([PNG, Buffer.alloc(16)])
+ const path = await writeFaviconDatabase([
+ { pageUrl: 'https://example.com/', width: 16 },
+ { pageUrl: 'https://example.com/other', width: 32, data: large },
+ ])
+
+ const icon = await readBrowserFavicons(path, new Set(['https://example.com']))
+
+ expect(icon.get('https://example.com')).toBe(
+ `data:image/png;base64,${large.toString('base64')}`
+ )
+ })
+
+ it('ignores rows that are not usable icons', async () => {
+ const path = await writeFaviconDatabase([
+ { pageUrl: 'https://notpng.test/', data: Buffer.from(' ') },
+ { pageUrl: 'https://huge.test/', data: Buffer.concat([PNG, Buffer.alloc(17 * 1024)]) },
+ { pageUrl: 'https://oversized.test/', width: 512 },
+ ])
+
+ const icons = await readBrowserFavicons(
+ path,
+ new Set(['https://notpng.test', 'https://huge.test', 'https://oversized.test'])
+ )
+
+ expect(icons.size).toBe(0)
+ })
+
+ it('treats an unreadable favicon store as simply having no icons', async () => {
+ // Icons are decoration; failing to read them must not fail an import.
+ await expect(
+ readBrowserFavicons(join(directory, 'absent'), new Set(['https://example.com']))
+ ).resolves.toEqual(new Map())
+ })
+})
diff --git a/apps/desktop/src/main/browser-import/chromium-favicons.ts b/apps/desktop/src/main/browser-import/chromium-favicons.ts
new file mode 100644
index 00000000000..304991909cc
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/chromium-favicons.ts
@@ -0,0 +1,87 @@
+import { normalizeOrigin } from '@/main/browser-credentials/origin'
+import { queryBrowserDatabase } from '@/main/browser-import/sqlite-source'
+import { toNumber, toText } from '@/main/browser-import/types'
+
+/**
+ * Reads site icons out of a Chromium profile's `Favicons` database.
+ *
+ * Deliberately local. The obvious way to get a favicon is to ask a public
+ * service for it, which would hand that service the domain of every site the
+ * user has a password for — the exact list this feature exists to protect.
+ * The browser being imported from already has these icons on disk, so they
+ * come from there and never touch the network.
+ */
+
+/** Chromium stores several sizes per icon; this is the one worth keeping. */
+const PREFERRED_SIZE = 32
+const MAX_SIZE = 128
+/** A favicon larger than this is not worth carrying inside the vault. */
+const MAX_BYTES = 16 * 1024
+const MAX_ROWS = 20_000
+
+const FAVICON_QUERY = `
+ SELECT icon_mapping.page_url AS page_url,
+ favicon_bitmaps.image_data AS image_data,
+ favicon_bitmaps.width AS width
+ FROM icon_mapping
+ JOIN favicon_bitmaps ON favicon_bitmaps.icon_id = icon_mapping.icon_id
+ WHERE favicon_bitmaps.image_data IS NOT NULL
+ LIMIT ${MAX_ROWS}
+`
+
+/** PNG magic number — the only format written back out as a data URL. */
+const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
+
+function isPng(data: Uint8Array): boolean {
+ return data.length > PNG_SIGNATURE.length && PNG_SIGNATURE.equals(data.subarray(0, 8))
+}
+
+/** How far a bitmap's width is from the size worth keeping. */
+function sizeDistance(width: number): number {
+ return Math.abs((width || PREFERRED_SIZE) - PREFERRED_SIZE)
+}
+
+/**
+ * Site icons for `origins`, keyed by origin, as `data:` URLs.
+ *
+ * Only the requested origins are returned, so a profile's entire browsing
+ * history does not come along with a password import. Never throws: an
+ * unreadable or unfamiliar favicon store just means no icons.
+ */
+export async function readBrowserFavicons(
+ faviconsPath: string,
+ origins: ReadonlySet
+): Promise> {
+ if (origins.size === 0) return new Map()
+
+ let rows: Record[]
+ try {
+ rows = await queryBrowserDatabase(faviconsPath, 'Favicons', FAVICON_QUERY)
+ } catch {
+ // Icons are decoration. Failing to read them must not fail the import.
+ return new Map()
+ }
+
+ const best = new Map()
+ for (const row of rows) {
+ const origin = normalizeOrigin(toText(row.page_url))
+ if (origin === null || !origins.has(origin)) continue
+
+ const data = row.image_data
+ if (!(data instanceof Uint8Array) || data.length > MAX_BYTES || !isPng(data)) continue
+
+ const width = toNumber(row.width)
+ if (width > MAX_SIZE) continue
+
+ const distance = sizeDistance(width)
+ const current = best.get(origin)
+ if (!current || distance < current.distance) best.set(origin, { distance, data })
+ }
+
+ return new Map(
+ [...best].map(([origin, { data }]) => [
+ origin,
+ `data:image/png;base64,${Buffer.from(data).toString('base64')}`,
+ ])
+ )
+}
diff --git a/apps/desktop/src/main/browser-import/chromium-passwords.test.ts b/apps/desktop/src/main/browser-import/chromium-passwords.test.ts
new file mode 100644
index 00000000000..00b992b3933
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/chromium-passwords.test.ts
@@ -0,0 +1,152 @@
+import { createCipheriv } from 'node:crypto'
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { deriveEncryptionKey } from '@/main/browser-import/chromium-crypto'
+import { readBrowserPasswords } from '@/main/browser-import/chromium-passwords'
+
+/**
+ * Runs against a synthetic `Login Data` database. Tests never read a
+ * developer's real Chrome profile or Keychain.
+ */
+const sqliteAvailable = await import('node:sqlite').then(
+ () => true,
+ () => false
+)
+
+const KEY = deriveEncryptionKey('test-safe-storage-password')
+
+function encryptV10(plaintext: string): Uint8Array {
+ const cipher = createCipheriv('aes-128-cbc', KEY, Buffer.alloc(16, 0x20))
+ return Buffer.concat([Buffer.from('v10'), cipher.update(Buffer.from(plaintext)), cipher.final()])
+}
+
+interface FixtureLogin {
+ signonRealm: string
+ username: string
+ passwordValue?: Uint8Array
+ blacklisted?: number
+ originUrl?: string
+}
+
+let directory: string
+
+beforeEach(async () => {
+ directory = await mkdtemp(join(tmpdir(), 'sim-chrome-logins-test-'))
+})
+
+afterEach(async () => {
+ await rm(directory, { recursive: true, force: true })
+})
+
+async function writeLoginDatabase(logins: FixtureLogin[]): Promise {
+ const { DatabaseSync } = await import('node:sqlite')
+ const path = join(directory, 'Login Data')
+ const database = new DatabaseSync(path)
+ database.exec(`
+ CREATE TABLE logins (
+ origin_url TEXT NOT NULL, signon_realm TEXT NOT NULL,
+ username_value TEXT NOT NULL, password_value BLOB,
+ blacklisted_by_user INTEGER NOT NULL
+ )
+ `)
+ const insert = database.prepare(
+ 'INSERT INTO logins (origin_url, signon_realm, username_value, password_value, blacklisted_by_user) VALUES (?, ?, ?, ?, ?)'
+ )
+ for (const login of logins) {
+ insert.run(
+ login.originUrl ?? `${login.signonRealm}login`,
+ login.signonRealm,
+ login.username,
+ login.passwordValue ?? null,
+ login.blacklisted ?? 0
+ )
+ }
+ database.close()
+ return path
+}
+
+describe.skipIf(!sqliteAvailable)('readBrowserPasswords', () => {
+ it('decrypts saved logins', async () => {
+ const path = await writeLoginDatabase([
+ {
+ signonRealm: 'https://example.com/',
+ username: 'ada',
+ passwordValue: encryptV10('hunter2'),
+ },
+ ])
+
+ const result = await readBrowserPasswords(path, KEY)
+
+ expect(result.rowsSeen).toBe(1)
+ expect(result.credentials).toEqual([
+ { origin: 'https://example.com/', username: 'ada', password: 'hunter2' },
+ ])
+ })
+
+ it('does not strip a prefix from password plaintext', async () => {
+ // Unlike cookies, saved passwords carry no domain-bound prefix. Removing
+ // 32 bytes here would silently corrupt every password.
+ const password = 'x'.repeat(48)
+ const path = await writeLoginDatabase([
+ {
+ signonRealm: 'https://example.com/',
+ username: 'ada',
+ passwordValue: encryptV10(password),
+ },
+ ])
+
+ expect((await readBrowserPasswords(path, KEY)).credentials[0].password).toBe(password)
+ })
+
+ it('skips never-saved sites, empty rows, and undecryptable values', async () => {
+ const path = await writeLoginDatabase([
+ {
+ signonRealm: 'https://good.test/',
+ username: 'ada',
+ passwordValue: encryptV10('keep'),
+ },
+ { signonRealm: 'https://blocked.test/', username: '', blacklisted: 1 },
+ { signonRealm: 'https://empty.test/', username: 'a' },
+ {
+ signonRealm: 'https://broken.test/',
+ username: 'b',
+ passwordValue: Buffer.from('v20-app-bound'),
+ },
+ ])
+
+ const result = await readBrowserPasswords(path, KEY)
+
+ expect(result.rowsSeen).toBe(4)
+ expect(result.credentials.map(({ origin }) => origin)).toEqual(['https://good.test/'])
+ expect(result.skipped).toBe(3)
+ })
+
+ it('falls back to the origin url when there is no signon realm', async () => {
+ const path = await writeLoginDatabase([
+ {
+ signonRealm: '',
+ originUrl: 'https://fallback.test/login',
+ username: 'ada',
+ passwordValue: encryptV10('p'),
+ },
+ ])
+
+ expect((await readBrowserPasswords(path, KEY)).credentials[0].origin).toBe(
+ 'https://fallback.test/login'
+ )
+ })
+
+ it('reports an unrecognised schema rather than guessing', async () => {
+ const { DatabaseSync } = await import('node:sqlite')
+ const path = join(directory, 'Login Data')
+ const database = new DatabaseSync(path)
+ database.exec('CREATE TABLE not_logins (a TEXT)')
+ database.close()
+
+ await expect(readBrowserPasswords(path, KEY)).rejects.toMatchObject({
+ code: 'unsupported-schema',
+ })
+ })
+})
diff --git a/apps/desktop/src/main/browser-import/chromium-passwords.ts b/apps/desktop/src/main/browser-import/chromium-passwords.ts
new file mode 100644
index 00000000000..f2408641671
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/chromium-passwords.ts
@@ -0,0 +1,70 @@
+import type { ImportCandidate } from '@/main/browser-credentials/vault'
+import { decryptChromiumValue } from '@/main/browser-import/chromium-crypto'
+import { queryBrowserDatabase } from '@/main/browser-import/sqlite-source'
+import { toNumber, toText } from '@/main/browser-import/types'
+
+/**
+ * Decrypts a Chrome profile's saved passwords.
+ *
+ * Same Keychain key and `v10` scheme as the cookie reader, with one
+ * difference: password plaintext carries no domain-bound prefix, so no
+ * `domain` is passed to the decryptor and nothing is stripped.
+ *
+ * The values produced here are the most sensitive material the importer
+ * handles. They are passed straight to the encrypted vault and are never
+ * logged, counted by site, or returned to a renderer.
+ */
+
+const MAX_LOGIN_ROWS = 20_000
+
+const LOGIN_QUERY = `
+ SELECT signon_realm, origin_url, username_value, password_value, blacklisted_by_user
+ FROM logins
+ LIMIT ${MAX_LOGIN_ROWS}
+`
+
+export interface ReadPasswordsResult {
+ credentials: ImportCandidate[]
+ skipped: number
+ /** Rows examined, so the caller can tell "no passwords" from "none decrypted". */
+ rowsSeen: number
+}
+
+export async function readBrowserPasswords(
+ loginDataPath: string,
+ key: Buffer
+): Promise {
+ const rows = await queryBrowserDatabase(loginDataPath, 'Login Data', LOGIN_QUERY)
+ const credentials: ImportCandidate[] = []
+ let skipped = 0
+
+ for (const raw of rows) {
+ // "Never saved for this site" rows exist to suppress Chrome's save prompt
+ // and carry no usable password.
+ if (toNumber(raw.blacklisted_by_user) !== 0) {
+ skipped += 1
+ continue
+ }
+
+ const encrypted = raw.password_value
+ if (!(encrypted instanceof Uint8Array) || encrypted.length === 0) {
+ skipped += 1
+ continue
+ }
+
+ const password = decryptChromiumValue(Buffer.from(encrypted), key)
+ if (password === null || password.length === 0) {
+ skipped += 1
+ continue
+ }
+
+ // `signon_realm` is Chrome's authority for where a credential belongs;
+ // `origin_url` is the page it was captured on. Federated and Android
+ // realms do not reduce to an http(s) origin and are dropped by the vault's
+ // own normalization rather than being coerced into one here.
+ const origin = toText(raw.signon_realm) || toText(raw.origin_url)
+ credentials.push({ origin, username: toText(raw.username_value), password })
+ }
+
+ return { credentials, skipped, rowsSeen: rows.length }
+}
diff --git a/apps/desktop/src/main/browser-import/chromium-profiles.test.ts b/apps/desktop/src/main/browser-import/chromium-profiles.test.ts
new file mode 100644
index 00000000000..2d62feb7760
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/chromium-profiles.test.ts
@@ -0,0 +1,225 @@
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import {
+ BROWSER_SOURCES,
+ type BrowserSource,
+ userDataDirFor,
+} from '@/main/browser-import/browser-sources'
+import {
+ listAllBrowserProfiles,
+ listBrowserProfiles,
+} from '@/main/browser-import/chromium-profiles'
+
+const CHROME = BROWSER_SOURCES.find(({ id }) => id === 'chrome') as BrowserSource
+const ARC = BROWSER_SOURCES.find(({ id }) => id === 'arc') as BrowserSource
+
+let home: string
+
+beforeEach(async () => {
+ home = await mkdtemp(join(tmpdir(), 'sim-browser-profiles-test-'))
+})
+
+afterEach(async () => {
+ await rm(home, { recursive: true, force: true })
+})
+
+/** Writes a profile directory holding a database at `relativePath`. */
+async function addProfile(
+ source: BrowserSource,
+ dir: string,
+ relativePath = join('Network', 'Cookies')
+): Promise {
+ const path = join(userDataDirFor(source, home), dir, relativePath)
+ await mkdir(join(path, '..'), { recursive: true })
+ await writeFile(path, '')
+}
+
+async function writeLocalState(
+ source: BrowserSource,
+ infoCache: Record
+): Promise {
+ const userDataDir = userDataDirFor(source, home)
+ await mkdir(userDataDir, { recursive: true })
+ await writeFile(
+ join(userDataDir, 'Local State'),
+ JSON.stringify({ profile: { info_cache: infoCache } })
+ )
+}
+
+describe('listBrowserProfiles', () => {
+ it('returns display names, default profile first, with namespaced ids', async () => {
+ await addProfile(CHROME, 'Profile 2')
+ await addProfile(CHROME, 'Default')
+ await writeLocalState(CHROME, { Default: { name: 'Person 1' }, 'Profile 2': { name: 'Work' } })
+
+ const profiles = await listBrowserProfiles(CHROME, home)
+
+ expect(profiles.map(({ id, label }) => ({ id, label }))).toEqual([
+ // `Person 1` is Chromium's placeholder, so it reads as unnamed.
+ { id: 'chrome:Default', label: '' },
+ { id: 'chrome:Profile 2', label: 'Work' },
+ ])
+ expect(profiles[0].cookiesPath).toBe(
+ join(userDataDirFor(CHROME, home), 'Default/Network/Cookies')
+ )
+ expect(profiles[0].source.id).toBe('chrome')
+ })
+
+ it('orders numbered profiles numerically rather than as strings', async () => {
+ for (const dir of ['Profile 10', 'Profile 2', 'Default']) await addProfile(CHROME, dir)
+
+ const profiles = await listBrowserProfiles(CHROME, home)
+ expect(profiles.map(({ id }) => id)).toEqual([
+ 'chrome:Default',
+ 'chrome:Profile 2',
+ 'chrome:Profile 10',
+ ])
+ })
+
+ it('finds the cookie database at the pre-M96 location', async () => {
+ await addProfile(CHROME, 'Default', 'Cookies')
+
+ const [profile] = await listBrowserProfiles(CHROME, home)
+ expect(profile.cookiesPath).toBe(join(userDataDirFor(CHROME, home), 'Default/Cookies'))
+ })
+
+ it('finds the saved-password database alongside the cookies', async () => {
+ await addProfile(CHROME, 'Default')
+ await addProfile(CHROME, 'Default', 'Login Data')
+
+ const [profile] = await listBrowserProfiles(CHROME, home)
+ expect(profile.loginDataPath).toBe(join(userDataDirFor(CHROME, home), 'Default/Login Data'))
+ })
+
+ it('lists a profile that has only saved passwords', async () => {
+ // Passwords and cookies import independently, so a profile with one and
+ // not the other must still be offered.
+ await addProfile(CHROME, 'Default', 'Login Data')
+
+ const [profile] = await listBrowserProfiles(CHROME, home)
+ expect(profile).toMatchObject({ id: 'chrome:Default', cookiesPath: null })
+ })
+
+ it('omits profiles with no readable database', async () => {
+ await mkdir(join(userDataDirFor(CHROME, home), 'Profile 3'), { recursive: true })
+ await addProfile(CHROME, 'Default')
+
+ const profiles = await listBrowserProfiles(CHROME, home)
+ expect(profiles.map(({ id }) => id)).toEqual(['chrome:Default'])
+ })
+
+ it('ignores internal profiles', async () => {
+ await addProfile(CHROME, 'System Profile')
+ await addProfile(CHROME, 'Guest Profile')
+ await addProfile(CHROME, 'Default')
+
+ const profiles = await listBrowserProfiles(CHROME, home)
+ expect(profiles.map(({ id }) => id)).toEqual(['chrome:Default'])
+ })
+
+ it('refuses profile keys that would escape the user-data directory', async () => {
+ // Local State is data this process does not own, so a crafted key must not
+ // become a path.
+ await addProfile(CHROME, 'Default')
+ await writeLocalState(CHROME, {
+ Default: { name: 'Person 1' },
+ '../../../../etc': { name: 'Escaped' },
+ '/absolute/elsewhere': { name: 'Absolute' },
+ })
+
+ const profiles = await listBrowserProfiles(CHROME, home)
+ expect(profiles.map(({ id }) => id)).toEqual(['chrome:Default'])
+ })
+
+ it('falls back to directory names when Local State is unreadable', async () => {
+ await addProfile(CHROME, 'Default')
+ await writeFile(join(userDataDirFor(CHROME, home), 'Local State'), 'not json{{')
+
+ const profiles = await listBrowserProfiles(CHROME, home)
+ expect(profiles.map(({ id, label }) => ({ id, label }))).toEqual([
+ { id: 'chrome:Default', label: '' },
+ ])
+ })
+
+ it('skips a browser\u2019s internal profiles', async () => {
+ // Arc keeps `__ARC_SYSTEM_PROFILE` in an ordinary `Profile N` directory,
+ // so only the name gives it away.
+ await addProfile(ARC, 'Default')
+ await addProfile(ARC, 'Profile 1')
+ await addProfile(ARC, 'Profile 2')
+ await writeLocalState(ARC, {
+ Default: { name: 'Your Chromium' },
+ 'Profile 1': { name: '__ARC_SYSTEM_PROFILE' },
+ 'Profile 2': { name: 'Microtrades' },
+ })
+
+ const profiles = await listBrowserProfiles(ARC, home)
+
+ expect(profiles.map(({ id }) => id)).toEqual(['arc:Default', 'arc:Profile 2'])
+ })
+
+ it.each([['Your Chrome'], ['Your Chromium'], ['Person 1'], ['Default'], ['Chrome']])(
+ 'treats the placeholder name %s as unnamed',
+ async (name) => {
+ await addProfile(CHROME, 'Default')
+ await writeLocalState(CHROME, { Default: { name } })
+
+ const [profile] = await listBrowserProfiles(CHROME, home)
+ expect(profile.label).toBe('')
+ }
+ )
+
+ it('keeps a name the user actually chose', async () => {
+ await addProfile(CHROME, 'Default')
+ await writeLocalState(CHROME, { Default: { name: 'sim.ai' } })
+
+ const [profile] = await listBrowserProfiles(CHROME, home)
+ expect(profile.label).toBe('sim.ai')
+ })
+
+ it('reports no profiles when the browser is not installed', async () => {
+ await expect(listBrowserProfiles(ARC, home)).resolves.toEqual([])
+ })
+})
+
+describe('listAllBrowserProfiles', () => {
+ it('collects profiles from every installed browser', async () => {
+ await addProfile(CHROME, 'Default')
+ await addProfile(ARC, 'Default')
+ await addProfile(ARC, 'Profile 1')
+ await writeLocalState(ARC, { Default: { name: 'Personal' }, 'Profile 1': { name: 'Work' } })
+
+ const profiles = await listAllBrowserProfiles([CHROME, ARC], home)
+
+ expect(profiles.map(({ id }) => id)).toEqual(['chrome:Default', 'arc:Default', 'arc:Profile 1'])
+ expect(profiles.map(({ source }) => source.label)).toEqual(['Chrome', 'Arc', 'Arc'])
+ })
+
+ it('keeps every profile distinct even though each browser has a Default', async () => {
+ // The namespaced id is what stops one browser's Default from resolving to
+ // another's.
+ await addProfile(CHROME, 'Default')
+ await addProfile(ARC, 'Default')
+
+ const profiles = await listAllBrowserProfiles([CHROME, ARC], home)
+ expect(new Set(profiles.map(({ id }) => id)).size).toBe(2)
+ })
+
+ it('does not let one broken browser hide the others', async () => {
+ await addProfile(CHROME, 'Default')
+ const broken: BrowserSource = {
+ ...ARC,
+ // A path that cannot be enumerated stands in for a damaged install.
+ userDataSegments: ['\u0000invalid'],
+ }
+
+ const profiles = await listAllBrowserProfiles([broken, CHROME], home)
+ expect(profiles.map(({ id }) => id)).toEqual(['chrome:Default'])
+ })
+
+ it('reports nothing when no supported browser is installed', async () => {
+ await expect(listAllBrowserProfiles([CHROME, ARC], home)).resolves.toEqual([])
+ })
+})
diff --git a/apps/desktop/src/main/browser-import/chromium-profiles.ts b/apps/desktop/src/main/browser-import/chromium-profiles.ts
new file mode 100644
index 00000000000..c955b1dd7d9
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/chromium-profiles.ts
@@ -0,0 +1,178 @@
+import { constants } from 'node:fs'
+import { access, readdir, readFile } from 'node:fs/promises'
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+import {
+ BROWSER_SOURCES,
+ type BrowserSource,
+ formatProfileId,
+ userDataDirFor,
+} from '@/main/browser-import/browser-sources'
+import type { BrowserProfile } from '@/main/browser-import/types'
+
+/**
+ * Discovers profiles across the Chromium-family browsers on this device.
+ *
+ * Strictly read-only: a browser's own directory is never written to, and a
+ * profile is only offered when its database is actually readable, so the UI
+ * cannot advertise an import that is guaranteed to fail.
+ */
+
+/** Chromium moved the cookie database under `Network/` in M96. */
+const COOKIE_DB_RELATIVE_PATHS = [join('Network', 'Cookies'), 'Cookies']
+/** Saved passwords stayed at the profile root across that move. */
+const LOGIN_DB_RELATIVE_PATHS = ['Login Data']
+/** Site icons, used to give saved passwords a recognisable face. */
+const FAVICON_DB_RELATIVE_PATHS = ['Favicons']
+/** Page titles, read only to learn what the imported sites are called. */
+const HISTORY_DB_RELATIVE_PATHS = ['History']
+/** Internal profiles that never hold a user's browsing session. */
+const IGNORED_PROFILE_DIRS = new Set(['System Profile', 'Guest Profile'])
+const PROFILE_DIR_PATTERN = /^(Default|Profile \d{1,4})$/
+const MAX_PROFILES_PER_BROWSER = 32
+
+/**
+ * Names a browser uses for its own machinery rather than for a person. Arc
+ * keeps `__ARC_SYSTEM_PROFILE` in an ordinary `Profile N` directory, so the
+ * directory name alone does not reveal it — offering it as something to import
+ * would be meaningless at best.
+ */
+function isInternalProfileName(name: string): boolean {
+ return name.startsWith('__')
+}
+
+/**
+ * Whether a profile's name is just Chromium's placeholder rather than
+ * something the user chose. `Your Chrome`, `Your Chromium`, and `Person 1` all
+ * mean "unnamed" — showing them as a profile's identity is noise, and shows
+ * the same string twice when two browsers are both unnamed.
+ */
+function isGenericProfileName(name: string, directory: string, browserLabel: string): boolean {
+ return (
+ name === directory ||
+ name === browserLabel ||
+ /^Person \d+$/.test(name) ||
+ /^Your [\w ]+$/.test(name)
+ )
+}
+
+async function isReadableFile(path: string): Promise {
+ try {
+ await access(path, constants.R_OK)
+ return true
+ } catch {
+ return false
+ }
+}
+
+async function resolveFirstReadable(
+ profileDir: string,
+ relativePaths: readonly string[]
+): Promise {
+ for (const relative of relativePaths) {
+ const candidate = join(profileDir, relative)
+ if (await isReadableFile(candidate)) return candidate
+ }
+ return null
+}
+
+/**
+ * A browser's display names, keyed by profile directory.
+ *
+ * `Local State` is JSON this process does not own, so every key is checked
+ * against {@link PROFILE_DIR_PATTERN} before it is used in a path — that check
+ * is what keeps a crafted key from escaping the user-data directory.
+ */
+async function readProfileDisplayNames(userDataDir: string): Promise> {
+ const names = new Map()
+ try {
+ const raw = await readFile(join(userDataDir, 'Local State'), 'utf8')
+ const infoCache = (JSON.parse(raw) as { profile?: { info_cache?: unknown } }).profile
+ ?.info_cache
+ if (infoCache && typeof infoCache === 'object' && !Array.isArray(infoCache)) {
+ for (const [dir, info] of Object.entries(infoCache as Record)) {
+ if (!PROFILE_DIR_PATTERN.test(dir)) continue
+ const name = (info as { name?: unknown })?.name
+ names.set(dir, typeof name === 'string' && name.trim().length > 0 ? name.trim() : dir)
+ }
+ }
+ } catch {
+ // No or unreadable Local State: fall back to directory scanning below.
+ }
+ return names
+}
+
+async function listProfileDirNames(userDataDir: string): Promise {
+ try {
+ const entries = await readdir(userDataDir, { withFileTypes: true })
+ return entries
+ .filter((entry) => entry.isDirectory() && PROFILE_DIR_PATTERN.test(entry.name))
+ .map((entry) => entry.name)
+ } catch {
+ return []
+ }
+}
+
+/** `Default` first, then `Profile 2`, `Profile 10`, … in numeric order. */
+function compareProfileDirs(left: string, right: string): number {
+ if (left === right) return 0
+ if (left === 'Default') return -1
+ if (right === 'Default') return 1
+ const index = (dir: string): number => Number(dir.slice('Profile '.length))
+ return index(left) - index(right)
+}
+
+/**
+ * Profiles belonging to one browser. Resolves to an empty list when that
+ * browser is not installed — absence is a normal state, not an error.
+ */
+export async function listBrowserProfiles(
+ source: BrowserSource,
+ home: string = homedir()
+): Promise {
+ const userDataDir = userDataDirFor(source, home)
+ const displayNames = await readProfileDisplayNames(userDataDir)
+ const dirNames = new Set([...displayNames.keys(), ...(await listProfileDirNames(userDataDir))])
+
+ const profiles: BrowserProfile[] = []
+ for (const dir of [...dirNames].sort(compareProfileDirs).slice(0, MAX_PROFILES_PER_BROWSER)) {
+ if (IGNORED_PROFILE_DIRS.has(dir) || !PROFILE_DIR_PATTERN.test(dir)) continue
+ const name = displayNames.get(dir) ?? dir
+ if (isInternalProfileName(name)) continue
+ const profileDir = join(userDataDir, dir)
+ const cookiesPath = await resolveFirstReadable(profileDir, COOKIE_DB_RELATIVE_PATHS)
+ const loginDataPath = await resolveFirstReadable(profileDir, LOGIN_DB_RELATIVE_PATHS)
+ const faviconsPath = await resolveFirstReadable(profileDir, FAVICON_DB_RELATIVE_PATHS)
+ const historyPath = await resolveFirstReadable(profileDir, HISTORY_DB_RELATIVE_PATHS)
+ if (cookiesPath === null && loginDataPath === null) continue
+ profiles.push({
+ id: formatProfileId(source.id, dir),
+ directory: dir,
+ // Empty means "the user never named this one", which lets the caller
+ // fall back to the browser's name instead of printing a placeholder.
+ label: isGenericProfileName(name, dir, source.label) ? '' : name,
+ source,
+ cookiesPath,
+ loginDataPath,
+ faviconsPath,
+ historyPath,
+ })
+ }
+ return profiles
+}
+
+/**
+ * Every importable profile on this device, across every supported browser.
+ *
+ * One browser failing to enumerate must not hide the others — a broken Brave
+ * install should not cost the user their Chrome and Arc profiles.
+ */
+export async function listAllBrowserProfiles(
+ sources: readonly BrowserSource[] = BROWSER_SOURCES,
+ home: string = homedir()
+): Promise {
+ const perBrowser = await Promise.all(
+ sources.map((source) => listBrowserProfiles(source, home).catch((): BrowserProfile[] => []))
+ )
+ return perBrowser.flat()
+}
diff --git a/apps/desktop/src/main/browser-import/chromium-site-names.test.ts b/apps/desktop/src/main/browser-import/chromium-site-names.test.ts
new file mode 100644
index 00000000000..e31bfdd56b1
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/chromium-site-names.test.ts
@@ -0,0 +1,314 @@
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import {
+ isCoveredByDomain,
+ MAX_IMPORTED_SITES,
+ readBrowserSites,
+} from '@/main/browser-import/chromium-site-names'
+
+const sqliteAvailable = await import('node:sqlite').then(
+ () => true,
+ () => false
+)
+
+interface FixturePage {
+ url: string
+ /** Omitted for the rows Chromium stores with no title at all. */
+ title?: string
+ visitCount?: number
+ /** Chromium's own flag for redirect hops nobody chose to open. */
+ hidden?: boolean
+}
+
+let directory: string
+
+beforeEach(async () => {
+ directory = await mkdtemp(join(tmpdir(), 'sim-site-names-test-'))
+})
+
+afterEach(async () => {
+ await rm(directory, { recursive: true, force: true })
+})
+
+async function writeHistoryDatabase(pages: FixturePage[]): Promise {
+ const { DatabaseSync } = await import('node:sqlite')
+ const path = join(directory, 'History')
+ const database = new DatabaseSync(path)
+ database.exec(`
+ CREATE TABLE urls (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ url LONGVARCHAR,
+ title LONGVARCHAR,
+ visit_count INTEGER DEFAULT 0 NOT NULL,
+ typed_count INTEGER DEFAULT 0 NOT NULL,
+ last_visit_time INTEGER NOT NULL,
+ hidden INTEGER DEFAULT 0 NOT NULL
+ );
+ `)
+ const insert = database.prepare(
+ 'INSERT INTO urls (url, title, visit_count, last_visit_time, hidden) VALUES (?, ?, ?, 0, ?)'
+ )
+ for (const page of pages) {
+ insert.run(page.url, page.title ?? null, page.visitCount ?? 1, page.hidden ? 1 : 0)
+ }
+ database.close()
+ return path
+}
+
+function hostnames(sites: { hostname: string }[]): string[] {
+ return sites.map((site) => site.hostname)
+}
+
+describe.skipIf(!sqliteAvailable)('readBrowserSites', () => {
+ it('learns a site’s name from the part of its titles that never changes', async () => {
+ const path = await writeHistoryDatabase([
+ {
+ url: 'https://mail.google.com/mail/u/0/#inbox',
+ title: 'Inbox (12) - ada@example.com - Gmail',
+ },
+ { url: 'https://mail.google.com/mail/u/0/#sent', title: 'Sent - ada@example.com - Gmail' },
+ {
+ url: 'https://mail.google.com/mail/u/0/#drafts',
+ title: 'Drafts (2) - ada@example.com - Gmail',
+ },
+ ])
+
+ const sites = await readBrowserSites(path, new Set(['mail.google.com']))
+
+ // Nothing here hardcodes Gmail — it is the only segment on every page.
+ expect(sites[0]?.name).toBe('Gmail')
+ })
+
+ it('finds a name that leads the title rather than trailing it', async () => {
+ const path = await writeHistoryDatabase([
+ { url: 'https://github.com/', title: 'GitHub - Where the world builds software' },
+ { url: 'https://github.com/pulls', title: 'GitHub - Pull requests' },
+ ])
+
+ const sites = await readBrowserSites(path, new Set(['github.com']))
+
+ expect(sites[0]?.name).toBe('GitHub')
+ })
+
+ it('handles a title with no separator at all', async () => {
+ const path = await writeHistoryDatabase([{ url: 'https://example.com/', title: 'Example' }])
+
+ const sites = await readBrowserSites(path, new Set(['example.com']))
+
+ expect(sites[0]?.name).toBe('Example')
+ })
+
+ it('prefers the shorter candidate when pages are split evenly', async () => {
+ const path = await writeHistoryDatabase([
+ { url: 'https://linear.app/a', title: 'Linear - Issue tracking' },
+ { url: 'https://linear.app/b', title: 'Issue tracking - Linear' },
+ ])
+
+ const sites = await readBrowserSites(path, new Set(['linear.app']))
+
+ expect(sites[0]?.name).toBe('Linear')
+ })
+
+ it('rejects a whole-title tagline as a name while still offering the site', async () => {
+ const long = 'A very long marketing sentence that is plainly not what this site is called'
+ const path = await writeHistoryDatabase([{ url: 'https://example.com/', title: long }])
+
+ const sites = await readBrowserSites(path, new Set(['example.com']))
+
+ expect(hostnames(sites)).toEqual(['example.com'])
+ expect(sites[0]?.name).toBeUndefined()
+ })
+
+ it('offers a host that is visited but has never carried a title', async () => {
+ const path = await writeHistoryDatabase([
+ { url: 'https://untitled.example.com/feed', visitCount: 7 },
+ ])
+
+ const sites = await readBrowserSites(path, new Set(['example.com']))
+
+ expect(sites).toEqual([{ hostname: 'untitled.example.com', name: undefined, visits: 7 }])
+ })
+
+ it('ignores pages that are not on the web', async () => {
+ const path = await writeHistoryDatabase([
+ { url: 'chrome-extension://abc/page.html', title: 'Extension' },
+ { url: 'file:///Users/ada/notes.html', title: 'Notes' },
+ ])
+
+ const sites = await readBrowserSites(path, new Set(['abc', '']))
+
+ expect(sites).toEqual([])
+ })
+
+ it('imports the same list every time for the same profile', async () => {
+ const path = await writeHistoryDatabase([
+ { url: 'https://example.com/a', title: 'Alpha - Example' },
+ { url: 'https://example.com/b', title: 'Beta - Sample' },
+ ])
+
+ const first = await readBrowserSites(path, new Set(['example.com']))
+ const second = await readBrowserSites(path, new Set(['example.com']))
+
+ expect(first).toEqual(second)
+ })
+
+ /**
+ * The regression this pins: the shared reader enables BigInt reads, so every
+ * SQLite integer arrives as `bigint` and a `typeof value === 'number'` guard
+ * scored every imported site zero, flattening the omnibox ordering.
+ */
+ it('carries the source browser’s visit count across as a real number', async () => {
+ const path = await writeHistoryDatabase([
+ { url: 'https://example.com/', title: 'Example', visitCount: 4242 },
+ ])
+
+ const sites = await readBrowserSites(path, new Set(['example.com']))
+
+ expect(sites[0]?.visits).toBe(4242)
+ expect(typeof sites[0]?.visits).toBe('number')
+ })
+
+ it('ranks a site used across many pages above one reached by refreshing a single page', async () => {
+ const path = await writeHistoryDatabase([
+ { url: 'https://deep.example.com/dashboard', title: 'Deep', visitCount: 100 },
+ { url: 'https://broad.example.com/a', title: 'Broad', visitCount: 30 },
+ { url: 'https://broad.example.com/b', title: 'Broad', visitCount: 30 },
+ { url: 'https://broad.example.com/c', title: 'Broad', visitCount: 30 },
+ { url: 'https://broad.example.com/d', title: 'Broad', visitCount: 30 },
+ { url: 'https://broad.example.com/e', title: 'Broad', visitCount: 30 },
+ ])
+
+ const sites = await readBrowserSites(path, new Set(['example.com']))
+
+ expect(hostnames(sites)).toEqual(['broad.example.com', 'deep.example.com'])
+ expect(sites[0]?.visits).toBe(150)
+ })
+
+ it('orders the most-used first and settles ties alphabetically', async () => {
+ const path = await writeHistoryDatabase([
+ { url: 'https://zeta.example.com/', title: 'Zeta', visitCount: 10 },
+ { url: 'https://alpha.example.com/', title: 'Alpha', visitCount: 10 },
+ { url: 'https://middle.example.com/', title: 'Middle', visitCount: 50 },
+ ])
+
+ const sites = await readBrowserSites(path, new Set(['example.com']))
+
+ expect(hostnames(sites)).toEqual([
+ 'middle.example.com',
+ 'alpha.example.com',
+ 'zeta.example.com',
+ ])
+ })
+
+ it('leaves out the redirect hops Chromium hides from its own omnibox', async () => {
+ const path = await writeHistoryDatabase([
+ { url: 'https://redirect.example.com/', title: 'Redirect', visitCount: 900, hidden: true },
+ { url: 'https://real.example.com/', title: 'Real', visitCount: 3 },
+ ])
+
+ const sites = await readBrowserSites(path, new Set(['example.com']))
+
+ expect(hostnames(sites)).toEqual(['real.example.com'])
+ })
+
+ it('admits a subdomain the imported apex domain covers', async () => {
+ const path = await writeHistoryDatabase([
+ { url: 'https://www.google.com/search?q=sim', title: 'sim - Google Search' },
+ ])
+
+ // The cookie jar contributes `.google.com` with its leading dot stripped;
+ // the page the user actually opened is the subdomain.
+ const sites = await readBrowserSites(path, new Set(['google.com']))
+
+ expect(hostnames(sites)).toEqual(['www.google.com'])
+ })
+
+ it('imports only hosts the imported domains cover, never the rest of the history', async () => {
+ const path = await writeHistoryDatabase([
+ { url: 'https://mail.google.com/', title: 'Gmail' },
+ { url: 'https://somewhere-private.example/', title: 'Private' },
+ ])
+
+ const sites = await readBrowserSites(path, new Set(['google.com']))
+
+ expect(hostnames(sites)).toEqual(['mail.google.com'])
+ })
+
+ it('drops the profile’s most-visited host when nothing imported covers it', async () => {
+ const path = await writeHistoryDatabase([
+ { url: 'https://uncovered.example.org/', title: 'Uncovered', visitCount: 5000 },
+ { url: 'https://docs.example.com/', title: 'Docs', visitCount: 2 },
+ ])
+
+ const sites = await readBrowserSites(path, new Set(['example.com']))
+
+ expect(hostnames(sites)).toEqual(['docs.example.com'])
+ })
+
+ it('contributes no more hosts than one import may, keeping the most-used', async () => {
+ const overflow = MAX_IMPORTED_SITES + 50
+ const pages = Array.from({ length: overflow }, (_, index) => ({
+ url: `https://site-${String(index).padStart(3, '0')}.example.com/`,
+ title: `Site ${index}`,
+ visitCount: overflow - index,
+ }))
+ const path = await writeHistoryDatabase(pages)
+
+ const sites = await readBrowserSites(path, new Set(['example.com']))
+
+ expect(sites).toHaveLength(MAX_IMPORTED_SITES)
+ expect(sites[0]?.hostname).toBe('site-000.example.com')
+ expect(sites.at(-1)?.hostname).toBe(
+ `site-${String(MAX_IMPORTED_SITES - 1).padStart(3, '0')}.example.com`
+ )
+ })
+
+ it('survives an unreadable history rather than failing the import', async () => {
+ const sites = await readBrowserSites(join(directory, 'absent'), new Set(['example.com']))
+
+ expect(sites).toEqual([])
+ })
+
+ it('asks for nothing when no domain was imported to cover it', async () => {
+ const path = await writeHistoryDatabase([{ url: 'https://example.com/', title: 'Example' }])
+
+ expect(await readBrowserSites(path, new Set())).toEqual([])
+ })
+})
+
+describe('isCoveredByDomain', () => {
+ it('covers a host that is itself an imported domain', () => {
+ expect(isCoveredByDomain('example.com', new Set(['example.com']))).toBe(true)
+ })
+
+ it('covers a subdomain of an imported domain, however deep', () => {
+ const domains = new Set(['example.com'])
+
+ expect(isCoveredByDomain('mail.example.com', domains)).toBe(true)
+ expect(isCoveredByDomain('a.b.c.example.com', domains)).toBe(true)
+ })
+
+ it('does not cover an apex whose subdomain is all that was imported', () => {
+ expect(isCoveredByDomain('example.com', new Set(['mail.example.com']))).toBe(false)
+ })
+
+ it('matches on label boundaries, not bare string suffixes', () => {
+ expect(isCoveredByDomain('notexample.com', new Set(['example.com']))).toBe(false)
+ })
+
+ it('covers nothing when no domain was imported', () => {
+ expect(isCoveredByDomain('example.com', new Set())).toBe(false)
+ })
+
+ /**
+ * The documented boundary: coverage is a pure label walk with no public
+ * suffix list, so a cookie on a registry suffix such as `github.io` admits
+ * every user site under it. Should a PSL guard ever be added, this test is
+ * meant to fail loudly rather than let the change land unnoticed.
+ */
+ it('lets a public-suffix domain cover the sites beneath it', () => {
+ expect(isCoveredByDomain('alice.github.io', new Set(['github.io']))).toBe(true)
+ })
+})
diff --git a/apps/desktop/src/main/browser-import/chromium-site-names.ts b/apps/desktop/src/main/browser-import/chromium-site-names.ts
new file mode 100644
index 00000000000..2485e6e9077
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/chromium-site-names.ts
@@ -0,0 +1,205 @@
+import { queryBrowserDatabase } from '@/main/browser-import/sqlite-source'
+import { toNumber, toText } from '@/main/browser-import/types'
+
+/**
+ * Reads which sites a Chromium profile's owner actually uses, out of its
+ * `History` database.
+ *
+ * This is the seed for the omnibox, and history is the only honest source for
+ * it. The obvious alternative — the cookie jar — is not a list of sites anyone
+ * visits: it is every origin that ever set state, which is dominated by ad
+ * networks, analytics, and embedded widgets the user never navigated to. Rows
+ * in `urls` are pages someone opened, so trackers are structurally absent.
+ *
+ * Cookies still get a say, as a filter: only hosts covered by a cookie the
+ * import brought over come back, so what is remembered stays within the data
+ * the user chose to bring. Sim's browser records no history of its own, and
+ * none is reconstructed here — no visit times, no URLs beyond the host, no
+ * sequence. What survives is a host, what its own titles call it, and how much
+ * it is used relative to the others.
+ */
+
+/**
+ * Rows scanned from one profile. Set well past the size of a real history —
+ * Chrome expires visits at 90 days — so the cut is a guard against a pathological
+ * file rather than something a normal profile meets.
+ *
+ * Ordering by `visit_count` before the cut matters: per-host counts are summed,
+ * so truncating has to drop the rows that contribute least to any sum. A profile
+ * large enough to hit this still ranks on its most-visited pages.
+ */
+const MAX_ROWS = 200_000
+/** Longer than this is a page title, not what the site is called. */
+const MAX_NAME_LENGTH = 40
+/** Hosts one import may contribute, most-used first. Bounds the favicon lookup too. */
+export const MAX_IMPORTED_SITES = 200
+
+/**
+ * `hidden` marks rows Chromium itself keeps out of autocomplete — redirect
+ * hops and other URLs nobody chose to open. Excluding them is the same
+ * predicate Chrome's own omnibox uses.
+ */
+const SITE_QUERY = `
+ SELECT url, title, visit_count
+ FROM urls
+ WHERE hidden = 0
+ ORDER BY visit_count DESC
+ LIMIT ${MAX_ROWS}
+`
+
+/** Separators sites put between the page and their own name. */
+const TITLE_SEPARATOR = /\s+[|·•‧–—]\s+|\s+-\s+|\s+:\s+/
+
+/** A host the source browser's owner visited, and what it is called there. */
+export interface ImportedSite {
+ hostname: string
+ name?: string
+ /**
+ * The source browser's own visit count. An aggregate popularity signal used
+ * to order suggestions — never a timestamp, a URL, or a sequence of visits.
+ */
+ visits: number
+}
+
+function hostnameOf(url: string): string | null {
+ try {
+ const { hostname, protocol } = new URL(url)
+ // Extension and file pages are not sites the omnibox can offer.
+ if (protocol !== 'https:' && protocol !== 'http:') return null
+ return hostname || null
+ } catch {
+ return null
+ }
+}
+
+/**
+ * `visit_count` for one row. Goes through {@link toNumber} because the shared
+ * reader enables BigInt reads, so every SQLite integer arrives as `bigint` — a
+ * bare `typeof value === 'number'` guard silently scores every site zero.
+ */
+function visitsOf(value: unknown): number {
+ const count = toNumber(value)
+ return count > 0 ? Math.floor(count) : 0
+}
+
+/**
+ * Whether a cookie covers this host.
+ *
+ * Cookie hosts arrive with the domain-cookie dot already stripped, so
+ * `.google.com` reaches here as `google.com` while the page the user opened is
+ * `www.google.com` or `mail.google.com`. Matching on equality alone would miss
+ * every site not served from its apex — which is most of them — so each of the
+ * host's parent suffixes is tried. Walking labels keeps this O(labels) per row
+ * rather than scanning the whole cookie set.
+ */
+export function isCoveredByDomain(hostname: string, domains: ReadonlySet): boolean {
+ if (domains.has(hostname)) return true
+ let dot = hostname.indexOf('.')
+ while (dot !== -1) {
+ if (domains.has(hostname.slice(dot + 1))) return true
+ dot = hostname.indexOf('.', dot + 1)
+ }
+ return false
+}
+
+/**
+ * The parts of a page title that could be the site's name.
+ *
+ * A title is typically the page, then the site: "Inbox (12) - Gmail". Which
+ * end holds the name is not consistent enough to pick by position — "GitHub -
+ * Where software is built" puts it first — so every segment is a candidate and
+ * frequency decides between them.
+ */
+function nameCandidates(title: string): string[] {
+ return title
+ .split(TITLE_SEPARATOR)
+ .map((segment) => segment.trim())
+ .filter((segment) => segment.length > 0 && segment.length <= MAX_NAME_LENGTH)
+}
+
+/**
+ * The site's name is the part of its titles that does not change: every Gmail
+ * page ends in "Gmail" while the rest of each title differs, so the segment
+ * appearing across the most distinct pages of a host is its name. Ties go to
+ * the shorter candidate, which prefers "GitHub" over a tagline, and then to
+ * alphabetical order so the same profile always imports the same name.
+ */
+function bestName(candidates: Map>): string | undefined {
+ let best: { name: string; pages: number } | null = null
+ for (const [candidate, seenIn] of candidates) {
+ const pages = seenIn.size
+ if (
+ !best ||
+ pages > best.pages ||
+ (pages === best.pages &&
+ (candidate.length < best.name.length ||
+ (candidate.length === best.name.length && candidate < best.name)))
+ ) {
+ best = { name: candidate, pages }
+ }
+ }
+ return best?.name
+}
+
+/**
+ * The most-used hosts in this profile's history that `domains` covers, keyed by
+ * the host actually visited — the same string the omnibox and the favicon store
+ * use, so lookups downstream cannot miss.
+ *
+ * Never throws. A profile with no readable history contributes no sites, and
+ * the caller carries on with whatever the rest of the import found.
+ */
+export async function readBrowserSites(
+ historyPath: string,
+ domains: ReadonlySet
+): Promise {
+ if (domains.size === 0) return []
+
+ let rows: Record[]
+ try {
+ rows = await queryBrowserDatabase(historyPath, 'History', SITE_QUERY)
+ } catch {
+ // History is a nicety layered on the cookies that were already imported.
+ // Chrome holding a lock on it must not fail the import.
+ return []
+ }
+
+ /** host -> candidate name -> the distinct page titles that produced it. */
+ const tally = new Map>>()
+ const visits = new Map()
+
+ for (const row of rows) {
+ const hostname = hostnameOf(toText(row.url))
+ if (hostname === null || !isCoveredByDomain(hostname, domains)) continue
+
+ // Summed across the host's pages, not maxed: how much a site is used is
+ // every page opened there, so a site visited broadly must not lose to one
+ // visited only through a single much-refreshed page.
+ visits.set(hostname, (visits.get(hostname) ?? 0) + visitsOf(row.visit_count))
+
+ const title = toText(row.title)
+ if (title === '') continue
+ const candidates = tally.get(hostname) ?? new Map>()
+ for (const candidate of nameCandidates(title)) {
+ const seenIn = candidates.get(candidate) ?? new Set()
+ seenIn.add(title)
+ candidates.set(candidate, seenIn)
+ }
+ tally.set(hostname, candidates)
+ }
+
+ const sites: ImportedSite[] = []
+ for (const [hostname, count] of visits) {
+ const candidates = tally.get(hostname)
+ sites.push({
+ hostname,
+ name: candidates ? bestName(candidates) : undefined,
+ visits: count,
+ })
+ }
+
+ // Most-used first, then alphabetical so the same profile always imports the
+ // same list, and only as many as the directory is willing to carry.
+ sites.sort((a, b) => b.visits - a.visits || a.hostname.localeCompare(b.hostname))
+ return sites.slice(0, MAX_IMPORTED_SITES)
+}
diff --git a/apps/desktop/src/main/browser-import/cookie-translate.test.ts b/apps/desktop/src/main/browser-import/cookie-translate.test.ts
new file mode 100644
index 00000000000..7b6ee7b08c6
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/cookie-translate.test.ts
@@ -0,0 +1,124 @@
+import { describe, expect, it } from 'vitest'
+import { chromeTimeToUnixSeconds, translateCookieRow } from '@/main/browser-import/cookie-translate'
+import type { ChromiumCookieRow } from '@/main/browser-import/types'
+
+const NOW_SECONDS = 1_800_000_000
+
+/** Chrome's microsecond-since-1601 encoding of a Unix timestamp. */
+function chromeTime(unixSeconds: number): number {
+ return (unixSeconds + 11_644_473_600) * 1_000_000
+}
+
+function row(overrides: Partial = {}): ChromiumCookieRow {
+ return {
+ hostKey: 'www.example.com',
+ name: 'session',
+ path: '/',
+ expiresUtc: chromeTime(NOW_SECONDS + 3600),
+ isSecure: true,
+ isHttpOnly: true,
+ hasExpires: true,
+ isPersistent: true,
+ sameSite: 1,
+ ...overrides,
+ }
+}
+
+describe('chromeTimeToUnixSeconds', () => {
+ it('converts from the 1601 epoch', () => {
+ expect(chromeTimeToUnixSeconds(chromeTime(1_700_000_000))).toBe(1_700_000_000)
+ })
+})
+
+describe('translateCookieRow', () => {
+ it('preserves the security attributes of a host-only cookie', () => {
+ const outcome = translateCookieRow(row(), 'abc', NOW_SECONDS)
+
+ expect(outcome).toEqual({
+ ok: true,
+ cookie: {
+ url: 'https://www.example.com/',
+ name: 'session',
+ value: 'abc',
+ path: '/',
+ secure: true,
+ httpOnly: true,
+ sameSite: 'lax',
+ expirationDate: NOW_SECONDS + 3600,
+ },
+ })
+ })
+
+ it('omits domain for host-only cookies so they are not widened to subdomains', () => {
+ const outcome = translateCookieRow(row({ hostKey: 'www.example.com' }), 'abc', NOW_SECONDS)
+ expect(outcome.ok && 'domain' in outcome.cookie).toBe(false)
+ })
+
+ it('carries the leading dot through for domain cookies', () => {
+ const outcome = translateCookieRow(row({ hostKey: '.example.com' }), 'abc', NOW_SECONDS)
+ expect(outcome.ok && outcome.cookie.domain).toBe('.example.com')
+ expect(outcome.ok && outcome.cookie.url).toBe('https://example.com/')
+ })
+
+ it('uses an http target for cookies that are not Secure', () => {
+ const outcome = translateCookieRow(row({ isSecure: false }), 'abc', NOW_SECONDS)
+ expect(outcome.ok && outcome.cookie.url).toBe('http://www.example.com/')
+ expect(outcome.ok && outcome.cookie.secure).toBe(false)
+ })
+
+ it.each([
+ [-1, 'unspecified'],
+ [0, 'no_restriction'],
+ [1, 'lax'],
+ [2, 'strict'],
+ [99, 'unspecified'],
+ ])('maps Chrome samesite %i to %s', (sameSite, expected) => {
+ const outcome = translateCookieRow(row({ sameSite }), 'abc', NOW_SECONDS)
+ expect(outcome.ok && outcome.cookie.sameSite).toBe(expected)
+ })
+
+ it('drops expired cookies', () => {
+ const outcome = translateCookieRow(
+ row({ expiresUtc: chromeTime(NOW_SECONDS - 1) }),
+ 'abc',
+ NOW_SECONDS
+ )
+ expect(outcome).toEqual({ ok: false, reason: 'expired' })
+ })
+
+ it('keeps session cookies without an expiry', () => {
+ const outcome = translateCookieRow(
+ row({ hasExpires: false, isPersistent: false }),
+ 'abc',
+ NOW_SECONDS
+ )
+ expect(outcome.ok && 'expirationDate' in outcome.cookie).toBe(false)
+ })
+
+ it('normalizes a path that is missing its leading slash', () => {
+ const outcome = translateCookieRow(row({ path: 'account' }), 'abc', NOW_SECONDS)
+ expect(outcome.ok && outcome.cookie.path).toBe('/account')
+ expect(outcome.ok && outcome.cookie.url).toBe('https://www.example.com/account')
+ })
+
+ it.each([
+ ['evil.com/path@good.com'],
+ ['good.com:8443'],
+ ['user:pass@good.com'],
+ [''],
+ ['.'],
+ ['has space.com'],
+ ])('refuses a host_key that cannot be trusted to name one host: %s', (hostKey) => {
+ expect(translateCookieRow(row({ hostKey }), 'abc', NOW_SECONDS)).toEqual({
+ ok: false,
+ reason: 'invalid-target',
+ })
+ })
+
+ it('drops a row with neither a name nor a value', () => {
+ expect(translateCookieRow(row({ name: '' }), '', NOW_SECONDS)).toEqual({
+ ok: false,
+ reason: 'empty',
+ })
+ })
+})
diff --git a/apps/desktop/src/main/browser-import/cookie-translate.ts b/apps/desktop/src/main/browser-import/cookie-translate.ts
new file mode 100644
index 00000000000..91dbdf47cff
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/cookie-translate.ts
@@ -0,0 +1,109 @@
+import type {
+ ChromiumCookieRow,
+ CookieSkipReason,
+ ImportableCookie,
+} from '@/main/browser-import/types'
+
+/**
+ * Translates Chrome cookie rows into the shape Electron's cookie API accepts.
+ *
+ * Two rules govern everything here. Security attributes are preserved exactly
+ * — `Secure`, `HttpOnly`, `SameSite`, host-only versus domain scope, and path
+ * are carried across unchanged, and a cookie is dropped rather than imported
+ * under weaker terms. And the destination is derived, never trusted: a corrupt
+ * or hostile `host_key` must not be able to steer a cookie at a host other
+ * than the one it came from.
+ */
+
+/** Chrome stores timestamps as microseconds since 1601-01-01 UTC. */
+const WINDOWS_EPOCH_OFFSET_SECONDS = 11_644_473_600
+const MICROSECONDS_PER_SECOND = 1_000_000
+
+export function chromeTimeToUnixSeconds(chromeTime: number): number {
+ return chromeTime / MICROSECONDS_PER_SECOND - WINDOWS_EPOCH_OFFSET_SECONDS
+}
+
+/** Chrome's `samesite` column: -1 unspecified, 0 None, 1 Lax, 2 Strict. */
+function toSameSite(value: number): ImportableCookie['sameSite'] {
+ switch (value) {
+ case 0:
+ return 'no_restriction'
+ case 1:
+ return 'lax'
+ case 2:
+ return 'strict'
+ default:
+ return 'unspecified'
+ }
+}
+
+/**
+ * Builds the URL the cookie is written against, or null when the row cannot
+ * be trusted to describe one host.
+ *
+ * `host_key` comes from a database this process does not own, so the parsed
+ * URL is checked back against it: anything that reparses to a different host,
+ * or that smuggles in credentials, a port, or extra path structure, is
+ * refused instead of being normalised into something plausible.
+ */
+export function buildCookieUrl(hostKey: string, path: string, secure: boolean): string | null {
+ const bareHost = hostKey.replace(/^\./, '').toLowerCase()
+ if (bareHost.length === 0 || bareHost.length > 253) return null
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`
+ try {
+ const url = new URL(`${secure ? 'https' : 'http'}://${bareHost}${normalizedPath}`)
+ if (url.hostname !== bareHost || url.username || url.password || url.port) return null
+ return url.toString()
+ } catch {
+ return null
+ }
+}
+
+export type TranslationOutcome =
+ | { ok: true; cookie: ImportableCookie }
+ | { ok: false; reason: CookieSkipReason }
+
+/**
+ * Converts one decrypted row, or explains why it was dropped.
+ *
+ * `nowSeconds` is injected so expiry is evaluated against a single instant for
+ * the whole import rather than drifting row to row.
+ */
+export function translateCookieRow(
+ row: ChromiumCookieRow,
+ value: string,
+ nowSeconds: number
+): TranslationOutcome {
+ if (row.name.length === 0 && value.length === 0) {
+ return { ok: false, reason: 'empty' }
+ }
+
+ const url = buildCookieUrl(row.hostKey, row.path, row.isSecure)
+ if (url === null) {
+ return { ok: false, reason: 'invalid-target' }
+ }
+
+ const cookie: ImportableCookie = {
+ url,
+ name: row.name,
+ value,
+ // A leading dot is Chrome's marker for a domain cookie. Host-only cookies
+ // omit `domain` entirely so Electron scopes them to the URL's host —
+ // sending the bare host instead would widen them to every subdomain.
+ ...(row.hostKey.startsWith('.') ? { domain: row.hostKey } : {}),
+ path: row.path.startsWith('/') ? row.path : `/${row.path}`,
+ secure: row.isSecure,
+ httpOnly: row.isHttpOnly,
+ sameSite: toSameSite(row.sameSite),
+ }
+
+ if (row.hasExpires && row.isPersistent) {
+ const expirationDate = chromeTimeToUnixSeconds(row.expiresUtc)
+ if (!Number.isFinite(expirationDate) || expirationDate <= nowSeconds) {
+ return { ok: false, reason: 'expired' }
+ }
+ cookie.expirationDate = expirationDate
+ }
+
+ return { ok: true, cookie }
+}
diff --git a/apps/desktop/src/main/browser-import/import-service.test.ts b/apps/desktop/src/main/browser-import/import-service.test.ts
new file mode 100644
index 00000000000..2c66c825f8d
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/import-service.test.ts
@@ -0,0 +1,928 @@
+import { describe, expect, it, vi } from 'vitest'
+import { BROWSER_SOURCES, type BrowserSource } from '@/main/browser-import/browser-sources'
+import type { ReadCookiesResult } from '@/main/browser-import/chromium-cookies'
+import type { ReadPasswordsResult } from '@/main/browser-import/chromium-passwords'
+import type { ImportedSite } from '@/main/browser-import/chromium-site-names'
+import {
+ type ImportServiceDeps,
+ importChromeCookies,
+ importChromeData,
+ importChromePasswords,
+ listImportableProfiles,
+ toDisplayProfiles,
+} from '@/main/browser-import/import-service'
+import {
+ type BrowserProfile,
+ emptySkipCounts,
+ type ImportableCookie,
+ ImportFailure,
+} from '@/main/browser-import/types'
+
+const CHROME = BROWSER_SOURCES.find(({ id }) => id === 'chrome') as BrowserSource
+const ARC = BROWSER_SOURCES.find(({ id }) => id === 'arc') as BrowserSource
+const DIA = BROWSER_SOURCES.find(({ id }) => id === 'dia') as BrowserSource
+
+const PROFILES: BrowserProfile[] = [
+ {
+ id: 'chrome:Default',
+ directory: 'Default',
+ label: 'Person 1',
+ source: CHROME,
+ cookiesPath: '/chrome/Default/Cookies',
+ loginDataPath: '/chrome/Default/Login Data',
+ faviconsPath: '/chrome/Default/Favicons',
+ historyPath: '/chrome/Default/History',
+ },
+ {
+ id: 'arc:Profile 2',
+ directory: 'Profile 2',
+ label: 'Work',
+ source: ARC,
+ cookiesPath: '/arc/Profile 2/Cookies',
+ loginDataPath: '/arc/Profile 2/Login Data',
+ faviconsPath: '/arc/Profile 2/Favicons',
+ historyPath: '/arc/Profile 2/History',
+ },
+]
+
+function cookie(name = 'session'): ImportableCookie {
+ return {
+ url: 'https://example.com/',
+ name,
+ value: 'value',
+ path: '/',
+ secure: true,
+ httpOnly: true,
+ sameSite: 'lax',
+ }
+}
+
+function read(overrides: Partial = {}): ReadCookiesResult {
+ return { cookies: [cookie()], skipped: emptySkipCounts(), rowsSeen: 1, ...overrides }
+}
+
+function readPasswords(overrides: Partial = {}): ReadPasswordsResult {
+ return {
+ credentials: [{ origin: 'https://example.com', username: 'ada', password: 'hunter2' }],
+ skipped: 0,
+ rowsSeen: 1,
+ ...overrides,
+ }
+}
+
+function createDeps(overrides: Partial = {}): ImportServiceDeps {
+ return {
+ platform: 'darwin',
+ listProfiles: async () => PROFILES,
+ readSafeStoragePassword: async () => 'safe-storage-password',
+ readCookies: async () => read(),
+ writeCookies: async (cookies) => ({ imported: cookies.length, failed: 0 }),
+ readPasswords: async () => readPasswords(),
+ readFavicons: async () => new Map(),
+ readSites: async () => [],
+ rememberSites: async () => {},
+ vault: {
+ isAvailable: () => true,
+ importCredentials: async (candidates) => ({
+ added: candidates.length,
+ updated: 0,
+ skipped: 0,
+ }),
+ },
+ ...overrides,
+ }
+}
+
+describe('toDisplayProfiles', () => {
+ function profile(source: BrowserSource, directory: string, label: string): BrowserProfile {
+ return {
+ id: `${source.id}:${directory}`,
+ directory,
+ label,
+ source,
+ cookiesPath: '/cookies',
+ loginDataPath: null,
+ faviconsPath: null,
+ historyPath: null,
+ }
+ }
+
+ it('leads with the browser, which is the identity that matters', () => {
+ // Regression: labelling by profile name alone produced a list like
+ // "Your Chrome / sim.ai / Your Chromium / Microtrades" with no way to tell
+ // which browser any of them belonged to.
+ const labels = toDisplayProfiles([
+ profile(CHROME, 'Default', ''),
+ profile(CHROME, 'Profile 1', 'sim.ai'),
+ profile(ARC, 'Default', ''),
+ profile(ARC, 'Profile 2', 'Microtrades'),
+ ]).map(({ label }) => label)
+
+ expect(labels).toEqual(['Chrome', 'Chrome · sim.ai', 'Arc', 'Arc · Microtrades'])
+ })
+
+ it('uses the browser alone when the profile was never named', () => {
+ expect(toDisplayProfiles([profile(CHROME, 'Default', '')])[0].label).toBe('Chrome')
+ })
+
+ it('keeps two unnamed profiles of one browser distinguishable', () => {
+ const labels = toDisplayProfiles([
+ profile(CHROME, 'Default', ''),
+ profile(CHROME, 'Profile 1', ''),
+ ]).map(({ label }) => label)
+
+ expect(labels).toEqual(['Chrome · Default', 'Chrome · Profile 1'])
+ expect(new Set(labels).size).toBe(2)
+ })
+
+ it('does not disambiguate across browsers, which are already distinct', () => {
+ const labels = toDisplayProfiles([
+ profile(ARC, 'Default', ''),
+ profile(DIA, 'Default', ''),
+ ]).map(({ label }) => label)
+
+ expect(labels).toEqual(['Arc', 'Dia'])
+ })
+
+ it('names an unnamed profile by its directory in a profile-only picker', () => {
+ // The browser dropdown already says "Chrome", so the profile dropdown
+ // beside it needs something to show rather than a blank row.
+ expect(
+ toDisplayProfiles([profile(CHROME, 'Default', '')]).map(({ profileLabel }) => profileLabel)
+ ).toEqual(['Default'])
+
+ expect(
+ toDisplayProfiles([profile(CHROME, 'Profile 1', 'sim.ai')]).map(
+ ({ profileLabel }) => profileLabel
+ )
+ ).toEqual(['sim.ai'])
+ })
+})
+
+describe('listImportableProfiles', () => {
+ it('exposes ids, labels, and the owning browser — never a profile path', async () => {
+ await expect(listImportableProfiles(createDeps())).resolves.toEqual([
+ {
+ id: 'chrome:Default',
+ label: 'Chrome · Person 1',
+ browserId: 'chrome',
+ browserLabel: 'Chrome',
+ profileLabel: 'Person 1',
+ },
+ {
+ id: 'arc:Profile 2',
+ label: 'Arc · Work',
+ browserId: 'arc',
+ browserLabel: 'Arc',
+ profileLabel: 'Work',
+ },
+ ])
+ })
+
+ it('is empty off macOS without consulting the disk', async () => {
+ const listProfiles = vi.fn()
+ await expect(
+ listImportableProfiles(createDeps({ platform: 'win32', listProfiles }))
+ ).resolves.toEqual([])
+ expect(listProfiles).not.toHaveBeenCalled()
+ })
+
+ it('reports no profiles rather than throwing when discovery fails', async () => {
+ const listProfiles = async () => {
+ throw new Error('unreadable')
+ }
+ await expect(listImportableProfiles(createDeps({ listProfiles }))).resolves.toEqual([])
+ })
+})
+
+describe('importChromeCookies', () => {
+ it('imports the requested profile and reports counts', async () => {
+ const readCookies = vi.fn(async () => read({ cookies: [cookie('a'), cookie('b')] }))
+ const result = await importChromeCookies('arc:Profile 2', createDeps({ readCookies }))
+
+ expect(result).toEqual({ cookiesImported: 2, cookiesSkipped: 0 })
+ expect(readCookies).toHaveBeenCalledWith('/arc/Profile 2/Cookies', expect.any(Buffer))
+ })
+
+ it('defaults to the first profile when none is named', async () => {
+ const readCookies = vi.fn(async () => read())
+ await importChromeCookies(undefined, createDeps({ readCookies }))
+ expect(readCookies).toHaveBeenCalledWith('/chrome/Default/Cookies', expect.any(Buffer))
+ })
+
+ it('refuses an unknown profile instead of falling back to the default', async () => {
+ const readCookies = vi.fn(async () => read())
+ const result = await importChromeCookies('../../elsewhere', createDeps({ readCookies }))
+
+ expect(result.error).toBe('chrome-not-found')
+ expect(readCookies).not.toHaveBeenCalled()
+ })
+
+ it('counts cookies the browser profile rejected as skipped', async () => {
+ const deps = createDeps({
+ readCookies: async () =>
+ read({
+ cookies: [cookie('a'), cookie('b'), cookie('c')],
+ skipped: { ...emptySkipCounts(), expired: 4 },
+ }),
+ writeCookies: async () => ({ imported: 2, failed: 1 }),
+ })
+
+ await expect(importChromeCookies(undefined, deps)).resolves.toEqual({
+ cookiesImported: 2,
+ cookiesSkipped: 5,
+ })
+ })
+
+ it('zeroes the derived key once the rows are decrypted', async () => {
+ let observed: Buffer | undefined
+ const deps = createDeps({
+ readCookies: async (_path, key) => {
+ observed = key
+ expect(key.some((byte) => byte !== 0)).toBe(true)
+ return read()
+ },
+ })
+
+ await importChromeCookies(undefined, deps)
+ expect(observed?.every((byte) => byte === 0)).toBe(true)
+ })
+
+ it('zeroes the derived key even when reading throws', async () => {
+ let observed: Buffer | undefined
+ const deps = createDeps({
+ readCookies: async (_path, key) => {
+ observed = key
+ throw new ImportFailure('profile-unreadable', 'locked')
+ },
+ })
+
+ await importChromeCookies(undefined, deps)
+ expect(observed?.every((byte) => byte === 0)).toBe(true)
+ })
+
+ it.each([
+ ['unsupported-platform', createDeps({ platform: 'win32' })],
+ ['chrome-not-found', createDeps({ listProfiles: async () => [] })],
+ ['keychain-unavailable', createDeps({ readSafeStoragePassword: async () => null })],
+ ] as const)('fails closed with %s', async (error, deps) => {
+ await expect(importChromeCookies(undefined, deps)).resolves.toEqual({
+ cookiesImported: 0,
+ cookiesSkipped: 0,
+ error,
+ })
+ })
+
+ it('never reaches the Keychain on an unsupported platform', async () => {
+ const readSafeStoragePassword = vi.fn()
+ await importChromeCookies(undefined, createDeps({ platform: 'linux', readSafeStoragePassword }))
+ expect(readSafeStoragePassword).not.toHaveBeenCalled()
+ })
+
+ it('surfaces a reader failure as its own category', async () => {
+ const deps = createDeps({
+ readCookies: async () => {
+ throw new ImportFailure('unsupported-schema', 'unknown table shape')
+ },
+ })
+ await expect(importChromeCookies(undefined, deps)).resolves.toEqual({
+ cookiesImported: 0,
+ cookiesSkipped: 0,
+ error: 'unsupported-schema',
+ })
+ })
+
+ it('reports rows that all failed to decrypt as an import failure', async () => {
+ const deps = createDeps({
+ readCookies: async () =>
+ read({ cookies: [], skipped: { ...emptySkipCounts(), 'decrypt-failed': 9 }, rowsSeen: 9 }),
+ })
+ await expect(importChromeCookies(undefined, deps)).resolves.toEqual({
+ cookiesImported: 0,
+ cookiesSkipped: 9,
+ error: 'nothing-imported',
+ })
+ })
+
+ it('treats a genuinely empty profile as a success', async () => {
+ const deps = createDeps({ readCookies: async () => read({ cookies: [], rowsSeen: 0 }) })
+ await expect(importChromeCookies(undefined, deps)).resolves.toEqual({
+ cookiesImported: 0,
+ cookiesSkipped: 0,
+ })
+ })
+
+ it('does not leak an unexpected error to the caller', async () => {
+ const deps = createDeps({
+ writeCookies: async () => {
+ throw new Error('/Users/someone/Library/Application Support/Google/Chrome exploded')
+ },
+ })
+ await expect(importChromeCookies(undefined, deps)).resolves.toEqual({
+ cookiesImported: 0,
+ cookiesSkipped: 0,
+ error: 'unknown',
+ })
+ })
+
+ it('reports a profile with no cookie database rather than reading another one', async () => {
+ const deps = createDeps({
+ listProfiles: async () => [
+ {
+ id: 'chrome:Default',
+ directory: 'Default',
+ label: 'Person 1',
+ source: CHROME,
+ cookiesPath: null,
+ loginDataPath: '/chrome/Login Data',
+ faviconsPath: null,
+ historyPath: null,
+ },
+ ],
+ })
+ await expect(importChromeCookies(undefined, deps)).resolves.toMatchObject({
+ error: 'profile-unreadable',
+ })
+ })
+})
+
+describe('importChromePasswords', () => {
+ it('stores decrypted passwords in the vault and reports counts', async () => {
+ const importCredentials = vi.fn(async () => ({ added: 2, updated: 1, skipped: 0 }))
+ const deps = createDeps({
+ readPasswords: async () =>
+ readPasswords({
+ credentials: [
+ { origin: 'https://example.com', username: 'ada', password: 'a' },
+ { origin: 'https://other.test', username: 'grace', password: 'b' },
+ ],
+ skipped: 3,
+ rowsSeen: 5,
+ }),
+ vault: { isAvailable: () => true, importCredentials },
+ })
+
+ await expect(importChromePasswords('arc:Profile 2', 'replace', deps)).resolves.toEqual({
+ passwordsAdded: 2,
+ passwordsUpdated: 1,
+ passwordsSkipped: 3,
+ })
+ expect(importCredentials).toHaveBeenCalledWith(expect.any(Array), 'replace')
+ })
+
+ it('attaches each site\u2019s icon from the same profile', async () => {
+ const importCredentials = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 }))
+ const readFavicons = vi.fn(
+ async () => new Map([['https://example.com', 'data:image/png;base64,AA']])
+ )
+ const deps = createDeps({
+ readPasswords: async () =>
+ readPasswords({
+ credentials: [{ origin: 'https://example.com/login', username: 'ada', password: 'p' }],
+ }),
+ readFavicons,
+ vault: { isAvailable: () => true, importCredentials },
+ })
+
+ await importChromePasswords('chrome:Default', 'keep-existing', deps)
+
+ // Only the origins being imported are looked up, never the whole history.
+ expect(readFavicons).toHaveBeenCalledWith(
+ '/chrome/Default/Favicons',
+ new Set(['https://example.com'])
+ )
+ expect(importCredentials).toHaveBeenCalledWith(
+ [expect.objectContaining({ icon: 'data:image/png;base64,AA' })],
+ 'keep-existing'
+ )
+ })
+
+ it('imports without icons when the profile has no favicon store', async () => {
+ const importCredentials = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 }))
+ const readFavicons = vi.fn()
+ const deps = createDeps({
+ listProfiles: async () => [{ ...PROFILES[0], faviconsPath: null, historyPath: null }],
+ readFavicons,
+ vault: { isAvailable: () => true, importCredentials },
+ })
+
+ await importChromePasswords(undefined, 'keep-existing', deps)
+
+ expect(readFavicons).not.toHaveBeenCalled()
+ expect(importCredentials).toHaveBeenCalledWith(
+ [expect.not.objectContaining({ icon: expect.anything() })],
+ 'keep-existing'
+ )
+ })
+
+ it('still imports passwords when the favicon store will not read', async () => {
+ const importCredentials = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 }))
+ const deps = createDeps({
+ readFavicons: async () => {
+ throw new Error('corrupt')
+ },
+ vault: { isAvailable: () => true, importCredentials },
+ })
+
+ await expect(importChromePasswords(undefined, 'keep-existing', deps)).resolves.toMatchObject({
+ passwordsAdded: 1,
+ })
+ })
+
+ it('reads the requested profile\u2019s password database', async () => {
+ const readPasswordsSpy = vi.fn(async () => readPasswords())
+ await importChromePasswords(
+ 'arc:Profile 2',
+ 'keep-existing',
+ createDeps({ readPasswords: readPasswordsSpy })
+ )
+
+ expect(readPasswordsSpy).toHaveBeenCalledWith('/arc/Profile 2/Login Data', expect.any(Buffer))
+ })
+
+ it('uses the chosen browser\u2019s own Keychain item', async () => {
+ // Reading Chrome's item to import Arc would prompt the user about the
+ // wrong browser — and would derive a key that cannot decrypt anything.
+ const readSafeStoragePassword = vi.fn(async () => 'password')
+ await importChromePasswords(
+ 'arc:Profile 2',
+ 'keep-existing',
+ createDeps({ readSafeStoragePassword })
+ )
+
+ expect(readSafeStoragePassword).toHaveBeenCalledWith({
+ service: 'Arc Safe Storage',
+ account: 'Arc',
+ })
+ })
+
+ it('refuses an unknown profile rather than falling back to the default', async () => {
+ const readPasswordsSpy = vi.fn(async () => readPasswords())
+ const result = await importChromePasswords(
+ '../../elsewhere',
+ 'keep-existing',
+ createDeps({ readPasswords: readPasswordsSpy })
+ )
+
+ expect(result.error).toBe('chrome-not-found')
+ expect(readPasswordsSpy).not.toHaveBeenCalled()
+ })
+
+ it('will not run without secure storage, and never reaches the Keychain', async () => {
+ // There is no plaintext fallback for passwords: an unavailable vault ends
+ // the import instead of degrading it.
+ const readSafeStoragePassword = vi.fn()
+ const deps = createDeps({
+ readSafeStoragePassword,
+ vault: { isAvailable: () => false, importCredentials: vi.fn() },
+ })
+
+ await expect(importChromePasswords(undefined, 'keep-existing', deps)).resolves.toEqual({
+ passwordsAdded: 0,
+ passwordsUpdated: 0,
+ passwordsSkipped: 0,
+ error: 'vault-unavailable',
+ })
+ expect(readSafeStoragePassword).not.toHaveBeenCalled()
+ })
+
+ it('zeroes the derived key after reading, including on failure', async () => {
+ let observed: Buffer | undefined
+ await importChromePasswords(
+ undefined,
+ 'keep-existing',
+ createDeps({
+ readPasswords: async (_path, key) => {
+ observed = key
+ throw new ImportFailure('unsupported-schema', 'unknown logins table')
+ },
+ })
+ )
+
+ expect(observed?.every((byte) => byte === 0)).toBe(true)
+ })
+
+ it('reports rows that all failed to decrypt as an import failure', async () => {
+ const deps = createDeps({
+ readPasswords: async () => readPasswords({ credentials: [], skipped: 7, rowsSeen: 7 }),
+ })
+
+ await expect(importChromePasswords(undefined, 'keep-existing', deps)).resolves.toEqual({
+ passwordsAdded: 0,
+ passwordsUpdated: 0,
+ passwordsSkipped: 7,
+ error: 'nothing-imported',
+ })
+ })
+
+ it('treats a profile with no saved passwords as a success', async () => {
+ const deps = createDeps({
+ readPasswords: async () => readPasswords({ credentials: [], skipped: 0, rowsSeen: 0 }),
+ })
+
+ await expect(importChromePasswords(undefined, 'keep-existing', deps)).resolves.toEqual({
+ passwordsAdded: 0,
+ passwordsUpdated: 0,
+ passwordsSkipped: 0,
+ })
+ })
+
+ it.each([
+ ['unsupported-platform', { platform: 'win32' as const }],
+ ['chrome-not-found', { listProfiles: async () => [] }],
+ ['keychain-unavailable', { readSafeStoragePassword: async () => null }],
+ ])('fails closed with %s', async (error, overrides) => {
+ await expect(
+ importChromePasswords(undefined, 'keep-existing', createDeps(overrides))
+ ).resolves.toMatchObject({ error })
+ })
+
+ it('does not leak an unexpected error to the caller', async () => {
+ const deps = createDeps({
+ vault: {
+ isAvailable: () => true,
+ importCredentials: async () => {
+ throw new Error('/Users/someone/Library/.../Login Data exploded')
+ },
+ },
+ })
+
+ await expect(importChromePasswords(undefined, 'keep-existing', deps)).resolves.toEqual({
+ passwordsAdded: 0,
+ passwordsUpdated: 0,
+ passwordsSkipped: 0,
+ error: 'unknown',
+ })
+ })
+})
+
+describe('importChromeData', () => {
+ const COOKIES_IMPORTED = { cookiesImported: 1, cookiesSkipped: 0 }
+ const PASSWORDS_IMPORTED = { passwordsAdded: 1, passwordsUpdated: 0, passwordsSkipped: 0 }
+
+ /** Runs a combined import while holding on to the key the halves were handed. */
+ async function importObservingKey(overrides: Partial = {}) {
+ const base = createDeps(overrides)
+ let observed: Buffer | undefined
+ const result = await importChromeData(undefined, 'keep-existing', {
+ ...base,
+ readCookies: (path, key) => {
+ observed = key
+ return base.readCookies(path, key)
+ },
+ readPasswords: (path, key) => {
+ observed = key
+ return base.readPasswords(path, key)
+ },
+ })
+ return { result, observed }
+ }
+
+ it('brings over both halves in one action', async () => {
+ await expect(importChromeData('arc:Profile 2', 'keep-existing', createDeps())).resolves.toEqual(
+ { cookies: COOKIES_IMPORTED, passwords: PASSWORDS_IMPORTED }
+ )
+ })
+
+ it('refuses both halves off macOS without consulting the disk', async () => {
+ const listProfiles = vi.fn()
+ const readSafeStoragePassword = vi.fn()
+
+ await expect(
+ importChromeData(
+ undefined,
+ 'keep-existing',
+ createDeps({ platform: 'win32', listProfiles, readSafeStoragePassword })
+ )
+ ).resolves.toEqual({
+ cookies: { cookiesImported: 0, cookiesSkipped: 0, error: 'unsupported-platform' },
+ passwords: {
+ passwordsAdded: 0,
+ passwordsUpdated: 0,
+ passwordsSkipped: 0,
+ error: 'unsupported-platform',
+ },
+ })
+ expect(listProfiles).not.toHaveBeenCalled()
+ expect(readSafeStoragePassword).not.toHaveBeenCalled()
+ })
+
+ it('prompts for the Keychain once, not once per half', async () => {
+ // Regression: running the two exported halves in sequence read the Safe
+ // Storage item twice, so a user who answered the first prompt with "Allow"
+ // rather than "Always Allow" got a second prompt mid-import — one arriving
+ // without a user gesture behind it, which silently fails the password half.
+ const listProfiles = vi.fn(async () => PROFILES)
+ const readSafeStoragePassword = vi.fn(async () => 'safe-storage-password')
+
+ await importChromeData(
+ 'chrome:Default',
+ 'keep-existing',
+ createDeps({ listProfiles, readSafeStoragePassword })
+ )
+
+ expect(readSafeStoragePassword).toHaveBeenCalledTimes(1)
+ expect(listProfiles).toHaveBeenCalledTimes(1)
+ })
+
+ it('hands both halves the one key that open derived', async () => {
+ let cookieKey: Buffer | undefined
+ let passwordKey: Buffer | undefined
+ const deps = createDeps({
+ readCookies: async (_path, key) => {
+ cookieKey = key
+ return read()
+ },
+ readPasswords: async (_path, key) => {
+ passwordKey = key
+ // The cookie half must not wipe the key out from under this one.
+ expect(key.some((byte) => byte !== 0)).toBe(true)
+ return readPasswords()
+ },
+ })
+
+ await importChromeData(undefined, 'keep-existing', deps)
+
+ expect(passwordKey).toBe(cookieKey)
+ })
+
+ it('reports the same reason for both halves when the profile will not open', async () => {
+ await expect(
+ importChromeData('../../elsewhere', 'keep-existing', createDeps())
+ ).resolves.toEqual({
+ cookies: { cookiesImported: 0, cookiesSkipped: 0, error: 'chrome-not-found' },
+ passwords: {
+ passwordsAdded: 0,
+ passwordsUpdated: 0,
+ passwordsSkipped: 0,
+ error: 'chrome-not-found',
+ },
+ })
+ })
+
+ it('still imports passwords when the cookie half throws', async () => {
+ const deps = createDeps({
+ readCookies: async () => {
+ throw new ImportFailure('unsupported-schema', 'unknown cookies table')
+ },
+ })
+
+ await expect(importChromeData(undefined, 'keep-existing', deps)).resolves.toEqual({
+ cookies: { cookiesImported: 0, cookiesSkipped: 0, error: 'unsupported-schema' },
+ passwords: PASSWORDS_IMPORTED,
+ })
+ })
+
+ it('still reports the imported cookies when the password half throws', async () => {
+ const deps = createDeps({
+ readPasswords: async () => {
+ throw new ImportFailure('unsupported-schema', 'unknown logins table')
+ },
+ })
+
+ await expect(importChromeData(undefined, 'keep-existing', deps)).resolves.toEqual({
+ cookies: COOKIES_IMPORTED,
+ passwords: {
+ passwordsAdded: 0,
+ passwordsUpdated: 0,
+ passwordsSkipped: 0,
+ error: 'unsupported-schema',
+ },
+ })
+ })
+
+ it('imports cookies even though secure storage cannot take the passwords', async () => {
+ // There is no plaintext fallback for passwords, but cookies do not need
+ // the vault — so an unavailable one must not cost the user both halves.
+ const importCredentials = vi.fn()
+ const deps = createDeps({ vault: { isAvailable: () => false, importCredentials } })
+
+ await expect(importChromeData(undefined, 'keep-existing', deps)).resolves.toEqual({
+ cookies: COOKIES_IMPORTED,
+ passwords: {
+ passwordsAdded: 0,
+ passwordsUpdated: 0,
+ passwordsSkipped: 0,
+ error: 'vault-unavailable',
+ },
+ })
+ expect(importCredentials).not.toHaveBeenCalled()
+ })
+
+ it('filters history by the cookie hosts and the password origins together', async () => {
+ // Either half alone is a partial picture of what the user brought over.
+ const readSites = vi.fn(async () => [])
+ const deps = createDeps({
+ readCookies: async () =>
+ read({ cookies: [{ ...cookie(), url: 'https://news.example.com/' }] }),
+ readPasswords: async () =>
+ readPasswords({
+ credentials: [{ origin: 'https://accounts.other.test', username: 'ada', password: 'p' }],
+ }),
+ readSites,
+ })
+
+ await importChromeData(undefined, 'keep-existing', deps)
+
+ expect(readSites).toHaveBeenCalledTimes(1)
+ expect(readSites).toHaveBeenCalledWith(
+ '/chrome/Default/History',
+ new Set(['news.example.com', 'accounts.other.test'])
+ )
+ })
+
+ const KEY_PATHS: [string, Partial][] = [
+ ['both halves land', {}],
+ [
+ 'the cookie half throws',
+ {
+ readCookies: async () => {
+ throw new ImportFailure('unsupported-schema', 'unknown cookies table')
+ },
+ },
+ ],
+ [
+ 'the password half throws',
+ {
+ readPasswords: async () => {
+ throw new Error('/Users/someone/Library/.../Login Data exploded')
+ },
+ },
+ ],
+ [
+ 'secure storage is unavailable',
+ { vault: { isAvailable: () => false, importCredentials: vi.fn() } },
+ ],
+ [
+ 'remembering the sites throws',
+ {
+ rememberSites: async () => {
+ throw new Error('directory is unwritable')
+ },
+ },
+ ],
+ ]
+
+ it.each(KEY_PATHS)('zeroes the derived key when %s', async (_path, overrides) => {
+ const { observed } = await importObservingKey(overrides)
+
+ expect(observed).toBeDefined()
+ expect(observed?.every((byte) => byte === 0)).toBe(true)
+ })
+})
+
+describe('remembering the sites an import brought over', () => {
+ const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/
+
+ it('records the host history knows, not the cookie domain it was filtered by', async () => {
+ // The filter is `google.com` (a domain cookie with its dot stripped) but
+ // the site the user opened is `mail.google.com`. The favicon lookup and the
+ // omnibox both key off the recorded string, so it must be the visited host.
+ const rememberSites = vi.fn(async () => {})
+ const readSites = vi.fn(async () => [
+ { hostname: 'mail.google.com', name: 'Gmail', visits: 412 },
+ ])
+ const readFavicons = vi.fn(
+ async () => new Map([['https://mail.google.com', 'data:image/png;base64,AA']])
+ )
+ const deps = createDeps({
+ readCookies: async () => read({ cookies: [{ ...cookie(), url: 'https://google.com/' }] }),
+ readSites,
+ readFavicons,
+ rememberSites,
+ })
+
+ await importChromeCookies(undefined, deps)
+
+ expect(readSites).toHaveBeenCalledWith('/chrome/Default/History', new Set(['google.com']))
+ expect(readFavicons).toHaveBeenCalledWith(
+ '/chrome/Default/Favicons',
+ new Set(['https://mail.google.com'])
+ )
+ expect(rememberSites).toHaveBeenCalledWith([
+ {
+ hostname: 'mail.google.com',
+ name: 'Gmail',
+ icon: 'data:image/png;base64,AA',
+ visits: 412,
+ importedAt: expect.stringMatching(ISO_TIMESTAMP),
+ },
+ ])
+ })
+
+ it('carries the visit count and the import time, which order the suggestions', async () => {
+ const rememberSites = vi.fn(async () => {})
+ const deps = createDeps({
+ readSites: async () => [
+ { hostname: 'example.com', name: 'Example', visits: 90 },
+ { hostname: 'docs.example.com', visits: 4 },
+ ],
+ rememberSites,
+ })
+
+ await importChromeCookies(undefined, deps)
+
+ const [records] = rememberSites.mock.calls[0]
+ expect(records.map(({ hostname, visits }) => ({ hostname, visits }))).toEqual([
+ { hostname: 'example.com', visits: 90 },
+ { hostname: 'docs.example.com', visits: 4 },
+ ])
+ // One wall-clock stamp for the whole import, never the source browser's
+ // own last-visit time — that would be the visit log this store avoids.
+ expect(new Set(records.map(({ importedAt }) => importedAt)).size).toBe(1)
+ expect(records[0].importedAt).toMatch(ISO_TIMESTAMP)
+ })
+
+ it('leaves a finished import alone when reading history throws synchronously', async () => {
+ // Regression: a dependency that threw before returning its promise escaped
+ // the `.catch()` chain and rewrote a completed import as
+ // { cookiesImported: 0, error: 'unknown' } — losing cookies already written.
+ const readSites = vi.fn((): Promise => {
+ throw new Error('History is locked by the source browser')
+ })
+
+ await expect(importChromeCookies(undefined, createDeps({ readSites }))).resolves.toEqual({
+ cookiesImported: 1,
+ cookiesSkipped: 0,
+ })
+ expect(readSites).toHaveBeenCalled()
+ })
+
+ it('leaves a finished import alone when writing the directory throws synchronously', async () => {
+ const rememberSites = vi.fn((): Promise => {
+ throw new Error('site directory is unwritable')
+ })
+ const deps = createDeps({
+ readSites: async () => [{ hostname: 'example.com', visits: 3 }],
+ rememberSites,
+ })
+
+ await expect(importChromePasswords(undefined, 'keep-existing', deps)).resolves.toEqual({
+ passwordsAdded: 1,
+ passwordsUpdated: 0,
+ passwordsSkipped: 0,
+ })
+ expect(rememberSites).toHaveBeenCalled()
+ })
+
+ it('does not reject the combined import when remembering the sites fails', async () => {
+ // The combined entry point awaits this last, outside its own guard, so a
+ // throw here would reach the IPC caller as a rejected promise.
+ const deps = createDeps({
+ readSites: async () => [{ hostname: 'example.com', visits: 3 }],
+ rememberSites: async () => {
+ throw new Error('directory is unwritable')
+ },
+ })
+
+ await expect(importChromeData(undefined, 'keep-existing', deps)).resolves.toEqual({
+ cookies: { cookiesImported: 1, cookiesSkipped: 0 },
+ passwords: { passwordsAdded: 1, passwordsUpdated: 0, passwordsSkipped: 0 },
+ })
+ })
+
+ it('never records an Android package name a saved credential carried', async () => {
+ const readSites = vi.fn(async () => [])
+ const deps = createDeps({
+ readPasswords: async () =>
+ readPasswords({
+ credentials: [
+ { origin: 'android://hash@com.example.app/', username: 'ada', password: 'p' },
+ { origin: 'https://example.com', username: 'ada', password: 'p' },
+ ],
+ }),
+ readSites,
+ })
+
+ await importChromePasswords(undefined, 'keep-existing', deps)
+
+ expect(readSites).toHaveBeenCalledWith('/chrome/Default/History', new Set(['example.com']))
+ })
+
+ it('does not open history for a profile that has none', async () => {
+ const readSites = vi.fn(async () => [])
+ const deps = createDeps({
+ listProfiles: async () => [{ ...PROFILES[0], historyPath: null }],
+ readSites,
+ })
+
+ await importChromeCookies(undefined, deps)
+
+ expect(readSites).not.toHaveBeenCalled()
+ })
+
+ it('writes nothing when the import vouched for no hosts', async () => {
+ const rememberSites = vi.fn(async () => {})
+ const readSites = vi.fn(async () => [])
+ const deps = createDeps({
+ readCookies: async () => read({ cookies: [], rowsSeen: 0 }),
+ readSites,
+ rememberSites,
+ })
+
+ await importChromeCookies(undefined, deps)
+
+ expect(readSites).not.toHaveBeenCalled()
+ expect(rememberSites).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/desktop/src/main/browser-import/import-service.ts b/apps/desktop/src/main/browser-import/import-service.ts
new file mode 100644
index 00000000000..38d3fbc9be3
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/import-service.ts
@@ -0,0 +1,511 @@
+import type {
+ BrowserChromeImportResult,
+ BrowserCredentialConflictPolicy,
+ BrowserImportProfile,
+ BrowserImportResult,
+ BrowserPasswordImportResult,
+} from '@sim/desktop-bridge'
+import { createLogger } from '@sim/logger'
+import { normalizeOrigin } from '@/main/browser-credentials/origin'
+import type { ImportCandidate, ImportOutcome } from '@/main/browser-credentials/vault'
+import type { ReadCookiesResult } from '@/main/browser-import/chromium-cookies'
+import { deriveEncryptionKey } from '@/main/browser-import/chromium-crypto'
+import type { ReadPasswordsResult } from '@/main/browser-import/chromium-passwords'
+import type { ImportedSite } from '@/main/browser-import/chromium-site-names'
+import {
+ type BrowserProfile,
+ type ImportableCookie,
+ ImportFailure,
+ totalSkipped,
+} from '@/main/browser-import/types'
+import type { SiteRecord } from '@/main/browser-sites/directory'
+
+const logger = createLogger('BrowserImport')
+
+/**
+ * Orchestrates Chrome imports.
+ *
+ * Every dependency is injected so the policy in this module — platform gating,
+ * profile resolution, key lifetime, failure categorisation — can be tested
+ * without a Keychain, a Chrome profile, an Electron session, or a real vault.
+ */
+export interface ImportServiceDeps {
+ platform: NodeJS.Platform
+ listProfiles: () => Promise
+ /** Reads one browser's Safe Storage key; the item comes from its source. */
+ readSafeStoragePassword: (item: { service: string; account: string }) => Promise
+ readCookies: (cookiesPath: string, key: Buffer) => Promise
+ writeCookies: (cookies: ImportableCookie[]) => Promise<{ imported: number; failed: number }>
+ readPasswords: (loginDataPath: string, key: Buffer) => Promise
+ /** Site icons for the given origins, read from the source browser's own store. */
+ readFavicons: (faviconsPath: string, origins: ReadonlySet) => Promise>
+ /** The most-used hosts in the source browser's history that `domains` covers. */
+ readSites: (historyPath: string, domains: ReadonlySet) => Promise
+ /** Records the hosts an import brought over, with their names and icons. */
+ rememberSites: (records: readonly SiteRecord[]) => Promise
+ vault: {
+ isAvailable: () => boolean
+ importCredentials: (
+ candidates: ImportCandidate[],
+ policy: BrowserCredentialConflictPolicy
+ ) => Promise
+ }
+}
+
+function cookieFailure(error: BrowserImportResult['error']): BrowserImportResult {
+ return { cookiesImported: 0, cookiesSkipped: 0, error }
+}
+
+function passwordFailure(error: BrowserImportResult['error']): BrowserPasswordImportResult {
+ return { passwordsAdded: 0, passwordsUpdated: 0, passwordsSkipped: 0, error }
+}
+
+/**
+ * Resolves the profile to read and derives its decryption key.
+ *
+ * A renderer-supplied id is matched against what was actually discovered and
+ * is never joined into a path, so it cannot escape Chrome's directory, and an
+ * unknown id is refused rather than quietly falling back to the default
+ * profile — that would read the wrong account.
+ */
+interface OpenProfile {
+ profile: BrowserProfile
+ key: Buffer
+}
+
+/**
+ * Wipes the derived key once every read that needs it is done. (The password
+ * string itself is immutable and cannot be wiped; it is never persisted or
+ * logged.)
+ */
+function zeroKey({ key }: OpenProfile): void {
+ key.fill(0)
+}
+
+async function openBrowserProfile(
+ profileId: string | undefined,
+ deps: ImportServiceDeps
+): Promise {
+ const profiles = await deps.listProfiles()
+ const profile = profileId ? profiles.find(({ id }) => id === profileId) : profiles[0]
+ if (!profile) {
+ throw new ImportFailure('chrome-not-found', 'No matching browser profile.')
+ }
+ // Each browser has its own Safe Storage item, so the prompt the user sees
+ // names the browser they actually chose.
+ const safeStoragePassword = await deps.readSafeStoragePassword(profile.source.keychain)
+ if (safeStoragePassword === null) {
+ throw new ImportFailure('keychain-unavailable', 'Keychain access was unavailable.')
+ }
+ return { profile, key: deriveEncryptionKey(safeStoragePassword) }
+}
+
+/**
+ * Chrome profiles offered to the user, stripped to what the UI needs. Returns
+ * an empty list rather than throwing when Chrome is absent or unsupported.
+ */
+export async function listImportableProfiles(
+ deps: ImportServiceDeps
+): Promise {
+ if (deps.platform !== 'darwin') return []
+ try {
+ return toDisplayProfiles(await deps.listProfiles())
+ } catch {
+ return []
+ }
+}
+
+/**
+ * Turns discovered profiles into things a person can pick between.
+ *
+ * The browser is the identity that matters — "Arc", not "Microtrades" — so
+ * every label leads with it, and a profile name is only appended when the user
+ * actually gave the profile one. Two unnamed profiles in the same browser fall
+ * back to the directory, because a list with the same entry twice is worse
+ * than one with an ugly entry.
+ */
+export function toDisplayProfiles(profiles: BrowserProfile[]): BrowserImportProfile[] {
+ const unnamedPerBrowser = new Map()
+ for (const { source, label } of profiles) {
+ if (label === '') {
+ unnamedPerBrowser.set(source.id, (unnamedPerBrowser.get(source.id) ?? 0) + 1)
+ }
+ }
+
+ return profiles.map(({ id, label, directory, source }) => {
+ let suffix = label
+ if (suffix === '' && (unnamedPerBrowser.get(source.id) ?? 0) > 1) {
+ suffix = directory
+ }
+ return {
+ id,
+ label: suffix === '' ? source.label : `${source.label} · ${suffix}`,
+ browserId: source.id,
+ browserLabel: source.label,
+ // Shown on its own in a profile picker, where the browser is already
+ // chosen — so an unnamed profile reads as "Default" rather than blank.
+ profileLabel: label === '' ? directory : label,
+ }
+ })
+}
+
+/**
+ * Copies one Chrome profile's cookies into the built-in browser.
+ *
+ * Fails closed at every step: an unsupported platform, an absent Chrome, a
+ * refused Keychain prompt, or an unrecognised database all stop the import
+ * with a category instead of falling back to a weaker path. The result carries
+ * counts only — no cookie material, domain, or path reaches the caller.
+ */
+export async function importChromeCookies(
+ profileId: string | undefined,
+ deps: ImportServiceDeps
+): Promise {
+ if (deps.platform !== 'darwin') return cookieFailure('unsupported-platform')
+
+ try {
+ const open = await openBrowserProfile(profileId, deps)
+ let outcome: CookieOutcome
+ try {
+ outcome = await runCookieImport(open, deps)
+ } finally {
+ zeroKey(open)
+ }
+ await rememberImportedSites(outcome.domains, open.profile, deps)
+ return outcome.result
+ } catch (error) {
+ return cookieFailure(categorize(error, 'cookie'))
+ }
+}
+
+/** What an import half brought over, and the hosts it can vouch for. */
+interface CookieOutcome {
+ result: BrowserImportResult
+ domains: Set
+}
+
+interface PasswordOutcome {
+ result: BrowserPasswordImportResult
+ domains: Set
+}
+
+/**
+ * The cookie half against an already-open profile.
+ *
+ * Key lifetime belongs to the caller: the combined import shares one key across
+ * both halves, so this must not wipe it out from under the password half.
+ */
+async function runCookieImport(
+ { profile, key }: OpenProfile,
+ deps: ImportServiceDeps
+): Promise {
+ if (profile.cookiesPath === null) {
+ return { result: cookieFailure('profile-unreadable'), domains: new Set() }
+ }
+
+ const read = await deps.readCookies(profile.cookiesPath, key)
+ const skippedReading = totalSkipped(read.skipped)
+ // Rows present but nothing usable means the profile did not decrypt —
+ // typically a scheme this importer does not support. Report it as a
+ // failure rather than as a successful import of zero cookies.
+ if (read.cookies.length === 0) {
+ return {
+ result:
+ read.rowsSeen > 0
+ ? { cookiesImported: 0, cookiesSkipped: skippedReading, error: 'nothing-imported' }
+ : { cookiesImported: 0, cookiesSkipped: 0 },
+ domains: new Set(),
+ }
+ }
+
+ const written = await deps.writeCookies(read.cookies)
+ const result: BrowserImportResult = {
+ cookiesImported: written.imported,
+ cookiesSkipped: skippedReading + written.failed,
+ }
+ // Counts only: cookie names, values, domains, and the profile path are
+ // deliberately absent from local diagnostics.
+ logger.info('Chrome cookie import finished', {
+ imported: result.cookiesImported,
+ skipped: result.cookiesSkipped,
+ })
+ return { result, domains: cookieHostnames(read.cookies) }
+}
+
+/**
+ * Copies one Chrome profile's saved passwords into the encrypted vault.
+ *
+ * Refuses to run when secure storage is unavailable: there is no plaintext
+ * fallback for passwords, so an unavailable vault ends the import rather than
+ * degrading it. Decrypted passwords pass straight from the reader into the
+ * vault and are never logged or counted per site.
+ */
+export async function importChromePasswords(
+ profileId: string | undefined,
+ policy: BrowserCredentialConflictPolicy,
+ deps: ImportServiceDeps
+): Promise {
+ if (deps.platform !== 'darwin') return passwordFailure('unsupported-platform')
+ if (!deps.vault.isAvailable()) return passwordFailure('vault-unavailable')
+
+ try {
+ const open = await openBrowserProfile(profileId, deps)
+ let outcome: PasswordOutcome
+ try {
+ outcome = await runPasswordImport(open, policy, deps)
+ } finally {
+ zeroKey(open)
+ }
+ await rememberImportedSites(outcome.domains, open.profile, deps)
+ return outcome.result
+ } catch (error) {
+ return passwordFailure(categorize(error, 'password'))
+ }
+}
+
+/** The password half against an already-open profile. @see runCookieImport */
+async function runPasswordImport(
+ { profile, key }: OpenProfile,
+ policy: BrowserCredentialConflictPolicy,
+ deps: ImportServiceDeps
+): Promise {
+ if (profile.loginDataPath === null) {
+ return { result: passwordFailure('profile-unreadable'), domains: new Set() }
+ }
+
+ const read: ReadPasswordsResult = await deps.readPasswords(profile.loginDataPath, key)
+ if (read.credentials.length === 0) {
+ return {
+ result:
+ read.rowsSeen > 0
+ ? {
+ passwordsAdded: 0,
+ passwordsUpdated: 0,
+ passwordsSkipped: read.skipped,
+ error: 'nothing-imported',
+ }
+ : { passwordsAdded: 0, passwordsUpdated: 0, passwordsSkipped: 0 },
+ domains: new Set(),
+ }
+ }
+
+ const outcome = await deps.vault.importCredentials(
+ await withFavicons(read.credentials, profile.faviconsPath, deps),
+ policy
+ )
+ const result: BrowserPasswordImportResult = {
+ passwordsAdded: outcome.added,
+ passwordsUpdated: outcome.updated,
+ passwordsSkipped: outcome.skipped + read.skipped,
+ }
+ logger.info('Chrome password import finished', {
+ added: result.passwordsAdded,
+ updated: result.passwordsUpdated,
+ skipped: result.passwordsSkipped,
+ })
+ return { result, domains: credentialHostnames(read.credentials) }
+}
+
+/**
+ * Imports cookies and saved passwords in one action.
+ *
+ * Deliberately one call rather than two from the UI: the Keychain prompt can
+ * easily outlive the page's transient user activation, so a second gated call
+ * afterwards would be refused for a user who did nothing wrong. Each half
+ * still reports its own outcome, so one failing does not hide the other.
+ *
+ * The profile is opened ONCE for both halves. Calling the two exported halves
+ * in sequence would read the Safe Storage item twice, and a user who answered
+ * the first Keychain prompt with "Allow" rather than "Always Allow" gets a
+ * second prompt they did not ask for — which, arriving without a user gesture
+ * behind it, is exactly the failure this combined entry point exists to
+ * prevent. One open also means one history read, over the union of what both
+ * halves vouched for. (The favicon store is still read twice — once for the
+ * saved-password origins, once for the history hosts — because the two sets are
+ * only known at different points.)
+ */
+export async function importChromeData(
+ profileId: string | undefined,
+ policy: BrowserCredentialConflictPolicy,
+ deps: ImportServiceDeps
+): Promise {
+ if (deps.platform !== 'darwin') {
+ return {
+ cookies: cookieFailure('unsupported-platform'),
+ passwords: passwordFailure('unsupported-platform'),
+ }
+ }
+
+ let open: OpenProfile
+ try {
+ open = await openBrowserProfile(profileId, deps)
+ } catch (error) {
+ // Neither half ran, so both report the same reason rather than one of them
+ // claiming a success it never attempted.
+ return {
+ cookies: cookieFailure(categorize(error, 'cookie')),
+ passwords: passwordFailure(categorize(error, 'password')),
+ }
+ }
+
+ let cookies: CookieOutcome = { result: cookieFailure('unknown'), domains: new Set() }
+ let passwords: PasswordOutcome = { result: passwordFailure('unknown'), domains: new Set() }
+ try {
+ try {
+ cookies = await runCookieImport(open, deps)
+ } catch (error) {
+ cookies = { result: cookieFailure(categorize(error, 'cookie')), domains: new Set() }
+ }
+ // One half failing must not take the other with it.
+ try {
+ passwords = deps.vault.isAvailable()
+ ? await runPasswordImport(open, policy, deps)
+ : { result: passwordFailure('vault-unavailable'), domains: new Set() }
+ } catch (error) {
+ passwords = { result: passwordFailure(categorize(error, 'password')), domains: new Set() }
+ }
+ } finally {
+ zeroKey(open)
+ }
+
+ await rememberImportedSites(union(cookies.domains, passwords.domains), open.profile, deps)
+ return { cookies: cookies.result, passwords: passwords.result }
+}
+
+function union(first: ReadonlySet, second: ReadonlySet): Set {
+ return new Set([...first, ...second])
+}
+
+/**
+ * Attaches each credential's site icon, where the source browser has one.
+ *
+ * Icons are cosmetic, so a profile without a favicon store — or a favicon
+ * store that will not read — returns the credentials unchanged rather than
+ * failing an import that otherwise succeeded.
+ */
+async function withFavicons(
+ credentials: ImportCandidate[],
+ faviconsPath: string | null,
+ deps: ImportServiceDeps
+): Promise {
+ if (faviconsPath === null) return credentials
+ // Only the origins being imported are looked up, so a password import never
+ // drags the browser's whole history along with it.
+ const origins = new Set(
+ credentials.flatMap((candidate) => {
+ const origin = normalizeOrigin(candidate.origin)
+ return origin === null ? [] : [origin]
+ })
+ )
+
+ const icons = await deps
+ .readFavicons(faviconsPath, origins)
+ .catch(() => new Map())
+ if (icons.size === 0) return credentials
+
+ return credentials.map((candidate) => {
+ const icon = icons.get(normalizeOrigin(candidate.origin) ?? '')
+ return icon ? { ...candidate, icon } : candidate
+ })
+}
+
+/** The hosts a set of cookies belongs to, with the domain-cookie dot removed. */
+function cookieHostnames(cookies: readonly ImportableCookie[]): Set {
+ const hostnames = new Set()
+ for (const cookie of cookies) {
+ const hostname = hostnameOf(cookie.url)
+ if (hostname) hostnames.add(hostname)
+ }
+ return hostnames
+}
+
+function credentialHostnames(credentials: readonly ImportCandidate[]): Set {
+ const hostnames = new Set()
+ for (const candidate of credentials) {
+ const hostname = hostnameOf(candidate.origin)
+ if (hostname) hostnames.add(hostname)
+ }
+ return hostnames
+}
+
+/**
+ * Chrome stores Android app credentials with an `android://…@com.example.app/`
+ * realm, so without a scheme guard a package name would pass as a host and
+ * reach the omnibox as a site nothing can navigate to.
+ */
+function hostnameOf(url: string): string | null {
+ try {
+ const { hostname, protocol } = new URL(url)
+ if (protocol !== 'https:' && protocol !== 'http:') return null
+ return hostname || null
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Records the sites this import brought over, so the omnibox can offer them —
+ * as "Gmail" rather than `mail.google.com` where the source browser knew that.
+ *
+ * `domains` are the hosts the import has evidence for: cookie hosts with the
+ * domain dot stripped, plus saved-password origins. They are the FILTER; the
+ * source browser's history is the SOURCE. Being sourced from pages someone
+ * opened is what keeps trackers out — a cookie jar would be mostly ad networks.
+ * The filter's job is narrower: it holds what gets remembered inside the data
+ * the user chose to bring over. See {@link readBrowserSites}.
+ *
+ * Each record is keyed by the host actually visited, so the favicon lookup and
+ * the omnibox agree on the string.
+ *
+ * Cannot throw. A name and an icon are a nicety layered on cookies and
+ * passwords that have already landed, so no failure in here — including a
+ * synchronous one from a dependency — may reach the caller and rewrite a
+ * finished import as a failed one.
+ */
+async function rememberImportedSites(
+ domains: ReadonlySet,
+ profile: BrowserProfile,
+ deps: ImportServiceDeps
+): Promise {
+ if (domains.size === 0 || !profile.historyPath) return
+
+ try {
+ const sites = await deps.readSites(profile.historyPath, domains)
+ if (sites.length === 0) return
+
+ const origins = new Set(sites.map((site) => originOf(site.hostname)))
+ const icons = profile.faviconsPath
+ ? await deps
+ .readFavicons(profile.faviconsPath, origins)
+ .catch(() => new Map())
+ : new Map()
+
+ const importedAt = new Date().toISOString()
+ await deps.rememberSites(
+ sites.map((site) => ({
+ hostname: site.hostname,
+ name: site.name,
+ icon: icons.get(originOf(site.hostname)),
+ visits: site.visits,
+ importedAt,
+ }))
+ )
+ } catch {
+ // Category only, like every other failure path here: the detail that would
+ // make this message useful is the hostnames, which is exactly what must not
+ // reach a local log. Without the line, a directory that can never be
+ // written leaves the omnibox permanently empty with no signal anywhere.
+ logger.warn('Could not record the sites an import brought over')
+ }
+}
+
+const originOf = (hostname: string) => `https://${hostname}`
+
+function categorize(error: unknown, kind: 'cookie' | 'password'): BrowserImportResult['error'] {
+ if (error instanceof ImportFailure) {
+ logger.warn(`Chrome ${kind} import failed`, { code: error.code })
+ return error.code
+ }
+ logger.warn(`Chrome ${kind} import failed with an unexpected error`)
+ return 'unknown'
+}
diff --git a/apps/desktop/src/main/browser-import/index.ts b/apps/desktop/src/main/browser-import/index.ts
new file mode 100644
index 00000000000..92ec971868d
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/index.ts
@@ -0,0 +1,68 @@
+import type {
+ BrowserChromeImportResult,
+ BrowserCredentialConflictPolicy,
+ BrowserImportProfile,
+ BrowserImportResult,
+ BrowserPasswordImportResult,
+} from '@sim/desktop-bridge'
+import { importAgentCookies } from '@/main/browser-agent/session'
+import { credentialsAvailable, importCredentials } from '@/main/browser-credentials'
+import { readBrowserCookies } from '@/main/browser-import/chromium-cookies'
+import { readSafeStoragePassword } from '@/main/browser-import/chromium-crypto'
+import { readBrowserFavicons } from '@/main/browser-import/chromium-favicons'
+import { readBrowserPasswords } from '@/main/browser-import/chromium-passwords'
+import { listAllBrowserProfiles } from '@/main/browser-import/chromium-profiles'
+import { readBrowserSites } from '@/main/browser-import/chromium-site-names'
+import {
+ type ImportServiceDeps,
+ listImportableProfiles,
+ importChromeCookies as runCookieImport,
+ importChromeData as runDataImport,
+ importChromePasswords as runPasswordImport,
+} from '@/main/browser-import/import-service'
+import { rememberSites } from '@/main/browser-sites'
+
+/**
+ * Composition root for the Chrome importer: binds the real Keychain, profile,
+ * database, browser-profile, and vault implementations to the policy in
+ * `import-service`. The IPC layer talks to this module and nothing deeper.
+ */
+function deps(): ImportServiceDeps {
+ return {
+ platform: process.platform,
+ listProfiles: () => listAllBrowserProfiles(),
+ readSafeStoragePassword: (item) => readSafeStoragePassword(item),
+ readCookies: (cookiesPath, key) => readBrowserCookies(cookiesPath, key),
+ writeCookies: (cookies) => importAgentCookies(cookies),
+ readPasswords: (loginDataPath, key) => readBrowserPasswords(loginDataPath, key),
+ readFavicons: (faviconsPath, origins) => readBrowserFavicons(faviconsPath, origins),
+ readSites: (historyPath, domains) => readBrowserSites(historyPath, domains),
+ rememberSites: (records) => rememberSites(records),
+ vault: {
+ isAvailable: () => credentialsAvailable(),
+ importCredentials: (candidates, policy) => importCredentials(candidates, policy),
+ },
+ }
+}
+
+export function listChromeImportProfiles(): Promise {
+ return listImportableProfiles(deps())
+}
+
+export function importChromeCookies(profileId?: string): Promise {
+ return runCookieImport(profileId, deps())
+}
+
+export function importChromePasswords(
+ profileId?: string,
+ policy: BrowserCredentialConflictPolicy = 'keep-existing'
+): Promise {
+ return runPasswordImport(profileId, policy, deps())
+}
+
+export function importChromeData(
+ profileId?: string,
+ policy: BrowserCredentialConflictPolicy = 'keep-existing'
+): Promise {
+ return runDataImport(profileId, policy, deps())
+}
diff --git a/apps/desktop/src/main/browser-import/sqlite-source.ts b/apps/desktop/src/main/browser-import/sqlite-source.ts
new file mode 100644
index 00000000000..69b6d6c2be9
--- /dev/null
+++ b/apps/desktop/src/main/browser-import/sqlite-source.ts
@@ -0,0 +1,76 @@
+import { copyFile, mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { ImportFailure } from '@/main/browser-import/types'
+
+/**
+ * Reads a Chrome SQLite database without touching the original.
+ *
+ * Chrome keeps its databases open and may hold a write-ahead log, so every
+ * read works from a private copy: the source is only ever read, never opened
+ * for writing and never locked, and the copy lives in a fresh temporary
+ * directory removed on success, failure, and cancellation alike. Both the
+ * cookie and password readers go through here so that guarantee is written
+ * once rather than reimplemented per database.
+ */
+
+/** SQLite keeps recent writes beside the main file; both are needed for a faithful copy. */
+const SQLITE_SIDECAR_SUFFIXES = ['-wal', '-shm']
+
+async function queryCopy(databasePath: string, query: string): Promise