diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml
index db1e403eb5d..b3c51213545 100644
--- a/.github/workflows/desktop-release.yml
+++ b/.github/workflows/desktop-release.yml
@@ -55,6 +55,7 @@ jobs:
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
+ persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.sha }}
# Prerelease versions carry their environment in the tag: -dev.N is a
@@ -340,10 +341,18 @@ jobs:
exit 1
fi
export GH_TOKEN
- if ! gh release view "$VERSION" --repo "$RELEASE_REPOSITORY" >/dev/null; then
+ if ! RELEASE_ID="$(
+ gh release view "$VERSION" --repo "$RELEASE_REPOSITORY" \
+ --json databaseId --jq '.databaseId'
+ )"; then
echo "::error::Release $VERSION does not exist in $RELEASE_REPOSITORY."
exit 1
fi
+ if ! [[ "$RELEASE_ID" =~ ^[0-9]+$ ]]; then
+ echo "::error::Release $VERSION returned an invalid database ID."
+ exit 1
+ fi
+ RELEASE_JSON="$(gh api "repos/${RELEASE_REPOSITORY}/releases/${RELEASE_ID}")"
SEMVER="${VERSION#v}"
ARTIFACTS=(
"apps/desktop/release/Sim-${SEMVER}-universal.dmg"
@@ -354,11 +363,10 @@ jobs:
)
upload_or_verify() {
local ARTIFACT="$1"
- local NAME SIZE DIGEST RELEASE_JSON REMOTE REMOTE_SIZE REMOTE_DIGEST
+ local NAME SIZE DIGEST 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")"
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index 0d9b1bdfe98..c8deeb969d1 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -34,7 +34,9 @@ src/main/ # main process (bundled to dist/main.cjs)
browser-credentials/ # saved passwords, OS-auth gated, safeStorage at rest
browser-sites/ # imported site directory, safeStorage at rest
browser-import/ # one-shot import of profiles, cookies and passwords
-src/preload/ # contextBridge IPC bridge (bundled to dist/preload.cjs)
+src/preload/ # isolated renderer bridges
+ index.ts # hosted-app contextBridge IPC bridge (dist/preload.cjs)
+ browser/ # minimal agent-browser credential helper (dist/browser-preload.cjs)
native/ # Node-API/AppKit bridge for native macOS Help docs search
static/ # bundled local pages (offline.html)
e2e/ # Playwright _electron smoke suite
@@ -54,7 +56,7 @@ SIM_DESKTOP_ORIGIN=http://localhost:3000 bun run dev # against local sim
- `bun run type-check` / `lint:check` — standard workspace checks; CI picks these up automatically via `turbo run`.
- `SIM_DESKTOP_USER_DATA=
` isolates settings/partition state (used by e2e).
-Everything is bundled by esbuild into `dist/main.cjs` + `dist/preload.cjs` — including `electron-updater` and the `@sim/*` packages — so the packaged app has **no runtime node_modules** and `electron-builder` needs no lockfile/npmRebuild step (this is the deliberate workaround for Bun ↔ electron-builder friction; there is no `package-lock.json`).
+The main process and two preloads are bundled by esbuild into `dist/main.cjs`, `dist/preload.cjs`, and `dist/browser-preload.cjs`, including `electron-updater` and the `@sim/*` packages. The native `@lydell/node-pty` packages stay external so Electron can load their architecture-specific prebuilds from the packaged runtime `node_modules`; `npmRebuild` remains disabled because those Node-API prebuilds are already ABI-stable. There is no `package-lock.json`.
## Auth model (read before touching auth)
@@ -99,7 +101,7 @@ Overall this is **within normal thin-wrapper coupling** — every item is either
Local unsigned build: `bun run package:dir` (app in `release/mac-universal/`). Signed: `bun run package:mac` with `CSC_LINK`/`CSC_KEY_PASSWORD` exported.
-Pre-release share (no Developer ID yet): `SIM_DESKTOP_DEFAULT_ORIGIN=https://www.dev.sim.ai bun run package:share` builds a DMG whose fresh installs default to that origin (baked at build time; official builds leave it unset → prod) and skips per-file signature timestamps. Recipients must clear quarantine once: `xattr -cr /Applications/Sim.app`.
+Local unsigned pre-release share: `SIM_DESKTOP_DEFAULT_ORIGIN=https://www.dev.sim.ai bun run package:share` builds a DMG whose fresh installs default to that origin (baked at build time; official builds leave it unset → prod) and skips per-file signature timestamps. Recipients must clear quarantine once: `xattr -cr /Applications/Sim.app`.
The build also derives the app icon from `SIM_DESKTOP_DEFAULT_ORIGIN`. Every channel uses the exact production icon with its white background and black `sim` mark. Non-production channels add a thin outline using existing platform colors: dev uses orange, staging uses Loop blue, and localhost uses Workflow violet. The macOS menu-bar icon also carries a compact `D`, `S`, or `L` subscript for those environments; production remains unmarked. 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.
@@ -185,10 +187,11 @@ Raw local file bytes are never exposed through the preload bridge and cannot be
## Known caveats
-- Microphone and camera are denied by design (the permission matrix grants only sanitized clipboard writes to the app origin).
+- The hosted Sim renderer may request microphone access for voice input from the configured app origin; camera access remains denied. On macOS the shell also requires the operating-system microphone grant. Separately, a page in the isolated agent browser may request microphone or camera only from its main frame after a recent native user gesture; Sim then requires an explicit document-scoped prompt and the operating-system grant where applicable.
+- The built-in agent browser is not a general-purpose download manager. Its dedicated partition applies the same bounded policy to every download, including one started by a direct user click: at most 2 GiB per file, two active downloads per task, six app-wide, and a 1 GiB free-disk reserve. A rejected download appears in the browser's downloads menu; use a normal browser for an intentionally larger transfer.
- Default Electron ships H.264/AAC/MP3 — do not swap in the codec-free ffmpeg build.
- Third-party web analytics (GTM/GA) are blocked at the network layer by default (`blockThirdPartyAnalytics`); first-party PostHog `/ingest` is untouched.
-- `Cmd+F` find-in-page overlay is not implemented (Monaco and tables ship their own finds); revisit if users ask.
+- `Cmd+F` opens the native find overlay in built-in browser tabs. The hosted Sim workspace continues to use Monaco- and table-specific find surfaces.
- Sign-in uses only the `127.0.0.1` loopback callback, which needs no OS registration — so it completes identically under `bun run dev` (unpackaged) and in a packaged build. There is no custom URL scheme.
## Electron upgrades
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 52161642cb8..ec3d66e756a 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -50,7 +50,7 @@
"@sim/tsconfig": "workspace:*",
"@types/micromatch": "4.0.10",
"@types/node": "24.2.1",
- "electron": "43.1.1",
+ "electron": "43.5.0",
"electron-builder": "26.15.3",
"esbuild": "0.28.1",
"typescript": "^7.0.2",
diff --git a/apps/desktop/src/main/browser-agent/driver-profile.test.ts b/apps/desktop/src/main/browser-agent/driver-profile.test.ts
index fb77abf35d5..90d5dab667d 100644
--- a/apps/desktop/src/main/browser-agent/driver-profile.test.ts
+++ b/apps/desktop/src/main/browser-agent/driver-profile.test.ts
@@ -10,6 +10,8 @@ const mocks = vi.hoisted(() => ({
vi.mock('@/main/browser-agent/session', () => ({
clearProfileStorage: mocks.clearProfileStorage,
initSession: vi.fn(),
+ isBrowserScopeSuspended: vi.fn(() => false),
+ resolveBrowserScopeId: vi.fn((scopeId: string) => scopeId),
}))
vi.mock('@/main/browser-credentials', () => ({
@@ -18,7 +20,12 @@ vi.mock('@/main/browser-credentials', () => ({
initFillCoordinator: vi.fn(),
}))
-import { clearBrowserProfile, initDriver } from '@/main/browser-agent/driver'
+import {
+ captureBrowserToolQueueBoundary,
+ clearBrowserProfile,
+ executeTool,
+ initDriver,
+} from '@/main/browser-agent/driver'
import type { ConfigStore } from '@/main/config'
describe('clearBrowserProfile', () => {
@@ -52,4 +59,35 @@ describe('clearBrowserProfile', () => {
expect(mocks.clearCredentials).toHaveBeenCalledTimes(2)
expect(config.flush).toHaveBeenCalledTimes(2)
})
+
+ it('invalidates pre-wipe authorization and retires live work before profile teardown', async () => {
+ initDriver(
+ {
+ onPageState: vi.fn(),
+ onTabsState: vi.fn(),
+ onSessionStatus: vi.fn(),
+ onFillAvailability: vi.fn(),
+ },
+ () => null
+ )
+ const boundary = captureBrowserToolQueueBoundary('chat-before-wipe')
+ expect(boundary).not.toBeNull()
+ if (!boundary) throw new Error('Expected browser tool authorization admission')
+
+ await clearBrowserProfile()
+ const staleExecution = await executeTool(
+ 'chat-before-wipe',
+ 'browser_list_sessions',
+ {},
+ 'tool-authorized-before-wipe',
+ boundary
+ )
+
+ expect(mocks.clearProfileStorage).toHaveBeenCalledOnce()
+ expect(mocks.clearCredentials).toHaveBeenCalledOnce()
+ expect(staleExecution).toMatchObject({
+ ok: false,
+ error: expect.stringContaining('cancelled before it started'),
+ })
+ })
})
diff --git a/apps/desktop/src/main/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts
index ade81aff505..4610e0999f1 100644
--- a/apps/desktop/src/main/browser-agent/driver.test.ts
+++ b/apps/desktop/src/main/browser-agent/driver.test.ts
@@ -1,3 +1,4 @@
+import { BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS } from '@sim/browser-protocol'
import type { MenuItemConstructorOptions } from 'electron'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -33,6 +34,29 @@ function freshDriver(): DriverModule {
return driverModule
}
+type BrowserToolQueueBoundary = NonNullable<
+ ReturnType
+>
+
+function capturePendingAuthorizations(
+ driver: DriverModule,
+ scopeId: string,
+ count: number = driver.BROWSER_TOOL_ADMISSION_LIMITS.perScope
+): BrowserToolQueueBoundary[] {
+ const boundaries = Array.from({ length: count }, () =>
+ driver.captureBrowserToolQueueBoundary(scopeId)
+ )
+ expect(boundaries.every((boundary) => boundary !== null)).toBe(true)
+ return boundaries.filter((boundary): boundary is BrowserToolQueueBoundary => boundary !== null)
+}
+
+function releasePendingAuthorizations(
+ driver: DriverModule,
+ boundaries: readonly BrowserToolQueueBoundary[]
+): void {
+ for (const boundary of boundaries) driver.releaseBrowserToolQueueBoundary(boundary)
+}
+
/** Match the serialized function invocation, not comments or helper names in its body. */
function isPageCall(expression: string, fnName: string): boolean {
return expression.includes(`function ${fnName}(`)
@@ -53,6 +77,7 @@ describe('executeTool', () => {
})
it('validates navigation URLs before touching the session', async () => {
+ const grant = vi.spyOn(session, 'grantSiteOriginForAgentNavigation')
const result = await driver.executeTool('chat-test', 'browser_navigate', {
url: 'file:///etc/passwd',
})
@@ -60,6 +85,7 @@ describe('executeTool', () => {
ok: false,
error: 'URL must be absolute and start with http:// or https://',
})
+ expect(grant).not.toHaveBeenCalled()
})
it('reports missing required parameters by name', async () => {
@@ -68,6 +94,23 @@ describe('executeTool', () => {
expect(result.error).toMatch(/Missing required parameter "url"/)
})
+ it('grants only SSRF-checked agent navigation destinations before loading them', async () => {
+ const grant = vi.spyOn(session, 'grantSiteOriginForAgentNavigation')
+ const navigations = [
+ ['browser_navigate', 'http://127.0.0.1:4011/navigate'],
+ ['browser_open_url', 'http://127.0.0.1:4012/open'],
+ ['browser_open_tab', 'http://127.0.0.1:4013/tab'],
+ ] as const
+
+ for (const [tool, url] of navigations) {
+ await expect(driver.executeTool('chat-test', tool, { url })).resolves.toMatchObject({
+ ok: true,
+ })
+ expect(grant).toHaveBeenCalledWith(expect.anything(), url)
+ }
+ expect(grant).toHaveBeenCalledTimes(navigations.length)
+ })
+
it('reports an aborted navigation when Chromium never leaves the current URL', async () => {
vi.useFakeTimers()
try {
@@ -250,6 +293,410 @@ describe('executeTool', () => {
}
})
+ it('does not let a detached takeover poll touch a disposed scope', async () => {
+ await driver.executeTool('chat-test', 'browser_open_tab', {})
+ vi.useFakeTimers()
+ const hasSession = vi.spyOn(session, 'hasSession')
+ try {
+ const takeover = driver.executeTool(
+ 'chat-test',
+ 'browser_request_takeover',
+ { reason: 'Please finish in the browser' },
+ 'tool-disposed-takeover'
+ )
+ await vi.advanceTimersByTimeAsync(0)
+
+ driver.disposeBrowserScope('chat-test')
+ hasSession.mockClear()
+ await expect(takeover).resolves.toMatchObject({
+ ok: false,
+ error: expect.stringContaining('cancelled'),
+ })
+ await vi.advanceTimersByTimeAsync(1_500)
+
+ expect(hasSession).not.toHaveBeenCalled()
+ } finally {
+ hasSession.mockRestore()
+ vi.useRealTimers()
+ }
+ })
+
+ it('does not let a detached text wait touch a disposed scope', async () => {
+ await driver.executeTool('chat-test', 'browser_open_tab', {})
+ const contents = session.requireTab().view.webContents
+ vi.mocked(contents.getURL).mockReturnValue('https://example.com/')
+ let resolvePageProbe: (value: boolean) => void = () => {}
+ vi.mocked(contents.executeJavaScript).mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolvePageProbe = resolve
+ })
+ )
+ vi.useFakeTimers()
+ const automationTab = vi.spyOn(session, 'automationTab')
+ try {
+ const waiting = driver.executeTool(
+ 'chat-test',
+ 'browser_wait_for',
+ { text: 'ready', timeoutMs: 120_000 },
+ 'tool-disposed-wait'
+ )
+ await vi.advanceTimersByTimeAsync(0)
+ expect(contents.executeJavaScript).toHaveBeenCalled()
+
+ driver.disposeBrowserScope('chat-test')
+ automationTab.mockClear()
+ await expect(waiting).resolves.toMatchObject({
+ ok: false,
+ error: expect.stringContaining('cancelled'),
+ })
+ resolvePageProbe(false)
+ await vi.advanceTimersByTimeAsync(300)
+
+ expect(automationTab).not.toHaveBeenCalled()
+ } finally {
+ automationTab.mockRestore()
+ vi.useRealTimers()
+ }
+ })
+
+ it('does not let a detached screenshot verification touch a disposed scope', async () => {
+ await driver.executeTool('chat-test', 'browser_open_tab', {})
+ const contents = session.requireTab().view.webContents
+ vi.mocked(contents.getURL).mockReturnValue('https://example.com/')
+ let resolveCapture: (capture: cdp.ScreenshotCapture) => void = () => {}
+ const captureScreenshot = vi.spyOn(cdp, 'captureScreenshot').mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveCapture = resolve
+ })
+ )
+ const automationTab = vi.spyOn(session, 'automationTab')
+ try {
+ const screenshot = driver.executeTool(
+ 'chat-test',
+ 'browser_screenshot',
+ {},
+ 'tool-disposed-screenshot'
+ )
+ await Promise.resolve()
+ expect(captureScreenshot).toHaveBeenCalledOnce()
+
+ driver.disposeBrowserScope('chat-test')
+ automationTab.mockClear()
+ await expect(screenshot).resolves.toMatchObject({
+ ok: false,
+ error: expect.stringContaining('cancelled'),
+ })
+ resolveCapture({
+ dataUrl: 'data:image/jpeg;base64,c2lt',
+ scale: 1,
+ viewport: { width: 800, height: 600 },
+ imageSize: { width: 800, height: 600 },
+ })
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(automationTab).not.toHaveBeenCalled()
+ } finally {
+ automationTab.mockRestore()
+ captureScreenshot.mockRestore()
+ }
+ })
+
+ it('cancels active and queued work before closing the browser session', async () => {
+ await driver.executeTool('chat-test', 'browser_open_tab', {})
+ vi.useFakeTimers()
+ try {
+ const waiting = driver.executeTool(
+ 'chat-test',
+ 'browser_wait_for',
+ { timeoutMs: 120_000 },
+ 'tool-active-at-close'
+ )
+ await vi.advanceTimersByTimeAsync(0)
+ const queuedOpen = driver.executeTool(
+ 'chat-test',
+ 'browser_open_tab',
+ {},
+ 'tool-queued-at-close'
+ )
+
+ driver.closeBrowserSession()
+
+ await expect(waiting).resolves.toMatchObject({
+ ok: false,
+ error: expect.stringContaining('cancelled'),
+ })
+ await expect(queuedOpen).resolves.toMatchObject({
+ ok: false,
+ error: expect.stringContaining('cancelled before it started'),
+ })
+ expect(session.withBrowserScope('chat-test', () => session.peekTabsState()).tabs).toEqual([])
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('rejects authorization captured before a browser-session teardown', async () => {
+ const boundary = driver.captureBrowserToolQueueBoundary('chat-test')
+ expect(boundary).not.toBeNull()
+ if (!boundary) throw new Error('Expected browser tool authorization admission')
+ driver.closeBrowserSession()
+ driver.activateBrowserScope('chat-test')
+
+ await expect(
+ driver.executeTool(
+ 'chat-test',
+ 'browser_open_tab',
+ {},
+ 'tool-authorized-before-close',
+ boundary
+ )
+ ).resolves.toMatchObject({
+ ok: false,
+ error: expect.stringContaining('cancelled before it started'),
+ })
+ expect(session.withBrowserScope('chat-test', () => session.peekTabsState()).tabs).toEqual([])
+ })
+
+ it('captures a missing scope without materializing driver state', async () => {
+ const boundary = driver.captureBrowserToolQueueBoundary('chat-not-yet-active')
+ expect(boundary).not.toBeNull()
+ if (!boundary) throw new Error('Expected browser tool authorization admission')
+
+ expect(boundary).toMatchObject({
+ scopeId: 'chat-not-yet-active',
+ generation: null,
+ cancellationEpoch: null,
+ })
+
+ driver.activateBrowserScope('chat-not-yet-active')
+ await expect(
+ driver.executeTool(
+ 'chat-not-yet-active',
+ 'browser_list_tabs',
+ {},
+ 'tool-authorized-before-activation',
+ boundary
+ )
+ ).resolves.toMatchObject({ ok: true })
+ })
+
+ it('rejects a missing-scope authorization after process-wide browser teardown', async () => {
+ const boundary = driver.captureBrowserToolQueueBoundary('chat-not-yet-active')
+ expect(boundary).not.toBeNull()
+ if (!boundary) throw new Error('Expected browser tool authorization admission')
+ driver.closeBrowserSession()
+ driver.activateBrowserScope('chat-not-yet-active')
+
+ await expect(
+ driver.executeTool(
+ 'chat-not-yet-active',
+ 'browser_list_tabs',
+ {},
+ 'tool-authorized-before-global-close',
+ boundary
+ )
+ ).resolves.toMatchObject({
+ ok: false,
+ error: expect.stringContaining('cancelled before it started'),
+ })
+ })
+
+ it('rejects a first-use authorization after its scope is disposed and reopened', async () => {
+ const boundary = driver.captureBrowserToolQueueBoundary('chat-first-use-disposed')
+ expect(boundary).not.toBeNull()
+ if (!boundary) throw new Error('Expected browser tool authorization admission')
+
+ driver.disposeBrowserScope('chat-first-use-disposed')
+ driver.activateBrowserScope('chat-first-use-disposed')
+
+ await expect(
+ driver.executeTool(
+ 'chat-first-use-disposed',
+ 'browser_open_tab',
+ {},
+ 'tool-authorized-before-first-use-disposal',
+ boundary
+ )
+ ).resolves.toMatchObject({
+ ok: false,
+ error: expect.stringContaining('cancelled before it started'),
+ })
+ expect(
+ session.withBrowserScope('chat-first-use-disposed', () => session.peekTabsState()).tabs
+ ).toEqual([])
+ })
+
+ it('rejects a first-use authorization after its scope is suspended and reopened', async () => {
+ const boundary = driver.captureBrowserToolQueueBoundary('chat-first-use-suspended')
+ expect(boundary).not.toBeNull()
+ if (!boundary) throw new Error('Expected browser tool authorization admission')
+
+ expect(driver.suspendBrowserScope('chat-first-use-suspended')).toBe(true)
+ driver.activateBrowserScope('chat-first-use-suspended')
+
+ await expect(
+ driver.executeTool(
+ 'chat-first-use-suspended',
+ 'browser_open_tab',
+ {},
+ 'tool-authorized-before-first-use-suspension',
+ boundary
+ )
+ ).resolves.toMatchObject({
+ ok: false,
+ error: expect.stringContaining('cancelled before it started'),
+ })
+ expect(
+ session.withBrowserScope('chat-first-use-suspended', () => session.peekTabsState()).tabs
+ ).toEqual([])
+ })
+
+ it('cancels a provisional first-use authorization when its durable scope is disposed', async () => {
+ const boundary = driver.captureBrowserToolQueueBoundary('pending:first-use-disposed')
+ expect(boundary).not.toBeNull()
+ if (!boundary) throw new Error('Expected browser tool authorization admission')
+ expect(driver.migrateBrowserScope('pending:first-use-disposed', 'chat-first-use-durable')).toBe(
+ true
+ )
+
+ driver.disposeBrowserScope('chat-first-use-durable')
+ driver.activateBrowserScope('chat-first-use-durable')
+
+ await expect(
+ driver.executeTool(
+ 'chat-first-use-durable',
+ 'browser_open_tab',
+ {},
+ 'tool-authorized-before-migrated-disposal',
+ boundary
+ )
+ ).resolves.toMatchObject({
+ ok: false,
+ error: expect.stringContaining('cancelled before it started'),
+ })
+ })
+
+ it('keeps authorization teardown scoped to its existing driver state', async () => {
+ driver.activateBrowserScope('chat-other')
+ const boundary = driver.captureBrowserToolQueueBoundary('chat-other')
+ expect(boundary).not.toBeNull()
+ if (!boundary) throw new Error('Expected browser tool authorization admission')
+
+ driver.disposeBrowserScope('chat-test')
+
+ await expect(
+ driver.executeTool(
+ 'chat-other',
+ 'browser_list_tabs',
+ {},
+ 'tool-authorized-in-other-scope',
+ boundary
+ )
+ ).resolves.toMatchObject({ ok: true })
+ })
+
+ it('rejects an existing-scope authorization after disposal and recreation', async () => {
+ const boundary = driver.captureBrowserToolQueueBoundary('chat-test')
+ expect(boundary).not.toBeNull()
+ if (!boundary) throw new Error('Expected browser tool authorization admission')
+
+ driver.disposeBrowserScope('chat-test')
+ driver.activateBrowserScope('chat-test')
+
+ await expect(
+ driver.executeTool(
+ 'chat-test',
+ 'browser_list_tabs',
+ {},
+ 'tool-authorized-before-scope-disposal',
+ boundary
+ )
+ ).resolves.toMatchObject({
+ ok: false,
+ error: expect.stringContaining('cancelled before it started'),
+ })
+ })
+
+ it('bounds pending authorizations without materializing their scopes', () => {
+ const boundaries = capturePendingAuthorizations(driver, 'chat-pending-authorization')
+
+ expect(boundaries.every((boundary) => boundary?.generation === null)).toBe(true)
+ expect(driver.captureBrowserToolQueueBoundary('chat-pending-authorization')).toBeNull()
+
+ releasePendingAuthorizations(driver, boundaries)
+ const replacement = driver.captureBrowserToolQueueBoundary('chat-pending-authorization')
+ expect(replacement).not.toBeNull()
+ if (replacement) driver.releaseBrowserToolQueueBoundary(replacement)
+ })
+
+ it('retains cancelled authorization admissions until their fetches settle', () => {
+ const boundaries = capturePendingAuthorizations(driver, 'chat-test')
+
+ expect(driver.cancelActiveTool('chat-test')).toBe(true)
+ expect(boundaries.every((boundary) => boundary.cancelled)).toBe(true)
+ expect(driver.captureBrowserToolQueueBoundary('chat-test')).toBeNull()
+
+ releasePendingAuthorizations(driver, boundaries)
+ const replacement = driver.captureBrowserToolQueueBoundary('chat-test')
+ expect(replacement).not.toBeNull()
+ if (replacement) driver.releaseBrowserToolQueueBoundary(replacement)
+ })
+
+ it('retains disposed-scope authorization admissions until their fetches settle', () => {
+ const boundaries = capturePendingAuthorizations(driver, 'chat-disposed-authorizations')
+
+ driver.disposeBrowserScope('chat-disposed-authorizations')
+ expect(boundaries.every((boundary) => boundary.cancelled)).toBe(true)
+ expect(driver.captureBrowserToolQueueBoundary('chat-disposed-authorizations')).toBeNull()
+
+ releasePendingAuthorizations(driver, boundaries)
+ const replacement = driver.captureBrowserToolQueueBoundary('chat-disposed-authorizations')
+ expect(replacement).not.toBeNull()
+ if (replacement) driver.releaseBrowserToolQueueBoundary(replacement)
+ })
+
+ it('retains suspended-scope authorization admissions until their fetches settle', () => {
+ const boundaries = capturePendingAuthorizations(driver, 'chat-suspended-authorizations')
+
+ expect(driver.suspendBrowserScope('chat-suspended-authorizations')).toBe(true)
+ expect(boundaries.every((boundary) => boundary.cancelled)).toBe(true)
+ expect(driver.captureBrowserToolQueueBoundary('chat-suspended-authorizations')).toBeNull()
+
+ releasePendingAuthorizations(driver, boundaries)
+ const replacement = driver.captureBrowserToolQueueBoundary('chat-suspended-authorizations')
+ expect(replacement).not.toBeNull()
+ if (replacement) driver.releaseBrowserToolQueueBoundary(replacement)
+ })
+
+ it('retains process-wide authorization admissions across driver reinitialization', () => {
+ const boundaries = ['chat-auth-a', 'chat-auth-b', 'chat-auth-c', 'chat-auth-d'].flatMap(
+ (scopeId) => capturePendingAuthorizations(driver, scopeId)
+ )
+ expect(boundaries).toHaveLength(driver.BROWSER_TOOL_ADMISSION_LIMITS.process)
+
+ driver.initDriver(
+ {
+ onPageState: vi.fn(),
+ onTabsState: vi.fn(),
+ onSessionStatus: vi.fn(),
+ onFillAvailability: vi.fn(),
+ },
+ () => null
+ )
+
+ expect(boundaries.every((boundary) => boundary.cancelled)).toBe(true)
+ expect(driver.captureBrowserToolQueueBoundary('chat-after-reinit')).toBeNull()
+
+ driver.releaseBrowserToolQueueBoundary(boundaries[0])
+ const replacement = driver.captureBrowserToolQueueBoundary('chat-after-reinit')
+ expect(replacement).not.toBeNull()
+ if (replacement) driver.releaseBrowserToolQueueBoundary(replacement)
+ releasePendingAuthorizations(driver, boundaries.slice(1))
+ })
+
it('honors cancellation that arrives before the authorized tool invocation', async () => {
expect(driver.cancelTool('chat-test', 'tool-before-authorization')).toBe(true)
@@ -588,6 +1035,110 @@ describe('executeTool', () => {
expect(respond).toHaveBeenCalledWith('request-1', true)
})
+ it('routes an exact renderer site decision through the scoped session boundary', async () => {
+ const respond = vi.spyOn(session, 'respondToSitePermission').mockReturnValue(true)
+
+ await driver.handlePanelAction('chat-test', {
+ action: 'respond-site-permission',
+ requestId: 'request-1',
+ allowed: true,
+ })
+ await driver.handlePanelAction('chat-test', {
+ action: 'respond-site-permission',
+ requestId: 'request-2',
+ })
+
+ expect(respond).toHaveBeenCalledOnce()
+ expect(respond).toHaveBeenCalledWith('request-1', true)
+ })
+
+ it('grants only the exact origin entered through the user omnibox', async () => {
+ await driver.executeTool('chat-test', 'browser_open_tab', {})
+ const contents = session.requireTab().view.webContents
+ const grant = vi.spyOn(session, 'grantSiteOriginForUserNavigation')
+
+ await driver.handlePanelAction('chat-test', {
+ action: 'navigate',
+ url: 'https://docs.example/private?token=secret',
+ })
+
+ expect(grant).toHaveBeenCalledOnce()
+ expect(grant).toHaveBeenCalledWith(contents, 'https://docs.example/private?token=secret')
+ expect(contents.loadURL).toHaveBeenCalledWith('https://docs.example/private?token=secret')
+ })
+
+ it('waits for a selected restored tab before reporting it ready to the model', async () => {
+ await driver.executeTool('chat-test', 'browser_open_tab', {})
+ const tab = session.requireTab()
+ let releaseRestore = () => {}
+ const wait = vi.spyOn(session, 'waitForPendingTabRestore').mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ releaseRestore = () => resolve(true)
+ })
+ )
+ let settled = false
+ const switched = driver
+ .executeTool('chat-test', 'browser_switch_tab', { tabId: tab.id })
+ .then((result) => {
+ settled = true
+ return result
+ })
+
+ await Promise.resolve()
+ expect(settled).toBe(false)
+ expect(wait).toHaveBeenCalledWith(tab)
+
+ releaseRestore()
+ await expect(switched).resolves.toMatchObject({
+ ok: true,
+ result: { tabId: tab.id },
+ })
+ wait.mockRestore()
+ })
+
+ it('does not report a timed-out restored tab as ready to the model', async () => {
+ await driver.executeTool('chat-test', 'browser_open_tab', {})
+ const tab = session.requireTab()
+ const wait = vi.spyOn(session, 'waitForPendingTabRestore').mockResolvedValue(false)
+
+ await expect(
+ driver.executeTool('chat-test', 'browser_switch_tab', { tabId: tab.id })
+ ).resolves.toMatchObject({
+ ok: false,
+ error: expect.stringContaining('did not finish loading'),
+ })
+
+ wait.mockRestore()
+ })
+
+ it('allows a fifty-second restored-tab consent and load without duplicating the tab', async () => {
+ vi.useFakeTimers()
+ try {
+ await driver.executeTool('chat-test', 'browser_open_tab', {})
+ const tab = session.requireTab()
+ const wait = vi.spyOn(session, 'waitForPendingTabRestore').mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ setTimeout(() => resolve(true), 50_000)
+ })
+ )
+
+ const switched = driver.executeTool('chat-test', 'browser_switch_tab', { tabId: tab.id })
+ await vi.advanceTimersByTimeAsync(50_000)
+
+ await expect(switched).resolves.toMatchObject({
+ ok: true,
+ result: { tabId: tab.id },
+ })
+ expect(session.listTabs()).toHaveLength(1)
+ expect(session.requireTab()).toBe(tab)
+ wait.mockRestore()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
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', {})
@@ -621,6 +1172,121 @@ describe('executeTool', () => {
expect(driver.migrateBrowserScope('pending:other-chat', 'chat-occupied')).toBe(false)
})
+ it('cancels only the replaced destination authorizations during migration', async () => {
+ await driver.executeTool('pending:new-chat', 'browser_open_tab', {})
+ driver.activateBrowserScope('chat-real')
+ const sourceBoundary = driver.captureBrowserToolQueueBoundary('pending:new-chat')
+ const destinationBoundary = driver.captureBrowserToolQueueBoundary('chat-real')
+ const otherBoundary = driver.captureBrowserToolQueueBoundary('chat-other')
+ expect(sourceBoundary).not.toBeNull()
+ expect(destinationBoundary).not.toBeNull()
+ expect(otherBoundary).not.toBeNull()
+ if (!sourceBoundary || !destinationBoundary || !otherBoundary) {
+ throw new Error('Expected browser tool authorization admissions')
+ }
+
+ expect(driver.migrateBrowserScope('pending:new-chat', 'chat-real')).toBe(true)
+
+ await expect(
+ driver.executeTool(
+ 'chat-real',
+ 'browser_list_tabs',
+ {},
+ 'tool-destination-before-migration',
+ destinationBoundary
+ )
+ ).resolves.toMatchObject({
+ ok: false,
+ error: expect.stringContaining('cancelled before it started'),
+ })
+ await expect(
+ driver.executeTool(
+ 'chat-real',
+ 'browser_list_tabs',
+ {},
+ 'tool-source-before-migration',
+ sourceBoundary
+ )
+ ).resolves.toMatchObject({ ok: true })
+ await expect(
+ driver.executeTool(
+ 'chat-other',
+ 'browser_list_tabs',
+ {},
+ 'tool-other-during-migration',
+ otherBoundary
+ )
+ ).resolves.toMatchObject({ ok: true })
+ })
+
+ it('retains replaced destination admissions until their authorization fetches settle', async () => {
+ await driver.executeTool('pending:new-chat', 'browser_open_tab', {})
+ driver.activateBrowserScope('chat-real')
+ const sourceBoundary = driver.captureBrowserToolQueueBoundary('pending:new-chat')
+ const destinationBoundaries = capturePendingAuthorizations(
+ driver,
+ 'chat-real',
+ driver.BROWSER_TOOL_ADMISSION_LIMITS.perScope - 1
+ )
+ expect(sourceBoundary).not.toBeNull()
+ if (!sourceBoundary) throw new Error('Expected source authorization admission')
+
+ expect(driver.migrateBrowserScope('pending:new-chat', 'chat-real')).toBe(true)
+
+ expect(destinationBoundaries.every((boundary) => boundary.cancelled)).toBe(true)
+ expect(sourceBoundary.cancelled).toBe(false)
+ expect(driver.captureBrowserToolQueueBoundary('chat-real')).toBeNull()
+
+ releasePendingAuthorizations(driver, destinationBoundaries)
+ await expect(
+ driver.executeTool(
+ 'chat-real',
+ 'browser_list_tabs',
+ {},
+ 'tool-source-after-destination-settlement',
+ sourceBoundary
+ )
+ ).resolves.toMatchObject({ ok: true })
+ const replacement = driver.captureBrowserToolQueueBoundary('chat-real')
+ expect(replacement).not.toBeNull()
+ if (replacement) driver.releaseBrowserToolQueueBoundary(replacement)
+ })
+
+ it('keeps migrated source admissions charged to the durable scope after disposal', () => {
+ driver.activateBrowserScope('pending:new-chat')
+ const sourceBoundaries = capturePendingAuthorizations(driver, 'pending:new-chat')
+
+ expect(driver.migrateBrowserScope('pending:new-chat', 'chat-real')).toBe(true)
+ expect(sourceBoundaries.every((boundary) => boundary.scopeId === 'chat-real')).toBe(true)
+
+ driver.disposeBrowserScope('chat-real')
+ driver.activateBrowserScope('chat-real')
+ expect(sourceBoundaries.every((boundary) => boundary.cancelled)).toBe(true)
+ expect(driver.captureBrowserToolQueueBoundary('chat-real')).toBeNull()
+
+ releasePendingAuthorizations(driver, sourceBoundaries)
+ const replacement = driver.captureBrowserToolQueueBoundary('chat-real')
+ expect(replacement).not.toBeNull()
+ if (replacement) driver.releaseBrowserToolQueueBoundary(replacement)
+ })
+
+ it('retains a migrated provisional alias for callbacks until durable disposal', async () => {
+ await driver.executeTool('pending:new-chat', 'browser_open_tab', {})
+ const tab = session.withBrowserScope('pending:new-chat', () => session.requireTab())
+ expect(driver.migrateBrowserScope('pending:new-chat', 'chat-real')).toBe(true)
+
+ driver.disposeBrowserScope('pending:new-chat')
+
+ await expect(
+ driver.executeTool('pending:new-chat', 'browser_list_tabs', {})
+ ).resolves.toMatchObject({
+ ok: true,
+ result: { scopeId: 'chat-real', tabs: [{ tabId: tab.id }] },
+ })
+ driver.disposeBrowserScope('chat-real')
+ expect(tab.view.webContents.close).toHaveBeenCalledOnce()
+ })
+
it('keeps activation lazy, then restores and disposes through the driver API', async () => {
const snapshot: BrowserSessionSnapshot = {
v: 1,
@@ -753,6 +1419,141 @@ describe('executeTool', () => {
}
})
+ it('expires a bounded queue wait without running the stale action later', async () => {
+ await driver.executeTool('chat-test', 'browser_open_tab', {})
+ const contents = session.requireTab().view.webContents
+ vi.mocked(contents.loadURL).mockClear()
+ vi.useFakeTimers()
+ try {
+ const waiting = driver.executeTool(
+ 'chat-test',
+ 'browser_wait_for',
+ { timeoutMs: 120_000 },
+ 'tool-queue-head'
+ )
+ await vi.advanceTimersByTimeAsync(0)
+ const queued = driver.executeTool(
+ 'chat-test',
+ 'browser_navigate',
+ { url: 'http://127.0.0.1/expired' },
+ 'tool-queue-expired'
+ )
+
+ await vi.advanceTimersByTimeAsync(BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS)
+ await expect(queued).resolves.toMatchObject({
+ ok: false,
+ error: expect.stringContaining('waited too long for earlier browser work'),
+ })
+
+ expect(driver.cancelTool('chat-test', 'tool-queue-head')).toBe(true)
+ await vi.advanceTimersByTimeAsync(0)
+ await expect(waiting).resolves.toMatchObject({ ok: false })
+ expect(contents.loadURL).not.toHaveBeenCalledWith('http://127.0.0.1/expired')
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('bounds one scope queue and admits new work after the held head is cancelled', async () => {
+ vi.useFakeTimers()
+ try {
+ await driver.executeTool('chat-test', 'browser_open_tab', {})
+ const contents = session.requireTab().view.webContents
+ vi.mocked(contents.getURL).mockReturnValue('https://example.com/')
+ vi.mocked(contents.executeJavaScript).mockImplementation(() => new Promise(() => {}))
+
+ const held = driver.executeTool('chat-test', 'browser_snapshot', {}, 'held-scope-head')
+ await vi.advanceTimersByTimeAsync(0)
+ const queued = Array.from(
+ { length: driver.BROWSER_TOOL_ADMISSION_LIMITS.perScope - 1 },
+ (_, index) =>
+ driver.executeTool('chat-test', 'browser_list_tabs', {}, `queued-scope-${index}`)
+ )
+
+ await expect(
+ driver.executeTool('chat-test', 'browser_list_tabs', {}, 'scope-overflow')
+ ).resolves.toEqual({
+ ok: false,
+ error:
+ 'This task browser already has too many actions queued. Wait for earlier actions to finish.',
+ })
+
+ expect(driver.cancelTool('chat-test', 'held-scope-head')).toBe(true)
+ await expect(held).resolves.toMatchObject({
+ ok: false,
+ error: expect.stringContaining('cancelled'),
+ })
+ await expect(Promise.all(queued)).resolves.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ ok: true,
+ result: expect.objectContaining({ tabs: expect.any(Array) }),
+ }),
+ ])
+ )
+ await expect(
+ driver.executeTool('chat-test', 'browser_list_tabs', {}, 'scope-recovered')
+ ).resolves.toMatchObject({ ok: true })
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('bounds process-wide queues across scopes and recovers capacity on disposal', async () => {
+ vi.useFakeTimers()
+ const scopes = Array.from(
+ {
+ length:
+ driver.BROWSER_TOOL_ADMISSION_LIMITS.process /
+ driver.BROWSER_TOOL_ADMISSION_LIMITS.perScope,
+ },
+ (_, index) => `chat-admission-${index}`
+ )
+ const executions: Array> = []
+ try {
+ for (const scopeId of scopes) {
+ await driver.executeTool(scopeId, 'browser_open_tab', {})
+ const contents = session.withBrowserScope(
+ scopeId,
+ () => session.requireTab().view.webContents
+ )
+ vi.mocked(contents.getURL).mockReturnValue('https://example.com/')
+ vi.mocked(contents.executeJavaScript).mockImplementation(() => new Promise(() => {}))
+ executions.push(
+ driver.executeTool(scopeId, 'browser_snapshot', {}, `held-process-${scopeId}`)
+ )
+ await vi.advanceTimersByTimeAsync(0)
+ for (let index = 1; index < driver.BROWSER_TOOL_ADMISSION_LIMITS.perScope; index++) {
+ executions.push(
+ driver.executeTool(
+ scopeId,
+ 'browser_list_tabs',
+ {},
+ `queued-process-${scopeId}-${index}`
+ )
+ )
+ }
+ }
+
+ await expect(
+ driver.executeTool('chat-process-overflow', 'browser_list_tabs', {}, 'process-overflow')
+ ).resolves.toEqual({
+ ok: false,
+ error:
+ 'Sim already has too many browser actions queued. Wait for earlier actions to finish.',
+ })
+
+ driver.disposeBrowserScope(scopes[0])
+ await expect(
+ driver.executeTool('chat-process-recovered', 'browser_list_tabs', {}, 'process-recovered')
+ ).resolves.toMatchObject({ ok: true })
+ } finally {
+ for (const scopeId of scopes) driver.disposeBrowserScope(scopeId)
+ await Promise.allSettled(executions)
+ vi.useRealTimers()
+ }
+ })
+
it('sanitizes hostile tab titles before returning them across the tool boundary', async () => {
await driver.executeTool('chat-test', 'browser_open_tab', {})
const contents = session.requireTab().view.webContents
@@ -1089,6 +1890,10 @@ describe('executeTool', () => {
})
describe('browserToolWatchdogMs', () => {
+ it('budgets restored-tab switching as navigation work', () => {
+ expect(driverModule.browserToolWatchdogMs('browser_switch_tab', {})).toBe(60_000)
+ })
+
it.each([
['number', 30_000, 35_000],
['numeric string', '30000', 35_000],
diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts
index 216ecd958de..985bc6ea4f6 100644
--- a/apps/desktop/src/main/browser-agent/driver.ts
+++ b/apps/desktop/src/main/browser-agent/driver.ts
@@ -18,6 +18,8 @@
*/
import {
BROWSER_DATA_KINDS,
+ BROWSER_NAVIGATION_NATIVE_WATCHDOG_MS,
+ BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS,
type BrowserDataKind,
type BrowserKnownSessionsState,
type BrowserPageState,
@@ -79,8 +81,12 @@ const TAKEOVER_POLL_MS = 1_500
* legitimate tool (browser_wait_for caps at 120s).
*/
const DEFAULT_TOOL_WATCHDOG_MS = 20_000
-const NAVIGATION_TOOL_WATCHDOG_MS = 30_000
const WAIT_FOR_TOOL_WATCHDOG_GRACE_MS = 5_000
+/** Retained native tool calls: generous for normal serial use, finite under a wedged caller. */
+export const BROWSER_TOOL_ADMISSION_LIMITS = Object.freeze({
+ perScope: 16,
+ process: 64,
+})
const MAX_CROSS_ORIGIN_SNAPSHOT_FRAMES = 8
const MAX_CROSS_ORIGIN_SCAN_FRAMES = 32
const COMBINED_SNAPSHOT_LINE_CAP = 900
@@ -94,6 +100,8 @@ export interface DriverCallbacks {
onPageState: (state: BrowserPageState) => void
onTabsState: (state: BrowserTabsState) => void
onSessionStatus: (alive: boolean, scopeId: string) => void
+ /** Whether a live renderer for the scope registered support for the consent prompt. */
+ sitePermissionPromptSupported?: (scopeId: string) => boolean
/** Whether the active tab shows a login form Sim holds a credential for. */
onFillAvailability: (available: boolean, scopeId: string) => void
/** Live native download state for one isolated browser scope. */
@@ -111,6 +119,8 @@ let configStore: ConfigStore | null = null
* actually happened on the page.
*/
interface DriverScopeState {
+ /** Unique state generation so teardown cannot suffer an epoch ABA race. */
+ generation: number
pendingNotices: string[]
takeoverActive: boolean
takeoverDone: boolean
@@ -127,6 +137,10 @@ interface DriverScopeState {
toolQueueCancellationEpoch: number
lastTabsStateFingerprint: string | null
toolQueue: Promise
+ /** Admissions held by queued and in-flight tools for this scope. */
+ toolAdmissions: Set
+ /** Prevents detached queue entries from running after their scope is torn down. */
+ disposed: boolean
/** True while activation is the only operation that has touched this scope. */
activationOnly: boolean
/** Tab whose latest monotonic element refs are valid for element actions. */
@@ -144,11 +158,20 @@ interface DriverScopeState {
/** Captures the native queue boundary before an async authorization round trip. */
export interface BrowserToolQueueBoundary {
scopeId: string
- cancellationEpoch: number
+ /** Invalidates authorization captured before a process-wide browser teardown. */
+ lifecycleEpoch: number
+ /** Present only when the scope already existed at capture time. */
+ generation: number | null
+ cancellationEpoch: number | null
+ cancelled: boolean
}
+let nextDriverScopeGeneration = 1
+let browserToolQueueLifecycleEpoch = 0
+
function createDriverScopeState(): DriverScopeState {
return {
+ generation: nextDriverScopeGeneration++,
pendingNotices: [],
takeoverActive: false,
takeoverDone: false,
@@ -160,6 +183,8 @@ function createDriverScopeState(): DriverScopeState {
toolQueueCancellationEpoch: 0,
lastTabsStateFingerprint: null,
toolQueue: Promise.resolve(),
+ toolAdmissions: new Set(),
+ disposed: false,
activationOnly: true,
snapshotTabId: null,
snapshotTargets: new Map(),
@@ -200,6 +225,8 @@ function frameNavigationEpoch(contents: WebContents, frame: WebFrameMain): numbe
const driverScopeStates = new Map()
const driverScopeAliases = new Map()
+const activeBrowserToolAdmissions = new Set()
+const pendingBrowserToolQueueBoundaries = new Set()
const CANCELLED_TOOL_TTL_MS = 5 * 60_000
const MAX_CANCELLED_TOOL_TOMBSTONES = 256
const cancelledToolCallIds = new Map()
@@ -237,17 +264,98 @@ function driverScopeState(scopeId = session.getBrowserScopeId()): DriverScopeSta
return state
}
-export function captureBrowserToolQueueBoundary(scopeId: string): BrowserToolQueueBoundary {
+function reserveBrowserToolAdmission(state: DriverScopeState): symbol {
+ const admission = Symbol('browser-tool-admission')
+ state.toolAdmissions.add(admission)
+ activeBrowserToolAdmissions.add(admission)
+ return admission
+}
+
+function releaseBrowserToolAdmission(state: DriverScopeState, admission: symbol): void {
+ state.toolAdmissions.delete(admission)
+ activeBrowserToolAdmissions.delete(admission)
+}
+
+function retireDriverScopeState(state: DriverScopeState): void {
+ state.disposed = true
+ state.toolQueueCancellationEpoch++
+ state.toolInvocationEpoch++
+ state.toolExecutionEpoch++
+ state.activeToolCancel?.()
+ state.takeoverActive = false
+ state.takeoverDone = false
+ state.takeoverResponse = null
+ state.takeoverInvocationEpoch = null
+ for (const admission of state.toolAdmissions) {
+ activeBrowserToolAdmissions.delete(admission)
+ }
+ state.toolAdmissions.clear()
+}
+
+function retireAllDriverScopeStates(): void {
+ browserToolQueueLifecycleEpoch++
+ for (const state of driverScopeStates.values()) retireDriverScopeState(state)
+ for (const boundary of pendingBrowserToolQueueBoundaries) boundary.cancelled = true
+ driverScopeStates.clear()
+ activeBrowserToolAdmissions.clear()
+ driverScopeAliases.clear()
+}
+
+export function captureBrowserToolQueueBoundary(scopeId: string): BrowserToolQueueBoundary | null {
const resolvedScopeId = resolveDriverScopeId(scopeId)
- return {
+ const state = driverScopeStates.get(resolvedScopeId)
+ if (
+ activeBrowserToolAdmissions.size + pendingBrowserToolQueueBoundaries.size >=
+ BROWSER_TOOL_ADMISSION_LIMITS.process ||
+ (state?.toolAdmissions.size ?? 0) +
+ [...pendingBrowserToolQueueBoundaries].filter(
+ (boundary) => resolveDriverScopeId(boundary.scopeId) === resolvedScopeId
+ ).length >=
+ BROWSER_TOOL_ADMISSION_LIMITS.perScope
+ ) {
+ return null
+ }
+ const boundary: BrowserToolQueueBoundary = {
scopeId: resolvedScopeId,
- cancellationEpoch: driverScopeState(resolvedScopeId).toolQueueCancellationEpoch,
+ lifecycleEpoch: browserToolQueueLifecycleEpoch,
+ generation: state?.generation ?? null,
+ cancellationEpoch: state?.toolQueueCancellationEpoch ?? null,
+ cancelled: false,
+ }
+ pendingBrowserToolQueueBoundaries.add(boundary)
+ return boundary
+}
+
+export function releaseBrowserToolQueueBoundary(boundary: BrowserToolQueueBoundary): void {
+ pendingBrowserToolQueueBoundaries.delete(boundary)
+}
+
+function cancelPendingBrowserToolQueueBoundaries(scopeId: string): boolean {
+ const resolvedScopeId = resolveDriverScopeId(scopeId)
+ let cancelled = false
+ for (const boundary of pendingBrowserToolQueueBoundaries) {
+ if (resolveDriverScopeId(boundary.scopeId) !== resolvedScopeId) continue
+ boundary.cancelled = true
+ cancelled = true
+ }
+ return cancelled
+}
+
+function cancelBrowserToolQueueBoundaries(boundaries: readonly BrowserToolQueueBoundary[]): void {
+ for (const boundary of boundaries) {
+ boundary.cancelled = true
}
}
function isBrowserToolQueueBoundaryCurrent(boundary: BrowserToolQueueBoundary): boolean {
+ if (boundary.cancelled) return false
+ if (boundary.lifecycleEpoch !== browserToolQueueLifecycleEpoch) return false
+ if (boundary.generation === null) return true
const state = driverScopeStates.get(resolveDriverScopeId(boundary.scopeId))
- return state?.toolQueueCancellationEpoch === boundary.cancellationEpoch
+ return (
+ state?.generation === boundary.generation &&
+ state.toolQueueCancellationEpoch === boundary.cancellationEpoch
+ )
}
function recordNotice(notice: string): void {
@@ -265,6 +373,7 @@ function recordNotice(notice: string): void {
function pageStateFor(contents: WebContents, tabId: string): BrowserPageState {
const issue = session.pageIssueForContents(contents)
const mediaPermissionRequest = session.mediaPermissionRequestForContents(contents)
+ const sitePermissionRequest = session.sitePermissionRequestForScope()
return {
scopeId: session.getBrowserScopeId(),
tabId,
@@ -275,6 +384,7 @@ function pageStateFor(contents: WebContents, tabId: string): BrowserPageState {
canGoForward: session.canGoForward(contents),
...(issue ? { issue } : {}),
...(mediaPermissionRequest ? { mediaPermissionRequest } : {}),
+ ...(sitePermissionRequest ? { sitePermissionRequest } : {}),
}
}
@@ -415,8 +525,7 @@ export function initDriver(
// session inherits the previous one's pending notices, a takeover still
// waiting on a user who is gone, and a fingerprint that suppresses its very
// first tab push as a duplicate.
- driverScopeStates.clear()
- driverScopeAliases.clear()
+ retireAllDriverScopeStates()
cancelledToolCallIds.clear()
// The serialization chain, too. A takeover from the previous session can sit
// unresolved indefinitely, and its `takeoverDone` flag is reset above — so
@@ -458,6 +567,8 @@ export function initDriver(
void fillCoordinator()?.refreshAvailability(true)
},
onPageStateChanged: pushPageState,
+ sitePermissionPromptSupported: (scopeId) =>
+ driverCallbacks?.sitePermissionPromptSupported?.(scopeId) === true,
onTabsChanged: pushTabsState,
onTabThemeChanged: (contents, theme) => {
void cdp.setColorScheme(contents, theme).catch((error) => {
@@ -603,9 +714,20 @@ export function migrateBrowserScope(fromScopeId: string, toScopeId: string): boo
if (from === to) return true
const state = driverScopeStates.get(from)
const destinationState = driverScopeStates.get(to)
+ const sourceBoundaries = [...pendingBrowserToolQueueBoundaries].filter(
+ (boundary) => resolveDriverScopeId(boundary.scopeId) === from
+ )
+ const destinationBoundaries = [...pendingBrowserToolQueueBoundaries].filter(
+ (boundary) => resolveDriverScopeId(boundary.scopeId) === to
+ )
if (destinationState && !destinationState.activationOnly) return false
if (!session.migrateBrowserScope(from, to)) return false
- if (destinationState) driverScopeStates.delete(to)
+ for (const boundary of sourceBoundaries) boundary.scopeId = to
+ cancelBrowserToolQueueBoundaries(destinationBoundaries)
+ if (destinationState) {
+ retireDriverScopeState(destinationState)
+ driverScopeStates.delete(to)
+ }
if (state) {
driverScopeStates.delete(from)
driverScopeStates.set(to, state)
@@ -619,10 +741,12 @@ export function disposeBrowserScope(scopeId: string): void {
const resolved = resolveDriverScopeId(scopeId)
session.disposeBrowserScope(scopeId)
if (wasAlias) {
- driverScopeAliases.delete(scopeId)
return
}
+ cancelPendingBrowserToolQueueBoundaries(resolved)
+ const state = driverScopeStates.get(resolved)
+ if (state) retireDriverScopeState(state)
driverScopeStates.delete(resolved)
for (const [alias, target] of driverScopeAliases) {
if (alias === resolved || resolveDriverScopeId(target) === resolved) {
@@ -639,6 +763,9 @@ export function disposeBrowserScope(scopeId: string): void {
export function suspendBrowserScope(scopeId: string): boolean {
const resolved = resolveDriverScopeId(scopeId)
if (!session.suspendBrowserScope(resolved)) return false
+ cancelPendingBrowserToolQueueBoundaries(resolved)
+ const state = driverScopeStates.get(resolved)
+ if (state) retireDriverScopeState(state)
driverScopeStates.delete(resolved)
return true
}
@@ -681,6 +808,7 @@ export interface ClearBrowserProfileOptions {
export async function clearBrowserProfile(
options: ClearBrowserProfileOptions = { settingsPersistence: 'required' }
): Promise {
+ retireAllDriverScopeStates()
const settingsCleared = knownSessions?.clear() !== false
const outcomes = await Promise.allSettled([session.clearProfileStorage(), clearCredentials()])
// Last, covering the pinned-tab list `clearProfileStorage` just emptied.
@@ -701,6 +829,12 @@ export async function clearBrowserProfile(
}
}
+/** Stops every authorized or queued browser action before closing its live pages. */
+export function closeBrowserSession(): void {
+ retireAllDriverScopeStates()
+ session.closeSession()
+}
+
function str(params: Record, key: string): string | undefined {
const value = params[key]
return typeof value === 'string' && value.length > 0 ? value : undefined
@@ -731,9 +865,10 @@ export function browserToolWatchdogMs(
tool === 'browser_open_url' ||
tool === 'browser_go_back' ||
tool === 'browser_go_forward' ||
- tool === 'browser_open_tab'
+ tool === 'browser_open_tab' ||
+ tool === 'browser_switch_tab'
) {
- return NAVIGATION_TOOL_WATCHDOG_MS
+ return BROWSER_NAVIGATION_NATIVE_WATCHDOG_MS
}
if (tool === 'browser_wait_for') {
const requested = normalizeBrowserWaitForTimeoutMs(params.timeoutMs)
@@ -1054,10 +1189,14 @@ async function navigationResult(
return { url: contents.getURL(), title: contents.getTitle() }
}
-async function loadUrlAndGetResult(
+async function loadAgentCheckedUrlAndGetResult(
contents: WebContents,
url: string
): Promise> {
+ session.prepareExplicitNavigation(contents)
+ if (!session.grantSiteOriginForAgentNavigation(contents, url)) {
+ throw new ToolError('The tab was closed before navigation could start.')
+ }
const beforeUrl = contents.getURL()
try {
await contents.loadURL(url)
@@ -1908,14 +2047,14 @@ async function runTakeover(purpose: string | undefined, invocationEpoch: number)
try {
for (;;) {
await sleep(TAKEOVER_POLL_MS)
+ if (state.toolInvocationEpoch !== invocationEpoch) {
+ throw new ToolError('The browser takeover was superseded by a newer browser action.')
+ }
if (!session.hasSession() || contents.isDestroyed()) {
throw new ToolError(
'The browser session was closed during takeover. Ask the user what happened, then reopen with browser_navigate.'
)
}
- if (state.toolInvocationEpoch !== invocationEpoch) {
- throw new ToolError('The browser takeover was superseded by a newer browser action.')
- }
if (state.takeoverDone) {
if (purpose === 'sign_in') {
const activeContents = session.automationTab()?.view.webContents
@@ -1968,7 +2107,7 @@ async function executeToolInner(
const tab = session.ensureAutomationTab()
const contents = tab.view.webContents
assertCurrentExecution()
- return await loadUrlAndGetResult(contents, url)
+ return await loadAgentCheckedUrlAndGetResult(contents, url)
}
case 'browser_open_url': {
@@ -1985,7 +2124,7 @@ async function executeToolInner(
const tab = session.ensureAutomationTab()
const contents = tab.view.webContents
assertCurrentExecution()
- const nav = await loadUrlAndGetResult(contents, url)
+ const nav = await loadAgentCheckedUrlAndGetResult(contents, url)
// A failed snapshot (browser-internal page, injection error) should not
// fail the open itself — the page is on screen either way.
assertCurrentExecution()
@@ -2032,7 +2171,7 @@ async function executeToolInner(
const contents = tab.view.webContents
if (url) {
assertCurrentExecution()
- const result = await loadUrlAndGetResult(contents, url)
+ const result = await loadAgentCheckedUrlAndGetResult(contents, url)
return { tabId: tab.id, ...result }
}
return { tabId: tab.id, url: '', title: '' }
@@ -2041,7 +2180,17 @@ async function executeToolInner(
case 'browser_switch_tab': {
invalidateSnapshot()
const tab = session.switchAutomationTab(requireStr(params, 'tabId'))
+ const restored = await session.waitForPendingTabRestore(tab)
+ assertCurrentExecution()
const contents = tab.view.webContents
+ if (contents.isDestroyed() || session.automationTab()?.id !== tab.id) {
+ throw new ToolError('The tab was closed or replaced while it was being restored.')
+ }
+ if (!restored) {
+ throw new ToolError(
+ 'The saved tab did not finish loading. Retry browser_switch_tab, or navigate it to the saved URL from browser_list_tabs.'
+ )
+ }
return { tabId: tab.id, url: contents.getURL(), title: contents.getTitle() }
}
@@ -2072,6 +2221,7 @@ async function executeToolInner(
const waitedTab = session.requireAutomationTab()
const contents = waitedTab.view.webContents
while (Date.now() - startedAt < timeoutMs) {
+ assertCurrentExecution()
const active = session.automationTab()
if (active?.id !== waitedTab.id || active.view.webContents !== contents) {
throw new ToolError(
@@ -2135,6 +2285,7 @@ async function executeToolInner(
const capturedViewportUrl = capturedUrl.slice(0, 4096)
const capturedViewportTitle = capturedTitle.slice(0, 500)
const captureIsCurrent = (): boolean => {
+ assertCurrentExecution()
const activeTab = session.automationTab()
return (
activeTab?.id === capturedTab.id &&
@@ -3753,111 +3904,164 @@ export async function executeTool(
toolCallId?: string,
authorizationBoundary?: BrowserToolQueueBoundary
): Promise<{ ok: boolean; result?: unknown; error?: string }> {
- const queuedAt = Date.now()
const resolvedScopeId = resolveDriverScopeId(scopeId)
+ if (authorizationBoundary) {
+ releaseBrowserToolQueueBoundary(authorizationBoundary)
+ if (!isBrowserToolQueueBoundaryCurrent(authorizationBoundary)) {
+ return {
+ ok: false,
+ error: 'This browser action was cancelled before it started.',
+ }
+ }
+ }
if (session.isBrowserScopeSuspended(resolvedScopeId)) {
return {
ok: false,
error: 'This task browser is suspended until the task is reopened.',
}
}
+ if (activeBrowserToolAdmissions.size >= BROWSER_TOOL_ADMISSION_LIMITS.process) {
+ return {
+ ok: false,
+ error: 'Sim already has too many browser actions queued. Wait for earlier actions to finish.',
+ }
+ }
const state = driverScopeState(resolvedScopeId)
- state.activationOnly = false
- const invocationEpoch = ++state.toolInvocationEpoch
- const queueCancellationEpoch = state.toolQueueCancellationEpoch
- const run = async () => {
- const queueWaitMs = Date.now() - queuedAt
- const executionStartedAt = Date.now()
- if (
- (authorizationBoundary && !isBrowserToolQueueBoundaryCurrent(authorizationBoundary)) ||
- queueCancellationEpoch !== state.toolQueueCancellationEpoch ||
- isToolCallCancelled(toolCallId)
- ) {
- throw new ToolError('This browser action was cancelled before it started.')
+ if (state.toolAdmissions.size >= BROWSER_TOOL_ADMISSION_LIMITS.perScope) {
+ return {
+ ok: false,
+ error:
+ 'This task browser already has too many actions queued. Wait for earlier actions to finish.',
}
- state.activeToolCallId = toolCallId ?? null
- let cancelActiveExecution: () => void = () => {}
- const cancellation = new Promise((_resolve, reject) => {
- cancelActiveExecution = () => reject(new ToolError('This browser action was cancelled.'))
- })
- state.activeToolCancel = cancelActiveExecution
- return await session.withBrowserScope(resolvedScopeId, async () => {
- logger.info('Executing browser tool', {
- tool,
- toolCallId,
- scopeId: resolvedScopeId,
- queueWaitMs,
- })
- const keepHiddenPageActive = tool !== 'browser_request_takeover'
- if (keepHiddenPageActive) {
- session.setAutomationActive(true)
- }
- try {
- const executionEpoch = ++state.toolExecutionEpoch
- const watchdogMs = browserToolWatchdogMs(tool, params)
- const executionDeadline = watchdogMs === null ? undefined : Date.now() + watchdogMs
- const assertCurrentExecution = () => {
- if (state.toolExecutionEpoch !== executionEpoch) {
- throw new ToolError('This browser action expired before it could dispatch input.')
- }
- }
- const execution = executeToolInner(
- tool,
- params,
- assertCurrentExecution,
- executionDeadline,
- invocationEpoch
+ }
+ const admission = reserveBrowserToolAdmission(state)
+ let admissionReleased = false
+ const releaseAdmission = () => {
+ if (admissionReleased) return
+ admissionReleased = true
+ releaseBrowserToolAdmission(state, admission)
+ }
+ const queuedAt = Date.now()
+ let queueWaitExpired = false
+ let queueWaitTimeoutId: ReturnType | undefined
+ const queueWaitTimeout = new Promise((_resolve, reject) => {
+ queueWaitTimeoutId = setTimeout(() => {
+ queueWaitExpired = true
+ reject(
+ new ToolError(
+ 'This browser action waited too long for earlier browser work and was cancelled before it started.'
)
- const guardedExecution =
- watchdogMs === null
- ? execution
- : raceAgainstWatchdog(execution, watchdogMs, () => {
- if (state.toolExecutionEpoch === executionEpoch) state.toolExecutionEpoch++
- if (tool === 'browser_snapshot' || tool === 'browser_open_url') {
- invalidateSnapshot(state)
- }
- })
- const result = withNotices(await Promise.race([guardedExecution, cancellation]))
- logger.info('Browser tool completed', {
+ )
+ }, BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS)
+ })
+ try {
+ state.activationOnly = false
+ const invocationEpoch = ++state.toolInvocationEpoch
+ const queueCancellationEpoch = state.toolQueueCancellationEpoch
+ const run = async () => {
+ clearTimeout(queueWaitTimeoutId)
+ const queueWaitMs = Date.now() - queuedAt
+ const executionStartedAt = Date.now()
+ if (
+ queueWaitExpired ||
+ state.disposed ||
+ (authorizationBoundary && !isBrowserToolQueueBoundaryCurrent(authorizationBoundary)) ||
+ queueCancellationEpoch !== state.toolQueueCancellationEpoch ||
+ isToolCallCancelled(toolCallId)
+ ) {
+ throw new ToolError('This browser action was cancelled before it started.')
+ }
+ state.activeToolCallId = toolCallId ?? null
+ let cancelActiveExecution: () => void = () => {}
+ const cancellation = new Promise((_resolve, reject) => {
+ cancelActiveExecution = () => reject(new ToolError('This browser action was cancelled.'))
+ })
+ state.activeToolCancel = cancelActiveExecution
+ return await session.withBrowserScope(resolvedScopeId, async () => {
+ logger.info('Executing browser tool', {
tool,
toolCallId,
scopeId: resolvedScopeId,
queueWaitMs,
- executionMs: Date.now() - executionStartedAt,
})
- return result
- } finally {
+ const keepHiddenPageActive = tool !== 'browser_request_takeover'
if (keepHiddenPageActive) {
- session.setAutomationActive(false)
+ session.setAutomationActive(true)
}
- if (state.activeToolCancel === cancelActiveExecution) {
- state.activeToolCallId = null
- state.activeToolCancel = null
+ try {
+ const executionEpoch = ++state.toolExecutionEpoch
+ const watchdogMs = browserToolWatchdogMs(tool, params)
+ const executionDeadline = watchdogMs === null ? undefined : Date.now() + watchdogMs
+ const assertCurrentExecution = () => {
+ if (state.toolExecutionEpoch !== executionEpoch) {
+ throw new ToolError('This browser action expired before it could dispatch input.')
+ }
+ }
+ const execution = executeToolInner(
+ tool,
+ params,
+ assertCurrentExecution,
+ executionDeadline,
+ invocationEpoch
+ )
+ const guardedExecution =
+ watchdogMs === null
+ ? execution
+ : raceAgainstWatchdog(execution, watchdogMs, () => {
+ if (state.toolExecutionEpoch === executionEpoch) state.toolExecutionEpoch++
+ if (tool === 'browser_snapshot' || tool === 'browser_open_url') {
+ invalidateSnapshot(state)
+ }
+ })
+ const result = withNotices(await Promise.race([guardedExecution, cancellation]))
+ logger.info('Browser tool completed', {
+ tool,
+ toolCallId,
+ scopeId: resolvedScopeId,
+ queueWaitMs,
+ executionMs: Date.now() - executionStartedAt,
+ })
+ return result
+ } finally {
+ if (keepHiddenPageActive && !state.disposed) {
+ session.setAutomationActive(false)
+ }
+ if (state.activeToolCancel === cancelActiveExecution) {
+ state.activeToolCallId = null
+ state.activeToolCancel = null
+ }
}
- }
- })
- }
+ })
+ }
- const settled = state.toolQueue.then(run, run)
- state.toolQueue = settled.catch(() => {})
- try {
- return { ok: true, result: sanitizeBrowserResult(await settled) }
- } catch (error) {
- // The watchdog cannot cancel an in-flight renderer promise. Invalidate its
- // capture token before releasing the queue so a late snapshot cannot
- // overwrite refs belonging to a newer tab or snapshot.
- if (tool === 'browser_snapshot' || tool === 'browser_open_url') {
- invalidateSnapshot(state)
- }
- const message = String(sanitizeBrowserResult(getErrorMessage(error), undefined, 0, 'error'))
- logger.warn('Browser tool failed', {
- tool,
- toolCallId,
- scopeId: resolvedScopeId,
- totalMs: Date.now() - queuedAt,
- error: message,
- })
- return { ok: false, error: message }
+ const settled = state.toolQueue.then(run, run)
+ state.toolQueue = settled.catch(() => {})
+ settled.then(releaseAdmission, releaseAdmission)
+ try {
+ return {
+ ok: true,
+ result: sanitizeBrowserResult(await Promise.race([settled, queueWaitTimeout])),
+ }
+ } catch (error) {
+ // The watchdog cannot cancel an in-flight renderer promise. Invalidate its
+ // capture token before releasing the queue so a late snapshot cannot
+ // overwrite refs belonging to a newer tab or snapshot.
+ if (tool === 'browser_snapshot' || tool === 'browser_open_url') {
+ invalidateSnapshot(state)
+ }
+ const message = String(sanitizeBrowserResult(getErrorMessage(error), undefined, 0, 'error'))
+ logger.warn('Browser tool failed', {
+ tool,
+ toolCallId,
+ scopeId: resolvedScopeId,
+ totalMs: Date.now() - queuedAt,
+ error: message,
+ })
+ return { ok: false, error: message }
+ }
+ } finally {
+ clearTimeout(queueWaitTimeoutId)
+ if (!queueWaitExpired) releaseAdmission()
}
}
@@ -3888,11 +4092,12 @@ export function cancelTool(scopeId: string, toolCallId: string): boolean {
/** Cancels the active tool and every older invocation already queued for this scope. */
export function cancelActiveTool(scopeId: string): boolean {
const resolvedScopeId = resolveDriverScopeId(scopeId)
+ const cancelledPendingAuthorization = cancelPendingBrowserToolQueueBoundaries(resolvedScopeId)
const state = driverScopeStates.get(resolvedScopeId)
- if (!state) return false
+ if (!state) return cancelledPendingAuthorization
state.toolQueueCancellationEpoch++
const toolCallId = state.activeToolCallId
- return toolCallId ? cancelTool(resolvedScopeId, toolCallId) : false
+ return toolCallId ? cancelTool(resolvedScopeId, toolCallId) : cancelledPendingAuthorization
}
/** Browser-chrome commands from the panel header; fire-and-forget. */
@@ -3923,6 +4128,12 @@ export async function handlePanelAction(
}
return
}
+ if (action.action === 'respond-site-permission') {
+ if (typeof action.requestId === 'string' && typeof action.allowed === 'boolean') {
+ session.respondToSitePermission(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.
@@ -3930,6 +4141,8 @@ export async function handlePanelAction(
if (typeof action.url === 'string' && /^https?:\/\//i.test(action.url)) {
session.claimActiveTabForUser()
const contents = session.ensureTab().view.webContents
+ session.prepareExplicitNavigation(contents)
+ session.grantSiteOriginForUserNavigation(contents, action.url)
void contents.loadURL(action.url).catch(() => {})
}
return
diff --git a/apps/desktop/src/main/browser-agent/session.test.ts b/apps/desktop/src/main/browser-agent/session.test.ts
index 84de584d9d1..2f81cc51f8d 100644
--- a/apps/desktop/src/main/browser-agent/session.test.ts
+++ b/apps/desktop/src/main/browser-agent/session.test.ts
@@ -4,9 +4,21 @@ import { join } from 'node:path'
import type { MenuItemConstructorOptions, WebContents } from 'electron'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-vi.mock('electron', () => import('@/test/electron-mock'))
+const { mockLookup } = vi.hoisted(() => ({ mockLookup: vi.fn() }))
-import { BrowserWindow, session as electronSession, Menu, shell, systemPreferences } from 'electron'
+vi.mock('electron', () => import('@/test/electron-mock'))
+vi.mock('node:dns/promises', () => ({
+ default: { lookup: mockLookup },
+}))
+
+import {
+ BrowserWindow,
+ dialog,
+ 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'
@@ -25,12 +37,14 @@ interface MockView {
session: {
setPermissionRequestHandler: ReturnType
setPermissionCheckHandler: ReturnType
+ webRequest: { onBeforeRequest: ReturnType }
}
on: ReturnType
setUserAgent: ReturnType
setWindowOpenHandler: ReturnType
loadURL: ReturnType
reload: ReturnType
+ stop: ReturnType
forcefullyCrashRenderer: ReturnType
getURL: ReturnType
getTitle: ReturnType
@@ -92,6 +106,7 @@ function freshSession(
onTabCreated: vi.fn(),
onActiveTabChanged: vi.fn(),
onPageStateChanged: vi.fn(),
+ sitePermissionPromptSupported: vi.fn(() => true),
onTabsChanged: vi.fn(),
onTabThemeChanged: vi.fn(),
onTabNavigated: vi.fn(),
@@ -142,13 +157,159 @@ function hostResizeHandler(win: BrowserWindow): () => void {
function mainFrameNavigationStarted(
contents: MockView['webContents'],
- isSameDocument = false
+ isSameDocument = false,
+ url = (contents.getURL as unknown as () => string)()
): void {
const handler = contents.on.mock.calls
.filter(([eventName]) => eventName === 'did-start-navigation')
.at(-1)?.[1]
if (typeof handler !== 'function') throw new Error('no navigation-start listener bound')
- handler({ isMainFrame: true, isSameDocument })
+ handler({ isMainFrame: true, isSameDocument, url })
+}
+
+function beginMainFrameRequest(
+ contents: MockView['webContents'],
+ url: string,
+ id = 1
+): Promise<{ cancel: boolean }> {
+ const handler = contents.session.webRequest.onBeforeRequest.mock.calls[0]?.[0]
+ if (typeof handler !== 'function') throw new Error('no before-request listener bound')
+ return new Promise((resolve) => {
+ handler(
+ {
+ id,
+ url,
+ method: 'GET',
+ webContents: contents,
+ resourceType: 'mainFrame',
+ referrer: (contents.getURL as unknown as () => string)(),
+ timestamp: Date.now(),
+ uploadData: [],
+ },
+ resolve
+ )
+ })
+}
+
+function beginSubresourceRequest(
+ contents: MockView['webContents'],
+ url: string,
+ resourceType: string,
+ id = 1
+): Promise<{ cancel: boolean }> {
+ const handler = contents.session.webRequest.onBeforeRequest.mock.calls[0]?.[0]
+ if (typeof handler !== 'function') throw new Error('no before-request listener bound')
+ return new Promise((resolve) => {
+ handler(
+ {
+ id,
+ url,
+ method: 'GET',
+ webContents: contents,
+ resourceType,
+ referrer: (contents.getURL as unknown as () => string)(),
+ timestamp: Date.now(),
+ uploadData: [],
+ },
+ resolve
+ )
+ })
+}
+
+type MockDownloadDoneState = 'completed' | 'cancelled' | 'interrupted'
+
+interface MockDownloadHarness {
+ item: {
+ getFilename: ReturnType
+ getMimeType: ReturnType
+ getReceivedBytes: ReturnType
+ getTotalBytes: ReturnType
+ setSavePath: ReturnType
+ pause: ReturnType
+ resume: ReturnType
+ cancel: ReturnType
+ on: ReturnType
+ once: ReturnType
+ }
+ setReceivedBytes: (bytes: number) => void
+ setTotalBytes: (bytes: number) => void
+ emitUpdated: (state?: 'progressing' | 'interrupted') => void
+ emitDone: (state: MockDownloadDoneState) => void
+}
+
+function deferred(): {
+ promise: Promise
+ resolve: (value: T) => void
+ reject: (reason?: unknown) => void
+} {
+ let resolve!: (value: T) => void
+ let reject!: (reason?: unknown) => void
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise
+ reject = rejectPromise
+ })
+ return { promise, resolve, reject }
+}
+
+function mockDownloadItem({
+ filename = 'report.csv',
+ mimeType = 'text/csv',
+ receivedBytes: initialReceivedBytes = 0,
+ totalBytes: initialTotalBytes = 0,
+}: {
+ filename?: string
+ mimeType?: string
+ receivedBytes?: number
+ totalBytes?: number
+} = {}): MockDownloadHarness {
+ let receivedBytes = initialReceivedBytes
+ let totalBytes = initialTotalBytes
+ const item = {
+ getFilename: vi.fn(() => filename),
+ getMimeType: vi.fn(() => mimeType),
+ getReceivedBytes: vi.fn(() => receivedBytes),
+ getTotalBytes: vi.fn(() => totalBytes),
+ setSavePath: vi.fn(),
+ pause: vi.fn(),
+ resume: vi.fn(),
+ cancel: vi.fn(),
+ on: vi.fn(),
+ once: vi.fn(),
+ }
+ return {
+ item,
+ setReceivedBytes: (bytes) => {
+ receivedBytes = bytes
+ },
+ setTotalBytes: (bytes) => {
+ totalBytes = bytes
+ },
+ emitUpdated: (state = 'progressing') => {
+ const handler = item.on.mock.calls.find(([eventName]) => eventName === 'updated')?.[1] as
+ | ((event: unknown, nextState: 'progressing' | 'interrupted') => void)
+ | undefined
+ handler?.({}, state)
+ },
+ emitDone: (state) => {
+ const handler = item.once.mock.calls.find(([eventName]) => eventName === 'done')?.[1] as
+ | ((event: unknown, nextState: MockDownloadDoneState) => void)
+ | undefined
+ handler?.({}, state)
+ },
+ }
+}
+
+function startMockDownload(contents: MockView['webContents'], download: MockDownloadHarness): void {
+ const webSession = contents.session as typeof contents.session & {
+ on: ReturnType
+ }
+ const willDownload = webSession.on.mock.calls.find(
+ ([eventName]) => eventName === 'will-download'
+ )?.[1] as
+ | ((event: unknown, item: MockDownloadHarness['item'], contents: unknown) => void)
+ | undefined
+ if (!willDownload) throw new Error('no will-download listener bound')
+ willDownload({}, download.item, contents)
}
describe('browser-agent session', () => {
@@ -156,6 +317,8 @@ describe('browser-agent session', () => {
let session: SessionModule
beforeEach(async () => {
+ mockLookup.mockReset()
+ mockLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }])
win = mainWindowMock()
session = freshSession(win)
})
@@ -322,6 +485,23 @@ describe('browser-agent session', () => {
expect(session.migrateBrowserScope('chat-real', 'occupied')).toBe(false)
})
+ it('retains a migrated provisional alias until the durable scope is disposed', () => {
+ const tab = session.withBrowserScope('pending:workspace', () => session.ensureTab())
+ expect(session.migrateBrowserScope('pending:workspace', 'chat-real')).toBe(true)
+
+ session.disposeBrowserScope('pending:workspace')
+
+ expect(session.withBrowserScope('pending:workspace', () => session.activeTab())).toBe(tab)
+ session.withBrowserScope('pending:workspace', () => session.claimActiveTabForUser())
+ expect(session.withBrowserScope('chat-real', () => session.activeTab())).toBe(tab)
+
+ session.disposeBrowserScope('chat-real')
+ expect((tab.view as unknown as MockView).webContents.close).toHaveBeenCalledOnce()
+ expect(
+ session.withBrowserScope('pending:workspace', () => session.peekTabsState().tabs)
+ ).toEqual([])
+ })
+
it('preserves a persisted destination behind a lazy activation', () => {
const existingSnapshot: BrowserSessionSnapshot = {
v: 1,
@@ -458,6 +638,622 @@ describe('browser-agent session', () => {
)
})
+ it('selects and starts the active restore before three bounded background loads', async () => {
+ const tabs = Array.from({ length: 7 }, (_, index) => ({
+ url: `https://restore-${index}.example/`,
+ pinned: index < 2,
+ }))
+ const { persistence } = memoryBrowserPersistence({
+ 'chat-restore-order': {
+ v: 1,
+ tabs,
+ activeIndex: 5,
+ downloads: [],
+ },
+ })
+ const createdContents: MockView['webContents'][] = []
+ const resolveLoads: Array<(() => void) | undefined> = []
+ session = freshSession(
+ win,
+ {
+ onTabCreated: (webContents) => {
+ const contents = webContents as unknown as MockView['webContents']
+ const index = createdContents.push(contents) - 1
+ contents.loadURL.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveLoads[index] = resolve
+ })
+ )
+ },
+ },
+ persistence
+ )
+
+ session.withBrowserScope('chat-restore-order', () => session.restoreBrowserSession())
+
+ expect(
+ session.withBrowserScope('chat-restore-order', () => session.getTabsState())
+ ).toMatchObject({
+ activeTabId: '6',
+ tabs: [
+ { tabId: '1', pinned: true },
+ { tabId: '2', pinned: true },
+ { tabId: '3', pinned: false },
+ { tabId: '4', pinned: false },
+ { tabId: '5', pinned: false },
+ { tabId: '6', pinned: false, active: true },
+ { tabId: '7', pinned: false },
+ ],
+ })
+ expect(createdContents[5].loadURL).toHaveBeenCalledWith(tabs[5].url)
+ expect(createdContents[5].loadURL.mock.invocationCallOrder[0]).toBeLessThan(
+ createdContents[0].loadURL.mock.invocationCallOrder[0]
+ )
+ expect(
+ createdContents.filter((contents) => contents.loadURL.mock.calls.length > 0)
+ ).toHaveLength(4)
+ expect(createdContents[3].loadURL).not.toHaveBeenCalled()
+
+ resolveLoads[0]?.()
+ await vi.waitFor(() => {
+ expect(createdContents[3].loadURL).toHaveBeenCalledWith(tabs[3].url)
+ })
+ })
+
+ it('preempts a background restore for a user-selected queued tab', async () => {
+ const tabs = Array.from({ length: 7 }, (_, index) => ({
+ url: `https://priority-${index}.example/`,
+ pinned: false,
+ }))
+ const { persistence } = memoryBrowserPersistence({
+ 'chat-restore-priority': {
+ v: 1,
+ tabs,
+ activeIndex: 0,
+ downloads: [],
+ },
+ })
+ const createdContents: MockView['webContents'][] = []
+ const resolveLoads: Array<(() => void) | undefined> = []
+ session = freshSession(
+ win,
+ {
+ onTabCreated: (webContents) => {
+ const contents = webContents as unknown as MockView['webContents']
+ const index = createdContents.push(contents) - 1
+ contents.loadURL.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveLoads[index] = resolve
+ })
+ )
+ },
+ },
+ persistence
+ )
+
+ session.withBrowserScope('chat-restore-priority', () => {
+ session.restoreBrowserSession()
+ session.switchTab('7')
+ session.closeTab('5')
+ })
+ expect(createdContents[6].loadURL).toHaveBeenCalledWith(tabs[6].url)
+ expect(
+ createdContents.slice(1, 4).some((contents) => contents.stop.mock.calls.length > 0)
+ ).toBe(true)
+ resolveLoads[1]?.()
+ expect(createdContents[4].loadURL).not.toHaveBeenCalled()
+
+ resolveLoads[2]?.()
+ await vi.waitFor(() => {
+ expect(createdContents[5].loadURL).toHaveBeenCalledWith(tabs[5].url)
+ })
+ expect(createdContents[4].loadURL).not.toHaveBeenCalled()
+
+ resolveLoads[3]?.()
+ await vi.waitFor(() => {
+ expect(createdContents[1].loadURL).toHaveBeenCalledTimes(2)
+ })
+ })
+
+ it('keeps a deferred restore intact when Back and Forward cannot move', () => {
+ const tabs = Array.from({ length: 6 }, (_, index) => ({
+ url: `https://deferred-history-${index}.example/`,
+ pinned: false,
+ }))
+ const { persistence } = memoryBrowserPersistence({
+ 'chat-deferred-history': { v: 1, tabs, activeIndex: 0, downloads: [] },
+ })
+ const createdContents: MockView['webContents'][] = []
+ session = freshSession(
+ win,
+ {
+ onTabCreated: (webContents) => {
+ const contents = webContents as unknown as MockView['webContents']
+ createdContents.push(contents)
+ contents.loadURL.mockImplementation(() => new Promise(() => {}))
+ },
+ },
+ persistence
+ )
+
+ session.withBrowserScope('chat-deferred-history', () => {
+ session.restoreBrowserSession()
+ const deferred = createdContents[5] as unknown as WebContents
+ expect(session.goBack(deferred)).toBe(false)
+ expect(session.goForward(deferred)).toBe(false)
+ session.switchTab('6')
+ })
+
+ expect(createdContents[5].loadURL).toHaveBeenCalledWith(tabs[5].url)
+ })
+
+ it('promotes a model-selected queued restore and waits for its exact load', async () => {
+ vi.useFakeTimers()
+ try {
+ const tabs = Array.from({ length: 7 }, (_, index) => ({
+ url: `https://model-restore-${index}.example/`,
+ pinned: false,
+ }))
+ const { persistence } = memoryBrowserPersistence({
+ 'chat-model-restore': { v: 1, tabs, activeIndex: 0, downloads: [] },
+ })
+ const createdContents: MockView['webContents'][] = []
+ const resolveLoads: Array<(() => void) | undefined> = []
+ session = freshSession(
+ win,
+ {
+ onTabCreated: (webContents) => {
+ const contents = webContents as unknown as MockView['webContents']
+ const index = createdContents.push(contents) - 1
+ contents.loadURL.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveLoads[index] = resolve
+ })
+ )
+ },
+ },
+ persistence
+ )
+
+ const selected = session.withBrowserScope('chat-model-restore', () => {
+ session.restoreBrowserSession()
+ return session.switchAutomationTab('7')
+ })
+ let ready = false
+ const selection = session.withBrowserScope('chat-model-restore', () =>
+ session.waitForPendingTabRestore(selected)
+ )
+ void selection.then(() => {
+ ready = true
+ })
+
+ expect(createdContents[6].loadURL).toHaveBeenCalledWith(tabs[6].url)
+ expect(
+ createdContents.slice(1, 4).some((contents) => contents.stop.mock.calls.length > 0)
+ ).toBe(true)
+ expect(ready).toBe(false)
+
+ await vi.advanceTimersByTimeAsync(15_000)
+ expect(createdContents[6].stop).not.toHaveBeenCalled()
+ expect(ready).toBe(false)
+
+ resolveLoads[6]?.()
+ await expect(selection).resolves.toBe(true)
+ expect(ready).toBe(true)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('extends an in-flight background restore without restarting its load', async () => {
+ vi.useFakeTimers()
+ try {
+ const tabs = Array.from({ length: 4 }, (_, index) => ({
+ url: `https://active-restore-${index}.example/`,
+ pinned: false,
+ }))
+ const { persistence } = memoryBrowserPersistence({
+ 'chat-active-restore': { v: 1, tabs, activeIndex: 0, downloads: [] },
+ })
+ const createdContents: MockView['webContents'][] = []
+ const selectedLoads: Array<() => void> = []
+ session = freshSession(
+ win,
+ {
+ onTabCreated: (webContents) => {
+ const contents = webContents as unknown as MockView['webContents']
+ const index = createdContents.push(contents) - 1
+ contents.loadURL.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ if (index === 1) selectedLoads.push(resolve)
+ })
+ )
+ },
+ },
+ persistence
+ )
+
+ const selected = session.withBrowserScope('chat-active-restore', () => {
+ session.restoreBrowserSession()
+ return session.switchAutomationTab('2')
+ })
+ const selection = session.withBrowserScope('chat-active-restore', () =>
+ session.waitForPendingTabRestore(selected)
+ )
+
+ expect(createdContents[1].loadURL).toHaveBeenCalledOnce()
+ expect(createdContents[1].stop).not.toHaveBeenCalled()
+ await vi.advanceTimersByTimeAsync(15_000)
+ expect(createdContents[1].stop).not.toHaveBeenCalled()
+
+ selectedLoads[0]?.()
+ await expect(selection).resolves.toBe(true)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('queues a fifth foreground restore without preempting another foreground restore', async () => {
+ const snapshots = Object.fromEntries(
+ Array.from({ length: 5 }, (_, index) => [
+ `chat-foreground-${index}`,
+ {
+ v: 1 as const,
+ tabs: [{ url: `https://foreground-${index}.example/`, pinned: false }],
+ activeIndex: 0,
+ downloads: [],
+ },
+ ])
+ )
+ const { persistence } = memoryBrowserPersistence(snapshots)
+ const createdContents: MockView['webContents'][] = []
+ const resolveLoads: Array<(() => void) | undefined> = []
+ session = freshSession(
+ win,
+ {
+ onTabCreated: (webContents) => {
+ const contents = webContents as unknown as MockView['webContents']
+ const index = createdContents.push(contents) - 1
+ contents.loadURL.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveLoads[index] = resolve
+ })
+ )
+ },
+ },
+ persistence
+ )
+
+ for (let index = 0; index < 5; index += 1) {
+ session.withBrowserScope(`chat-foreground-${index}`, () => session.restoreBrowserSession())
+ }
+
+ expect(
+ createdContents.slice(0, 4).every((contents) => contents.loadURL.mock.calls.length === 1)
+ ).toBe(true)
+ expect(createdContents[4].loadURL).not.toHaveBeenCalled()
+ expect(
+ createdContents.slice(0, 4).every((contents) => contents.stop.mock.calls.length === 0)
+ ).toBe(true)
+
+ resolveLoads[0]?.()
+ await vi.waitFor(() => {
+ expect(createdContents[4].loadURL).toHaveBeenCalledWith('https://foreground-4.example/')
+ })
+ })
+
+ it('releases hung global restore slots so another task can make progress', async () => {
+ vi.useFakeTimers()
+ try {
+ const firstTabs = Array.from({ length: 6 }, (_, index) => ({
+ url: `https://hung-a-${index}.example/`,
+ pinned: false,
+ }))
+ const secondTabs = Array.from({ length: 2 }, (_, index) => ({
+ url: `https://waiting-b-${index}.example/`,
+ pinned: false,
+ }))
+ const { persistence } = memoryBrowserPersistence({
+ 'chat-hung-a': { v: 1, tabs: firstTabs, activeIndex: 0, downloads: [] },
+ 'chat-waiting-b': { v: 1, tabs: secondTabs, activeIndex: 0, downloads: [] },
+ })
+ const createdContents: MockView['webContents'][] = []
+ session = freshSession(
+ win,
+ {
+ onTabCreated: (webContents) => {
+ const contents = webContents as unknown as MockView['webContents']
+ createdContents.push(contents)
+ contents.loadURL.mockImplementation(() => new Promise(() => {}))
+ },
+ },
+ persistence
+ )
+
+ session.withBrowserScope('chat-hung-a', () => session.restoreBrowserSession())
+ session.withBrowserScope('chat-waiting-b', () => session.restoreBrowserSession())
+ const waitingBackground = createdContents[7]
+ expect(waitingBackground.loadURL).not.toHaveBeenCalled()
+
+ await vi.advanceTimersByTimeAsync(15_000)
+
+ expect(
+ createdContents.slice(1, 4).every((contents) => contents.stop.mock.calls.length > 0)
+ ).toBe(true)
+ expect(waitingBackground.loadURL).not.toHaveBeenCalled()
+
+ await vi.advanceTimersByTimeAsync(15_000)
+
+ expect(waitingBackground.loadURL).toHaveBeenCalledWith(secondTabs[1].url)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('finishes a timed-out restore even when Electron throws while stopping it', async () => {
+ vi.useFakeTimers()
+ try {
+ const restoredUrl = 'https://throwing-stop.example/'
+ const { persistence } = memoryBrowserPersistence({
+ 'chat-throwing-stop': {
+ v: 1,
+ tabs: [{ url: restoredUrl, pinned: false }],
+ activeIndex: 0,
+ downloads: [],
+ },
+ })
+ session = freshSession(
+ win,
+ {
+ onTabCreated: (webContents) => {
+ const contents = webContents as unknown as MockView['webContents']
+ contents.loadURL.mockImplementation(() => new Promise(() => {}))
+ contents.stop.mockImplementationOnce(() => {
+ throw new Error('destroy race')
+ })
+ },
+ },
+ persistence
+ )
+
+ session.withBrowserScope('chat-throwing-stop', () => session.restoreBrowserSession())
+ await vi.advanceTimersByTimeAsync(20_000)
+
+ const state = session.withBrowserScope('chat-throwing-stop', () => session.getTabsState())
+ expect(state.tabs[0]).toMatchObject({
+ url: restoredUrl,
+ loading: false,
+ issue: { kind: 'load-error', code: -7, description: 'ERR_TIMED_OUT' },
+ })
+ const contents = session.withBrowserScope(
+ 'chat-throwing-stop',
+ () => session.requireTab().view.webContents
+ )
+ session.withBrowserScope('chat-throwing-stop', () => session.reloadPage(contents))
+ expect(contents.loadURL).toHaveBeenLastCalledWith(restoredUrl)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('gives a redirected background restore its complete site-decision window', async () => {
+ vi.useFakeTimers()
+ try {
+ const tabs = [
+ { url: 'http://127.0.0.1:4601/active', pinned: false },
+ { url: 'http://127.0.0.1:4601/background', pinned: false },
+ ]
+ const { persistence } = memoryBrowserPersistence({
+ 'chat-stale-restore-prompt': { v: 1, tabs, activeIndex: 0, downloads: [] },
+ })
+ const createdContents: MockView['webContents'][] = []
+ session = freshSession(
+ win,
+ {
+ onTabCreated: (webContents) => {
+ const contents = webContents as unknown as MockView['webContents']
+ createdContents.push(contents)
+ contents.loadURL.mockImplementation(() => new Promise(() => {}))
+ },
+ },
+ persistence
+ )
+
+ session.withBrowserScope('chat-stale-restore-prompt', () => session.restoreBrowserSession())
+ session.activateBrowserScope('chat-stale-restore-prompt')
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const background = createdContents[1]
+ const redirected = beginMainFrameRequest(background, 'http://127.0.0.1:4602/redirect')
+ await vi.advanceTimersByTimeAsync(0)
+ expect(
+ session.withBrowserScope('chat-stale-restore-prompt', () =>
+ session.sitePermissionRequestForScope()
+ )
+ ).toMatchObject({ origin: 'http://127.0.0.1:4602' })
+
+ await vi.advanceTimersByTimeAsync(15_000)
+
+ expect(background.stop).not.toHaveBeenCalled()
+ expect(
+ session.withBrowserScope('chat-stale-restore-prompt', () =>
+ session.sitePermissionRequestForScope()
+ )
+ ).toBeDefined()
+
+ await vi.advanceTimersByTimeAsync(5_000)
+
+ await expect(redirected).resolves.toEqual({ cancel: true })
+ expect(
+ session.withBrowserScope('chat-stale-restore-prompt', () =>
+ session.sitePermissionRequestForScope()
+ )
+ ).toBeUndefined()
+ expect(background.stop).not.toHaveBeenCalled()
+
+ await vi.advanceTimersByTimeAsync(15_000)
+
+ expect(background.stop).toHaveBeenCalledOnce()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('does not let repeated redirect prompts extend a restore without bound', async () => {
+ vi.useFakeTimers()
+ try {
+ const tabs = [
+ { url: 'http://127.0.0.1:4611/active', pinned: false },
+ { url: 'http://127.0.0.1:4611/background', pinned: false },
+ ]
+ const { persistence } = memoryBrowserPersistence({
+ 'chat-bounded-restore-prompt': { v: 1, tabs, activeIndex: 0, downloads: [] },
+ })
+ const createdContents: MockView['webContents'][] = []
+ session = freshSession(
+ win,
+ {
+ onTabCreated: (webContents) => {
+ const contents = webContents as unknown as MockView['webContents']
+ createdContents.push(contents)
+ contents.loadURL.mockImplementation(() => new Promise(() => {}))
+ },
+ },
+ persistence
+ )
+
+ session.withBrowserScope('chat-bounded-restore-prompt', () => session.restoreBrowserSession())
+ session.activateBrowserScope('chat-bounded-restore-prompt')
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const background = createdContents[1]
+ const firstRedirect = beginMainFrameRequest(background, 'http://127.0.0.1:4612/first')
+ await vi.advanceTimersByTimeAsync(0)
+ expect(
+ session.withBrowserScope('chat-bounded-restore-prompt', () =>
+ session.sitePermissionRequestForScope()
+ )
+ ).toMatchObject({ origin: 'http://127.0.0.1:4612' })
+
+ await vi.advanceTimersByTimeAsync(20_000)
+ await expect(firstRedirect).resolves.toEqual({ cancel: true })
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const secondRedirect = beginMainFrameRequest(background, 'http://127.0.0.1:4613/second', 2)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(
+ session.withBrowserScope('chat-bounded-restore-prompt', () =>
+ session.sitePermissionRequestForScope()
+ )
+ ).toMatchObject({ origin: 'http://127.0.0.1:4613' })
+
+ await vi.advanceTimersByTimeAsync(15_000)
+
+ expect(background.stop).toHaveBeenCalledOnce()
+ await expect(secondRedirect).resolves.toEqual({ cancel: true })
+ expect(
+ session.withBrowserScope('chat-bounded-restore-prompt', () =>
+ session.sitePermissionRequestForScope()
+ )
+ ).toBeUndefined()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('discards a queued restore before an explicit replacement navigation can race it', async () => {
+ const tabs = Array.from({ length: 6 }, (_, index) => ({
+ url: `https://stale-restore-${index}.example/`,
+ pinned: false,
+ }))
+ const { persistence } = memoryBrowserPersistence({
+ 'chat-replace-restore': { v: 1, tabs, activeIndex: 0, downloads: [] },
+ })
+ const createdContents: MockView['webContents'][] = []
+ const resolveLoads: Array<(() => void) | undefined> = []
+ session = freshSession(
+ win,
+ {
+ onTabCreated: (webContents) => {
+ const contents = webContents as unknown as MockView['webContents']
+ const index = createdContents.push(contents) - 1
+ contents.loadURL.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveLoads[index] = resolve
+ })
+ )
+ },
+ },
+ persistence
+ )
+
+ session.withBrowserScope('chat-replace-restore', () => session.restoreBrowserSession())
+ const queued = createdContents[5]
+ const replacement = 'https://fresh.example/'
+ session.withBrowserScope('chat-replace-restore', () => {
+ session.prepareExplicitNavigation(queued as unknown as WebContents)
+ })
+ void (queued.loadURL as unknown as (url: string) => Promise)(replacement)
+ resolveLoads[1]?.()
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(queued.loadURL).toHaveBeenCalledOnce()
+ expect(queued.loadURL).toHaveBeenCalledWith(replacement)
+ expect(queued.loadURL).not.toHaveBeenCalledWith(tabs[5].url)
+ })
+
+ it('does not start queued restores after their task browser is suspended', async () => {
+ const tabs = Array.from({ length: 6 }, (_, index) => ({
+ url: `https://suspended-${index}.example/`,
+ pinned: false,
+ }))
+ const { persistence } = memoryBrowserPersistence({
+ 'chat-restore-suspended': {
+ v: 1,
+ tabs,
+ activeIndex: 0,
+ downloads: [],
+ },
+ })
+ const createdContents: MockView['webContents'][] = []
+ const resolveLoads: Array<(() => void) | undefined> = []
+ session = freshSession(
+ win,
+ {
+ onTabCreated: (webContents) => {
+ const contents = webContents as unknown as MockView['webContents']
+ const index = createdContents.push(contents) - 1
+ contents.loadURL.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveLoads[index] = resolve
+ })
+ )
+ },
+ },
+ persistence
+ )
+
+ session.withBrowserScope('chat-restore-suspended', () => session.restoreBrowserSession())
+ expect(
+ createdContents.filter((contents) => contents.loadURL.mock.calls.length > 0)
+ ).toHaveLength(4)
+
+ session.suspendBrowserScope('chat-restore-suspended')
+ resolveLoads[1]?.()
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(
+ createdContents.filter((contents) => contents.loadURL.mock.calls.length > 0)
+ ).toHaveLength(4)
+ expect(createdContents.every((contents) => contents.close.mock.calls.length === 1)).toBe(true)
+ })
+
it('restores more than eight persisted tabs', () => {
const tabs = Array.from({ length: 12 }, (_, index) => ({
url: `https://tab-${index}.example/`,
@@ -2074,6 +2870,27 @@ describe('browser-agent session', () => {
expect(onTabCreated).toHaveBeenLastCalledWith(userTab?.view.webContents)
})
+ it('does not treat an untrusted page popup as user authorization for its origin', async () => {
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const source = (session.ensureTab().view as unknown as MockView).webContents
+ const openWindow = source.setWindowOpenHandler.mock.calls[0]?.[0] as (details: {
+ url: string
+ }) => { action: string }
+ const destination = 'http://127.0.0.1:4099/private?token=secret'
+
+ openWindow({ url: destination })
+ const popup = (session.activeTab()?.view as unknown as MockView).webContents
+ const request = beginMainFrameRequest(popup, destination)
+
+ await vi.waitFor(() =>
+ expect(session.sitePermissionRequestForScope()).toMatchObject({
+ origin: 'http://127.0.0.1:4099',
+ })
+ )
+ session.respondToSitePermission(session.sitePermissionRequestForScope()?.requestId ?? '', false)
+ await expect(request).resolves.toEqual({ cancel: true })
+ })
+
it('blocks controlled pages from moving or resizing the desktop window', () => {
const tab = session.ensureTab()
const contents = (tab.view as unknown as MockView).webContents
@@ -2359,14 +3176,327 @@ describe('browser-agent session', () => {
}
})
- it('leaves nothing of the signed-out user behind in the browser profile', async () => {
- const clearStorageData = vi.fn(async () => {})
- const clearCache = vi.fn(async () => {})
- vi.mocked(electronSession.fromPartition).mockReturnValue({
- clearStorageData,
- clearCache,
- } as unknown as ReturnType)
- const { persistence, snapshots } = memoryBrowserPersistence()
+ it('holds a new top-level origin for an exact task-scoped user decision', async () => {
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const first = beginMainFrameRequest(
+ contents,
+ 'http://127.0.0.1:4101/private?token=secret#fragment'
+ )
+
+ await vi.waitFor(() => {
+ expect(session.sitePermissionRequestForScope()).toMatchObject({
+ tabId: '1',
+ origin: 'http://127.0.0.1:4101',
+ })
+ })
+ const prompt = session.sitePermissionRequestForScope()
+ expect(prompt).not.toHaveProperty('url')
+ expect(win.focus).toHaveBeenCalled()
+ expect(win.webContents.focus).toHaveBeenCalled()
+ expect(session.respondToSitePermission(prompt?.requestId ?? '', true)).toBe(true)
+ await expect(first).resolves.toEqual({ cancel: false })
+
+ await expect(
+ beginMainFrameRequest(contents, 'http://127.0.0.1:4101/another?different=secret', 2)
+ ).resolves.toEqual({ cancel: false })
+ expect(session.sitePermissionRequestForScope()).toBeUndefined()
+
+ const otherOrigin = beginMainFrameRequest(contents, 'http://127.0.0.1:4102/', 3)
+ await vi.waitFor(() => expect(session.sitePermissionRequestForScope()).toBeDefined())
+ expect(session.respondToSitePermission('not-the-live-request', true)).toBe(false)
+ const otherPrompt = session.sitePermissionRequestForScope()
+ expect(session.respondToSitePermission(otherPrompt?.requestId ?? '', false)).toBe(true)
+ await expect(otherOrigin).resolves.toEqual({ cancel: true })
+ })
+
+ it('allows an SSRF-checked agent destination without granting a cross-origin redirect', async () => {
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const destination = 'http://127.0.0.1:4111/agent-path?token=secret'
+
+ expect(
+ session.grantSiteOriginForAgentNavigation(contents as unknown as WebContents, destination)
+ ).toBe(true)
+ await expect(beginMainFrameRequest(contents, destination)).resolves.toEqual({ cancel: false })
+ expect(session.sitePermissionRequestForScope()).toBeUndefined()
+
+ const redirect = beginMainFrameRequest(contents, 'http://127.0.0.1:4112/redirected', 2)
+ await vi.waitFor(() =>
+ expect(session.sitePermissionRequestForScope()).toMatchObject({
+ origin: 'http://127.0.0.1:4112',
+ })
+ )
+ const prompt = session.sitePermissionRequestForScope()
+ expect(session.respondToSitePermission(prompt?.requestId ?? '', false)).toBe(true)
+ await expect(redirect).resolves.toEqual({ cancel: true })
+ })
+
+ it('uses a native exact-origin prompt when the active renderer lacks prompt support', async () => {
+ session = freshSession(win, {
+ sitePermissionPromptSupported: vi.fn(() => false),
+ })
+ vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({
+ response: 1,
+ checkboxChecked: false,
+ })
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+
+ const request = beginMainFrameRequest(
+ contents,
+ 'http://127.0.0.1:4151/private?token=secret#fragment'
+ )
+
+ await expect(request).resolves.toEqual({ cancel: false })
+ expect(dialog.showMessageBox).toHaveBeenCalledWith(
+ win,
+ expect.objectContaining({
+ buttons: ['Block', 'Allow'],
+ defaultId: 0,
+ cancelId: 0,
+ message: 'Allow this browser task to open http://127.0.0.1:4151?',
+ })
+ )
+ expect(JSON.stringify(vi.mocked(dialog.showMessageBox).mock.lastCall)).not.toContain('secret')
+ expect(session.sitePermissionRequestForScope()).toBeUndefined()
+ })
+
+ it('attaches the native fallback to the window that owns the visible panel', async () => {
+ const panelOwner = mainWindowMock()
+ session = freshSession(win, {
+ sitePermissionPromptSupported: vi.fn(() => false),
+ })
+ vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({
+ response: 0,
+ checkboxChecked: false,
+ })
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }, panelOwner)
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+
+ await expect(beginMainFrameRequest(contents, 'http://127.0.0.1:4155/private')).resolves.toEqual(
+ { cancel: true }
+ )
+
+ expect(dialog.showMessageBox).toHaveBeenCalledWith(panelOwner, expect.any(Object))
+ })
+
+ it('denies a new site prompt immediately when its scope is hidden or inactive', async () => {
+ const hiddenContents = (session.ensureTab().view as unknown as MockView).webContents
+
+ await expect(
+ beginMainFrameRequest(hiddenContents, 'http://127.0.0.1:4156/hidden')
+ ).resolves.toEqual({ cancel: true })
+ expect(session.sitePermissionRequestForScope()).toBeUndefined()
+
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const inactiveContents = session.withBrowserScope(
+ 'chat-inactive',
+ () => session.ensureTab().view as unknown as MockView
+ ).webContents
+ await expect(
+ beginMainFrameRequest(inactiveContents, 'http://127.0.0.1:4157/inactive')
+ ).resolves.toEqual({ cancel: true })
+ expect(
+ session.withBrowserScope('chat-inactive', () => session.sitePermissionRequestForScope())
+ ).toBeUndefined()
+ })
+
+ it('does not show the native fallback when the active renderer owns the prompt', async () => {
+ vi.mocked(dialog.showMessageBox).mockClear()
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const request = beginMainFrameRequest(contents, 'http://127.0.0.1:4152/docs')
+
+ await vi.waitFor(() => expect(session.sitePermissionRequestForScope()).toBeDefined())
+
+ expect(dialog.showMessageBox).not.toHaveBeenCalled()
+ const prompt = session.sitePermissionRequestForScope()
+ expect(session.respondToSitePermission(prompt?.requestId ?? '', false)).toBe(true)
+ await expect(request).resolves.toEqual({ cancel: true })
+ })
+
+ it('revalidates a native allow decision after the held request becomes stale', async () => {
+ session = freshSession(win, {
+ sitePermissionPromptSupported: vi.fn(() => false),
+ })
+ vi.mocked(dialog.showMessageBox).mockClear()
+ let answerPrompt: ((result: { response: number; checkboxChecked: boolean }) => void) | undefined
+ vi.mocked(dialog.showMessageBox).mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ answerPrompt = resolve
+ })
+ )
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const request = beginMainFrameRequest(contents, 'http://127.0.0.1:4153/held')
+ await vi.waitFor(() => expect(dialog.showMessageBox).toHaveBeenCalled())
+ const signal = vi.mocked(dialog.showMessageBox).mock.lastCall?.at(-1)?.signal
+ expect(signal?.aborted).toBe(false)
+
+ mainFrameNavigationStarted(contents, false, 'http://127.0.0.1:4154/replacement')
+ await expect(request).resolves.toEqual({ cancel: true })
+ expect(signal?.aborted).toBe(true)
+ answerPrompt?.({ response: 1, checkboxChecked: false })
+
+ const retried = beginMainFrameRequest(contents, 'http://127.0.0.1:4153/retried', 2)
+ await expect(retried).resolves.toEqual({ cancel: true })
+ expect(dialog.showMessageBox).toHaveBeenCalledTimes(2)
+ })
+
+ it('keeps the held request alive through its own navigation-start event', async () => {
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const destination = 'http://127.0.0.1:4201/docs'
+ const request = beginMainFrameRequest(contents, destination)
+ await vi.waitFor(() => expect(session.sitePermissionRequestForScope()).toBeDefined())
+
+ mainFrameNavigationStarted(contents, false, `${destination}#section`)
+ const prompt = session.sitePermissionRequestForScope()
+ expect(prompt).toBeDefined()
+ expect(session.respondToSitePermission(prompt?.requestId ?? '', true)).toBe(true)
+ await expect(request).resolves.toEqual({ cancel: false })
+
+ const replaced = beginMainFrameRequest(contents, 'http://127.0.0.1:4202/', 2)
+ await vi.waitFor(() => expect(session.sitePermissionRequestForScope()).toBeDefined())
+ mainFrameNavigationStarted(contents, false, 'http://127.0.0.1:4203/')
+ await expect(replaced).resolves.toEqual({ cancel: true })
+ expect(session.sitePermissionRequestForScope()).toBeUndefined()
+ })
+
+ it('invalidates a held site decision before an explicit replacement navigation', async () => {
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const held = beginMainFrameRequest(contents, 'http://127.0.0.1:4204/held')
+ await vi.waitFor(() => expect(session.sitePermissionRequestForScope()).toBeDefined())
+ const requestId = session.sitePermissionRequestForScope()?.requestId
+
+ session.prepareExplicitNavigation(contents as unknown as WebContents)
+
+ await expect(held).resolves.toEqual({ cancel: true })
+ expect(session.sitePermissionRequestForScope()).toBeUndefined()
+ expect(session.respondToSitePermission(requestId ?? '', true)).toBe(false)
+ })
+
+ it('seeds restored origins before loading while still holding a new redirect origin', async () => {
+ const restoredUrl = 'http://127.0.0.1:4301/restored?private=value'
+ const { persistence } = memoryBrowserPersistence({
+ 'chat-test': {
+ v: 1,
+ tabs: [{ url: restoredUrl, pinned: true }],
+ activeIndex: 0,
+ downloads: [],
+ },
+ })
+ session = freshSession(win, {}, persistence)
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ session.restoreBrowserSession()
+ const contents = (session.requireTab().view as unknown as MockView).webContents
+ expect(contents.loadURL).toHaveBeenCalledWith(restoredUrl)
+
+ await expect(beginMainFrameRequest(contents, restoredUrl)).resolves.toEqual({ cancel: false })
+ expect(session.sitePermissionRequestForScope()).toBeUndefined()
+
+ const redirected = beginMainFrameRequest(contents, 'http://127.0.0.1:4302/login', 2)
+ await vi.waitFor(() =>
+ expect(session.sitePermissionRequestForScope()).toMatchObject({
+ origin: 'http://127.0.0.1:4302',
+ })
+ )
+ const prompt = session.sitePermissionRequestForScope()
+ session.respondToSitePermission(prompt?.requestId ?? '', false)
+ await expect(redirected).resolves.toEqual({ cancel: true })
+ })
+
+ it('bounds task grants and fails closed when a main-frame request cannot map to a live tab', async () => {
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ for (let index = 0; index <= 64; index += 1) {
+ expect(
+ session.grantSiteOriginForUserNavigation(
+ contents as unknown as WebContents,
+ `http://127.0.0.1:${4400 + index}/private`
+ )
+ ).toBe(true)
+ }
+
+ const evicted = beginMainFrameRequest(contents, 'http://127.0.0.1:4400/again')
+ await vi.waitFor(() =>
+ expect(session.sitePermissionRequestForScope()).toMatchObject({
+ origin: 'http://127.0.0.1:4400',
+ })
+ )
+ session.respondToSitePermission(session.sitePermissionRequestForScope()?.requestId ?? '', false)
+ await expect(evicted).resolves.toEqual({ cancel: true })
+
+ const handler = contents.session.webRequest.onBeforeRequest.mock.calls[0]?.[0]
+ const unmapped = new Promise<{ cancel: boolean }>((resolve) => {
+ handler(
+ {
+ id: 99,
+ url: 'http://127.0.0.1:4499/',
+ method: 'GET',
+ resourceType: 'mainFrame',
+ referrer: '',
+ timestamp: Date.now(),
+ uploadData: [],
+ },
+ resolve
+ )
+ })
+ await expect(unmapped).resolves.toEqual({ cancel: true })
+ })
+
+ it('blocks an image hostname that resolves to a private address', async () => {
+ mockLookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }])
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+
+ await expect(
+ beginSubresourceRequest(contents, 'https://private-image.evil.example/status.png', 'image')
+ ).resolves.toEqual({ cancel: true })
+ expect(mockLookup).toHaveBeenCalledWith('private-image.evil.example', {
+ all: true,
+ verbatim: true,
+ })
+ })
+
+ it('default-denies pending site requests on timeout, tab close, and stale-document approval', async () => {
+ vi.useFakeTimers()
+ try {
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const tab = session.ensureTab()
+ const contents = (tab.view as unknown as MockView).webContents
+ const timedOut = beginMainFrameRequest(contents, 'http://127.0.0.1:4501/')
+ await vi.advanceTimersByTimeAsync(0)
+ await vi.advanceTimersByTimeAsync(20_000)
+ await expect(timedOut).resolves.toEqual({ cancel: true })
+
+ panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 })
+ const stale = beginMainFrameRequest(contents, 'http://127.0.0.1:4502/', 2)
+ await vi.advanceTimersByTimeAsync(0)
+ const stalePrompt = session.sitePermissionRequestForScope()
+ contents.getURL.mockReturnValue('https://changed.example/')
+ expect(session.respondToSitePermission(stalePrompt?.requestId ?? '', true)).toBe(true)
+ await expect(stale).resolves.toEqual({ cancel: true })
+
+ const closing = beginMainFrameRequest(contents, 'http://127.0.0.1:4503/', 3)
+ await vi.advanceTimersByTimeAsync(0)
+ session.closeTab(tab.id)
+ await expect(closing).resolves.toEqual({ cancel: true })
+ } 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 () => {})
+ vi.mocked(electronSession.fromPartition).mockReturnValue({
+ clearStorageData,
+ clearCache,
+ } as unknown as ReturnType)
+ const { persistence, snapshots } = memoryBrowserPersistence()
session = freshSession(win, {}, persistence)
panel.setPanelBounds({ x: 0, y: 0, width: 800, height: 600 })
@@ -2520,12 +3650,13 @@ describe('browser-agent session', () => {
}
})
- it('saves downloads to the configured folder instead of cancelling them', () => {
+ it('pauses downloads until the async disk check passes, then saves them', async () => {
const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
const { persistence, snapshots } = memoryBrowserPersistence()
const onDownloadsChanged = vi.fn()
session = freshSession(win, { onDownloadsChanged }, persistence, {
getDirectory: () => directory,
+ getFreeDiskBytes: () => Number.MAX_SAFE_INTEGER,
})
const contents = (session.ensureTab().view as unknown as MockView).webContents
const webSession = contents.session as typeof contents.session & {
@@ -2542,6 +3673,8 @@ describe('browser-agent session', () => {
getReceivedBytes: vi.fn(() => 20),
getTotalBytes: vi.fn(() => 100),
setSavePath: vi.fn(),
+ pause: vi.fn(),
+ resume: vi.fn(),
cancel: vi.fn(),
on: vi.fn(),
once: vi.fn(),
@@ -2549,6 +3682,9 @@ describe('browser-agent session', () => {
willDownload?.({}, item, contents)
+ expect(item.pause).toHaveBeenCalledOnce()
+ expect(item.resume).not.toHaveBeenCalled()
+ await vi.waitFor(() => expect(item.resume).toHaveBeenCalledOnce())
expect(item.cancel).not.toHaveBeenCalled()
expect(item.setSavePath).toHaveBeenCalledWith(join(directory, 'report.csv'))
expect(item.once).toHaveBeenCalledWith('done', expect.any(Function))
@@ -2592,7 +3728,10 @@ describe('browser-agent session', () => {
reveal?.()
expect(shell.showItemInFolder).toHaveBeenCalledWith(join(directory, 'report.csv'))
- session = freshSession(win, {}, persistence, { getDirectory: () => directory })
+ session = freshSession(win, {}, persistence, {
+ getDirectory: () => directory,
+ getFreeDiskBytes: () => Number.MAX_SAFE_INTEGER,
+ })
session.restoreBrowserSession()
expect(session.getBrowserDownloadsState('chat-test').downloads[0]).toMatchObject({
filename: 'report.csv',
@@ -2600,10 +3739,736 @@ describe('browser-agent session', () => {
})
})
+ it('does not let a pre-allocation progress event consume the admission probe', async () => {
+ const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
+ const getFreeDiskBytes = vi.fn(() => Number.MAX_SAFE_INTEGER)
+ session = freshSession(win, {}, undefined, {
+ getDirectory: () => directory,
+ getFreeDiskBytes,
+ })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const download = mockDownloadItem({ filename: 'early-progress.bin', totalBytes: 100 })
+
+ startMockDownload(contents, download)
+ download.emitUpdated()
+
+ await vi.waitFor(() => expect(download.item.resume).toHaveBeenCalledOnce())
+ expect(getFreeDiskBytes).toHaveBeenCalledOnce()
+ expect(download.item.cancel).not.toHaveBeenCalled()
+ })
+
+ it('rejects a declared download above the byte cap with safe visible metadata', () => {
+ const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
+ session = freshSession(win, {}, undefined, {
+ getDirectory: () => directory,
+ getFreeDiskBytes: () => Number.MAX_SAFE_INTEGER,
+ })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const download = mockDownloadItem({
+ filename: 'oversized.zip',
+ totalBytes: 2 * 1024 ** 3 + 1,
+ })
+
+ startMockDownload(contents, download)
+
+ expect(download.item.cancel).toHaveBeenCalledOnce()
+ expect(download.item.setSavePath).not.toHaveBeenCalled()
+ expect(session.getBrowserDownloadsState('chat-test').downloads).toEqual([
+ expect.objectContaining({ filename: 'oversized.zip', state: 'interrupted' }),
+ ])
+ expect(session.getBrowserDownloadsState('chat-test').downloads[0]).not.toHaveProperty(
+ 'savePath'
+ )
+ expect(session.getBrowserDownloadsState('chat-test').downloads[0]).not.toHaveProperty(
+ 'interruptionReason'
+ )
+
+ vi.mocked(Menu.buildFromTemplate).mockClear()
+ session.showBrowserDownloadsMenu('chat-test', win, { x: 10, y: 20 })
+ const template = vi.mocked(Menu.buildFromTemplate).mock.calls[0]?.[0] as
+ | MenuItemConstructorOptions[]
+ | undefined
+ expect(template?.[0]).toMatchObject({
+ label: 'oversized.zip',
+ enabled: false,
+ })
+ expect(template?.[0]?.sublabel).toContain('2.0 GB download limit')
+ })
+
+ it('reserves a known download remaining size above the free-disk floor', async () => {
+ const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
+ const getFreeDiskBytes = vi.fn(() => 1.2 * 1024 ** 3)
+ session = freshSession(win, {}, undefined, {
+ getDirectory: () => directory,
+ getFreeDiskBytes,
+ })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const download = mockDownloadItem({
+ filename: 'known-size.iso',
+ totalBytes: 1.5 * 1024 ** 3,
+ })
+
+ startMockDownload(contents, download)
+
+ expect(download.item.pause).toHaveBeenCalledOnce()
+ await vi.waitFor(() => expect(getFreeDiskBytes).toHaveBeenCalledOnce())
+ await vi.waitFor(() => expect(download.item.cancel).toHaveBeenCalledOnce())
+ expect(download.item.resume).not.toHaveBeenCalled()
+ expect(session.getBrowserDownloadsState('chat-test').downloads[0]).toMatchObject({
+ filename: 'known-size.iso',
+ state: 'interrupted',
+ })
+ })
+
+ it('fails closed when the asynchronous free-space probe rejects', async () => {
+ const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
+ session = freshSession(win, {}, undefined, {
+ getDirectory: () => directory,
+ getFreeDiskBytes: () => Promise.reject(new Error('disk unavailable')),
+ })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const download = mockDownloadItem({ filename: 'probe-error.bin', totalBytes: 100 })
+
+ startMockDownload(contents, download)
+
+ expect(download.item.pause).toHaveBeenCalledOnce()
+ await vi.waitFor(() => expect(download.item.cancel).toHaveBeenCalledOnce())
+ expect(download.item.resume).not.toHaveBeenCalled()
+ expect(session.getBrowserDownloadsState('chat-test').downloads[0]).toMatchObject({
+ filename: 'probe-error.bin',
+ state: 'interrupted',
+ })
+ })
+
+ it('fails a hung admission probe closed and ignores its late rejection', async () => {
+ vi.useFakeTimers()
+ try {
+ const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
+ const probe = deferred()
+ const getFreeDiskBytes = vi.fn(() => probe.promise)
+ session = freshSession(win, {}, undefined, {
+ getDirectory: () => directory,
+ getFreeDiskBytes,
+ })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const download = mockDownloadItem({ filename: 'hung-admission.bin', totalBytes: 100 })
+
+ startMockDownload(contents, download)
+ await vi.waitFor(() => expect(getFreeDiskBytes).toHaveBeenCalledOnce())
+ expect(download.item.resume).not.toHaveBeenCalled()
+ const timersDuringProbe = vi.getTimerCount()
+
+ await vi.advanceTimersByTimeAsync(5_000)
+
+ expect(download.item.cancel).toHaveBeenCalledOnce()
+ expect(download.item.resume).not.toHaveBeenCalled()
+ expect(vi.getTimerCount()).toBe(timersDuringProbe - 1)
+ probe.reject(new Error('late disk failure'))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(download.item.cancel).toHaveBeenCalledOnce()
+ expect(download.item.resume).not.toHaveBeenCalled()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('fails a hung progress probe closed instead of disabling later disk checks', async () => {
+ vi.useFakeTimers()
+ try {
+ const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
+ const progressProbe = deferred()
+ const getFreeDiskBytes = vi
+ .fn<(directory: string) => number | Promise>()
+ .mockReturnValueOnce(Number.MAX_SAFE_INTEGER)
+ .mockReturnValueOnce(progressProbe.promise)
+ session = freshSession(win, {}, undefined, {
+ getDirectory: () => directory,
+ getFreeDiskBytes,
+ })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const download = mockDownloadItem({ filename: 'hung-progress.bin' })
+
+ startMockDownload(contents, download)
+ await vi.waitFor(() => expect(download.item.resume).toHaveBeenCalledOnce())
+ await vi.advanceTimersByTimeAsync(1_000)
+ download.emitUpdated()
+ expect(getFreeDiskBytes).toHaveBeenCalledTimes(2)
+
+ await vi.advanceTimersByTimeAsync(5_000)
+
+ expect(download.item.cancel).toHaveBeenCalledOnce()
+ progressProbe.resolve(Number.MAX_SAFE_INTEGER)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(download.item.cancel).toHaveBeenCalledOnce()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('fails hung path allocation closed without reserving a late destination', async () => {
+ vi.useFakeTimers()
+ try {
+ const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
+ const firstProbe = deferred()
+ const secondProbe = deferred()
+ const pathExists = vi
+ .fn<(path: string) => boolean | Promise>()
+ .mockReturnValueOnce(firstProbe.promise)
+ .mockReturnValueOnce(secondProbe.promise)
+ .mockReturnValue(false)
+ session = freshSession(win, {}, undefined, {
+ getDirectory: () => directory,
+ getFreeDiskBytes: () => Number.MAX_SAFE_INTEGER,
+ pathExists,
+ })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const first = mockDownloadItem({ filename: 'hung-path.bin', totalBytes: 100 })
+ const second = mockDownloadItem({ filename: 'hung-path.bin', totalBytes: 100 })
+
+ startMockDownload(contents, first)
+ startMockDownload(contents, second)
+ expect(pathExists).toHaveBeenCalledTimes(2)
+ const timersDuringAllocation = vi.getTimerCount()
+
+ await vi.advanceTimersByTimeAsync(5_000)
+
+ expect(first.item.cancel).toHaveBeenCalledOnce()
+ expect(second.item.cancel).toHaveBeenCalledOnce()
+ expect(vi.getTimerCount()).toBe(timersDuringAllocation - 2)
+
+ firstProbe.resolve(false)
+ secondProbe.reject(new Error('late path lookup failure'))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(first.item.setSavePath).not.toHaveBeenCalled()
+ expect(second.item.setSavePath).not.toHaveBeenCalled()
+ expect(first.item.resume).not.toHaveBeenCalled()
+ expect(second.item.resume).not.toHaveBeenCalled()
+
+ first.emitDone('cancelled')
+ second.emitDone('cancelled')
+ const replacement = mockDownloadItem({ filename: 'hung-path.bin', totalBytes: 100 })
+ startMockDownload(contents, replacement)
+ await vi.waitFor(() =>
+ expect(replacement.item.setSavePath).toHaveBeenCalledWith(join(directory, 'hung-path.bin'))
+ )
+ await vi.waitFor(() => expect(replacement.item.resume).toHaveBeenCalledOnce())
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('counts slow pending admissions against the per-task concurrency cap', async () => {
+ const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
+ const firstProbe = deferred()
+ const secondProbe = deferred()
+ const getFreeDiskBytes = vi
+ .fn<(directory: string) => Promise>()
+ .mockReturnValueOnce(firstProbe.promise)
+ .mockReturnValueOnce(secondProbe.promise)
+ session = freshSession(win, {}, undefined, {
+ getDirectory: () => directory,
+ getFreeDiskBytes,
+ })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const first = mockDownloadItem({ filename: 'pending-a.bin', totalBytes: 100 })
+ const second = mockDownloadItem({ filename: 'pending-b.bin', totalBytes: 100 })
+ const blocked = mockDownloadItem({ filename: 'blocked.bin', totalBytes: 100 })
+
+ startMockDownload(contents, first)
+ startMockDownload(contents, second)
+ startMockDownload(contents, blocked)
+
+ expect(first.item.pause).toHaveBeenCalledOnce()
+ expect(second.item.pause).toHaveBeenCalledOnce()
+ expect(blocked.item.pause).not.toHaveBeenCalled()
+ expect(blocked.item.setSavePath).not.toHaveBeenCalled()
+ expect(blocked.item.cancel).toHaveBeenCalledOnce()
+ await vi.waitFor(() => expect(getFreeDiskBytes).toHaveBeenCalledTimes(2))
+
+ firstProbe.resolve(Number.MAX_SAFE_INTEGER)
+ secondProbe.resolve(Number.MAX_SAFE_INTEGER)
+ await vi.waitFor(() => expect(first.item.resume).toHaveBeenCalledOnce())
+ await vi.waitFor(() => expect(second.item.resume).toHaveBeenCalledOnce())
+ })
+
+ it('does not construct or probe a save path for a synchronously rejected item', () => {
+ const unusableDirectory = Symbol('must not reach path construction') as unknown as string
+ session = freshSession(win, {}, undefined, {
+ getDirectory: () => unusableDirectory,
+ getFreeDiskBytes: () => Number.MAX_SAFE_INTEGER,
+ })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const download = mockDownloadItem({
+ filename: 'too-large.bin',
+ totalBytes: 2 * 1024 ** 3 + 1,
+ })
+
+ expect(() => startMockDownload(contents, download)).not.toThrow()
+ expect(download.item.cancel).toHaveBeenCalledOnce()
+ expect(download.item.setSavePath).not.toHaveBeenCalled()
+ expect(download.item.pause).not.toHaveBeenCalled()
+ })
+
+ it('fails closed when the configured download directory cannot contain a file', async () => {
+ const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
+ const notDirectory = join(directory, 'ordinary-file')
+ writeFileSync(notDirectory, 'not a directory')
+ session = freshSession(win, {}, undefined, {
+ getDirectory: () => notDirectory,
+ getFreeDiskBytes: () => Number.MAX_SAFE_INTEGER,
+ })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const download = mockDownloadItem({ filename: 'cannot-save.bin', totalBytes: 100 })
+
+ startMockDownload(contents, download)
+
+ await vi.waitFor(() => expect(download.item.cancel).toHaveBeenCalledOnce())
+ expect(download.item.setSavePath).not.toHaveBeenCalled()
+ expect(download.item.resume).not.toHaveBeenCalled()
+ expect(session.getBrowserDownloadsState('chat-test').downloads[0]).toMatchObject({
+ filename: 'cannot-save.bin',
+ state: 'interrupted',
+ })
+ })
+
+ it('reserves active downloads across different folders on the same disk', async () => {
+ const firstDirectory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-a-'))
+ const secondDirectory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-b-'))
+ let directory = firstDirectory
+ session = freshSession(win, {}, undefined, {
+ getDirectory: () => directory,
+ getFreeDiskBytes: () => 4 * 1024 ** 3,
+ })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const first = mockDownloadItem({ filename: 'first.iso', totalBytes: 2 * 1024 ** 3 })
+ const second = mockDownloadItem({ filename: 'second.iso', totalBytes: 2 * 1024 ** 3 })
+
+ startMockDownload(contents, first)
+ directory = secondDirectory
+ startMockDownload(contents, second)
+
+ await vi.waitFor(() => expect(first.item.resume).toHaveBeenCalledOnce())
+ await vi.waitFor(() => expect(second.item.cancel).toHaveBeenCalledOnce())
+ expect(first.item.cancel).not.toHaveBeenCalled()
+ expect(second.item.resume).not.toHaveBeenCalled()
+ })
+
+ it('stops an unknown-size download immediately when its received bytes cross the cap', async () => {
+ const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
+ session = freshSession(win, {}, undefined, {
+ getDirectory: () => directory,
+ getFreeDiskBytes: () => Number.MAX_SAFE_INTEGER,
+ })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const download = mockDownloadItem({ filename: 'stream.bin' })
+
+ startMockDownload(contents, download)
+ await vi.waitFor(() => expect(download.item.resume).toHaveBeenCalledOnce())
+ const firstSavePath = download.item.setSavePath.mock.calls[0]?.[0]
+ download.setReceivedBytes(2 * 1024 ** 3 + 1)
+ download.emitUpdated()
+
+ expect(download.item.cancel).toHaveBeenCalledOnce()
+ expect(session.getBrowserDownloadsState('chat-test').downloads[0]).toMatchObject({
+ filename: 'stream.bin',
+ state: 'interrupted',
+ receivedBytes: 2 * 1024 ** 3 + 1,
+ })
+
+ download.emitDone('cancelled')
+ const replacement = mockDownloadItem({ filename: 'stream.bin', totalBytes: 100 })
+ startMockDownload(contents, replacement)
+ expect(replacement.item.cancel).not.toHaveBeenCalled()
+ await vi.waitFor(() => expect(replacement.item.setSavePath).toHaveBeenCalledWith(firstSavePath))
+ })
+
+ it('throttles free-disk checks while stopping promptly after the interval', async () => {
+ vi.useFakeTimers()
+ try {
+ vi.setSystemTime(new Date('2026-08-31T00:00:00.000Z'))
+ const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
+ let freeDiskBytes = 4 * 1024 ** 3
+ let lastProbeAt = 0
+ const getFreeDiskBytes = vi.fn(() => {
+ lastProbeAt = Date.now()
+ return freeDiskBytes
+ })
+ session = freshSession(win, {}, undefined, {
+ getDirectory: () => directory,
+ getFreeDiskBytes,
+ })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const download = mockDownloadItem({ filename: 'unknown-size.bin' })
+
+ startMockDownload(contents, download)
+ await vi.waitFor(() => expect(download.item.resume).toHaveBeenCalledOnce())
+ expect(getFreeDiskBytes).toHaveBeenCalledOnce()
+ vi.setSystemTime(lastProbeAt)
+ freeDiskBytes = 512 * 1024 ** 2
+
+ download.setReceivedBytes(10)
+ download.emitUpdated()
+ vi.advanceTimersByTime(999)
+ download.setReceivedBytes(20)
+ download.emitUpdated()
+ expect(getFreeDiskBytes).toHaveBeenCalledOnce()
+ expect(download.item.cancel).not.toHaveBeenCalled()
+
+ vi.advanceTimersByTime(1)
+ download.setReceivedBytes(30)
+ download.emitUpdated()
+ expect(getFreeDiskBytes).toHaveBeenCalledTimes(2)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(download.item.cancel).toHaveBeenCalledOnce()
+ expect(session.getBrowserDownloadsState('chat-test').downloads[0]).toMatchObject({
+ state: 'interrupted',
+ receivedBytes: 30,
+ })
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('coalesces progress probes while a free-space check is still in flight', async () => {
+ vi.useFakeTimers()
+ try {
+ vi.setSystemTime(new Date('2026-08-31T00:00:00.000Z'))
+ const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
+ const progressProbe = deferred()
+ const getFreeDiskBytes = vi
+ .fn<(directory: string) => number | Promise>()
+ .mockReturnValueOnce(Number.MAX_SAFE_INTEGER)
+ .mockReturnValueOnce(progressProbe.promise)
+ session = freshSession(win, {}, undefined, {
+ getDirectory: () => directory,
+ getFreeDiskBytes,
+ })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const download = mockDownloadItem({ filename: 'coalesced.bin' })
+
+ startMockDownload(contents, download)
+ await vi.waitFor(() => expect(download.item.resume).toHaveBeenCalledOnce())
+
+ await vi.advanceTimersByTimeAsync(1_000)
+ download.emitUpdated()
+ expect(getFreeDiskBytes).toHaveBeenCalledTimes(2)
+ await vi.advanceTimersByTimeAsync(2_000)
+ download.emitUpdated()
+ download.emitUpdated()
+ expect(getFreeDiskBytes).toHaveBeenCalledTimes(2)
+
+ progressProbe.resolve(Number.MAX_SAFE_INTEGER)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(download.item.cancel).not.toHaveBeenCalled()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('ignores a late admission sample after the item reaches a terminal state', async () => {
+ const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
+ const probe = deferred()
+ session = freshSession(win, {}, undefined, {
+ getDirectory: () => directory,
+ getFreeDiskBytes: () => probe.promise,
+ })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const download = mockDownloadItem({ filename: 'finished-before-probe.bin', totalBytes: 100 })
+
+ startMockDownload(contents, download)
+ expect(download.item.pause).toHaveBeenCalledOnce()
+ download.emitDone('cancelled')
+ probe.resolve(Number.MAX_SAFE_INTEGER)
+ await vi.waitFor(() => expect(download.item.resume).not.toHaveBeenCalled())
+
+ const replacement = mockDownloadItem({ filename: 'replacement.bin', totalBytes: 100 })
+ startMockDownload(contents, replacement)
+ expect(replacement.item.cancel).not.toHaveBeenCalled()
+ })
+
+ it('cancels every active download on profile wipe without reviving late work', async () => {
+ const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
+ const firstProbe = deferred()
+ const secondProbe = deferred()
+ const { persistence, snapshots } = memoryBrowserPersistence()
+ const getFreeDiskBytes = vi
+ .fn<(directory: string) => number | Promise>()
+ .mockReturnValueOnce(firstProbe.promise)
+ .mockReturnValueOnce(secondProbe.promise)
+ .mockReturnValue(Number.MAX_SAFE_INTEGER)
+ session = freshSession(win, {}, persistence, {
+ getDirectory: () => directory,
+ getFreeDiskBytes,
+ })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const first = mockDownloadItem({ filename: 'same-name.bin', totalBytes: 100 })
+ const second = mockDownloadItem({ filename: 'same-name.bin', totalBytes: 100 })
+
+ startMockDownload(contents, first)
+ startMockDownload(contents, second)
+ await vi.waitFor(() => expect(getFreeDiskBytes).toHaveBeenCalledTimes(2))
+ const allocatedPaths = [
+ first.item.setSavePath.mock.calls[0]?.[0],
+ second.item.setSavePath.mock.calls[0]?.[0],
+ ]
+ expect(new Set(allocatedPaths).size).toBe(2)
+ expect(allocatedPaths).toContain(join(directory, 'same-name.bin'))
+
+ await session.clearProfileStorage()
+
+ expect(first.item.cancel).toHaveBeenCalledOnce()
+ expect(second.item.cancel).toHaveBeenCalledOnce()
+ expect(session.getBrowserDownloadsState('chat-test').downloads).toEqual([])
+
+ firstProbe.resolve(Number.MAX_SAFE_INTEGER)
+ secondProbe.reject(new Error('late profile probe rejection'))
+ await Promise.resolve()
+ await Promise.resolve()
+ expect(first.item.resume).not.toHaveBeenCalled()
+ expect(second.item.resume).not.toHaveBeenCalled()
+ expect(session.getBrowserDownloadsState('chat-test').downloads).toEqual([])
+ expect(snapshots.get('chat-test')?.downloads).toEqual([])
+
+ const nextContents = (session.ensureTab().view as unknown as MockView).webContents
+ const replacement = mockDownloadItem({ filename: 'same-name.bin', totalBytes: 100 })
+ startMockDownload(nextContents, replacement)
+ await vi.waitFor(() =>
+ expect(replacement.item.setSavePath).toHaveBeenCalledWith(join(directory, 'same-name.bin'))
+ )
+ await vi.waitFor(() => expect(replacement.item.resume).toHaveBeenCalledOnce())
+ expect(replacement.item.cancel).not.toHaveBeenCalled()
+
+ first.emitDone('completed')
+ second.emitDone('cancelled')
+ const concurrent = mockDownloadItem({ filename: 'same-name.bin', totalBytes: 100 })
+ startMockDownload(nextContents, concurrent)
+ await vi.waitFor(() => expect(concurrent.item.setSavePath).toHaveBeenCalledOnce())
+ expect(concurrent.item.setSavePath).not.toHaveBeenCalledWith(join(directory, 'same-name.bin'))
+ })
+
+ it('does not reserve a late filename after profile teardown starts', async () => {
+ const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
+ session = freshSession(win, {}, undefined, {
+ getDirectory: () => directory,
+ getFreeDiskBytes: () => Number.MAX_SAFE_INTEGER,
+ })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const download = mockDownloadItem({ filename: 'teardown-race.bin', totalBytes: 100 })
+
+ startMockDownload(contents, download)
+ await session.clearProfileStorage()
+ await Promise.resolve()
+
+ expect(download.item.cancel).toHaveBeenCalledOnce()
+ expect(download.item.setSavePath).not.toHaveBeenCalled()
+ expect(download.item.resume).not.toHaveBeenCalled()
+ })
+
+ it('does not let a cancelled allocation release another download path owner', async () => {
+ const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
+ const firstPathProbe = deferred()
+ const secondPathProbe = deferred()
+ const pathExists = vi
+ .fn<(path: string) => boolean | Promise>()
+ .mockReturnValueOnce(firstPathProbe.promise)
+ .mockReturnValueOnce(secondPathProbe.promise)
+ .mockReturnValue(false)
+ session = freshSession(win, {}, undefined, {
+ getDirectory: () => directory,
+ getFreeDiskBytes: () => Number.MAX_SAFE_INTEGER,
+ pathExists,
+ })
+ const firstContents = session.withBrowserScope(
+ 'chat-first',
+ () => (session.ensureTab().view as unknown as MockView).webContents
+ )
+ const secondContents = session.withBrowserScope(
+ 'chat-second',
+ () => (session.ensureTab().view as unknown as MockView).webContents
+ )
+ const thirdContents = session.withBrowserScope(
+ 'chat-third',
+ () => (session.ensureTab().view as unknown as MockView).webContents
+ )
+ const first = mockDownloadItem({ filename: 'shared.bin', totalBytes: 100 })
+ const second = mockDownloadItem({ filename: 'shared.bin', totalBytes: 100 })
+
+ startMockDownload(firstContents, first)
+ startMockDownload(secondContents, second)
+ firstPathProbe.resolve(false)
+ queueMicrotask(() => session.disposeBrowserScope('chat-first'))
+ secondPathProbe.resolve(false)
+
+ await vi.waitFor(() =>
+ expect(second.item.setSavePath).toHaveBeenCalledWith(join(directory, 'shared.bin'))
+ )
+ expect(first.item.setSavePath).not.toHaveBeenCalled()
+
+ const third = mockDownloadItem({ filename: 'shared.bin', totalBytes: 100 })
+ startMockDownload(thirdContents, third)
+ await vi.waitFor(() => expect(third.item.setSavePath).toHaveBeenCalledOnce())
+ expect(third.item.setSavePath).not.toHaveBeenCalledWith(join(directory, 'shared.bin'))
+ })
+
+ it('cancels only the disposed scope and ignores its late download callbacks', async () => {
+ const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
+ const disposedPathProbe = deferred()
+ const onDownloadsChanged = vi.fn()
+ session = freshSession(win, { onDownloadsChanged }, undefined, {
+ getDirectory: () => directory,
+ getFreeDiskBytes: () => Number.MAX_SAFE_INTEGER,
+ pathExists: (path) =>
+ path.endsWith('disposed.bin') ? disposedPathProbe.promise : Promise.resolve(false),
+ })
+ const disposedContents = session.withBrowserScope(
+ 'chat-disposed',
+ () => (session.ensureTab().view as unknown as MockView).webContents
+ )
+ const retainedContents = session.withBrowserScope(
+ 'chat-retained',
+ () => (session.ensureTab().view as unknown as MockView).webContents
+ )
+ const disposedDownload = mockDownloadItem({ filename: 'disposed.bin', totalBytes: 100 })
+ const retainedDownload = mockDownloadItem({ filename: 'retained.bin', totalBytes: 100 })
+
+ startMockDownload(disposedContents, disposedDownload)
+ startMockDownload(retainedContents, retainedDownload)
+ await vi.waitFor(() => expect(retainedDownload.item.resume).toHaveBeenCalledOnce())
+ onDownloadsChanged.mockClear()
+
+ session.disposeBrowserScope('chat-disposed')
+
+ expect(disposedDownload.item.cancel).toHaveBeenCalledOnce()
+ expect(retainedDownload.item.cancel).not.toHaveBeenCalled()
+ disposedPathProbe.resolve(false)
+ await Promise.resolve()
+ await Promise.resolve()
+ disposedDownload.emitUpdated()
+ disposedDownload.emitDone('cancelled')
+
+ expect(disposedDownload.item.setSavePath).not.toHaveBeenCalled()
+ expect(disposedDownload.item.resume).not.toHaveBeenCalled()
+ expect(session.getBrowserDownloadsState('chat-disposed').downloads).toEqual([])
+ expect(onDownloadsChanged).not.toHaveBeenCalledWith(
+ expect.objectContaining({ scopeId: 'chat-disposed' })
+ )
+ retainedDownload.emitUpdated()
+ expect(onDownloadsChanged).toHaveBeenCalledWith(
+ expect.objectContaining({ scopeId: 'chat-retained' })
+ )
+ })
+
+ it('cancels only the suspended scope and cannot republish it after reactivation', async () => {
+ const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
+ const suspendedDiskProbe = deferred()
+ const onDownloadsChanged = vi.fn()
+ const getFreeDiskBytes = vi
+ .fn<() => number | Promise>()
+ .mockReturnValueOnce(suspendedDiskProbe.promise)
+ .mockReturnValue(Number.MAX_SAFE_INTEGER)
+ session = freshSession(win, { onDownloadsChanged }, undefined, {
+ getDirectory: () => directory,
+ getFreeDiskBytes,
+ })
+ const suspendedContents = session.withBrowserScope(
+ 'chat-suspended',
+ () => (session.ensureTab().view as unknown as MockView).webContents
+ )
+ const retainedContents = session.withBrowserScope(
+ 'chat-retained',
+ () => (session.ensureTab().view as unknown as MockView).webContents
+ )
+ const suspendedDownload = mockDownloadItem({ filename: 'suspended.bin', totalBytes: 100 })
+ const retainedDownload = mockDownloadItem({ filename: 'retained.bin', totalBytes: 100 })
+
+ startMockDownload(suspendedContents, suspendedDownload)
+ startMockDownload(retainedContents, retainedDownload)
+ await vi.waitFor(() => expect(suspendedDownload.item.setSavePath).toHaveBeenCalledOnce())
+ await vi.waitFor(() => expect(retainedDownload.item.resume).toHaveBeenCalledOnce())
+ onDownloadsChanged.mockClear()
+
+ expect(session.suspendBrowserScope('chat-suspended')).toBe(true)
+ expect(suspendedDownload.item.cancel).toHaveBeenCalledOnce()
+ expect(retainedDownload.item.cancel).not.toHaveBeenCalled()
+ session.activateBrowserScope('chat-suspended')
+ onDownloadsChanged.mockClear()
+
+ suspendedDiskProbe.resolve(Number.MAX_SAFE_INTEGER)
+ await Promise.resolve()
+ await Promise.resolve()
+ suspendedDownload.emitUpdated()
+ suspendedDownload.emitDone('cancelled')
+
+ expect(suspendedDownload.item.resume).not.toHaveBeenCalled()
+ expect(session.getBrowserDownloadsState('chat-suspended').downloads).toEqual([])
+ expect(onDownloadsChanged).not.toHaveBeenCalledWith(
+ expect.objectContaining({ scopeId: 'chat-suspended' })
+ )
+ retainedDownload.emitUpdated()
+ expect(onDownloadsChanged).toHaveBeenCalledWith(
+ expect.objectContaining({ scopeId: 'chat-retained' })
+ )
+ })
+
+ it('bounds active downloads per task and releases the slot on completion', async () => {
+ const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
+ session = freshSession(win, {}, undefined, {
+ getDirectory: () => directory,
+ getFreeDiskBytes: () => Number.MAX_SAFE_INTEGER,
+ })
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ const first = mockDownloadItem({ filename: 'first.txt', totalBytes: 100 })
+ const second = mockDownloadItem({ filename: 'second.txt', totalBytes: 100 })
+ const rejected = mockDownloadItem({ filename: 'third.txt', totalBytes: 100 })
+
+ startMockDownload(contents, first)
+ startMockDownload(contents, second)
+ startMockDownload(contents, rejected)
+ expect(first.item.cancel).not.toHaveBeenCalled()
+ expect(second.item.cancel).not.toHaveBeenCalled()
+ expect(rejected.item.cancel).toHaveBeenCalledOnce()
+
+ first.emitDone('completed')
+ const replacement = mockDownloadItem({ filename: 'fourth.txt', totalBytes: 100 })
+ startMockDownload(contents, replacement)
+ expect(replacement.item.cancel).not.toHaveBeenCalled()
+ await vi.waitFor(() => expect(replacement.item.setSavePath).toHaveBeenCalledOnce())
+ })
+
+ it('bounds active browser downloads across tasks', () => {
+ const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
+ session = freshSession(win, {}, undefined, {
+ getDirectory: () => directory,
+ getFreeDiskBytes: () => Number.MAX_SAFE_INTEGER,
+ })
+ for (let scopeIndex = 0; scopeIndex < 3; scopeIndex++) {
+ session.withBrowserScope(`chat-download-${scopeIndex}`, () => {
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ startMockDownload(contents, mockDownloadItem({ filename: `${scopeIndex}-a.txt` }))
+ startMockDownload(contents, mockDownloadItem({ filename: `${scopeIndex}-b.txt` }))
+ })
+ }
+ const blocked = mockDownloadItem({ filename: 'global-overflow.txt' })
+ session.withBrowserScope('chat-download-overflow', () => {
+ const contents = (session.ensureTab().view as unknown as MockView).webContents
+ startMockDownload(contents, blocked)
+ })
+
+ expect(blocked.item.cancel).toHaveBeenCalledOnce()
+ expect(blocked.item.setSavePath).not.toHaveBeenCalled()
+ expect(session.getBrowserDownloadsState('chat-download-overflow').downloads[0]).toMatchObject({
+ filename: 'global-overflow.txt',
+ state: 'interrupted',
+ })
+ })
+
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 })
+ session = freshSession(win, {}, persistence, {
+ getDirectory: () => directory,
+ getFreeDiskBytes: () => Number.MAX_SAFE_INTEGER,
+ })
const contents = (session.ensureTab().view as unknown as MockView).webContents
const webSession = contents.session as typeof contents.session & {
on: ReturnType
@@ -2619,6 +4484,8 @@ describe('browser-agent session', () => {
getReceivedBytes: vi.fn(() => 4),
getTotalBytes: vi.fn(() => 4),
setSavePath: vi.fn(),
+ pause: vi.fn(),
+ resume: vi.fn(),
cancel: vi.fn(),
on: vi.fn(),
once: vi.fn(),
diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts
index 9d5c041a700..9bedd83f938 100644
--- a/apps/desktop/src/main/browser-agent/session.ts
+++ b/apps/desktop/src/main/browser-agent/session.ts
@@ -1,5 +1,6 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { existsSync } from 'node:fs'
+import { statfs } from 'node:fs/promises'
import { join } from 'node:path'
import type {
BrowserDataKind,
@@ -9,6 +10,7 @@ import type {
BrowserMediaPermissionRequest,
BrowserOmniboxFocusMode,
BrowserPageIssue,
+ BrowserSitePermissionRequest,
BrowserTabState,
BrowserTabsState,
BrowserTheme,
@@ -34,6 +36,7 @@ import type {
} from 'electron'
import {
app,
+ dialog,
session as electronSession,
Menu,
nativeTheme,
@@ -89,12 +92,14 @@ export interface AgentTab {
view: WebContentsView
pinned: boolean
pendingRestoreUrl?: string
+ pendingRestore?: PendingTabRestore
pageIssue?: BrowserPageIssue
syntheticForward?: { url: string; baseHistoryIndex: number }
preserveSyntheticForwardOnNextNavigation?: boolean
recoveringUnresponsive?: boolean
pendingMediaPermission?: PendingMediaPermission
mediaPermissionGrant?: MediaPermissionGrant
+ pendingSitePermission?: PendingSitePermission
lastRealUserGestureAt?: number
}
@@ -110,6 +115,19 @@ interface MediaPermissionGrant {
devices: Set
}
+interface PendingSitePermission {
+ request: BrowserSitePermissionRequest
+ /** Exact committed document from which the suspended request originated. */
+ documentUrl: string
+ /** Exact destination retained only in main-process memory for receipt validation. */
+ destinationUrl: string
+ contents: WebContents
+ networkRequestId: number
+ resolve: (allowed: boolean) => void
+ timeout: ReturnType
+ nativePromptController?: AbortController
+}
+
export interface BrowserSessionPersistence {
load: (scopeId: string) => BrowserSessionSnapshot | null
save: (scopeId: string, snapshot: BrowserSessionSnapshot) => boolean
@@ -120,6 +138,10 @@ export interface BrowserSessionPersistence {
export interface BrowserDownloadSettings {
/** Resolves the current destination when a download starts. */
getDirectory: () => string
+ /** Overrides the destination filesystem's available-byte lookup. */
+ getFreeDiskBytes?: (directory: string) => number | Promise
+ /** Overrides asynchronous destination collision checks. */
+ pathExists?: (path: string) => boolean | Promise
}
export interface AgentSessionEvents {
@@ -140,6 +162,8 @@ export interface AgentSessionEvents {
onActiveTabChanged: (contents: WebContents) => void
/** The active tab's recoverable page state changed without a navigation. */
onPageStateChanged: (contents: WebContents) => void
+ /** Whether the current app renderer can present and answer a site-origin prompt. */
+ sitePermissionPromptSupported: (scopeId: string) => boolean
/** The tab list or active tab changed. */
onTabsChanged: () => void
/** Sim's appearance preference changed for an existing tab. */
@@ -158,8 +182,27 @@ export interface AgentSessionEvents {
const MAX_RECENTLY_CLOSED_TABS = 10
const MAX_LIVE_TABS_PER_SCOPE = 32
const MAX_LIVE_TABS_GLOBAL = 96
+/**
+ * Admission reserves active downloads' worst-case remaining bytes so concurrent
+ * downloads cannot collectively consume the disk floor; unknown sizes reserve
+ * the per-file cap.
+ */
+const MAX_BROWSER_DOWNLOAD_BYTES = 2 * 1024 ** 3
+const MAX_ACTIVE_BROWSER_DOWNLOADS_PER_SCOPE = 2
+const MAX_ACTIVE_BROWSER_DOWNLOADS_GLOBAL = 6
+const MIN_BROWSER_DOWNLOAD_FREE_DISK_BYTES = 1024 ** 3
+const BROWSER_DOWNLOAD_DISK_CHECK_INTERVAL_MS = 1_000
+const BROWSER_DOWNLOAD_DISK_CHECK_TIMEOUT_MS = 5_000
+const BROWSER_DOWNLOAD_PATH_ALLOCATION_TIMEOUT_MS = 5_000
+/** One foreground reservation keeps a selected tab responsive under background restore load. */
+const MAX_TAB_RESTORE_CONCURRENCY = 4
+const MAX_BACKGROUND_TAB_RESTORE_CONCURRENCY = 3
+const BACKGROUND_TAB_RESTORE_TIMEOUT_MS = 15_000
+const FOREGROUND_TAB_RESTORE_TIMEOUT_MS = 20_000
const MEDIA_PERMISSION_GESTURE_WINDOW_MS = 10_000
const MEDIA_PERMISSION_PROMPT_TIMEOUT_MS = 30_000
+const SITE_PERMISSION_PROMPT_TIMEOUT_MS = 20_000
+const MAX_SITE_ORIGIN_GRANTS_PER_SCOPE = 64
export type BrowserShortcut = 'focus-omnibox' | 'new-tab' | 'close-tab' | 'find'
@@ -227,6 +270,8 @@ interface BrowserScopeState {
*/
findingTabId: string | null
findingRequestId: number | null
+ /** Memory-bounded, task-local origins explicitly reached or approved by the user. */
+ siteOriginGrants: Map
}
function createBrowserScopeState(): BrowserScopeState {
@@ -247,6 +292,7 @@ function createBrowserScopeState(): BrowserScopeState {
automationNeedsAttention: false,
findingTabId: null,
findingRequestId: null,
+ siteOriginGrants: new Map(),
}
}
@@ -365,13 +411,51 @@ let browserTheme: BrowserTheme = 'system'
let browserAppTheme: BrowserTheme = 'system'
let browserAppearanceTheme: DesktopAppearanceTheme = 'app'
let browserDefaultZoom: DesktopZoomPercent = 100
-const activeDownloadPaths = new Set()
-type TrackedBrowserDownload = BrowserDownloadInfo & { savePath: string }
+type TrackedBrowserDownload = BrowserDownloadInfo & {
+ savePath?: string
+ interruptionReason?: string
+}
type BrowserFinishedDownload = Omit & {
state: Exclude
+ savePath: string
+}
+
+interface ActiveBrowserDownload {
+ directory: string
+ download: TrackedBrowserDownload
+ item: DownloadItem
+ diskCheckInFlight: boolean
+ lastDiskCheckAt: number
+ savePath?: string
+ scopeId: string
+ terminal: boolean
+ limitReason?: string
+}
+
+const activeDownloadPaths = new Map()
+
+interface PendingTabRestore {
+ generation: number
+ tab: AgentTab
+ url: string
+ priority: 'foreground' | 'background'
+ ready: Promise
+ resolveReady: (loaded: boolean) => void
+ started: boolean
+ settled: boolean
+ requeueAfterPreemption: boolean
+ cancelLoad?: () => void
+ grantSitePermissionGrace?: () => void
+ promoteToForeground?: () => void
}
const browserDownloadsByScope = new Map()
+const activeBrowserDownloads = new Set()
+const pendingForegroundTabRestores: PendingTabRestore[] = []
+const pendingBackgroundTabRestores: PendingTabRestore[] = []
+const activeTabRestores = new Set()
+const activeBackgroundTabRestores = new Set()
+let backgroundTabRestoreGeneration = 0
/** Mirrors the compact recent-downloads panel used by mainstream browsers. */
const MAX_RECENT_FINISHED_DOWNLOADS = 5
@@ -381,7 +465,7 @@ function browserDownloadsState(scopeId: string): BrowserDownloadsState {
return {
scopeId: resolved,
downloads: (browserDownloadsByScope.get(resolved) ?? []).map(
- ({ savePath: _savePath, ...item }) => ({ ...item })
+ ({ savePath: _savePath, interruptionReason: _interruptionReason, ...item }) => ({ ...item })
),
}
}
@@ -409,10 +493,252 @@ function updateDownloadProgress(download: BrowserDownloadInfo, item: DownloadIte
download.totalBytes = Math.max(0, item.getTotalBytes())
}
+function activeBrowserDownloadCount(scopeId?: string): number {
+ if (!scopeId) return activeBrowserDownloads.size
+ const resolved = resolveBrowserScopeId(scopeId)
+ let count = 0
+ for (const active of activeBrowserDownloads) {
+ if (resolveBrowserScopeId(active.scopeId) === resolved) count += 1
+ }
+ return count
+}
+
+function withBrowserDownloadTimeout(
+ operation: Promise,
+ timeoutMs: number,
+ timeoutMessage: string,
+ onTimeout?: () => void
+): Promise {
+ let timeout: ReturnType | undefined
+ const expiry = new Promise((_resolve, reject) => {
+ timeout = setTimeout(() => {
+ onTimeout?.()
+ reject(new Error(timeoutMessage))
+ }, timeoutMs)
+ })
+ return Promise.race([operation, expiry]).finally(() => clearTimeout(timeout))
+}
+
+async function browserDownloadFreeDiskBytes(directory: string): Promise {
+ try {
+ const configured = browserDownloadSettings?.getFreeDiskBytes?.(directory)
+ const lookup =
+ configured === undefined
+ ? statfs(directory).then((stats) => stats.bavail * stats.bsize)
+ : Promise.resolve(configured)
+ const available = await withBrowserDownloadTimeout(
+ lookup,
+ BROWSER_DOWNLOAD_DISK_CHECK_TIMEOUT_MS,
+ 'Browser download disk-space check timed out'
+ )
+ if (!Number.isFinite(available) || available < 0) return null
+ return Math.min(Number.MAX_SAFE_INTEGER, Math.floor(available))
+ } catch (error) {
+ logger.warn('Could not determine free disk space for agent browser download', {
+ error: getErrorMessage(error),
+ })
+ return null
+ }
+}
+
+function browserDownloadSizeLimitReason(): string {
+ return `Stopped: exceeds the ${formatBrowserDownloadBytes(MAX_BROWSER_DOWNLOAD_BYTES)} download limit`
+}
+
+function browserDownloadDiskLimitReason(): string {
+ return `Stopped: not enough disk space to finish safely while keeping ${formatBrowserDownloadBytes(MIN_BROWSER_DOWNLOAD_FREE_DISK_BYTES)} free`
+}
+
+function downloadRemainingReservation(item: DownloadItem): number {
+ const receivedBytes = Math.max(0, item.getReceivedBytes())
+ const totalBytes = Math.max(0, item.getTotalBytes())
+ const targetBytes = totalBytes > 0 ? totalBytes : MAX_BROWSER_DOWNLOAD_BYTES
+ return Math.max(0, targetBytes - receivedBytes)
+}
+
+function activeDownloadReservations(through?: ActiveBrowserDownload): number {
+ let reservedBytes = 0
+ for (const active of activeBrowserDownloads) {
+ if (!active.limitReason) {
+ reservedBytes += downloadRemainingReservation(active.item)
+ }
+ if (active === through) break
+ }
+ return reservedBytes
+}
+
+function browserDownloadAdmissionReason(scopeId: string, item: DownloadItem): string | null {
+ if (Math.max(0, item.getTotalBytes()) > MAX_BROWSER_DOWNLOAD_BYTES) {
+ return browserDownloadSizeLimitReason()
+ }
+ if (activeBrowserDownloadCount(scopeId) >= MAX_ACTIVE_BROWSER_DOWNLOADS_PER_SCOPE) {
+ return `Stopped: this task already has ${MAX_ACTIVE_BROWSER_DOWNLOADS_PER_SCOPE} downloads in progress`
+ }
+ if (activeBrowserDownloadCount() >= MAX_ACTIVE_BROWSER_DOWNLOADS_GLOBAL) {
+ return `Stopped: Sim already has ${MAX_ACTIVE_BROWSER_DOWNLOADS_GLOBAL} browser downloads in progress`
+ }
+ return null
+}
+
+function browserDownloadSizeLimitReasonForItem(item: DownloadItem): string | null {
+ if (
+ Math.max(0, item.getReceivedBytes()) > MAX_BROWSER_DOWNLOAD_BYTES ||
+ Math.max(0, item.getTotalBytes()) > MAX_BROWSER_DOWNLOAD_BYTES
+ ) {
+ return browserDownloadSizeLimitReason()
+ }
+ return null
+}
+
+function createTrackedBrowserDownload(
+ item: DownloadItem,
+ state: BrowserDownloadInfo['state'],
+ interruptionReason?: string
+): TrackedBrowserDownload {
+ const filename = suggestedFilename(item.getFilename(), item.getMimeType())
+ return {
+ id: generateId(),
+ filename,
+ state,
+ receivedBytes: Math.max(0, item.getReceivedBytes()),
+ totalBytes: Math.max(0, item.getTotalBytes()),
+ startedAt: new Date().toISOString(),
+ interruptionReason,
+ }
+}
+
+function recordBrowserDownload(scopeId: string, download: TrackedBrowserDownload): void {
+ browserDownloadsByScope.set(scopeId, [download, ...(browserDownloadsByScope.get(scopeId) ?? [])])
+ trimBrowserDownloads(scopeId)
+ publishBrowserDownloads(scopeId)
+}
+
+function cancelBrowserDownloadForLimit(active: ActiveBrowserDownload, reason: string): void {
+ if (active.limitReason) return
+ active.limitReason = reason
+ active.download.interruptionReason = reason
+ active.download.state = 'interrupted'
+ try {
+ active.item.cancel()
+ } catch (error) {
+ logger.warn('Could not cancel an agent browser download after a safety limit', {
+ error: getErrorMessage(error),
+ })
+ }
+}
+
+function publishActiveBrowserDownload(active: ActiveBrowserDownload): void {
+ const liveScopeId = resolveBrowserScopeId(active.scopeId)
+ if (
+ suspendedBrowserScopes.has(liveScopeId) ||
+ !browserScopeStates.has(liveScopeId) ||
+ !browserDownloadsByScope.get(liveScopeId)?.includes(active.download)
+ ) {
+ return
+ }
+ publishBrowserDownloads(liveScopeId)
+}
+
+function checkBrowserDownloadDiskSpace(
+ active: ActiveBrowserDownload,
+ check: 'admission' | 'progress',
+ now = Date.now()
+): void {
+ if (active.terminal || active.limitReason || active.diskCheckInFlight) return
+ if (
+ check === 'progress' &&
+ now - active.lastDiskCheckAt < BROWSER_DOWNLOAD_DISK_CHECK_INTERVAL_MS
+ ) {
+ return
+ }
+
+ active.lastDiskCheckAt = now
+ active.diskCheckInFlight = true
+ void browserDownloadFreeDiskBytes(active.directory)
+ .then((freeDiskBytes) => {
+ if (active.terminal || active.limitReason || !activeBrowserDownloads.has(active)) {
+ return
+ }
+ const requiredFreeDiskBytes =
+ MIN_BROWSER_DOWNLOAD_FREE_DISK_BYTES +
+ activeDownloadReservations(check === 'admission' ? active : undefined)
+ if (freeDiskBytes === null) {
+ cancelBrowserDownloadForLimit(active, 'Stopped: available disk space could not be checked')
+ publishActiveBrowserDownload(active)
+ return
+ }
+ if (freeDiskBytes < requiredFreeDiskBytes) {
+ cancelBrowserDownloadForLimit(active, browserDownloadDiskLimitReason())
+ publishActiveBrowserDownload(active)
+ return
+ }
+ if (check === 'admission' && active.download.state === 'progressing') active.item.resume()
+ })
+ .catch((error) => {
+ if (active.terminal || active.limitReason || !activeBrowserDownloads.has(active)) return
+ logger.warn('Could not complete an agent browser download disk-space check', {
+ error: getErrorMessage(error),
+ })
+ cancelBrowserDownloadForLimit(active, 'Stopped: available disk space could not be checked')
+ publishActiveBrowserDownload(active)
+ })
+ .finally(() => {
+ active.diskCheckInFlight = false
+ })
+}
+
+function releaseActiveBrowserDownload(active: ActiveBrowserDownload): void {
+ if (active.terminal) return
+ active.terminal = true
+ activeBrowserDownloads.delete(active)
+ releaseActiveBrowserDownloadPath(active)
+}
+
+function releaseActiveBrowserDownloadPath(
+ active: ActiveBrowserDownload,
+ savePath = active.savePath
+): void {
+ if (savePath && activeDownloadPaths.get(savePath) === active) {
+ activeDownloadPaths.delete(savePath)
+ }
+}
+
+function cancelActiveBrowserDownloads(scopeId?: string): void {
+ const resolvedScopeId = scopeId === undefined ? null : resolveBrowserScopeId(scopeId)
+ const downloads = [...activeBrowserDownloads].filter(
+ (active) =>
+ resolvedScopeId === null || resolveBrowserScopeId(active.scopeId) === resolvedScopeId
+ )
+ for (const active of downloads) releaseActiveBrowserDownload(active)
+ const cancelledDownloads = new Set(downloads.map((active) => active.download))
+ for (const [downloadScopeId, trackedDownloads] of browserDownloadsByScope) {
+ if (resolvedScopeId !== null && resolveBrowserScopeId(downloadScopeId) !== resolvedScopeId) {
+ continue
+ }
+ const retainedDownloads = trackedDownloads.filter(
+ (download) => !cancelledDownloads.has(download)
+ )
+ if (retainedDownloads.length > 0) {
+ browserDownloadsByScope.set(downloadScopeId, retainedDownloads)
+ } else {
+ browserDownloadsByScope.delete(downloadScopeId)
+ }
+ }
+ for (const active of downloads) {
+ try {
+ active.item.cancel()
+ } catch (error) {
+ logger.warn('Could not cancel an active browser download while tearing down the session', {
+ error: getErrorMessage(error),
+ })
+ }
+ }
+}
+
function isFinishedBrowserDownload(
download: TrackedBrowserDownload
): download is BrowserFinishedDownload {
- return download.state !== 'progressing'
+ return download.state !== 'progressing' && typeof download.savePath === 'string'
}
/** Returns safe metadata only; local paths stay in the Electron main process. */
@@ -429,14 +755,16 @@ function formatBrowserDownloadBytes(bytes: number): string {
return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`
}
-function downloadMenuDetail(download: BrowserDownloadInfo): string {
+function downloadMenuDetail(download: TrackedBrowserDownload): string {
const received = formatBrowserDownloadBytes(download.receivedBytes)
if (download.state === 'progressing') {
return download.totalBytes > 0
? `${received} / ${formatBrowserDownloadBytes(download.totalBytes)}`
: `${received} · Downloading`
}
- if (download.state === 'interrupted') return `${received} · Failed`
+ if (download.state === 'interrupted') {
+ return `${received} · ${download.interruptionReason ?? 'Failed'}`
+ }
if (download.state === 'cancelled') return `${received} · Cancelled`
return received
}
@@ -454,7 +782,10 @@ export function showBrowserDownloadsMenu(
downloads.length === 0
? [{ label: 'No downloads yet', enabled: false }]
: downloads.map((download) => {
- const revealable = download.state === 'completed' && existsSync(download.savePath)
+ const revealable =
+ download.state === 'completed' &&
+ typeof download.savePath === 'string' &&
+ existsSync(download.savePath)
return {
label: download.filename,
sublabel: downloadMenuDetail(download),
@@ -480,7 +811,14 @@ export function showBrowserDownloadInFolder(scopeId: string, downloadId: string)
const download = browserDownloadsByScope
.get(resolved)
?.find((candidate) => candidate.id === downloadId)
- if (!download || download.state !== 'completed' || !existsSync(download.savePath)) return false
+ if (
+ !download ||
+ download.state !== 'completed' ||
+ typeof download.savePath !== 'string' ||
+ !existsSync(download.savePath)
+ ) {
+ return false
+ }
shell.showItemInFolder(download.savePath)
return true
}
@@ -511,8 +849,13 @@ function resetSessionState(): void {
browserAppTheme = 'system'
browserAppearanceTheme = 'app'
browserDefaultZoom = 100
- activeDownloadPaths.clear()
browserDownloadsByScope.clear()
+ cancelActiveBrowserDownloads()
+ backgroundTabRestoreGeneration += 1
+ pendingForegroundTabRestores.length = 0
+ pendingBackgroundTabRestores.length = 0
+ activeTabRestores.clear()
+ activeBackgroundTabRestores.clear()
activatePanelScope(null)
}
@@ -674,10 +1017,7 @@ export function migrateBrowserScope(fromScopeId: string, toScopeId: string): boo
/** Destroys one chat's live browser state without touching the shared profile. */
export function disposeBrowserScope(scopeId: string): void {
const resolved = resolveBrowserScopeId(scopeId)
- // A migrated provisional id is only an alias. Disposing that spelling must
- // never destroy the durable chat state it now points at.
if (resolved !== scopeId) {
- browserScopeAliases.delete(scopeId)
suspendedBrowserScopes.delete(scopeId)
browserDownloadsByScope.delete(scopeId)
try {
@@ -690,6 +1030,7 @@ export function disposeBrowserScope(scopeId: string): void {
return
}
browserDownloadsByScope.delete(resolved)
+ cancelActiveBrowserDownloads(resolved)
suspendedBrowserScopes.delete(resolved)
const state = browserScopeStates.get(resolved)
@@ -737,15 +1078,17 @@ export function suspendBrowserScope(scopeId: string): boolean {
const state = browserScopeStates.get(resolved)
if (!state) {
suspendedBrowserScopes.add(resolved)
+ cancelActiveBrowserDownloads(resolved)
return true
}
withBrowserScope(resolved, () => {
if (hasSession()) persistBrowserSession()
+ suspendedBrowserScopes.add(resolved)
+ cancelActiveBrowserDownloads(resolved)
closeLiveTabs()
})
- suspendedBrowserScopes.add(resolved)
browserScopeStates.delete(resolved)
if (getActiveBrowserScopeId() === resolved) {
activeBrowserScopeId = null
@@ -782,7 +1125,7 @@ function browserSessionSnapshot(): BrowserSessionSnapshot {
const downloads = (browserDownloadsByScope.get(getBrowserScopeId()) ?? [])
.filter(isFinishedBrowserDownload)
.slice(0, MAX_RECENT_FINISHED_DOWNLOADS)
- .map((download) => ({ ...download }))
+ .map(({ interruptionReason: _interruptionReason, ...download }) => ({ ...download }))
return {
v: 1,
tabs: liveTabs.map((tab) => ({ url: tabUrl(tab), pinned: tab.pinned })),
@@ -892,6 +1235,11 @@ function mediaOrigin(candidate: unknown): string | null {
}
}
+function withoutUrlFragment(url: string): string {
+ const fragmentIndex = url.indexOf('#')
+ return fragmentIndex < 0 ? url : url.slice(0, fragmentIndex)
+}
+
function requestedMediaDevices(candidate: unknown): BrowserMediaDevice[] | null {
if (!Array.isArray(candidate) || candidate.length === 0) return null
const devices = new Set()
@@ -1006,6 +1354,212 @@ export async function respondToMediaPermission(requestId: string, allowed: boole
publishPageIssue(tab)
}
+function grantSiteOrigin(state: BrowserScopeState, origin: string): void {
+ state.siteOriginGrants.delete(origin)
+ state.siteOriginGrants.set(origin, true)
+ while (state.siteOriginGrants.size > MAX_SITE_ORIGIN_GRANTS_PER_SCOPE) {
+ const oldest = state.siteOriginGrants.keys().next().value
+ if (typeof oldest !== 'string') break
+ state.siteOriginGrants.delete(oldest)
+ }
+}
+
+function hasSiteOriginGrant(state: BrowserScopeState, origin: string): boolean {
+ if (!state.siteOriginGrants.has(origin)) return false
+ grantSiteOrigin(state, origin)
+ return true
+}
+
+function publishSitePermissionState(scopeId: string): void {
+ const resolved = resolveBrowserScopeId(scopeId)
+ const state = browserScopeStates.get(resolved)
+ if (!state) return
+ const active = state.tabs.find((tab) => tab.id === state.activeTabId)
+ if (active && !active.view.webContents.isDestroyed()) {
+ withBrowserScope(resolved, () => events?.onPageStateChanged(active.view.webContents))
+ }
+}
+
+function settleSitePermission(tab: AgentTab, allowed: boolean, publish = true): boolean {
+ const pending = tab.pendingSitePermission
+ if (!pending) return false
+ tab.pendingSitePermission = undefined
+ clearTimeout(pending.timeout)
+ pending.nativePromptController?.abort()
+ pending.resolve(allowed)
+ if (publish) publishSitePermissionState(tab.scopeId)
+ return true
+}
+
+function scopedTabForRequest(details: {
+ webContents?: WebContents
+ webContentsId?: number
+}): { scopeId: string; tab: AgentTab } | null {
+ if (details.webContents) return scopedTabForContents(details.webContents)
+ if (typeof details.webContentsId !== 'number') return null
+ for (const [scopeId, state] of browserScopeStates) {
+ const tab = state.tabs.find(
+ (candidate) => candidate.view.webContents.id === details.webContentsId
+ )
+ if (tab) return { scopeId, tab }
+ }
+ return null
+}
+
+/** Highest-priority exact site request: visible tab, automation tab, then task tab order. */
+export function sitePermissionRequestForScope(): BrowserSitePermissionRequest | undefined {
+ const state = browserScopeState()
+ const active = state.tabs.find((tab) => tab.id === state.activeTabId)?.pendingSitePermission
+ if (active) return active.request
+ const automation = state.tabs.find(
+ (tab) => tab.id === state.automationTabId
+ )?.pendingSitePermission
+ if (automation) return automation.request
+ return state.tabs.find((tab) => tab.pendingSitePermission)?.pendingSitePermission?.request
+}
+
+function grantSiteOriginForExplicitNavigation(contents: WebContents, destination: string): boolean {
+ const scoped = scopedTabForContents(contents)
+ const origin = mediaOrigin(destination)
+ if (!scoped || !origin) return false
+ const state = browserScopeStates.get(scoped.scopeId)
+ if (!state || scoped.tab.view.webContents !== contents || contents.isDestroyed()) return false
+ grantSiteOrigin(state, origin)
+ return true
+}
+
+/** Grants only the destination origin entered through a native-activation-gated user action. */
+export function grantSiteOriginForUserNavigation(
+ contents: WebContents,
+ destination: string
+): boolean {
+ return grantSiteOriginForExplicitNavigation(contents, destination)
+}
+
+/** Grants the exact destination origin after the browser driver has completed its SSRF check. */
+export function grantSiteOriginForAgentNavigation(
+ contents: WebContents,
+ destination: string
+): boolean {
+ return grantSiteOriginForExplicitNavigation(contents, destination)
+}
+
+/** Applies a response only to the exact live task, tab, document, and suspended network request. */
+export function respondToSitePermission(requestId: string, allowed: boolean): boolean {
+ const scopeId = getBrowserScopeId()
+ const state = browserScopeStates.get(scopeId)
+ const tab = state?.tabs.find(
+ (candidate) => candidate.pendingSitePermission?.request.requestId === requestId
+ )
+ const pending = tab?.pendingSitePermission
+ if (!state || !tab || !pending) return false
+
+ if (!allowed) return settleSitePermission(tab, false)
+
+ const contents = tab.view.webContents
+ const live =
+ !contents.isDestroyed() &&
+ pending.contents === contents &&
+ pending.request.tabId === tab.id &&
+ pending.documentUrl === contents.getURL() &&
+ mediaOrigin(pending.destinationUrl) === pending.request.origin &&
+ scopeId === resolveBrowserScopeId(tab.scopeId) &&
+ scopeId === getActiveBrowserScopeId() &&
+ isPanelVisible()
+ if (!live) return settleSitePermission(tab, false)
+
+ grantSiteOrigin(state, pending.request.origin)
+ return settleSitePermission(tab, true)
+}
+
+async function requestSitePermission(details: {
+ id: number
+ url: string
+ webContents?: WebContents
+ webContentsId?: number
+}): Promise {
+ const origin = mediaOrigin(details.url)
+ const scoped = scopedTabForRequest(details)
+ if (!origin || !scoped || suspendedBrowserScopes.has(scoped.scopeId)) return false
+ const state = browserScopeStates.get(scoped.scopeId)
+ const contents = scoped.tab.view.webContents
+ if (!state || contents.isDestroyed()) return false
+
+ if (mediaOrigin(contents.getURL()) === origin || hasSiteOriginGrant(state, origin)) return true
+ if (scoped.scopeId !== getActiveBrowserScopeId() || !isPanelVisible()) return false
+ const win = panelWindow()
+ if (!win || win.isDestroyed()) return false
+
+ settleSitePermission(scoped.tab, false, false)
+ revokeTabMediaPermissions(scoped.tab, false)
+ const request: BrowserSitePermissionRequest = {
+ requestId: generateId(),
+ tabId: scoped.tab.id,
+ origin,
+ }
+ const allowed = new Promise((resolve) => {
+ scoped.tab.pendingSitePermission = {
+ request,
+ documentUrl: contents.getURL(),
+ destinationUrl: details.url,
+ contents,
+ networkRequestId: details.id,
+ resolve,
+ timeout: setTimeout(
+ bindToBrowserScope(scoped.scopeId, () => {
+ const pending = scoped.tab.pendingSitePermission
+ if (
+ pending?.request.requestId !== request.requestId ||
+ pending.networkRequestId !== details.id
+ ) {
+ return
+ }
+ settleSitePermission(scoped.tab, false)
+ }),
+ SITE_PERMISSION_PROMPT_TIMEOUT_MS
+ ),
+ }
+ })
+ scoped.tab.pendingRestore?.grantSitePermissionGrace?.()
+ if (events?.sitePermissionPromptSupported(scoped.scopeId)) {
+ win.focus()
+ win.webContents.focus()
+ publishSitePermissionState(scoped.scopeId)
+ } else {
+ const nativePromptController = new AbortController()
+ const pending = scoped.tab.pendingSitePermission
+ if (!pending || pending.request.requestId !== request.requestId) return await allowed
+ pending.nativePromptController = nativePromptController
+ void dialog
+ .showMessageBox(win, {
+ type: 'warning',
+ buttons: ['Block', 'Allow'],
+ defaultId: 0,
+ cancelId: 0,
+ noLink: true,
+ signal: nativePromptController.signal,
+ message: `Allow this browser task to open ${request.origin}?`,
+ detail: 'Only allow this site if it is expected for the current task.',
+ })
+ .then(({ response }) => {
+ withBrowserScope(scoped.scopeId, () => {
+ respondToSitePermission(request.requestId, response === 1)
+ })
+ })
+ .catch((error) => {
+ if (!nativePromptController.signal.aborted) {
+ logger.warn('Could not present the native site permission prompt', {
+ error: getErrorMessage(error),
+ })
+ }
+ withBrowserScope(scoped.scopeId, () => {
+ respondToSitePermission(request.requestId, false)
+ })
+ })
+ }
+ return await allowed
+}
+
/**
* Default-deny hardening for the agent partition. Site permissions remain
* denied apart from ALLOWED_SITE_PERMISSIONS. Media is granted only after a
@@ -1111,10 +1665,10 @@ function configureAgentPartition(ses: Session): void {
// redirects, link clicks, location.href, meta-refresh) — so an internal host
// can't slip in that way.
//
- // Subresources that come back readable or that execute get the resolving
- // check too, cached per host; images and fonts keep the cheap synchronous
- // path. See isBlockedSubresourceUrl and subresourceNeedsResolution for why
- // each way round.
+ // Subresources that come back readable, render into screenshots, or execute
+ // get the resolving check too, cached per host; fonts keep the cheap
+ // synchronous path. See isBlockedSubresourceUrl and
+ // subresourceNeedsResolution for why each way round.
ses.webRequest.onBeforeRequest((details, callback) => {
// Answered exactly once, and never throwing. A throw inside the `then`
// below would otherwise land in the `catch` and answer a second time, and
@@ -1130,13 +1684,15 @@ function configureAgentPartition(ses: Session): void {
logger.warn('Could not answer an agent request', { error: getErrorMessage(error) })
}
}
- if (details.resourceType === 'mainFrame' || details.resourceType === 'subFrame') {
+ if (details.resourceType === 'mainFrame') {
void checkAgentUrl(details.url)
- .then((guard) => {
+ .then(async (guard) => {
if (!guard.ok) {
logger.warn('Blocked agent document navigation to a private host')
+ settle(true)
+ return
}
- settle(!guard.ok)
+ settle(!(await requestSitePermission(details)))
})
.catch((error) => {
// Fail closed: an unexpected rejection must cancel, never leave the
@@ -1146,6 +1702,18 @@ function configureAgentPartition(ses: Session): void {
})
return
}
+ if (details.resourceType === 'subFrame') {
+ void checkAgentUrl(details.url)
+ .then((guard) => {
+ if (!guard.ok) logger.warn('Blocked agent document navigation to a private host')
+ settle(!guard.ok)
+ })
+ .catch((error) => {
+ logger.error('Agent SSRF check failed; cancelling request', { error })
+ settle(true)
+ })
+ return
+ }
if (!subresourceNeedsResolution(details.resourceType)) {
settle(isBlockedRequestUrl(details.url))
return
@@ -1169,31 +1737,68 @@ function configureAgentPartition(ses: Session): void {
item.cancel()
return
}
- const filename = suggestedFilename(item.getFilename(), item.getMimeType())
- const savePath = uniqueDownloadPath(
- directory,
- filename,
- (candidate) => activeDownloadPaths.has(candidate) || existsSync(candidate)
- )
- activeDownloadPaths.add(savePath)
- item.setSavePath(savePath)
- const download: BrowserDownloadInfo & { savePath: string } = {
- id: generateId(),
- filename,
- state: 'progressing',
- receivedBytes: Math.max(0, item.getReceivedBytes()),
- totalBytes: Math.max(0, item.getTotalBytes()),
- startedAt: new Date().toISOString(),
- savePath,
+ const admissionReason = browserDownloadAdmissionReason(scopeId, item)
+ if (admissionReason) {
+ item.cancel()
+ const rejected = createTrackedBrowserDownload(item, 'interrupted', admissionReason)
+ recordBrowserDownload(scopeId, rejected)
+ withBrowserScope(scopeId, persistBrowserSession)
+ logger.warn('Agent browser download rejected by a safety limit', {
+ filename: rejected.filename,
+ reason: admissionReason,
+ })
+ return
}
- browserDownloadsByScope.set(scopeId, [
+
+ const download = createTrackedBrowserDownload(item, 'progressing')
+ const { filename } = download
+ try {
+ item.pause()
+ } catch (error) {
+ const reason = 'Stopped: the download could not be paused for a disk-space safety check'
+ download.interruptionReason = reason
+ download.state = 'interrupted'
+ try {
+ item.cancel()
+ } catch (cancelError) {
+ logger.warn('Could not cancel an agent browser download after pause failed', {
+ error: getErrorMessage(cancelError),
+ filename,
+ })
+ }
+ recordBrowserDownload(scopeId, download)
+ withBrowserScope(scopeId, persistBrowserSession)
+ logger.warn('Agent browser download could not be paused for admission', {
+ error: getErrorMessage(error),
+ filename,
+ })
+ return
+ }
+ const active: ActiveBrowserDownload = {
+ directory,
download,
- ...(browserDownloadsByScope.get(scopeId) ?? []),
- ])
- trimBrowserDownloads(scopeId)
- publishBrowserDownloads(scopeId)
+ item,
+ diskCheckInFlight: false,
+ lastDiskCheckAt: 0,
+ scopeId,
+ terminal: false,
+ }
+ activeBrowserDownloads.add(active)
+ recordBrowserDownload(scopeId, download)
logger.info('Agent browser download started', { filename })
item.on('updated', (_updatedEvent, state) => {
+ updateDownloadProgress(download, item)
+ if (state === 'interrupted') {
+ download.state = 'interrupted'
+ } else {
+ const limitReason = browserDownloadSizeLimitReasonForItem(item)
+ if (limitReason) cancelBrowserDownloadForLimit(active, limitReason)
+ else {
+ download.state = 'progressing'
+ if (active.savePath) checkBrowserDownloadDiskSpace(active, 'progress')
+ }
+ }
+
const liveScopeId = resolveBrowserScopeId(scopeId)
if (
suspendedBrowserScopes.has(liveScopeId) ||
@@ -1202,12 +1807,10 @@ function configureAgentPartition(ses: Session): void {
) {
return
}
- updateDownloadProgress(download, item)
- download.state = state === 'interrupted' ? 'interrupted' : 'progressing'
publishBrowserDownloads(liveScopeId)
})
item.once('done', (_doneEvent, state) => {
- activeDownloadPaths.delete(savePath)
+ releaseActiveBrowserDownload(active)
const liveScopeId = resolveBrowserScopeId(scopeId)
if (
suspendedBrowserScopes.has(liveScopeId) ||
@@ -1217,17 +1820,98 @@ function configureAgentPartition(ses: Session): void {
return
}
updateDownloadProgress(download, item)
- download.state = state
+ download.state = active.limitReason ? 'interrupted' : state
trimBrowserDownloads(liveScopeId)
publishBrowserDownloads(liveScopeId)
withBrowserScope(liveScopeId, persistBrowserSession)
- if (state === 'completed') {
+ if (download.state === 'completed') {
logger.info('Agent browser download completed', { filename })
- if (process.platform === 'darwin') app.dock?.downloadFinished(savePath)
- } else if (state === 'interrupted') {
- logger.warn('Agent browser download interrupted', { filename })
+ if (process.platform === 'darwin' && active.savePath) {
+ app.dock?.downloadFinished(active.savePath)
+ }
+ } else if (download.state === 'interrupted') {
+ logger.warn('Agent browser download interrupted', {
+ filename,
+ reason: download.interruptionReason,
+ })
}
})
+ let allocationExpired = false
+ const allocation = uniqueDownloadPath(directory, filename, {
+ isActive: () =>
+ !allocationExpired &&
+ !active.terminal &&
+ !active.limitReason &&
+ activeBrowserDownloads.has(active),
+ pathExists: browserDownloadSettings?.pathExists,
+ reservePath: (candidate) => {
+ if (
+ allocationExpired ||
+ active.terminal ||
+ active.limitReason ||
+ !activeBrowserDownloads.has(active) ||
+ activeDownloadPaths.has(candidate)
+ ) {
+ return false
+ }
+ activeDownloadPaths.set(candidate, active)
+ active.savePath = candidate
+ return true
+ },
+ })
+ void withBrowserDownloadTimeout(
+ allocation,
+ BROWSER_DOWNLOAD_PATH_ALLOCATION_TIMEOUT_MS,
+ 'Browser download path allocation timed out',
+ () => {
+ allocationExpired = true
+ }
+ )
+ .then((savePath) => {
+ if (active.terminal || !activeBrowserDownloads.has(active)) {
+ releaseActiveBrowserDownloadPath(active, savePath ?? undefined)
+ return
+ }
+ if (!savePath) {
+ cancelBrowserDownloadForLimit(
+ active,
+ 'Stopped: a safe non-conflicting download filename could not be allocated'
+ )
+ publishActiveBrowserDownload(active)
+ return
+ }
+ download.savePath = savePath
+ try {
+ item.setSavePath(savePath)
+ } catch (error) {
+ releaseActiveBrowserDownloadPath(active, savePath)
+ active.savePath = undefined
+ download.savePath = undefined
+ logger.warn('Could not set the destination for an agent browser download', {
+ error: getErrorMessage(error),
+ filename,
+ })
+ cancelBrowserDownloadForLimit(
+ active,
+ 'Stopped: the download destination could not be prepared safely'
+ )
+ publishActiveBrowserDownload(active)
+ return
+ }
+ checkBrowserDownloadDiskSpace(active, 'admission')
+ })
+ .catch((error) => {
+ if (active.terminal || !activeBrowserDownloads.has(active)) return
+ logger.warn('Could not allocate an agent browser download destination', {
+ error: getErrorMessage(error),
+ filename,
+ })
+ cancelBrowserDownloadForLimit(
+ active,
+ 'Stopped: the download destination could not be prepared safely'
+ )
+ publishActiveBrowserDownload(active)
+ })
})
}
@@ -1308,6 +1992,7 @@ export function goBack(contents: WebContents): boolean {
const tab = tabForContents(contents)
if (!tab) return false
if (tab.pageIssue?.kind === 'load-error') {
+ prepareExplicitNavigation(contents)
tab.syntheticForward = {
url: tab.pageIssue.url,
baseHistoryIndex: contents.navigationHistory.getActiveIndex(),
@@ -1317,6 +2002,7 @@ export function goBack(contents: WebContents): boolean {
return true
}
if (!contents.navigationHistory.canGoBack()) return false
+ prepareExplicitNavigation(contents)
tab.preserveSyntheticForwardOnNextNavigation = Boolean(tab.syntheticForward)
contents.navigationHistory.goBack()
return true
@@ -1332,15 +2018,18 @@ export function goForward(contents: WebContents): boolean {
contents.navigationHistory.getActiveIndex() < syntheticForward.baseHistoryIndex &&
contents.navigationHistory.canGoForward()
) {
+ prepareExplicitNavigation(contents)
tab.preserveSyntheticForwardOnNextNavigation = true
contents.navigationHistory.goForward()
return true
}
+ prepareExplicitNavigation(contents)
tab.syntheticForward = undefined
void contents.loadURL(syntheticForward.url).catch(() => {})
return true
}
if (!contents.navigationHistory.canGoForward()) return false
+ prepareExplicitNavigation(contents)
contents.navigationHistory.goForward()
return true
}
@@ -1348,6 +2037,7 @@ export function goForward(contents: WebContents): boolean {
/** Retries the appropriate recovery path for a failed, crashed, or hung page. */
export function reloadPage(contents: WebContents): void {
const tab = tabForContents(contents)
+ prepareExplicitNavigation(contents)
const issue = tab?.pageIssue
if (issue?.kind === 'load-error') {
void contents.loadURL(issue.url).catch(() => {})
@@ -1485,10 +2175,14 @@ export function stopFindInActiveTab(focusPage: boolean): void {
* inside the browser resource rather than spawn a native window, and both are
* reached from an untrusted page, so the scheme is checked here once.
*/
-function openTabWithUrl(url: string, agentOwned: boolean): void {
+function openTabWithUrl(
+ url: string,
+ { agentOwned, userAuthorized }: { agentOwned: boolean; userAuthorized: boolean }
+): void {
if (!/^https?:\/\//i.test(url)) return
try {
const tab = agentOwned ? addAutomationTab() : addTab()
+ if (userAuthorized) grantSiteOriginForUserNavigation(tab.view.webContents, url)
void tab.view.webContents.loadURL(url).catch(() => {})
} catch (error) {
logger.warn('Could not open a link in a new browser tab', {
@@ -1540,7 +2234,10 @@ function initializeTabView(view: WebContentsView, scopeId: string): WebContentsV
contents.setUserAgent(browserUserAgent())
attachAgentContextMenu(contents, {
addToChat: (text) => withBrowserScope(scopeId, () => addPageSelectionToChat(contents, text)),
- openTab: (url) => withBrowserScope(scopeId, () => openTabWithUrl(url, false)),
+ openTab: (url) =>
+ withBrowserScope(scopeId, () =>
+ openTabWithUrl(url, { agentOwned: false, userAuthorized: true })
+ ),
defaultZoomFactor: getBrowserDefaultZoomFactor,
})
@@ -1597,7 +2294,12 @@ function initializeTabView(view: WebContentsView, scopeId: string): WebContentsV
// Keep popups inside the browser resource: http(s) window.open and
// target=_blank requests become a new internal tab, never a native window.
contents.setWindowOpenHandler((details) => {
- withBrowserScope(scopeId, () => openTabWithUrl(details.url, agentOwnsPopupFrom(contents)))
+ withBrowserScope(scopeId, () =>
+ openTabWithUrl(details.url, {
+ agentOwned: agentOwnsPopupFrom(contents),
+ userAuthorized: false,
+ })
+ )
return { action: 'deny' }
})
@@ -1624,6 +2326,7 @@ function initializeTabView(view: WebContentsView, scopeId: string): WebContentsV
return
}
dismissFind(tab.id)
+ settleSitePermission(tab, false)
revokeTabMediaPermissions(tab, false)
tab.pageIssue = {
kind: 'crashed',
@@ -1641,6 +2344,7 @@ function initializeTabView(view: WebContentsView, scopeId: string): WebContentsV
const tab = tabs.find((entry) => entry.view === view)
if (!tab || tab.pageIssue?.kind === 'crashed') return
dismissFind(tab.id)
+ settleSitePermission(tab, false)
revokeTabMediaPermissions(tab, false)
tab.pageIssue = {
kind: 'unresponsive',
@@ -1746,7 +2450,16 @@ function initializeTabView(view: WebContentsView, scopeId: string): WebContentsV
bindToBrowserScope(scopeId, (details) => {
if (!details.isMainFrame) return
const tab = tabs.find((entry) => entry.view === view)
- if (tab) revokeTabMediaPermissions(tab)
+ if (tab) {
+ if (
+ tab.pendingSitePermission &&
+ withoutUrlFragment(tab.pendingSitePermission.destinationUrl) !==
+ withoutUrlFragment(details.url)
+ ) {
+ settleSitePermission(tab, false)
+ }
+ revokeTabMediaPermissions(tab)
+ }
notePageNavigationStarted(contents)
events?.onTabNavigated(contents, false)
})
@@ -1765,7 +2478,10 @@ function initializeTabView(view: WebContentsView, scopeId: string): WebContentsV
'destroyed',
bindToBrowserScope(scopeId, () => {
const tab = tabs.find((entry) => entry.view === view)
- if (tab) revokeTabMediaPermissions(tab, false)
+ if (tab) {
+ settleSitePermission(tab, false)
+ revokeTabMediaPermissions(tab, false)
+ }
events?.onTabClosed(contents)
})
)
@@ -2007,6 +2723,257 @@ function closeTabAfterFailedRestore(tab: AgentTab): void {
}
}
+function isPendingTabRestoreLive(pending: PendingTabRestore): boolean {
+ if (pending.generation !== backgroundTabRestoreGeneration) return false
+ const state = browserScopeStates.get(resolveBrowserScopeId(pending.tab.scopeId))
+ return Boolean(
+ state?.tabs.includes(pending.tab) &&
+ !pending.tab.view.webContents.isDestroyed() &&
+ pending.tab.pendingRestoreUrl === pending.url
+ )
+}
+
+function loadPendingTabRestore(pending: PendingTabRestore, timeoutMs: number): Promise {
+ if (!isPendingTabRestoreLive(pending) || pending.url === 'about:blank') {
+ return Promise.resolve(false)
+ }
+ const contents = pending.tab.view.webContents
+ return new Promise((resolve) => {
+ let settled = false
+ let timeout: ReturnType | undefined
+ const startedAt = Date.now()
+ let hardDeadlineAt = startedAt + timeoutMs + SITE_PERMISSION_PROMPT_TIMEOUT_MS
+ let deadlineAt = startedAt + timeoutMs
+ let foregroundDeadlineGranted = pending.priority === 'foreground'
+ let sitePermissionGraceGranted = false
+ const finish = (loaded: boolean) => {
+ if (settled) return
+ settled = true
+ if (timeout) clearTimeout(timeout)
+ pending.cancelLoad = undefined
+ pending.grantSitePermissionGrace = undefined
+ pending.promoteToForeground = undefined
+ if (!loaded) settleSitePermission(pending.tab, false)
+ if (
+ loaded &&
+ isPendingTabRestoreLive(pending) &&
+ pending.tab.pendingRestoreUrl === pending.url
+ ) {
+ pending.tab.pendingRestoreUrl = undefined
+ }
+ resolve(loaded)
+ }
+ const stopLoad = (timedOut: boolean) => {
+ try {
+ if (!contents.isDestroyed()) contents.stop()
+ } catch (error) {
+ logger.warn('Could not stop a deferred browser tab restore', {
+ error: getErrorMessage(error),
+ })
+ } finally {
+ if (timedOut && isPendingTabRestoreLive(pending)) {
+ withBrowserScope(pending.tab.scopeId, () => {
+ recordPageLoadFailure(contents, {
+ kind: 'load-error',
+ code: -7,
+ description: 'ERR_TIMED_OUT',
+ url: pending.url,
+ })
+ })
+ }
+ finish(false)
+ }
+ }
+ pending.cancelLoad = () => stopLoad(false)
+ const scheduleDeadline = () => {
+ if (settled) return
+ if (timeout) clearTimeout(timeout)
+ timeout = setTimeout(() => stopLoad(true), Math.max(0, deadlineAt - Date.now()))
+ }
+ pending.grantSitePermissionGrace = () => {
+ if (settled || sitePermissionGraceGranted) return
+ sitePermissionGraceGranted = true
+ deadlineAt = Math.min(deadlineAt + SITE_PERMISSION_PROMPT_TIMEOUT_MS, hardDeadlineAt)
+ scheduleDeadline()
+ }
+ pending.promoteToForeground = () => {
+ if (settled || foregroundDeadlineGranted) return
+ foregroundDeadlineGranted = true
+ hardDeadlineAt =
+ startedAt + FOREGROUND_TAB_RESTORE_TIMEOUT_MS + SITE_PERMISSION_PROMPT_TIMEOUT_MS
+ deadlineAt = Math.min(
+ Math.max(deadlineAt, Date.now() + FOREGROUND_TAB_RESTORE_TIMEOUT_MS),
+ hardDeadlineAt
+ )
+ scheduleDeadline()
+ }
+ scheduleDeadline()
+ try {
+ void Promise.resolve(contents.loadURL(pending.url)).then(
+ () => finish(true),
+ () => finish(false)
+ )
+ } catch {
+ finish(false)
+ }
+ })
+}
+
+function createPendingTabRestore(
+ tab: AgentTab,
+ url: string,
+ priority: PendingTabRestore['priority']
+): PendingTabRestore {
+ let resolveReady = (_loaded: boolean) => {}
+ const ready = new Promise((resolve) => {
+ resolveReady = resolve
+ })
+ const pending: PendingTabRestore = {
+ generation: backgroundTabRestoreGeneration,
+ tab,
+ url,
+ priority,
+ ready,
+ resolveReady,
+ started: false,
+ settled: false,
+ requeueAfterPreemption: false,
+ }
+ tab.pendingRestore = pending
+ return pending
+}
+
+function settlePendingTabRestore(pending: PendingTabRestore, loaded = false): void {
+ if (pending.settled) return
+ pending.settled = true
+ if (pending.tab.pendingRestore === pending) pending.tab.pendingRestore = undefined
+ pending.resolveReady(loaded)
+}
+
+function startCountedTabRestore(pending: PendingTabRestore): void {
+ pending.started = true
+ activeTabRestores.add(pending)
+ if (pending.priority === 'background') activeBackgroundTabRestores.add(pending)
+ const timeoutMs =
+ pending.priority === 'foreground'
+ ? FOREGROUND_TAB_RESTORE_TIMEOUT_MS
+ : BACKGROUND_TAB_RESTORE_TIMEOUT_MS
+ void loadPendingTabRestore(pending, timeoutMs)
+ .then((loaded) => {
+ if (pending.requeueAfterPreemption && !loaded && isPendingTabRestoreLive(pending)) {
+ pending.requeueAfterPreemption = false
+ pending.started = false
+ const queue =
+ pending.priority === 'foreground'
+ ? pendingForegroundTabRestores
+ : pendingBackgroundTabRestores
+ queue.push(pending)
+ return
+ }
+ settlePendingTabRestore(pending, loaded)
+ })
+ .finally(() => {
+ if (pending.generation !== backgroundTabRestoreGeneration) return
+ activeTabRestores.delete(pending)
+ activeBackgroundTabRestores.delete(pending)
+ drainTabRestores()
+ })
+}
+
+/**
+ * Globally bounds restore work. Foreground entries have priority while one
+ * process-wide slot remains unavailable to background loads. The queues are
+ * bounded by the 96-live-tab process invariant.
+ */
+function drainTabRestores(): void {
+ while (activeTabRestores.size < MAX_TAB_RESTORE_CONCURRENCY) {
+ const pending =
+ pendingForegroundTabRestores.shift() ??
+ (activeBackgroundTabRestores.size < MAX_BACKGROUND_TAB_RESTORE_CONCURRENCY
+ ? pendingBackgroundTabRestores.shift()
+ : undefined)
+ if (!pending) break
+ if (!isPendingTabRestoreLive(pending)) {
+ settlePendingTabRestore(pending)
+ continue
+ }
+ startCountedTabRestore(pending)
+ }
+ if (
+ pendingForegroundTabRestores.length > 0 &&
+ activeTabRestores.size >= MAX_TAB_RESTORE_CONCURRENCY
+ ) {
+ const preempted = activeBackgroundTabRestores.values().next().value
+ if (preempted) {
+ activeBackgroundTabRestores.delete(preempted)
+ activeTabRestores.delete(preempted)
+ preempted.requeueAfterPreemption = true
+ preempted.cancelLoad?.()
+ drainTabRestores()
+ }
+ }
+}
+
+function queueBackgroundTabRestore(tab: AgentTab, url: string): void {
+ if (url === 'about:blank') return
+ if (tab.pendingRestore) return
+ pendingBackgroundTabRestores.push(createPendingTabRestore(tab, url, 'background'))
+ drainTabRestores()
+}
+
+function promotePendingTabRestore(tab: AgentTab): PendingTabRestore | undefined {
+ const url = tab.pendingRestoreUrl
+ if (!url || url === 'about:blank') return undefined
+ let pending = tab.pendingRestore
+ if (pending && activeBackgroundTabRestores.has(pending)) {
+ activeBackgroundTabRestores.delete(pending)
+ pending.priority = 'foreground'
+ pending.promoteToForeground?.()
+ drainTabRestores()
+ return pending
+ }
+ if (!pending) pending = createPendingTabRestore(tab, url, 'foreground')
+ if (!pending.started) {
+ const queueIndex = pendingBackgroundTabRestores.indexOf(pending)
+ if (queueIndex >= 0) pendingBackgroundTabRestores.splice(queueIndex, 1)
+ pending.priority = 'foreground'
+ if (!pendingForegroundTabRestores.includes(pending)) pendingForegroundTabRestores.push(pending)
+ drainTabRestores()
+ }
+ return pending
+}
+
+function discardPendingTabRestore(tab: AgentTab): void {
+ for (let index = pendingForegroundTabRestores.length - 1; index >= 0; index -= 1) {
+ if (pendingForegroundTabRestores[index]?.tab === tab) {
+ pendingForegroundTabRestores.splice(index, 1)
+ }
+ }
+ for (let index = pendingBackgroundTabRestores.length - 1; index >= 0; index -= 1) {
+ if (pendingBackgroundTabRestores[index]?.tab === tab) {
+ pendingBackgroundTabRestores.splice(index, 1)
+ }
+ }
+ const pending = tab.pendingRestore
+ if (!pending) return
+ pending.cancelLoad?.()
+ settlePendingTabRestore(pending)
+}
+
+export async function waitForPendingTabRestore(tab: AgentTab): Promise {
+ const pending = promotePendingTabRestore(tab)
+ return pending ? await pending.ready : true
+}
+
+/** Prevents a delayed restore slot from overwriting a newer explicit navigation. */
+export function prepareExplicitNavigation(contents: WebContents): void {
+ const tab = tabForContents(contents)
+ if (!tab) return
+ settleSitePermission(tab, false)
+ tab.pendingRestoreUrl = undefined
+ discardPendingTabRestore(tab)
+}
+
/** Marks the visible page as user-selected without blocking automation on it. */
export function claimActiveTabForUser(): AgentTab | null {
const tab = activeTab()
@@ -2081,9 +3048,11 @@ export function restoreBrowserSession(): void {
nextTabId: state.nextTabId,
restored: state.restored,
lastPersistedSnapshot: state.lastPersistedSnapshot,
+ siteOriginGrants: new Map(state.siteOriginGrants),
}
const previousDownloads = browserDownloadsByScope.get(scopeId)
const restoredTabs: AgentTab[] = []
+ const restoredLoads: Array<{ tab: AgentTab; url: string }> = []
state.restoring = true
try {
if (snapshot) {
@@ -2094,10 +3063,10 @@ export function restoreBrowserSession(): void {
for (const { entry } of selectedEntries) {
const tab = addTabInternal({ pinned: entry.pinned, activate: false, notify: false })
tab.pendingRestoreUrl = entry.url
+ const restoredOrigin = mediaOrigin(entry.url)
+ if (restoredOrigin) grantSiteOrigin(state, restoredOrigin)
restoredTabs.push(tab)
- if (entry.url !== 'about:blank') {
- void tab.view.webContents.loadURL(entry.url).catch(() => {})
- }
+ restoredLoads.push({ tab, url: entry.url })
}
const restoredActiveIndex = selectedEntries.findIndex(
({ sourceIndex }) => sourceIndex === snapshot.activeIndex
@@ -2116,6 +3085,7 @@ export function restoreBrowserSession(): void {
state.nextTabId = previousState.nextTabId
state.restored = previousState.restored
state.lastPersistedSnapshot = previousState.lastPersistedSnapshot
+ state.siteOriginGrants = previousState.siteOriginGrants
if (previousDownloads) browserDownloadsByScope.set(scopeId, previousDownloads)
else browserDownloadsByScope.delete(scopeId)
applyActiveTabThrottling()
@@ -2125,6 +3095,16 @@ export function restoreBrowserSession(): void {
}
applyActiveTabThrottling()
+ const restoredActive = restoredLoads.find(({ tab }) => tab.id === state.activeTabId)
+ if (restoredActive) {
+ pendingForegroundTabRestores.push(
+ createPendingTabRestore(restoredActive.tab, restoredActive.url, 'foreground')
+ )
+ drainTabRestores()
+ }
+ for (const restore of restoredLoads) {
+ if (restore !== restoredActive) queueBackgroundTabRestore(restore.tab, restore.url)
+ }
if (snapshot) publishBrowserDownloads(scopeId)
const active = activeTab()
if (active) {
@@ -2204,6 +3184,7 @@ export function reopenClosedTab(): AgentTab | null {
// onBeforeRequest still runs the full DNS-resolving SSRF check on the
// document load. Pre-checking would only buy a nicer error, and there is
// no model to report one to — this path is a user keystroke.
+ grantSiteOriginForUserNavigation(tab.view.webContents, url)
void tab.view.webContents.loadURL(url).catch(() => {})
}
return tab
@@ -2226,6 +3207,7 @@ export function duplicateTab(tabId: string): AgentTab | null {
// Sanitized to http(s) without embedded credentials above, and the
// partition's onBeforeRequest still runs the full SSRF check on the load —
// same reasoning as reopenClosedTab, and this is likewise a user action.
+ grantSiteOriginForUserNavigation(tab.view.webContents, url)
void tab.view.webContents.loadURL(url).catch(() => {})
}
return tab
@@ -2248,6 +3230,7 @@ export function switchTab(tabId: string): AgentTab {
}
currentScope.activeTabId = tab.id
currentScope.visibleTabUserSelected = true
+ promotePendingTabRestore(tab)
// Visible selection does not move the automation exemption; the user may
// inspect another page while a tool continues in its background tab.
applyActiveTabThrottling()
@@ -2308,6 +3291,8 @@ export function closeTab(tabId: string): void {
dismissFind(tabId)
clearAutomationIndicatorsForTab(tabId)
const [tab] = tabs.splice(index, 1)
+ discardPendingTabRestore(tab)
+ settleSitePermission(tab, false)
revokeTabMediaPermissions(tab, false)
recentlyClosedTabUrls.unshift(sanitizeRestorableUrl(tabUrl(tab)) ?? 'about:blank')
if (recentlyClosedTabUrls.length > MAX_RECENTLY_CLOSED_TABS) {
@@ -2341,6 +3326,7 @@ export function closeTab(tabId: string): void {
persistBrowserSession()
events?.onTabsChanged()
if (!hasSession()) {
+ currentScope.siteOriginGrants.clear()
events?.onSessionClosed()
}
}
@@ -2474,9 +3460,10 @@ export function handleFocusedShortcut(
focusRendererOmnibox('select')
return true
case 'reload-or-clear':
- shortcutTab.view.webContents.reload()
+ reloadPage(shortcutTab.view.webContents)
return true
case 'hard-reload':
+ prepareExplicitNavigation(shortcutTab.view.webContents)
shortcutTab.view.webContents.reloadIgnoringCache()
return true
}
@@ -2540,6 +3527,8 @@ function closeTabFromUser(tabId: string): void {
function closeLiveTabs(): void {
dismissFind(currentScope.findingTabId)
for (const tab of tabs.splice(0)) {
+ discardPendingTabRestore(tab)
+ settleSitePermission(tab, false, false)
revokeTabMediaPermissions(tab, false)
detachIfAttached(tab.view)
if (!tab.view.webContents.isDestroyed()) {
@@ -2552,6 +3541,7 @@ function closeLiveTabs(): void {
currentScope.automationActive = false
currentScope.automationNeedsAttention = false
currentScope.visibleTabUserSelected = false
+ currentScope.siteOriginGrants.clear()
clearFocusedBrowserTab()
}
@@ -2622,6 +3612,8 @@ export async function clearProfileStorage(): Promise {
events?.onTabsChanged()
})
}
+ browserDownloadsByScope.clear()
+ cancelActiveBrowserDownloads()
layout()
const ses = electronSession.fromPartition(AGENT_PARTITION)
diff --git a/apps/desktop/src/main/browser-agent/url-guard.test.ts b/apps/desktop/src/main/browser-agent/url-guard.test.ts
index 0468ffb799a..80df3e973ca 100644
--- a/apps/desktop/src/main/browser-agent/url-guard.test.ts
+++ b/apps/desktop/src/main/browser-agent/url-guard.test.ts
@@ -305,8 +305,11 @@ describe('isBlockedSubresourceUrl', () => {
})
describe('subresourceNeedsResolution', () => {
- it('exempts only the high-volume, non-readable types', () => {
- expect(subresourceNeedsResolution('image')).toBe(false)
+ it('resolves images because their rendered contents are observable in screenshots', () => {
+ expect(subresourceNeedsResolution('image')).toBe(true)
+ })
+
+ it('exempts only fonts from hostname resolution', () => {
expect(subresourceNeedsResolution('font')).toBe(false)
})
diff --git a/apps/desktop/src/main/browser-agent/url-guard.ts b/apps/desktop/src/main/browser-agent/url-guard.ts
index 75a20e2679d..b604c243703 100644
--- a/apps/desktop/src/main/browser-agent/url-guard.ts
+++ b/apps/desktop/src/main/browser-agent/url-guard.ts
@@ -43,10 +43,9 @@ function guardHost(rawUrl: string): string | null {
*
* Loopback is deliberately allowed: it is the user's own machine, and opening
* a dev server on localhost is one of the most ordinary things to do in this
- * panel — the URL bar already assumes `http://` for it. Nothing is given away
- * by it either, since the desktop app hands the same agent an unrestricted
- * shell on that machine, so a blocked `http://localhost:3000` is one
- * `curl http://localhost:3000` away regardless.
+ * panel — the URL bar already assumes `http://` for it. This is an explicit
+ * desktop-product capability, independent of whether terminal execution is
+ * enabled or separately approval-gated.
*
* Every other private range stays blocked. Those are a different matter: the
* LAN is other people's machines, and `169.254.169.254` is link-local rather
@@ -121,11 +120,13 @@ export async function checkAgentUrl(rawUrl: string): Promise {
/**
* Subresource types that keep the cheap synchronous literal-IP check.
*
- * Images and fonts are the high-volume types and are not readable
- * cross-origin, so the residual for them is a load/error timing oracle — a
- * documented, accepted trade against a DNS lookup per asset.
+ * Fonts are high-volume and their response bytes are not exposed to the model,
+ * so the residual is a load/error timing oracle — a documented, accepted trade
+ * against a DNS lookup per asset. Images are not exempt: browser screenshots
+ * make their rendered contents observable even when cross-origin reads are
+ * otherwise blocked.
*/
-const LITERAL_ONLY_RESOURCE_TYPES: ReadonlySet = new Set(['image', 'font'])
+const LITERAL_ONLY_RESOURCE_TYPES: ReadonlySet = new Set(['font'])
/**
* Whether a subresource needs the DNS-resolving check rather than the literal-IP
diff --git a/apps/desktop/src/main/downloads.test.ts b/apps/desktop/src/main/downloads.test.ts
index 92721bfa706..cc6b64c49bf 100644
--- a/apps/desktop/src/main/downloads.test.ts
+++ b/apps/desktop/src/main/downloads.test.ts
@@ -1,3 +1,6 @@
+import { mkdtempSync, symlinkSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => import('@/test/electron-mock'))
@@ -40,16 +43,96 @@ describe('suggestedFilename', () => {
})
describe('uniqueDownloadPath', () => {
- it('keeps the original name when it is available', () => {
- expect(uniqueDownloadPath('/Downloads', 'report.csv', () => false)).toBe(
- '/Downloads/report.csv'
- )
+ it('keeps the original name when it is available', async () => {
+ await expect(
+ uniqueDownloadPath('/Downloads', 'report.csv', { pathExists: () => false })
+ ).resolves.toBe('/Downloads/report.csv')
+ })
+
+ it('uses a collision-resistant suffix instead of scanning sequential copy names', async () => {
+ const occupied = new Set(['/Downloads/report.csv'])
+ await expect(
+ uniqueDownloadPath('/Downloads', 'report.csv', {
+ pathExists: (path) => occupied.has(path),
+ suffixForAttempt: () => 'safe-id',
+ })
+ ).resolves.toBe('/Downloads/report (safe-id).csv')
+ })
+
+ it('checks a pre-existing filesystem entry without blocking the caller', async () => {
+ const directory = mkdtempSync(join(tmpdir(), 'sim-download-path-'))
+ writeFileSync(join(directory, 'report.csv'), 'existing')
+
+ const allocation = uniqueDownloadPath(directory, 'report.csv', {
+ suffixForAttempt: () => 'safe-id',
+ })
+
+ await expect(allocation).resolves.toBe(join(directory, 'report (safe-id).csv'))
+ })
+
+ it('treats a dangling symlink as occupied', async () => {
+ const directory = mkdtempSync(join(tmpdir(), 'sim-download-path-'))
+ symlinkSync(join(directory, 'missing-target'), join(directory, 'report.csv'))
+
+ await expect(
+ uniqueDownloadPath(directory, 'report.csv', {
+ suffixForAttempt: () => 'safe-id',
+ })
+ ).resolves.toBe(join(directory, 'report (safe-id).csv'))
})
- it('adds a copy number before the extension instead of overwriting', () => {
- const occupied = new Set(['/Downloads/report.csv', '/Downloads/report (1).csv'])
- expect(uniqueDownloadPath('/Downloads', 'report.csv', (path) => occupied.has(path))).toBe(
- '/Downloads/report (2).csv'
+ it('atomically separates simultaneous allocations of the same name', async () => {
+ const reservations = new Set()
+ const options = {
+ pathExists: async () => false,
+ reservePath: (path: string) => {
+ if (reservations.has(path)) return false
+ reservations.add(path)
+ return true
+ },
+ suffixForAttempt: (attempt: number) => `copy-${attempt}`,
+ }
+
+ const [first, second] = await Promise.all([
+ uniqueDownloadPath('/Downloads', 'report.csv', options),
+ uniqueDownloadPath('/Downloads', 'report.csv', options),
+ ])
+
+ expect(new Set([first, second])).toEqual(
+ new Set(['/Downloads/report.csv', '/Downloads/report (copy-1).csv'])
)
})
+
+ it('stops after the configured collision cap', async () => {
+ const pathExists = vi.fn(async () => true)
+
+ await expect(
+ uniqueDownloadPath('/Downloads', 'report.csv', {
+ maxAttempts: 3,
+ pathExists,
+ suffixForAttempt: (attempt) => `copy-${attempt}`,
+ })
+ ).resolves.toBeNull()
+ expect(pathExists).toHaveBeenCalledTimes(3)
+ })
+
+ it('abandons an allocation torn down while the filesystem check is pending', async () => {
+ let resolveExists = (_exists: boolean) => {}
+ const exists = new Promise((resolve) => {
+ resolveExists = resolve
+ })
+ let active = true
+ const reservePath = vi.fn(() => true)
+ const allocation = uniqueDownloadPath('/Downloads', 'report.csv', {
+ isActive: () => active,
+ pathExists: () => exists,
+ reservePath,
+ })
+
+ active = false
+ resolveExists(false)
+
+ await expect(allocation).resolves.toBeNull()
+ expect(reservePath).not.toHaveBeenCalled()
+ })
})
diff --git a/apps/desktop/src/main/downloads.ts b/apps/desktop/src/main/downloads.ts
index f571acc675a..1267eeac3ea 100644
--- a/apps/desktop/src/main/downloads.ts
+++ b/apps/desktop/src/main/downloads.ts
@@ -1,6 +1,7 @@
-import { existsSync } from 'node:fs'
+import { lstat } from 'node:fs/promises'
import { basename, extname, join } from 'node:path'
import { createLogger } from '@sim/logger'
+import { generateShortId } from '@sim/utils/id'
import type { Session } from 'electron'
import { app } from 'electron'
import type { EventRecorder } from '@/main/observability'
@@ -8,6 +9,8 @@ import type { EventRecorder } from '@/main/observability'
const logger = createLogger('DesktopDownloads')
const MAX_FILENAME_LENGTH = 200
+const MAX_DOWNLOAD_PATH_ATTEMPTS = 16
+const DOWNLOAD_PATH_SUFFIX_LENGTH = 8
const MIME_EXTENSIONS: Record = {
'text/csv': '.csv',
@@ -53,25 +56,78 @@ export function suggestedFilename(
return `download-${stamp}${extension}`
}
+export interface UniqueDownloadPathOptions {
+ /** Asynchronous filesystem seam used by tests and non-standard storage backends. */
+ pathExists?: (path: string) => boolean | Promise
+ /** Atomically reserves a candidate against other allocations in this process. */
+ reservePath?: (path: string) => boolean
+ /** Stops an allocation whose owning download was torn down while I/O was pending. */
+ isActive?: () => boolean
+ /** Deterministic test seam for collision-resistant copy suffixes. */
+ suffixForAttempt?: (attempt: number) => string
+ maxAttempts?: number
+}
+
+async function downloadPathExists(path: string): Promise {
+ try {
+ await lstat(path)
+ return true
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
+ throw error
+ }
+}
+
+function suffixedFilename(filename: string, suffix: string): string {
+ const extension = extname(filename)
+ const stem = basename(filename, extension)
+ const marker = ` (${suffix})`
+ const maxStemLength = Math.max(1, MAX_FILENAME_LENGTH - extension.length - marker.length)
+ return `${stem.slice(0, maxStemLength)}${marker}${extension}`
+}
+
/**
- * Picks a Chrome-style non-conflicting destination without overwriting an
- * existing download: `report.csv`, `report (1).csv`, and so on.
+ * Asynchronously reserves a non-conflicting destination without blocking the
+ * Electron main thread. The original filename remains the first choice; a
+ * bounded number of collision-resistant alternatives avoids an unbounded scan
+ * through attacker-controlled pre-existing copy names.
*/
-export function uniqueDownloadPath(
+export async function uniqueDownloadPath(
directory: string,
rawFilename: string,
- pathExists: (path: string) => boolean = existsSync
-): string {
+ options: UniqueDownloadPathOptions = {}
+): Promise {
const filename = sanitizeFilename(rawFilename) || 'download'
- const extension = extname(filename)
- const stem = basename(filename, extension)
- let candidate = join(directory, filename)
- let copy = 1
- while (pathExists(candidate)) {
- candidate = join(directory, `${stem} (${copy})${extension}`)
- copy += 1
+ const pathExists = options.pathExists ?? downloadPathExists
+ const reservePath = options.reservePath ?? (() => true)
+ const isActive = options.isActive ?? (() => true)
+ const suffixForAttempt =
+ options.suffixForAttempt ?? (() => generateShortId(DOWNLOAD_PATH_SUFFIX_LENGTH))
+ const requestedAttempts = options.maxAttempts ?? MAX_DOWNLOAD_PATH_ATTEMPTS
+ const maxAttempts = Math.max(
+ 1,
+ Math.min(
+ MAX_DOWNLOAD_PATH_ATTEMPTS,
+ Number.isFinite(requestedAttempts)
+ ? Math.trunc(requestedAttempts)
+ : MAX_DOWNLOAD_PATH_ATTEMPTS
+ )
+ )
+
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
+ if (!isActive()) return null
+ const suffix =
+ attempt === 0
+ ? ''
+ : sanitizeFilename(suffixForAttempt(attempt)).slice(0, 32) ||
+ generateShortId(DOWNLOAD_PATH_SUFFIX_LENGTH)
+ const candidateFilename = attempt === 0 ? filename : suffixedFilename(filename, suffix)
+ const candidate = join(directory, candidateFilename)
+ if (await pathExists(candidate)) continue
+ if (!isActive()) return null
+ if (reservePath(candidate)) return candidate
}
- return candidate
+ return null
}
/**
diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts
index 539c2329a5a..fc60d931d18 100644
--- a/apps/desktop/src/main/index.ts
+++ b/apps/desktop/src/main/index.ts
@@ -18,6 +18,7 @@ import { newChatRoute, settingsRoute } from '@/main/app-routes'
import {
activateBrowserScope as activateAgentBrowserScope,
clearBrowserProfile as clearAgentBrowserProfile,
+ closeBrowserSession as closeAgentBrowserSession,
initDriver as initBrowserAgentDriver,
} from '@/main/browser-agent/driver'
import {
@@ -27,7 +28,6 @@ import {
setPanelOccluded as setBrowserAgentPanelOccluded,
} from '@/main/browser-agent/panel'
import {
- closeSession as closeAgentBrowserSession,
handleFocusedShortcut as handleFocusedBrowserShortcut,
isBrowserScopeSuspended,
quiesceBrowserSessions,
@@ -720,6 +720,8 @@ function main(): void {
onSessionStatus: (alive, scopeId) => {
scopeEvents.sendBrowser(scopeId, 'browser-agent:session-status', alive, scopeId)
},
+ sitePermissionPromptSupported: (scopeId) =>
+ scopeEvents.browserSitePermissionPromptSupported(scopeId),
onFillAvailability: (available, scopeId) => {
scopeEvents.sendBrowser(scopeId, 'browser-credentials:fill-availability', {
available,
diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts
index 118288347a2..b75537b14c6 100644
--- a/apps/desktop/src/main/ipc.test.ts
+++ b/apps/desktop/src/main/ipc.test.ts
@@ -139,6 +139,15 @@ import { findCachedTerminalThemeProfile, listTerminalThemeProfiles } from '@/mai
const APP = 'https://sim.ai'
const ESC = '\u001b'
const BEL = '\u0007'
+const CANONICAL_BROWSER_URL_INPUT =
+ 'HTTPS://B\u00dcCHER.Example:443/docs/../private?query=sim#result'
+const CANONICAL_BROWSER_URL = 'https://xn--bcher-kva.example/private?query=sim#result'
+const INVALID_BROWSER_URLS = [
+ 'https://[',
+ 'file:///tmp/private',
+ `https://docs.example/${'a'.repeat(8_192)}`,
+ `https://docs.example/${'\u00e9'.repeat(1_400)}`,
+] as const
const DEFAULT_DESKTOP_PREFERENCES: DesktopPreferences = {
notificationsEnabled: true,
@@ -289,6 +298,7 @@ describe('registerIpcHandlers', () => {
scopeEvents: {
activateBrowser: vi.fn(),
activateTerminal: vi.fn(),
+ registerBrowserSitePermissionPromptSupport: vi.fn(),
sendBrowser: vi.fn(),
sendTerminal: vi.fn(),
},
@@ -780,6 +790,43 @@ describe('registerIpcHandlers', () => {
)
})
+ it('rejects malformed browser execution envelopes before admission or authorization', async () => {
+ const { invoke } = collectHandlers()
+ const handler = invoke.get('browser-agent:execute-tool')
+ const fetchAuthorization = vi.fn(async () =>
+ Response.json({ chatId: 'chat-1', toolName: 'browser_snapshot', args: {} })
+ )
+ const malformedEvent = {
+ senderFrame: { url: `${APP}/workspace/ws1` },
+ sender: { session: { fetch: fetchAuthorization } },
+ }
+ const captureBoundary = vi.spyOn(browserDriver, 'captureBrowserToolQueueBoundary')
+ const invalidScopeFlood = Array.from(
+ { length: browserDriver.BROWSER_TOOL_ADMISSION_LIMITS.process * 2 },
+ (_, index) =>
+ handler?.(malformedEvent, `tool-${index}`, 'browser_snapshot', {}, `invalid scope ${index}`)
+ )
+
+ const results = await Promise.all([
+ ...invalidScopeFlood,
+ handler?.(malformedEvent, '', 'browser_snapshot', {}, 'chat-1'),
+ handler?.(malformedEvent, 'x'.repeat(257), 'browser_snapshot', {}, 'chat-1'),
+ handler?.(malformedEvent, 'tool-retired', 'browser_request_takeover', {}, 'chat-1'),
+ handler?.(malformedEvent, 'tool-non-string', 42, {}, 'chat-1'),
+ ])
+
+ expect(results).toHaveLength(browserDriver.BROWSER_TOOL_ADMISSION_LIMITS.process * 2 + 4)
+ expect(results).toEqual(
+ results.map(() => ({
+ ok: false,
+ error: 'This browser action is not an authorized pending Copilot tool call.',
+ }))
+ )
+ expect(captureBoundary).not.toHaveBeenCalled()
+ expect(fetchAuthorization).not.toHaveBeenCalled()
+ captureBoundary.mockRestore()
+ })
+
it('routes exact browser-tool cancellation without waiting for authorization', async () => {
const { invoke } = collectHandlers()
const cancel = vi.spyOn(browserDriver, 'cancelTool').mockReturnValue(true)
@@ -996,6 +1043,97 @@ describe('registerIpcHandlers', () => {
requestId: 'request-2',
allowed: true,
})
+ panelAction.mockRestore()
+ })
+
+ it('requires trusted input for site grants and user-origin navigation', 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-sites')
+ handler?.(
+ inactiveAppEvent,
+ { action: 'respond-site-permission', requestId: 'request-1', allowed: true },
+ 'chat-sites'
+ )
+ handler?.(
+ inactiveAppEvent,
+ { action: 'respond-site-permission', requestId: 'request-1', allowed: false },
+ 'chat-sites'
+ )
+ handler?.(
+ inactiveAppEvent,
+ { action: 'navigate', url: 'https://docs.example/private' },
+ 'chat-sites'
+ )
+
+ expect(panelAction).toHaveBeenCalledOnce()
+ expect(panelAction).toHaveBeenCalledWith('chat-sites', {
+ action: 'respond-site-permission',
+ requestId: 'request-1',
+ allowed: false,
+ })
+
+ await invoke.get('browser-agent:activate-scope')?.(activeAppEvent, 'chat-sites')
+ handler?.(
+ activeAppEvent,
+ { action: 'respond-site-permission', requestId: 'request-2', allowed: true },
+ 'chat-sites'
+ )
+ handler?.(
+ activeAppEvent,
+ { action: 'navigate', url: 'https://docs.example/private' },
+ 'chat-sites'
+ )
+
+ expect(panelAction).toHaveBeenNthCalledWith(2, 'chat-sites', {
+ action: 'respond-site-permission',
+ requestId: 'request-2',
+ allowed: true,
+ })
+ expect(panelAction).toHaveBeenNthCalledWith(3, 'chat-sites', {
+ action: 'navigate',
+ url: 'https://docs.example/private',
+ })
+ panelAction.mockRestore()
+ })
+
+ it('canonicalizes and validates panel navigation URLs before they reach the driver', 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')?.(activeAppEvent, 'chat-navigation')
+ handler?.(
+ activeAppEvent,
+ { action: 'navigate', url: CANONICAL_BROWSER_URL_INPUT },
+ 'chat-navigation'
+ )
+ for (const url of INVALID_BROWSER_URLS) {
+ handler?.(activeAppEvent, { action: 'navigate', url }, 'chat-navigation')
+ }
+
+ expect(panelAction).toHaveBeenCalledOnce()
+ expect(panelAction).toHaveBeenCalledWith('chat-navigation', {
+ action: 'navigate',
+ url: CANONICAL_BROWSER_URL,
+ })
+ panelAction.mockRestore()
+ })
+
+ it('accepts site permission prompt support only from the app renderer', () => {
+ const { on } = collectHandlers()
+ const register = on.get('browser-agent:register-site-permission-prompt-support')
+
+ register?.(evilEvent)
+ expect(deps.scopeEvents.registerBrowserSitePermissionPromptSupport).not.toHaveBeenCalled()
+
+ register?.(appEvent)
+ expect(deps.scopeEvents.registerBrowserSitePermissionPromptSupport).toHaveBeenCalledOnce()
+ expect(deps.scopeEvents.registerBrowserSitePermissionPromptSupport).toHaveBeenCalledWith(
+ appSender
+ )
})
it('ignores browser-agent panel actions from outside the app origin', () => {
@@ -1256,6 +1394,51 @@ describe('registerIpcHandlers', () => {
peek.mockRestore()
})
+ it('atomically creates and navigates a canonical user URL only from trusted input', async () => {
+ const tabsState = { scopeId: 'chat-links', tabs: [], activeTabId: '2' }
+ const tabContents = { loadURL: vi.fn(async () => {}) }
+ const add = vi.spyOn(browserSession, 'addTab').mockReturnValue({
+ view: { webContents: tabContents },
+ } as never)
+ const grant = vi.spyOn(browserSession, 'grantSiteOriginForUserNavigation').mockReturnValue(true)
+ const peek = vi.spyOn(browserSession, 'peekTabsState').mockReturnValue(tabsState)
+ const { invoke } = collectHandlers()
+
+ await invoke.get('browser-agent:activate-scope')?.(activeAppEvent, 'chat-links')
+ await expect(
+ invoke.get('browser-agent:open-url')?.(
+ activeAppEvent,
+ CANONICAL_BROWSER_URL_INPUT,
+ 'chat-links'
+ )
+ ).resolves.toEqual(tabsState)
+
+ expect(add).toHaveBeenCalledOnce()
+ expect(grant).toHaveBeenCalledWith(tabContents, CANONICAL_BROWSER_URL)
+ expect(tabContents.loadURL).toHaveBeenCalledWith(CANONICAL_BROWSER_URL)
+
+ for (const url of INVALID_BROWSER_URLS) {
+ await expect(
+ invoke.get('browser-agent:open-url')?.(activeAppEvent, url, 'chat-links')
+ ).resolves.toEqual({ scopeId: '', tabs: [], activeTabId: null })
+ }
+ expect(add).toHaveBeenCalledOnce()
+
+ await invoke.get('browser-agent:activate-scope')?.(inactiveAppEvent, 'chat-inactive-links')
+ await expect(
+ invoke.get('browser-agent:open-url')?.(
+ inactiveAppEvent,
+ 'https://docs.example/',
+ 'chat-inactive-links'
+ )
+ ).resolves.toEqual({ scopeId: '', tabs: [], activeTabId: null })
+ expect(add).toHaveBeenCalledOnce()
+
+ add.mockRestore()
+ grant.mockRestore()
+ peek.mockRestore()
+ })
+
it('routes browser scope events after activation and a valid provisional migration', async () => {
const migrate = vi.spyOn(browserDriver, 'migrateBrowserScope').mockReturnValue(true)
const { invoke } = collectHandlers()
diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts
index 8221c022fdf..3befddec165 100644
--- a/apps/desktop/src/main/ipc.ts
+++ b/apps/desktop/src/main/ipc.ts
@@ -1,6 +1,7 @@
import { normalize } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
+ BROWSER_TOOL_AUTHORIZATION_TIMEOUT_MS,
type BrowserPanelAction,
type BrowserPanelAnchor,
type BrowserPanelBounds,
@@ -43,6 +44,7 @@ import {
getKnownSessions,
handlePanelAction,
migrateBrowserScope,
+ releaseBrowserToolQueueBoundary,
restoreBrowserScope,
showToolbarMenu,
suspendBrowserScope,
@@ -52,6 +54,7 @@ import {
addTab,
findInActiveTab,
getBrowserDownloadsState,
+ grantSiteOriginForUserNavigation,
peekTabsState,
reorderTab,
setBrowserAppTheme,
@@ -94,7 +97,6 @@ const logger = createLogger('DesktopIpc')
/** Workspace/chat ids are opaque tokens; anything else never reaches a URL. */
const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
const TERMINAL_WRITE_CHUNK_CHARACTERS = 64 * 1024
-const DESKTOP_TOOL_AUTHORIZATION_TIMEOUT_MS = 8_000
function writeTerminalText(
terminal: TerminalRegistry,
@@ -151,6 +153,10 @@ function parseDesktopScope(raw: unknown): string | null {
return isDesktopScopeId(raw) ? raw : null
}
+function isDesktopToolCallId(raw: unknown): raw is string {
+ return typeof raw === 'string' && raw.length >= 1 && raw.length <= 256
+}
+
export interface OAuthConnectScope {
workspaceId?: string
credentialId?: string
@@ -333,7 +339,11 @@ export interface IpcDeps {
terminal: TerminalRegistry
scopeEvents: Pick<
ScopedEventRouter,
- 'activateBrowser' | 'activateTerminal' | 'sendBrowser' | 'sendTerminal'
+ | 'activateBrowser'
+ | 'activateTerminal'
+ | 'registerBrowserSitePermissionPromptSupport'
+ | 'sendBrowser'
+ | 'sendTerminal'
>
settings: DesktopSettingsService
getWindowState: (sender: WebContents) => DesktopWindowState
@@ -489,6 +499,18 @@ const PTY_REPLY = new RegExp(
)
const MAX_TERMINAL_WRITE_CHARS = 256_000
const MAX_PTY_REPLY_CHARS = 8_192
+const MAX_BROWSER_NAVIGATION_URL_CHARS = 8_192
+
+function canonicalHttpNavigationUrl(rawUrl: unknown): string | null {
+ if (typeof rawUrl !== 'string' || rawUrl.length > MAX_BROWSER_NAVIGATION_URL_CHARS) return null
+ try {
+ const url = new URL(rawUrl)
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
+ return url.href.length <= MAX_BROWSER_NAVIGATION_URL_CHARS ? url.href : null
+ } catch {
+ return null
+ }
+}
interface DesktopToolAuthorization {
chatId: string
@@ -501,9 +523,7 @@ async function fetchDesktopToolAuthorization(
deps: IpcDeps,
toolCallId: unknown
): Promise {
- if (typeof toolCallId !== 'string' || toolCallId.length < 1 || toolCallId.length > 256) {
- return null
- }
+ if (!isDesktopToolCallId(toolCallId)) return null
const startedAt = Date.now()
try {
const response = await event.sender.session.fetch(
@@ -513,7 +533,7 @@ async function fetchDesktopToolAuthorization(
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolCallId }),
- signal: AbortSignal.timeout(DESKTOP_TOOL_AUTHORIZATION_TIMEOUT_MS),
+ signal: AbortSignal.timeout(BROWSER_TOOL_AUTHORIZATION_TIMEOUT_MS),
}
)
if (!response.ok) {
@@ -918,6 +938,39 @@ export function registerIpcHandlers(deps: IpcDeps): void {
})
},
},
+ 'browser-agent:register-site-permission-prompt-support': {
+ kind: 'send',
+ gate: 'app-origin',
+ requires: 'browser',
+ passSender: true,
+ handler: (sender) => {
+ deps.scopeEvents.registerBrowserSitePermissionPromptSupport(sender as WebContents)
+ },
+ },
+ 'browser-agent:open-url': {
+ kind: 'invoke',
+ gate: 'app-origin',
+ requires: 'browser',
+ passSender: true,
+ needsUserActivation: true,
+ denied: { scopeId: '', tabs: [], activeTabId: null },
+ handler: (sender, rawUrl, rawScope) => {
+ const contents = sender as WebContents
+ const scope = activeRendererScope(browserScopeBySender, contents, rawScope)
+ const destination = canonicalHttpNavigationUrl(rawUrl)
+ if (!scope || !destination) {
+ return { scopeId: '', tabs: [], activeTabId: null }
+ }
+ return withBrowserScope(scope, () => {
+ const tab = addTab()
+ if (!grantSiteOriginForUserNavigation(tab.view.webContents, destination)) {
+ return peekTabsState()
+ }
+ void tab.view.webContents.loadURL(destination).catch(() => {})
+ return peekTabsState()
+ })
+ },
+ },
'browser-agent:activate-scope': {
kind: 'invoke',
gate: 'app-origin',
@@ -1100,10 +1153,15 @@ 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,
+ needsUserActivation: ([action]) => {
+ if (!isRecordLike(action)) return false
+ if (action.action === 'navigate') return true
+ return (
+ (action.action === 'respond-media-permission' ||
+ action.action === 'respond-site-permission') &&
+ action.allowed === true
+ )
+ },
handler: (sender, action, rawScope) => {
const scope = activeRendererScope(browserScopeBySender, sender as WebContents, rawScope)
if (
@@ -1114,7 +1172,14 @@ export function registerIpcHandlers(deps: IpcDeps): void {
) {
return
}
- void handlePanelAction(scope, action as BrowserPanelAction).catch(() => {})
+ const panelAction = action as BrowserPanelAction
+ if (panelAction.action === 'navigate') {
+ const destination = canonicalHttpNavigationUrl(panelAction.url)
+ if (!destination) return
+ void handlePanelAction(scope, { ...panelAction, url: destination }).catch(() => {})
+ return
+ }
+ void handlePanelAction(scope, panelAction).catch(() => {})
},
},
'browser-agent:set-tab-pinned': {
@@ -1900,20 +1965,35 @@ export function registerIpcHandlers(deps: IpcDeps): void {
}
let handlerArgs = args
if (channel === 'browser-agent:execute-tool') {
+ const toolCallId = args[0]
const requestedTool = args[1]
const requestedScope = parseDesktopScope(args[3])
- const authorizationBoundary = requestedScope
- ? captureBrowserToolQueueBoundary(requestedScope)
- : undefined
- const authorization = await fetchDesktopToolAuthorization(event, deps, args[0])
+ if (
+ !isDesktopToolCallId(toolCallId) ||
+ typeof requestedTool !== 'string' ||
+ !isCurrentBrowserToolName(requestedTool) ||
+ !requestedScope
+ ) {
+ return {
+ ok: false,
+ error: 'This browser action is not an authorized pending Copilot tool call.',
+ }
+ }
+ const authorizationBoundary = captureBrowserToolQueueBoundary(requestedScope)
+ if (!authorizationBoundary) {
+ return {
+ ok: false,
+ error:
+ 'Sim already has too many browser actions queued. Wait for earlier actions to finish.',
+ }
+ }
+ const authorization = await fetchDesktopToolAuthorization(event, deps, toolCallId)
if (
!authorization ||
- !requestedScope ||
authorization.chatId !== requestedScope ||
- typeof requestedTool !== 'string' ||
- authorization.toolName !== requestedTool ||
- !isCurrentBrowserToolName(authorization.toolName)
+ authorization.toolName !== requestedTool
) {
+ releaseBrowserToolQueueBoundary(authorizationBoundary)
return {
ok: false,
error: 'This browser action is not an authorized pending Copilot tool call.',
@@ -1921,7 +2001,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
}
handlerArgs = [
authorization.chatId,
- args[0],
+ toolCallId,
authorization.toolName,
authorization.args,
authorizationBoundary,
diff --git a/apps/desktop/src/main/observability.test.ts b/apps/desktop/src/main/observability.test.ts
index 836d61f8f01..967cd87f634 100644
--- a/apps/desktop/src/main/observability.test.ts
+++ b/apps/desktop/src/main/observability.test.ts
@@ -1,8 +1,17 @@
-import { existsSync, mkdtempSync, readFileSync } from 'node:fs'
+import { chmodSync, existsSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+const { mockLogger } = vi.hoisted(() => ({
+ mockLogger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+}))
+
+vi.mock('@sim/logger', () => ({ createLogger: () => mockLogger }))
vi.mock('electron', () => import('@/test/electron-mock'))
import { app, dialog } from 'electron'
@@ -21,6 +30,10 @@ describe('scrubUrl', () => {
})
describe('createEventLog', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
it('appends JSONL entries', () => {
const dir = mkdtempSync(join(tmpdir(), 'sim-desktop-events-'))
const events = createEventLog(dir)
@@ -42,6 +55,50 @@ describe('createEventLog', () => {
events.record('app_launch', { version: '1.0.0' })
events.record('app_launch', { version: '1.0.0' })
expect(existsSync(`${events.filePath}.1`)).toBe(true)
+ expect(statSync(`${events.filePath}.1`).mode & 0o777).toBe(0o600)
+ })
+
+ it('creates its directory and log with private permissions', () => {
+ const root = mkdtempSync(join(tmpdir(), 'sim-desktop-events-'))
+ const dir = join(root, 'logs')
+ const events = createEventLog(dir)
+ events.record('app_launch')
+
+ expect(statSync(dir).mode & 0o777).toBe(0o700)
+ expect(statSync(events.filePath).mode & 0o777).toBe(0o600)
+ })
+
+ it('tightens permissions on existing logs', () => {
+ const dir = mkdtempSync(join(tmpdir(), 'sim-desktop-events-'))
+ const filePath = join(dir, 'desktop-events.log')
+ const rotatedFilePath = `${filePath}.1`
+ writeFileSync(filePath, 'current\n')
+ writeFileSync(rotatedFilePath, 'rotated\n')
+ chmodSync(dir, 0o755)
+ chmodSync(filePath, 0o644)
+ chmodSync(rotatedFilePath, 0o644)
+
+ createEventLog(dir)
+
+ expect(statSync(dir).mode & 0o777).toBe(0o700)
+ expect(statSync(filePath).mode & 0o777).toBe(0o600)
+ expect(statSync(rotatedFilePath).mode & 0o777).toBe(0o600)
+ })
+
+ it('reports permission failures without exposing local paths or OS errors', () => {
+ const root = mkdtempSync(join(tmpdir(), 'sim-desktop-events-'))
+ const overlongDir = join(root, 'x'.repeat(300))
+
+ const events = createEventLog(overlongDir)
+ events.record('app_launch')
+
+ expect(mockLogger.warn.mock.calls).toEqual([
+ ['Could not apply private desktop event-log permissions', { target: 'directory' }],
+ ['Could not apply private desktop event-log permissions', { target: 'current-log' }],
+ ['Could not apply private desktop event-log permissions', { target: 'rotated-log' }],
+ ])
+ expect(JSON.stringify(mockLogger.warn.mock.calls)).not.toContain(root)
+ expect(JSON.stringify(mockLogger.warn.mock.calls)).not.toContain('ENAMETOOLONG')
})
})
diff --git a/apps/desktop/src/main/observability.ts b/apps/desktop/src/main/observability.ts
index 60c8e47b36c..142283ef9da 100644
--- a/apps/desktop/src/main/observability.ts
+++ b/apps/desktop/src/main/observability.ts
@@ -1,4 +1,4 @@
-import { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs'
+import { appendFileSync, chmodSync, mkdirSync, renameSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { createLogger } from '@sim/logger'
import type { BrowserWindow, Details } from 'electron'
@@ -7,6 +7,25 @@ import { app, dialog } from 'electron'
const logger = createLogger('DesktopEvents')
const DEFAULT_MAX_BYTES = 1_000_000
+const PRIVATE_DIRECTORY_MODE = 0o700
+const PRIVATE_FILE_MODE = 0o600
+type EventLogPermissionTarget = 'directory' | 'current-log' | 'rotated-log'
+
+function applyPrivateMode(
+ path: string,
+ mode: number,
+ target: EventLogPermissionTarget,
+ allowMissing = false
+): boolean {
+ try {
+ chmodSync(path, mode)
+ return true
+ } catch (error) {
+ if (allowMissing && (error as NodeJS.ErrnoException).code === 'ENOENT') return true
+ logger.warn('Could not apply private desktop event-log permissions', { target })
+ return false
+ }
+}
export type DesktopEventName =
| 'app_launch'
@@ -151,26 +170,43 @@ export function scrubUrl(raw: string): string {
*/
export function createEventLog(dir: string, maxBytes: number = DEFAULT_MAX_BYTES): EventRecorder {
const filePath = join(dir, 'desktop-events.log')
+ const rotatedFilePath = `${filePath}.1`
+ let privateModesEstablished = true
try {
- mkdirSync(dir, { recursive: true })
- } catch {}
-
- const rotateIfNeeded = () => {
+ mkdirSync(dir, { recursive: true, mode: PRIVATE_DIRECTORY_MODE })
+ } catch {
+ privateModesEstablished = false
+ }
+ privateModesEstablished =
+ applyPrivateMode(dir, PRIVATE_DIRECTORY_MODE, 'directory') && privateModesEstablished
+ privateModesEstablished =
+ applyPrivateMode(filePath, PRIVATE_FILE_MODE, 'current-log', true) && privateModesEstablished
+ privateModesEstablished =
+ applyPrivateMode(rotatedFilePath, PRIVATE_FILE_MODE, 'rotated-log', true) &&
+ privateModesEstablished
+
+ const rotateIfNeeded = (): boolean => {
try {
if (statSync(filePath).size > maxBytes) {
- renameSync(filePath, `${filePath}.1`)
+ renameSync(filePath, rotatedFilePath)
+ return applyPrivateMode(rotatedFilePath, PRIVATE_FILE_MODE, 'rotated-log')
}
} catch {}
+ return true
}
return {
filePath,
record(name, data) {
logger.info(`desktop event: ${name}`, data)
+ if (!privateModesEstablished) return
try {
- rotateIfNeeded()
+ if (!rotateIfNeeded()) {
+ privateModesEstablished = false
+ return
+ }
const entry = { at: new Date().toISOString(), name, ...(data ? { data } : {}) }
- appendFileSync(filePath, `${JSON.stringify(entry)}\n`)
+ appendFileSync(filePath, `${JSON.stringify(entry)}\n`, { mode: PRIVATE_FILE_MODE })
} catch (error) {
logger.warn('Failed to append desktop event', { error })
}
diff --git a/apps/desktop/src/main/scoped-event-router.test.ts b/apps/desktop/src/main/scoped-event-router.test.ts
index 7020b526b32..700d6dbc3d5 100644
--- a/apps/desktop/src/main/scoped-event-router.test.ts
+++ b/apps/desktop/src/main/scoped-event-router.test.ts
@@ -37,6 +37,10 @@ class FakeContents {
this.emit('destroyed')
}
+ markDestroyed(): void {
+ this.destroyed = true
+ }
+
private emit(channel: string, ...args: unknown[]): void {
for (const listener of [...(this.listeners.get(channel) ?? [])]) listener(...args)
}
@@ -48,6 +52,76 @@ function webContents(): { fake: FakeContents; contents: WebContents } {
}
describe('ScopedEventRouter', () => {
+ it('defaults old renderers to no site permission prompt support', () => {
+ const router = new ScopedEventRouter()
+ const renderer = webContents()
+
+ router.activateBrowser(renderer.contents, 'chat-a')
+
+ expect(router.browserSitePermissionPromptSupported('chat-a')).toBe(false)
+ })
+
+ it('recognizes an active renderer site permission prompt handshake', () => {
+ const router = new ScopedEventRouter()
+ const renderer = webContents()
+
+ router.registerBrowserSitePermissionPromptSupport(renderer.contents)
+ router.activateBrowser(renderer.contents, 'chat-a')
+
+ expect(router.browserSitePermissionPromptSupported('chat-a')).toBe(true)
+ })
+
+ it('requires a fresh site permission prompt handshake after renderer reload', () => {
+ const router = new ScopedEventRouter()
+ const renderer = webContents()
+
+ router.registerBrowserSitePermissionPromptSupport(renderer.contents)
+ router.activateBrowser(renderer.contents, 'chat-a')
+ renderer.fake.navigate()
+ router.activateBrowser(renderer.contents, 'chat-a')
+
+ expect(router.browserSitePermissionPromptSupported('chat-a')).toBe(false)
+
+ router.registerBrowserSitePermissionPromptSupport(renderer.contents)
+
+ expect(router.browserSitePermissionPromptSupported('chat-a')).toBe(true)
+ })
+
+ it('rejects ambiguous site prompts when two live renderers share a scope', () => {
+ const router = new ScopedEventRouter()
+ const first = webContents()
+ const second = webContents()
+
+ router.activateBrowser(first.contents, 'chat-a')
+ router.activateBrowser(second.contents, 'chat-a')
+ router.registerBrowserSitePermissionPromptSupport(first.contents)
+
+ expect(router.browserSitePermissionPromptSupported('chat-a')).toBe(false)
+
+ router.registerBrowserSitePermissionPromptSupport(second.contents)
+
+ expect(router.browserSitePermissionPromptSupported('chat-a')).toBe(false)
+ })
+
+ it('recovers site prompt support after an extra recipient moves or is destroyed', () => {
+ const router = new ScopedEventRouter()
+ const stable = webContents()
+ const moving = webContents()
+
+ router.registerBrowserSitePermissionPromptSupport(stable.contents)
+ router.registerBrowserSitePermissionPromptSupport(moving.contents)
+ router.activateBrowser(stable.contents, 'chat-a')
+ router.activateBrowser(moving.contents, 'chat-a')
+ router.activateBrowser(moving.contents, 'chat-b')
+
+ expect(router.browserSitePermissionPromptSupported('chat-a')).toBe(true)
+
+ router.activateBrowser(moving.contents, 'chat-a')
+ moving.fake.markDestroyed()
+
+ expect(router.browserSitePermissionPromptSupported('chat-a')).toBe(true)
+ })
+
it('sends resource events only to renderers activated for the matching scope', () => {
const router = new ScopedEventRouter()
const chatA = webContents()
diff --git a/apps/desktop/src/main/scoped-event-router.ts b/apps/desktop/src/main/scoped-event-router.ts
index 62541b098b2..7d36ca68bc5 100644
--- a/apps/desktop/src/main/scoped-event-router.ts
+++ b/apps/desktop/src/main/scoped-event-router.ts
@@ -14,6 +14,34 @@ export class ScopedEventRouter {
private readonly browser = this.createSurfaceRoutes()
private readonly terminal = this.createSurfaceRoutes()
private readonly observedContents = new WeakSet()
+ private readonly sitePermissionPromptRenderers = new WeakSet()
+
+ /** Records an active renderer handshake without trusting a shell-bundled preload flag. */
+ registerBrowserSitePermissionPromptSupport(contents: WebContents): void {
+ this.sitePermissionPromptRenderers.add(contents)
+ this.observe(contents)
+ }
+
+ /** True only when exactly one live renderer owns the scope and registered prompt support. */
+ browserSitePermissionPromptSupported(scopeId: string): boolean {
+ const recipients = this.browser.contentsByScope.get(scopeId)
+ if (!recipients) return false
+ let liveRecipientCount = 0
+ let supported = false
+ for (const contents of [...recipients]) {
+ if (contents.isDestroyed()) {
+ this.forget(contents)
+ continue
+ }
+ if (this.browser.activeByContents.get(contents) !== scopeId) {
+ this.removeFromScope(this.browser, contents, scopeId)
+ continue
+ }
+ liveRecipientCount++
+ supported ||= this.sitePermissionPromptRenderers.has(contents)
+ }
+ return liveRecipientCount === 1 && supported
+ }
activateBrowser(contents: WebContents, scopeId: string): void {
this.activate(this.browser, contents, scopeId)
@@ -66,6 +94,7 @@ export class ScopedEventRouter {
}
private forget(contents: WebContents): void {
+ this.sitePermissionPromptRenderers.delete(contents)
this.forgetSurface(this.browser, contents)
this.forgetSurface(this.terminal, contents)
}
diff --git a/apps/desktop/src/main/security-guards.ts b/apps/desktop/src/main/security-guards.ts
index 22a91554ec9..93d719e5e9b 100644
--- a/apps/desktop/src/main/security-guards.ts
+++ b/apps/desktop/src/main/security-guards.ts
@@ -24,11 +24,9 @@ export interface GuardDeps {
*/
export function attachNavigationGuards(contents: WebContents, deps: GuardDeps): void {
const handle = (event: { preventDefault(): void }, url: string) => {
- // The agent browser's tabs are general-purpose browsing surfaces: any
- // http(s) navigation is their job (they run isolated on their own
- // partition with no preload). Everything else stays denied.
if (isAgentWebContents(contents)) {
- if (!/^https?:/i.test(url)) {
+ const hasAllowedAgentBrowserScheme = /^https?:/i.test(url)
+ if (!hasAllowedAgentBrowserScheme) {
event.preventDefault()
logger.warn('Denied non-http navigation in agent browser', { url: scrubUrl(url) })
}
diff --git a/apps/desktop/src/preload/index.test.ts b/apps/desktop/src/preload/index.test.ts
index 4780e720e1e..5854b1b624b 100644
--- a/apps/desktop/src/preload/index.test.ts
+++ b/apps/desktop/src/preload/index.test.ts
@@ -1,9 +1,10 @@
import type { SimDesktopApi } from '@sim/desktop-bridge'
import { describe, expect, it, vi } from 'vitest'
-const { exposeInMainWorld, invoke } = vi.hoisted(() => ({
+const { exposeInMainWorld, invoke, send } = vi.hoisted(() => ({
exposeInMainWorld: vi.fn(),
invoke: vi.fn(() => Promise.resolve(true)),
+ send: vi.fn(),
}))
vi.mock('electron', () => ({
@@ -12,7 +13,7 @@ vi.mock('electron', () => ({
invoke,
on: vi.fn(),
removeListener: vi.fn(),
- send: vi.fn(),
+ send,
},
}))
@@ -27,6 +28,7 @@ describe('desktop preload bridge', () => {
if (!exposed) throw new Error('Expected the desktop preload API to be exposed')
expect(exposed.browserAgent.supportsAtomicPanelOcclusion).toBe(true)
+ exposed.browserAgent.registerSitePermissionPromptSupport?.()
await exposed.browserAgent.cancelTool?.('tool-1', 'chat-default')
await exposed.browserAgent.cancelActiveTool?.('chat-reloaded')
await exposed.browserAgent.setPanelOccluded(true, 'chat-default')
@@ -44,6 +46,7 @@ describe('desktop preload bridge', () => {
['browser-agent:search-suggestions', 'sim ai'],
['desktop:settings:set-browser-search-suggestions', false],
])
+ expect(send).toHaveBeenCalledWith('browser-agent:register-site-permission-prompt-support')
})
it('exposes native microphone settings only on supported platforms', async () => {
diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts
index b62def2f525..484bc2d26bd 100644
--- a/apps/desktop/src/preload/index.ts
+++ b/apps/desktop/src/preload/index.ts
@@ -197,6 +197,9 @@ const api: SimDesktopApi = {
},
browserAgent: {
supportsAtomicPanelOcclusion: true,
+ registerSitePermissionPromptSupport: (): void => {
+ ipcRenderer.send('browser-agent:register-site-permission-prompt-support')
+ },
executeTool: (
toolCallId: string,
tool: BrowserToolName,
@@ -213,6 +216,8 @@ const api: SimDesktopApi = {
},
openTab: (scopeId: string): Promise =>
ipcRenderer.invoke('browser-agent:open-tab', scopeId),
+ openUrl: (url: string, scopeId: string): Promise =>
+ ipcRenderer.invoke('browser-agent:open-url', url, scopeId),
activateScope: (scopeId: string): Promise =>
ipcRenderer.invoke('browser-agent:activate-scope', scopeId),
restoreScope: (scopeId: string): Promise =>
diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts
index 264e5342b67..bb4d81ddc57 100644
--- a/apps/desktop/src/test/electron-mock.ts
+++ b/apps/desktop/src/test/electron-mock.ts
@@ -156,6 +156,7 @@ function createWebContentsMock() {
getTitle: vi.fn(() => 'Example'),
loadURL: vi.fn(() => Promise.resolve()),
reload: vi.fn(),
+ stop: vi.fn(),
print: vi.fn(),
focus: vi.fn(),
invalidate: vi.fn(),
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts
index d7b5e8a20a5..0b593280917 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts
@@ -30,7 +30,6 @@ export type BrowserPanelSnapshotLayer = 'modal' | 'popover'
export type BrowserPanelOverlay =
| 'credentials'
| 'downloads'
- | 'permissions'
| 'resources'
| 'suggestions'
| 'tab'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts
index 642878d10ce..680ad92e1c2 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts
@@ -1,11 +1,16 @@
/**
* @vitest-environment jsdom
*/
-import { describe, expect, it, vi } from 'vitest'
+import { act, createElement, type ReactNode } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import {
+ BrowserPermissionModal,
browserPanelSnapshotStyle,
+ browserPermissionPrompt,
+ browserPermissionResponseAction,
browserSelectionContext,
- claimMediaPermissionResponse,
+ claimPermissionResponse,
clearOmniboxSelection,
exceededOmniboxDragThreshold,
hasConfirmedBrowserTabCreation,
@@ -15,18 +20,203 @@ import {
shouldOpenUrlSuggestions,
shouldRemoveBrowserResource,
shouldReportBrowserBounds,
+ shouldShowBrowserPermissionRequest,
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session'
-describe('claimMediaPermissionResponse', () => {
+let root: Root | null = null
+let container: HTMLDivElement | null = null
+
+function mount(ui: 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(ui))
+}
+
+function rerender(ui: ReactNode): void {
+ act(() => root?.render(ui))
+}
+
+function buttonByText(text: string): HTMLButtonElement {
+ const button = Array.from(document.querySelectorAll('button')).find(
+ (candidate) => candidate.textContent === text
+ )
+ if (!button) throw new Error(`No button labeled "${text}" rendered`)
+ return button
+}
+
+function makeElementsVisible(): void {
+ const rect = {
+ bottom: 1,
+ height: 1,
+ left: 0,
+ right: 1,
+ top: 0,
+ width: 1,
+ x: 0,
+ y: 0,
+ toJSON: () => ({}),
+ } satisfies DOMRect
+ const rects = {
+ 0: rect,
+ length: 1,
+ item: (index: number) => (index === 0 ? rect : null),
+ [Symbol.iterator]: function* () {
+ yield rect
+ },
+ } as DOMRectList
+ vi.spyOn(Element.prototype, 'getClientRects').mockReturnValue(rects)
+}
+
+afterEach(() => {
+ if (root) act(() => root?.unmount())
+ container?.remove()
+ root = null
+ container = null
+ vi.restoreAllMocks()
+})
+
+describe('claimPermissionResponse', () => {
it('allows one response per request id across effect recreation', () => {
- const handledRequestId = { current: null as string | null }
+ const handledRequestIds = { current: new Set() }
- expect(claimMediaPermissionResponse(handledRequestId, 'request-1')).toBe(true)
- expect(claimMediaPermissionResponse(handledRequestId, 'request-1')).toBe(false)
- expect(claimMediaPermissionResponse(handledRequestId, 'request-2')).toBe(true)
+ expect(claimPermissionResponse(handledRequestIds, 'request-1')).toBe(true)
+ expect(claimPermissionResponse(handledRequestIds, 'request-1')).toBe(false)
+ expect(claimPermissionResponse(handledRequestIds, 'request-2')).toBe(true)
+ expect(claimPermissionResponse(handledRequestIds, 'request-1')).toBe(false)
+ })
+})
- handledRequestId.current = null
- expect(claimMediaPermissionResponse(handledRequestId, 'request-1')).toBe(true)
+describe('browser permission prompt', () => {
+ const siteRequest = {
+ requestId: 'site-request-1',
+ tabId: 'tab-1',
+ origin: 'https://outside.example',
+ }
+ const mediaRequest = {
+ requestId: 'media-request-1',
+ origin: 'https://meeting.example',
+ devices: ['microphone', 'camera'] as const,
+ }
+
+ it('keeps an answered hidden request closed when visible again and opens a new id', () => {
+ expect(shouldShowBrowserPermissionRequest(siteRequest.requestId, null, false)).toBe(false)
+ expect(
+ shouldShowBrowserPermissionRequest(siteRequest.requestId, siteRequest.requestId, true)
+ ).toBe(false)
+ expect(shouldShowBrowserPermissionRequest('site-request-2', siteRequest.requestId, true)).toBe(
+ true
+ )
+ })
+
+ it('maps each request kind to its exact native response action', () => {
+ expect(browserPermissionResponseAction(siteRequest)).toBe('respond-site-permission')
+ expect(browserPermissionResponseAction(mediaRequest)).toBe('respond-media-permission')
+ })
+
+ it('describes the scope and consequence of site and media access', () => {
+ expect(browserPermissionPrompt(siteRequest)).toEqual({
+ title: 'Allow this browser task to visit https://outside.example?',
+ text: expect.stringContaining('send requests to and receive data from this origin'),
+ })
+ expect(browserPermissionPrompt(siteRequest).text).toContain(
+ 'the full path and query remain hidden'
+ )
+ expect(browserPermissionPrompt(mediaRequest)).toEqual({
+ title: 'Allow https://meeting.example to use your microphone and camera?',
+ text: expect.stringContaining('until it navigates'),
+ })
+ })
+
+ it('renders an accessible, fail-safe modal and makes Block an explicit decision', () => {
+ makeElementsVisible()
+ const onDecision = vi.fn()
+ mount(
+ createElement(BrowserPermissionModal, {
+ request: siteRequest,
+ open: true,
+ onDecision,
+ })
+ )
+
+ const dialog = document.querySelector('[role="dialog"]')
+ expect(dialog).not.toBeNull()
+ const labelledBy = dialog?.getAttribute('aria-labelledby')
+ expect(labelledBy).toBeTruthy()
+ expect(document.getElementById(labelledBy ?? '')?.textContent).toBe(
+ 'Allow this browser task to visit https://outside.example?'
+ )
+ expect(dialog?.textContent).toContain('the full path and query remain hidden')
+ expect(document.querySelector('[data-native-surface-occlusion="modal"]')).not.toBeNull()
+ expect(document.querySelector('[data-chip-modal-default-policy="dismiss"]')).not.toBeNull()
+ expect(document.activeElement).toBe(buttonByText('Block'))
+
+ act(() => buttonByText('Block').click())
+ expect(onDecision).toHaveBeenCalledOnce()
+ expect(onDecision).toHaveBeenCalledWith(siteRequest.requestId, 'respond-site-permission', false)
+ })
+
+ it('makes Allow an explicit primary decision', () => {
+ const onDecision = vi.fn()
+ mount(
+ createElement(BrowserPermissionModal, {
+ request: mediaRequest,
+ open: true,
+ onDecision,
+ })
+ )
+
+ const allow = buttonByText('Allow')
+ expect(allow.className).toContain('bg-[var(--text-primary)]')
+ act(() => allow.click())
+ expect(onDecision).toHaveBeenCalledOnce()
+ expect(onDecision).toHaveBeenCalledWith(
+ mediaRequest.requestId,
+ 'respond-media-permission',
+ true
+ )
+ })
+
+ it('blocks replaced and unmounted requests once without overriding an explicit answer', () => {
+ const handledRequestIds = { current: new Set() }
+ const responses = vi.fn()
+ const onDecision = (
+ requestId: string,
+ action: 'respond-media-permission' | 'respond-site-permission',
+ allowed: boolean
+ ) => {
+ if (claimPermissionResponse(handledRequestIds, requestId)) {
+ responses(requestId, action, allowed)
+ }
+ }
+
+ mount(
+ createElement(BrowserPermissionModal, {
+ request: siteRequest,
+ open: true,
+ onDecision,
+ })
+ )
+ rerender(
+ createElement(BrowserPermissionModal, {
+ request: mediaRequest,
+ open: true,
+ onDecision,
+ })
+ )
+
+ expect(responses).toHaveBeenCalledWith(siteRequest.requestId, 'respond-site-permission', false)
+ act(() => buttonByText('Allow').click())
+ act(() => root?.unmount())
+ root = null
+
+ expect(responses).toHaveBeenCalledTimes(2)
+ expect(responses).toHaveBeenLastCalledWith(
+ mediaRequest.requestId,
+ 'respond-media-permission',
+ true
+ )
})
})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx
index 07e14de4d4e..9621a98ca0d 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx
@@ -2,10 +2,12 @@
import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type {
+ BrowserMediaPermissionRequest,
BrowserOmniboxFocusMode,
BrowserPanelAnchor,
BrowserPanelBounds,
BrowserPanelSnapshot,
+ BrowserSitePermissionRequest,
BrowserTabState,
} from '@sim/browser-protocol'
import { isBrowserTheme } from '@sim/browser-protocol'
@@ -16,6 +18,7 @@ import type {
} from '@sim/desktop-bridge'
import {
Button,
+ ChipConfirmModal,
ChipInput,
chipVariants,
cn,
@@ -195,16 +198,99 @@ 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 },
+/** Covers both prompt types across every globally admitted native tab without unbounded retention. */
+const MAX_HANDLED_PERMISSION_REQUESTS = 256
+
+/** Claims the one renderer response allowed for a native browser permission request. */
+export function claimPermissionResponse(
+ handledRequestIds: { current: Set },
requestId: string
): boolean {
- if (handledRequestId.current === requestId) return false
- handledRequestId.current = requestId
+ if (handledRequestIds.current.has(requestId)) return false
+ handledRequestIds.current.add(requestId)
+ while (handledRequestIds.current.size > MAX_HANDLED_PERMISSION_REQUESTS) {
+ const oldest = handledRequestIds.current.values().next().value
+ if (typeof oldest !== 'string') break
+ handledRequestIds.current.delete(oldest)
+ }
return true
}
+type BrowserPermissionRequest = BrowserMediaPermissionRequest | BrowserSitePermissionRequest
+
+export function browserPermissionResponseAction(
+ request: BrowserPermissionRequest
+): 'respond-media-permission' | 'respond-site-permission' {
+ return 'tabId' in request ? 'respond-site-permission' : 'respond-media-permission'
+}
+
+export function shouldShowBrowserPermissionRequest(
+ requestId: string | undefined,
+ answeredRequestId: string | null,
+ visible: boolean
+): boolean {
+ return visible && requestId !== undefined && requestId !== answeredRequestId
+}
+
+export function browserPermissionPrompt(request: BrowserPermissionRequest): {
+ title: string
+ text: string
+} {
+ if ('tabId' in request) {
+ return {
+ title: `Allow this browser task to visit ${request.origin}?`,
+ text: 'Allowing lets the task send requests to and receive data from this origin until the browser task ends. Only the origin is shown here; the full path and query remain hidden.',
+ }
+ }
+
+ const devices = request.devices.join(' and ')
+ return {
+ title: `Allow ${request.origin} to use your ${devices}?`,
+ text: `Allowing gives this page access to your ${devices} until it navigates. Block if you did not expect this request.`,
+ }
+}
+
+interface BrowserPermissionModalProps {
+ request: BrowserPermissionRequest | undefined
+ open: boolean
+ onDecision: (
+ requestId: string,
+ action: ReturnType,
+ allowed: boolean
+ ) => void
+}
+
+export function BrowserPermissionModal({ request, open, onDecision }: BrowserPermissionModalProps) {
+ const requestId = request?.requestId
+ const responseAction = request ? browserPermissionResponseAction(request) : undefined
+ useEffect(() => {
+ if (!requestId || !responseAction) return
+ return () => onDecision(requestId, responseAction, false)
+ }, [onDecision, requestId, responseAction])
+
+ if (!request) return null
+ const prompt = browserPermissionPrompt(request)
+ const activeResponseAction = browserPermissionResponseAction(request)
+
+ return (
+ {
+ if (!nextOpen) onDecision(request.requestId, activeResponseAction, false)
+ }}
+ title={prompt.title}
+ text={prompt.text}
+ defaultAction='dismiss'
+ dismissLabel='Block'
+ confirm={{
+ label: 'Allow',
+ onClick: () => onDecision(request.requestId, activeResponseAction, true),
+ variant: 'primary',
+ }}
+ />
+ )
+}
+
/** Places the replacement on the native view's exact, unclipped viewport rectangle. */
export function browserPanelSnapshotStyle(
snapshot: BrowserPanelSnapshot,
@@ -396,6 +482,8 @@ export function BrowserSession({
const suspended = useBrowserSessionStore((state) => state.sessions[scopeId]?.suspended ?? false)
const hasPageIssue = Boolean(pageState?.issue)
const mediaPermissionRequest = pageState?.mediaPermissionRequest
+ const sitePermissionRequest = pageState?.sitePermissionRequest
+ const permissionRequest = sitePermissionRequest ?? mediaPermissionRequest
const panelRef = useRef(null)
const hostRef = useRef(null)
// Lets the occlusion handshake reject a capture taken before a modal's
@@ -408,7 +496,10 @@ export function BrowserSession({
const omniboxFocusRafRef = useRef(null)
const omniboxPointerSelectionRef = useRef(null)
const pendingNewTabFocusRef = useRef(null)
- const handledMediaPermissionRequestIdRef = useRef(null)
+ const handledPermissionRequestIdsRef = useRef>(new Set())
+ const [answeredPermissionRequestId, setAnsweredPermissionRequestId] = useState(
+ null
+ )
const visibleRef = useRef(visible)
visibleRef.current = visible
const { removeResource } = useMothershipResources()
@@ -483,68 +574,35 @@ 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)
+ const respondToPermission = useCallback(
+ (
+ requestId: string,
+ action: ReturnType,
+ allowed: boolean
+ ) => {
+ if (!claimPermissionResponse(handledPermissionRequestIdsRef, requestId)) {
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')
- },
- })
- })
+ setAnsweredPermissionRequestId(requestId)
+ sendBrowserPanelAction(action, { requestId, allowed }, scopeId)
+ },
+ [scopeId]
+ )
- return () => {
- active = false
- dismissPrompt()
- void closeOverlay('permissions')
- }
- }, [closeOverlay, mediaPermissionRequest, requestOverlay, scopeId, visible])
+ useEffect(() => {
+ if (visible || !permissionRequest) return
+ respondToPermission(
+ permissionRequest.requestId,
+ browserPermissionResponseAction(permissionRequest),
+ false
+ )
+ }, [permissionRequest, respondToPermission, visible])
+
+ const permissionModalOpen = shouldShowBrowserPermissionRequest(
+ permissionRequest?.requestId,
+ answeredPermissionRequestId,
+ 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
@@ -1011,7 +1069,7 @@ 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) {
+ if (mediaPermissionRequest || sitePermissionRequest) {
void closeOverlay('suggestions')
return
}
@@ -1024,7 +1082,14 @@ export function BrowserSession({
return
}
void closeOverlay('suggestions')
- }, [closeOverlay, hasPageIssue, mediaPermissionRequest, requestOverlay, suggestions.length])
+ }, [
+ closeOverlay,
+ hasPageIssue,
+ mediaPermissionRequest,
+ requestOverlay,
+ sitePermissionRequest,
+ suggestions.length,
+ ])
const suggestionsOpen = hasPageIssue
? suggestions.length > 0
@@ -1537,6 +1602,11 @@ export function BrowserSession({
/>
)}
+
)
}
diff --git a/apps/sim/lib/browser-agent/transport.test.ts b/apps/sim/lib/browser-agent/transport.test.ts
index bc3237dffe3..b384cea87a9 100644
--- a/apps/sim/lib/browser-agent/transport.test.ts
+++ b/apps/sim/lib/browser-agent/transport.test.ts
@@ -25,9 +25,12 @@ const {
onScopeSuspended,
onToolbarCommand,
openTab,
+ openUrl,
+ openUrlAvailable,
panelAction,
reorderTab,
reorderStoreTab,
+ registerSitePermissionPromptSupport,
restoreScope,
nativeSuspendScope,
setPageState,
@@ -66,9 +69,12 @@ const {
onScopeSuspended: vi.fn(),
onToolbarCommand: vi.fn(),
openTab: vi.fn(),
+ openUrl: vi.fn(),
+ openUrlAvailable: { current: true },
panelAction: vi.fn(),
reorderTab: vi.fn(),
reorderStoreTab: vi.fn(),
+ registerSitePermissionPromptSupport: vi.fn(),
restoreScope: vi.fn(),
nativeSuspendScope: vi.fn(async () => true),
setPageState: vi.fn(),
@@ -107,7 +113,9 @@ vi.mock('@/lib/desktop', () => ({
onSessionStatus,
onTabsState,
openTab,
+ openUrl: openUrlAvailable.current ? openUrl : undefined,
panelAction,
+ registerSitePermissionPromptSupport,
reorderTab,
restoreScope,
suspendScope: nativeSuspendScope,
@@ -203,6 +211,8 @@ describe('browser panel transport', () => {
executeTool.mockReset()
panelAction.mockClear()
openTab.mockReset()
+ openUrl.mockReset()
+ openUrlAvailable.current = true
setTabPinned.mockClear()
showTabContextMenu.mockClear()
showToolbarMenu.mockClear()
@@ -214,6 +224,12 @@ describe('browser panel transport', () => {
disposeScope.mockClear()
})
+ it('registers renderer-owned site permission prompt support', () => {
+ initBrowserAgentTransport()
+
+ expect(registerSitePermissionPromptSupport).toHaveBeenCalledOnce()
+ })
+
it('opens a browser tab through the acknowledged bridge and applies its state', async () => {
const state = {
scopeId: 'chat-test',
@@ -245,27 +261,45 @@ describe('browser panel transport', () => {
expect(setTabsState).toHaveBeenCalledWith(state)
})
- it('opens chat URLs in a distinct tab before navigating', async () => {
- const callOrder: string[] = []
- openTab.mockImplementation(async () => {
- callOrder.push('open-tab')
- return {
- scopeId: 'chat-test',
- activeTabId: '2',
- tabs: [],
- }
+ it('opens chat URLs through one acknowledged native operation', async () => {
+ openUrl.mockResolvedValue({
+ scopeId: 'chat-test',
+ activeTabId: '2',
+ tabs: [],
})
- panelAction.mockImplementation(() => {
- callOrder.push('navigate')
+
+ await openUrlInNewBrowserTab('https://example.com/docs', 'chat-test')
+
+ expect(openUrl).toHaveBeenCalledWith('https://example.com/docs', 'chat-test')
+ expect(openTab).not.toHaveBeenCalled()
+ expect(panelAction).not.toHaveBeenCalled()
+ expect(setTabsState).toHaveBeenCalledWith({
+ scopeId: 'chat-test',
+ activeTabId: '2',
+ tabs: [],
+ })
+ })
+
+ it('falls back to acknowledged tab creation on older installed shells', async () => {
+ openUrlAvailable.current = false
+ openTab.mockResolvedValue({
+ scopeId: 'chat-test',
+ activeTabId: '2',
+ tabs: [],
})
await openUrlInNewBrowserTab('https://example.com/docs', 'chat-test')
- expect(callOrder).toEqual(['open-tab', 'navigate'])
+ expect(openTab).toHaveBeenCalledWith('chat-test')
expect(panelAction).toHaveBeenCalledWith(
{ action: 'navigate', url: 'https://example.com/docs' },
'chat-test'
)
+ expect(setTabsState).toHaveBeenCalledWith({
+ scopeId: 'chat-test',
+ activeTabId: '2',
+ tabs: [],
+ })
})
it('keeps search suggestions local-only on older installed shells', async () => {
diff --git a/apps/sim/lib/browser-agent/transport.ts b/apps/sim/lib/browser-agent/transport.ts
index bf6a64a776e..b599012d19a 100644
--- a/apps/sim/lib/browser-agent/transport.ts
+++ b/apps/sim/lib/browser-agent/transport.ts
@@ -97,6 +97,7 @@ export function initBrowserAgentTransport(): void {
useBrowserSessionStore.getState().setSessionAlive(alive, scopeId)
})
agent.onScopeSuspended(applyBrowserScopeSuspended)
+ agent.registerSitePermissionPromptSupport?.()
}
/** Makes one chat's browser set active in both renderer and desktop. */
@@ -357,6 +358,16 @@ export async function openUrlInNewBrowserTab(
url: string,
scopeId = currentBrowserScopeId()
): Promise {
+ const agent = bridge()
+ if (!agent) throw new Error('The Sim desktop browser agent is unavailable.')
+ if (agent.openUrl) {
+ const state = await agent.openUrl(url, scopeId)
+ if (state.scopeId !== scopeId || !state.activeTabId) {
+ throw new Error('The desktop browser did not confirm the new tab.')
+ }
+ useBrowserSessionStore.getState().setTabsState(state)
+ return
+ }
await openBrowserTab(scopeId)
sendBrowserPanelAction('navigate', { url }, scopeId)
}
diff --git a/apps/sim/lib/copilot/tools/browser-protocol-contract.test.ts b/apps/sim/lib/copilot/tools/browser-protocol-contract.test.ts
index e98507283c9..2ac644f7c04 100644
--- a/apps/sim/lib/copilot/tools/browser-protocol-contract.test.ts
+++ b/apps/sim/lib/copilot/tools/browser-protocol-contract.test.ts
@@ -6,6 +6,8 @@ import {
import { describe, expect, it } from 'vitest'
import { TOOL_CATALOG } from '@/lib/copilot/generated/tool-catalog-v1'
+const BROWSER_RESULT_SCHEMA_BASELINE = { covered: 13, total: 23 } as const
+
describe('browser tool protocol contract', () => {
it('matches the current model-visible browser catalog after legacy exclusions', () => {
const protocolTools = [...CURRENT_BROWSER_TOOL_NAMES].sort()
@@ -16,6 +18,28 @@ describe('browser tool protocol contract', () => {
expect(protocolTools).toEqual(catalogTools)
})
+ it('keeps every current browser tool on the client execution boundary', () => {
+ for (const toolName of CURRENT_BROWSER_TOOL_NAMES) {
+ expect(TOOL_CATALOG[toolName]).toMatchObject({
+ id: toolName,
+ name: toolName,
+ route: 'client',
+ clientExecutable: true,
+ })
+ }
+ })
+
+ it('does not regress canonical browser result-schema coverage', () => {
+ const schemaCount = CURRENT_BROWSER_TOOL_NAMES.filter(
+ (toolName) => TOOL_CATALOG[toolName]?.resultSchema !== undefined
+ ).length
+
+ expect(schemaCount).toBeGreaterThanOrEqual(BROWSER_RESULT_SCHEMA_BASELINE.covered)
+ expect(schemaCount * BROWSER_RESULT_SCHEMA_BASELINE.total).toBeGreaterThanOrEqual(
+ BROWSER_RESULT_SCHEMA_BASELINE.covered * CURRENT_BROWSER_TOOL_NAMES.length
+ )
+ })
+
it('recognizes retired browser history without treating it as executable', () => {
expect(isBrowserToolName('browser_request_takeover')).toBe(true)
expect(isCurrentBrowserToolName('browser_request_takeover')).toBe(false)
diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts b/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts
index 8e18c68a2b3..82520de71c3 100644
--- a/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts
+++ b/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts
@@ -30,6 +30,7 @@ vi.mock('@/lib/copilot/tools/client/completion', () => ({
}))
import { executeBrowserToolOnClient } from '@/lib/copilot/tools/client/browser-tool-execution'
+import { BrowserToolReplayLedger } from '@/lib/copilot/tools/client/browser-tool-replay-ledger'
import { useBrowserSessionStore } from '@/stores/browser-session/store'
const CHAT_SCOPE = 'chat-test'
@@ -45,6 +46,25 @@ function nextToolCallId(): string {
return `tool-call-${toolCallCounter}`
}
+function setLiveBrowserSession(): void {
+ const session = {
+ pageState: null,
+ tabs: [],
+ activeTabId: null,
+ automationTabId: null,
+ automationActive: false,
+ automationNeedsAttention: false,
+ agentRunIds: [],
+ sessionAlive: true,
+ suspended: false,
+ }
+ useBrowserSessionStore.setState({
+ ...session,
+ activeScopeId: CHAT_SCOPE,
+ sessions: { [CHAT_SCOPE]: session },
+ })
+}
+
describe('executeBrowserToolOnClient', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -54,22 +74,7 @@ describe('executeBrowserToolOnClient', () => {
configurable: true,
value: vi.fn(() => true),
})
- const session = {
- pageState: null,
- tabs: [],
- activeTabId: null,
- automationTabId: null,
- automationActive: false,
- automationNeedsAttention: false,
- agentRunIds: [],
- sessionAlive: true,
- suspended: false,
- }
- useBrowserSessionStore.setState({
- ...session,
- activeScopeId: CHAT_SCOPE,
- sessions: { [CHAT_SCOPE]: session },
- })
+ setLiveBrowserSession()
mockReportCompletion.mockResolvedValue(undefined)
mockReportCompletionOnPageExit.mockResolvedValue(undefined)
mockRestoreBrowserScope.mockResolvedValue(false)
@@ -80,6 +85,178 @@ describe('executeBrowserToolOnClient', () => {
vi.unstubAllGlobals()
})
+ it('preserves every executed completion when a guard result arrives at retention capacity', async () => {
+ const replayClaim = vi
+ .spyOn(BrowserToolReplayLedger.prototype, 'claim')
+ .mockReturnValue('claimed')
+ const releases: Array<() => void> = []
+ mockExecuteBrowserTool.mockResolvedValue({ text: 'page content' })
+ mockReportCompletion.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ releases.push(resolve)
+ })
+ )
+ const executedToolCallIds = Array.from({ length: 2_048 }, () => nextToolCallId())
+ const overflowGuardToolCallId = nextToolCallId()
+ const overflowExecutedToolCallId = nextToolCallId()
+
+ try {
+ for (const toolCallId of executedToolCallIds) {
+ executeBrowserToolOnClient(toolCallId, 'browser_snapshot', {})
+ }
+ await flush()
+
+ expect(mockExecuteBrowserTool).toHaveBeenCalledTimes(executedToolCallIds.length)
+ expect(mockReportCompletion).toHaveBeenCalledTimes(4)
+
+ executeBrowserToolOnClient(
+ overflowGuardToolCallId,
+ 'browser_list_sessions',
+ {},
+ CHAT_SCOPE,
+ new Date(Date.now() - 10 * 60_000).toISOString()
+ )
+ await flush()
+
+ expect(mockReportCompletion).toHaveBeenCalledTimes(4)
+
+ executeBrowserToolOnClient(overflowExecutedToolCallId, 'browser_snapshot', {})
+ await flush()
+
+ expect(mockExecuteBrowserTool).toHaveBeenCalledTimes(executedToolCallIds.length)
+ expect(replayClaim).toHaveBeenCalledTimes(executedToolCallIds.length)
+
+ mockReportCompletion.mockResolvedValue(undefined)
+ for (const release of releases.splice(0)) release()
+ await vi.waitFor(
+ () => expect(mockReportCompletion).toHaveBeenCalledTimes(executedToolCallIds.length),
+ { timeout: 10_000 }
+ )
+
+ const reportedToolCallIds = new Set(
+ mockReportCompletion.mock.calls.map(([toolCallId]) => toolCallId)
+ )
+ expect(reportedToolCallIds).toEqual(new Set(executedToolCallIds))
+ expect(reportedToolCallIds.has(overflowGuardToolCallId)).toBe(false)
+ expect(reportedToolCallIds.has(overflowExecutedToolCallId)).toBe(false)
+ } finally {
+ mockReportCompletion.mockResolvedValue(undefined)
+ for (const release of releases.splice(0)) release()
+ replayClaim.mockRestore()
+ await flush()
+ }
+ })
+
+ it('displaces a guard completion for an executed result at retention capacity', async () => {
+ const replayClaim = vi
+ .spyOn(BrowserToolReplayLedger.prototype, 'claim')
+ .mockReturnValue('claimed')
+ const releases: Array<() => void> = []
+ mockReportCompletion.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ releases.push(resolve)
+ })
+ )
+ const staleTimestamp = new Date(Date.now() - 10 * 60_000).toISOString()
+ const guardToolCallIds = Array.from({ length: 2_048 }, () => nextToolCallId())
+ const executedToolCallId = nextToolCallId()
+
+ try {
+ for (const toolCallId of guardToolCallIds) {
+ executeBrowserToolOnClient(
+ toolCallId,
+ 'browser_list_sessions',
+ {},
+ CHAT_SCOPE,
+ staleTimestamp
+ )
+ }
+ await flush()
+
+ expect(mockReportCompletion).toHaveBeenCalledTimes(4)
+
+ mockExecuteBrowserTool.mockResolvedValue({ text: 'page content' })
+ executeBrowserToolOnClient(executedToolCallId, 'browser_snapshot', {})
+ await flush()
+ expect(mockExecuteBrowserTool).toHaveBeenCalledOnce()
+
+ releases.shift()?.()
+ await flush()
+ expect(mockReportCompletion.mock.calls[4]?.[0]).toBe(executedToolCallId)
+ } finally {
+ mockReportCompletion.mockResolvedValue(undefined)
+ for (const release of releases.splice(0)) release()
+ replayClaim.mockRestore()
+ await flush()
+ }
+ })
+
+ it('releases scheduler capacity after four timed-out completion deliveries', async () => {
+ const rejectors: Array<(reason?: unknown) => void> = []
+ mockExecuteBrowserTool.mockResolvedValue({ text: 'page content' })
+ mockReportCompletion.mockImplementation(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectors.push(reject)
+ })
+ )
+ const toolCallIds = Array.from({ length: 5 }, () => nextToolCallId())
+
+ for (const toolCallId of toolCallIds) {
+ executeBrowserToolOnClient(toolCallId, 'browser_snapshot', {})
+ }
+ await flush()
+
+ expect(mockReportCompletion).toHaveBeenCalledTimes(4)
+ expect(rejectors).toHaveLength(4)
+
+ mockReportCompletion.mockResolvedValue(undefined)
+ for (const reject of rejectors) {
+ reject(new DOMException('Completion report timed out', 'TimeoutError'))
+ }
+
+ await vi.waitFor(() => expect(mockReportCompletion).toHaveBeenCalledTimes(5))
+ expect(mockReportCompletion.mock.calls[4]?.[0]).toBe(toolCallIds[4])
+ expect(mockReportCompletionOnPageExit).toHaveBeenCalledTimes(4)
+ })
+
+ it('keeps an in-flight completion as the replay owner after the retention TTL', async () => {
+ const startedAt = Date.now()
+ const now = vi.spyOn(Date, 'now').mockReturnValue(startedAt)
+ let releaseReport: (() => void) | undefined
+ mockExecuteBrowserTool.mockResolvedValue({ text: 'page content' })
+ mockReportCompletion.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ releaseReport = resolve
+ })
+ )
+ const toolCallId = nextToolCallId()
+
+ try {
+ executeBrowserToolOnClient(toolCallId, 'browser_snapshot', {})
+ await flush()
+ expect(mockExecuteBrowserTool).toHaveBeenCalledOnce()
+ expect(mockReportCompletion).toHaveBeenCalledOnce()
+
+ now.mockReturnValue(startedAt + 5 * 60_000 + 1)
+ executeBrowserToolOnClient(toolCallId, 'browser_snapshot', {})
+ await flush()
+
+ expect(mockExecuteBrowserTool).toHaveBeenCalledOnce()
+ expect(mockReportCompletion).toHaveBeenCalledOnce()
+ expect(mockReportCompletion).toHaveBeenCalledWith(toolCallId, 'success', expect.any(String), {
+ text: 'page content',
+ })
+ } finally {
+ now.mockRestore()
+ releaseReport?.()
+ await flush()
+ }
+ })
+
it('executes the tool and reports success when the session is alive', async () => {
mockExecuteBrowserTool.mockResolvedValue({ text: 'page content' })
const toolCallId = nextToolCallId()
@@ -91,7 +268,7 @@ describe('executeBrowserToolOnClient', () => {
toolCallId,
'browser_snapshot',
{},
- 30_000,
+ 90_000,
CHAT_SCOPE,
expect.any(Function)
)
@@ -100,6 +277,369 @@ describe('executeBrowserToolOnClient', () => {
})
})
+ it('lets a running invocation own the genuine result when the same call is re-delivered', async () => {
+ let finishExecution: (result: { text: string }) => void = () => {}
+ mockExecuteBrowserTool.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ finishExecution = resolve
+ })
+ )
+ const toolCallId = nextToolCallId()
+
+ executeBrowserToolOnClient(toolCallId, 'browser_snapshot', {})
+ executeBrowserToolOnClient(
+ toolCallId,
+ 'browser_snapshot',
+ {},
+ CHAT_SCOPE,
+ new Date(Date.now() - 10 * 60_000).toISOString()
+ )
+ await flush()
+
+ expect(mockExecuteBrowserTool).toHaveBeenCalledOnce()
+ expect(mockReportCompletion).not.toHaveBeenCalled()
+
+ finishExecution({ text: 'page content' })
+ await flush()
+
+ expect(mockReportCompletion).toHaveBeenCalledOnce()
+ expect(mockReportCompletion).toHaveBeenCalledWith(toolCallId, 'success', expect.any(String), {
+ text: 'page content',
+ })
+ })
+
+ it('does not spend replay-ledger capacity on stale never-executed events', async () => {
+ const replayClaim = vi
+ .spyOn(BrowserToolReplayLedger.prototype, 'claim')
+ .mockReturnValue('claimed')
+ const staleTimestamp = new Date(Date.now() - 10 * 60_000).toISOString()
+ const staleToolCallIds = Array.from({ length: 2_049 }, () => nextToolCallId())
+
+ try {
+ for (const toolCallId of staleToolCallIds) {
+ executeBrowserToolOnClient(
+ toolCallId,
+ 'browser_list_sessions',
+ {},
+ CHAT_SCOPE,
+ staleTimestamp
+ )
+ }
+ expect(replayClaim).not.toHaveBeenCalled()
+
+ mockExecuteBrowserTool.mockResolvedValue({ text: 'fresh page content' })
+ const freshToolCallId = nextToolCallId()
+ executeBrowserToolOnClient(freshToolCallId, 'browser_snapshot', {})
+ await flush()
+
+ expect(replayClaim).toHaveBeenCalledOnce()
+ expect(replayClaim).toHaveBeenCalledWith(freshToolCallId)
+ expect(mockExecuteBrowserTool).toHaveBeenCalledOnce()
+ expect(mockExecuteBrowserTool).toHaveBeenCalledWith(
+ freshToolCallId,
+ 'browser_snapshot',
+ {},
+ 90_000,
+ CHAT_SCOPE,
+ expect.any(Function)
+ )
+ await vi.waitFor(() =>
+ expect(mockReportCompletion).toHaveBeenCalledWith(
+ freshToolCallId,
+ 'success',
+ expect.any(String),
+ { text: 'fresh page content' }
+ )
+ )
+ } finally {
+ replayClaim.mockRestore()
+ }
+ })
+
+ it('reports an unknown outcome for a durable duplicate with no same-runtime owner', async () => {
+ const replayClaim = vi
+ .spyOn(BrowserToolReplayLedger.prototype, 'claim')
+ .mockReturnValue('duplicate')
+ const toolCallId = nextToolCallId()
+
+ try {
+ executeBrowserToolOnClient(toolCallId, 'browser_click', { elementId: 1 })
+ executeBrowserToolOnClient(toolCallId, 'browser_click', { elementId: 1 })
+ await flush()
+
+ expect(mockExecuteBrowserTool).not.toHaveBeenCalled()
+ expect(mockReportCompletion).toHaveBeenCalledOnce()
+ expect(mockReportCompletion).toHaveBeenCalledWith(
+ toolCallId,
+ 'error',
+ expect.stringContaining('terminal result could not be recovered'),
+ expect.objectContaining({
+ outcomeUnknown: true,
+ doNotRetry: true,
+ replayRecoveredWithoutResult: true,
+ })
+ )
+ } finally {
+ replayClaim.mockRestore()
+ }
+ })
+
+ it('caps terminal-report concurrency and drains a completion burst', async () => {
+ const releases: Array<() => void> = []
+ let activeReports = 0
+ let maxActiveReports = 0
+ mockExecuteBrowserTool.mockResolvedValue({ text: 'page content' })
+ mockReportCompletion.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ activeReports += 1
+ maxActiveReports = Math.max(maxActiveReports, activeReports)
+ releases.push(() => {
+ activeReports -= 1
+ resolve()
+ })
+ })
+ )
+ const toolCallIds = Array.from({ length: 12 }, () => nextToolCallId())
+
+ for (const toolCallId of toolCallIds) {
+ executeBrowserToolOnClient(toolCallId, 'browser_snapshot', {})
+ }
+ await flush()
+
+ expect(mockReportCompletion).toHaveBeenCalledTimes(4)
+ for (let wave = 0; wave < 3; wave += 1) {
+ const currentWave = releases.splice(0)
+ for (const release of currentWave) release()
+ await flush()
+ }
+
+ expect(mockReportCompletion).toHaveBeenCalledTimes(toolCallIds.length)
+ expect(maxActiveReports).toBe(4)
+ for (const release of releases.splice(0)) release()
+ await flush()
+ })
+
+ it('prioritizes an executed result over a burst of queued guard rejections', async () => {
+ const releases: Array<() => void> = []
+ mockExecuteBrowserTool.mockResolvedValue({ text: 'page content' })
+ mockReportCompletion.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ releases.push(resolve)
+ })
+ )
+ for (let index = 0; index < 4; index += 1) {
+ executeBrowserToolOnClient(nextToolCallId(), 'browser_snapshot', {})
+ }
+ await flush()
+ expect(mockReportCompletion).toHaveBeenCalledTimes(4)
+
+ const staleTimestamp = new Date(Date.now() - 10 * 60_000).toISOString()
+ for (let index = 0; index < 70; index += 1) {
+ executeBrowserToolOnClient(
+ nextToolCallId(),
+ 'browser_list_sessions',
+ {},
+ CHAT_SCOPE,
+ staleTimestamp
+ )
+ }
+ const executedToolCallId = nextToolCallId()
+ executeBrowserToolOnClient(executedToolCallId, 'browser_snapshot', {})
+ await flush()
+
+ releases.shift()?.()
+ await flush()
+ expect(mockReportCompletion.mock.calls[4]?.[0]).toBe(executedToolCallId)
+
+ mockReportCompletion.mockResolvedValue(undefined)
+ for (const release of releases.splice(0)) release()
+ await flush()
+ await flush()
+ expect(mockReportCompletion).toHaveBeenCalledTimes(75)
+ })
+
+ it('coalesces duplicate guard delivery while its report is in flight', async () => {
+ let finishReport: () => void = () => {}
+ mockReportCompletion.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ finishReport = resolve
+ })
+ )
+ useBrowserSessionStore.setState({ activeScopeId: null })
+ const toolCallId = nextToolCallId()
+
+ for (let index = 0; index < 100; index += 1) {
+ executeBrowserToolOnClient(toolCallId, 'browser_click', { elementId: 1 }, null)
+ }
+ await flush()
+
+ expect(mockExecuteBrowserTool).not.toHaveBeenCalled()
+ expect(mockReportCompletion).toHaveBeenCalledOnce()
+ finishReport()
+ await flush()
+ })
+
+ it('recovers a legacy durable claim without repeating its action', async () => {
+ const toolCallId = nextToolCallId()
+ window.sessionStorage.setItem(`sim:copilot:browser-tool-executed:${toolCallId}`, '1')
+
+ executeBrowserToolOnClient(toolCallId, 'browser_click', { elementId: 1 })
+ await flush()
+
+ expect(mockExecuteBrowserTool).not.toHaveBeenCalled()
+ expect(mockReportCompletion).toHaveBeenCalledWith(
+ toolCallId,
+ 'error',
+ expect.stringContaining('terminal result could not be recovered'),
+ expect.objectContaining({ outcomeUnknown: true, doNotRetry: true })
+ )
+ })
+
+ it.each([
+ ['browser_snapshot' as const, {}],
+ ['browser_list_tabs' as const, {}],
+ ['browser_list_sessions' as const, {}],
+ ])(
+ 'keeps observation-only %s usable when the replay claim cannot be persisted',
+ async (toolName, params) => {
+ const storageWrite = vi.spyOn(window.sessionStorage, 'setItem').mockImplementation(() => {
+ throw new DOMException('Quota exceeded', 'QuotaExceededError')
+ })
+ mockExecuteBrowserTool.mockResolvedValue({ observed: true })
+ const toolCallId = nextToolCallId()
+
+ executeBrowserToolOnClient(toolCallId, toolName, params)
+ await flush()
+
+ expect(mockExecuteBrowserTool).toHaveBeenCalledWith(
+ toolCallId,
+ toolName,
+ params,
+ 90_000,
+ CHAT_SCOPE,
+ expect.any(Function)
+ )
+ expect(mockReportCompletion).toHaveBeenCalledWith(toolCallId, 'success', expect.any(String), {
+ observed: true,
+ })
+ storageWrite.mockRestore()
+ }
+ )
+
+ it.each([
+ ['browser_click' as const, { elementId: 1 }],
+ ['browser_navigate' as const, { url: 'https://example.com' }],
+ ['browser_open_tab' as const, { url: 'https://example.com' }],
+ ])(
+ 'fails closed before stateful %s executes when the replay claim cannot be persisted',
+ async (toolName, params) => {
+ const storageWrite = vi.spyOn(window.sessionStorage, 'setItem').mockImplementation(() => {
+ throw new DOMException('Quota exceeded', 'QuotaExceededError')
+ })
+ const toolCallId = nextToolCallId()
+
+ executeBrowserToolOnClient(toolCallId, toolName, params)
+ await flush()
+
+ expect(mockExecuteBrowserTool).not.toHaveBeenCalled()
+ expect(mockReportCompletion).toHaveBeenCalledWith(
+ toolCallId,
+ 'error',
+ expect.stringContaining('replay protection is unavailable'),
+ expect.objectContaining({ replayGuardStorageUnavailable: true })
+ )
+ storageWrite.mockRestore()
+ }
+ )
+
+ it('uses unload-safe delivery when a stateful replay-guard rejection cannot be reported normally', async () => {
+ const storageWrite = vi.spyOn(window.sessionStorage, 'setItem').mockImplementation(() => {
+ throw new DOMException('Quota exceeded', 'QuotaExceededError')
+ })
+ mockReportCompletion.mockRejectedValueOnce(new Error('confirmation unavailable'))
+ const toolCallId = nextToolCallId()
+
+ executeBrowserToolOnClient(toolCallId, 'browser_click', { elementId: 1 })
+ await flush()
+
+ expect(mockExecuteBrowserTool).not.toHaveBeenCalled()
+ expect(mockReportCompletion).toHaveBeenCalledWith(
+ toolCallId,
+ 'error',
+ expect.stringContaining('replay protection is unavailable'),
+ expect.objectContaining({ replayGuardStorageUnavailable: true })
+ )
+ expect(mockReportCompletionOnPageExit).toHaveBeenCalledWith(
+ toolCallId,
+ 'error',
+ expect.stringContaining('replay protection is unavailable'),
+ expect.objectContaining({ replayGuardStorageUnavailable: true })
+ )
+ storageWrite.mockRestore()
+ })
+
+ it('retries only terminal delivery after both replay-guard report paths fail', async () => {
+ const storageWrite = vi.spyOn(window.sessionStorage, 'setItem').mockImplementation(() => {
+ throw new DOMException('Quota exceeded', 'QuotaExceededError')
+ })
+ mockReportCompletion.mockRejectedValue(new Error('confirmation unavailable'))
+ mockReportCompletionOnPageExit
+ .mockRejectedValueOnce(new Error('keepalive unavailable'))
+ .mockResolvedValueOnce(undefined)
+ const toolCallId = nextToolCallId()
+
+ executeBrowserToolOnClient(toolCallId, 'browser_click', { elementId: 1 })
+ await flush()
+ executeBrowserToolOnClient(toolCallId, 'browser_click', { elementId: 1 })
+ await flush()
+
+ expect(mockExecuteBrowserTool).not.toHaveBeenCalled()
+ expect(mockReportCompletion).toHaveBeenCalledTimes(2)
+ expect(mockReportCompletionOnPageExit).toHaveBeenCalledTimes(2)
+
+ executeBrowserToolOnClient(toolCallId, 'browser_click', { elementId: 1 })
+ await flush()
+ expect(mockExecuteBrowserTool).not.toHaveBeenCalled()
+ expect(mockReportCompletion).toHaveBeenCalledTimes(2)
+ expect(mockReportCompletionOnPageExit).toHaveBeenCalledTimes(2)
+ storageWrite.mockRestore()
+ })
+
+ it('retries only terminal delivery after replay-guard capacity reporting fails', async () => {
+ const replayClaim = vi
+ .spyOn(BrowserToolReplayLedger.prototype, 'claim')
+ .mockReturnValueOnce('capacity-exhausted')
+ mockReportCompletion.mockRejectedValue(new Error('confirmation unavailable'))
+ mockReportCompletionOnPageExit
+ .mockRejectedValueOnce(new Error('keepalive unavailable'))
+ .mockResolvedValueOnce(undefined)
+ const toolCallId = nextToolCallId()
+
+ executeBrowserToolOnClient(toolCallId, 'browser_click', { elementId: 1 })
+ await flush()
+ executeBrowserToolOnClient(toolCallId, 'browser_click', { elementId: 1 })
+ await flush()
+ executeBrowserToolOnClient(toolCallId, 'browser_click', { elementId: 1 })
+ await flush()
+ const replayClaimCallCount = replayClaim.mock.calls.length
+ replayClaim.mockRestore()
+
+ expect(replayClaimCallCount).toBe(1)
+ expect(mockExecuteBrowserTool).not.toHaveBeenCalled()
+ expect(mockReportCompletion).toHaveBeenCalledTimes(2)
+ expect(mockReportCompletion).toHaveBeenLastCalledWith(
+ toolCallId,
+ 'error',
+ expect.stringContaining('replay guard is full'),
+ expect.objectContaining({ replayGuardCapacityExceeded: true })
+ )
+ expect(mockReportCompletionOnPageExit).toHaveBeenCalledTimes(2)
+ })
+
it('uses unload-safe delivery without reporting a successful action as failed', async () => {
mockExecuteBrowserTool.mockResolvedValue({ text: 'page content' })
mockReportCompletion.mockRejectedValue(new Error('confirmation unavailable'))
@@ -143,6 +683,81 @@ describe('executeBrowserToolOnClient', () => {
})
})
+ it('retries only a known terminal result after both delivery paths fail', async () => {
+ mockExecuteBrowserTool.mockResolvedValue({ text: 'page content' })
+ mockReportCompletion.mockRejectedValue(new Error('confirmation unavailable'))
+ mockReportCompletionOnPageExit
+ .mockRejectedValueOnce(new Error('keepalive unavailable'))
+ .mockResolvedValueOnce(undefined)
+ const toolCallId = nextToolCallId()
+
+ executeBrowserToolOnClient(toolCallId, 'browser_click', { elementId: 1 })
+ await flush()
+ executeBrowserToolOnClient(toolCallId, 'browser_click', { elementId: 1 })
+ await flush()
+ executeBrowserToolOnClient(toolCallId, 'browser_click', { elementId: 1 })
+ await flush()
+
+ expect(mockExecuteBrowserTool).toHaveBeenCalledOnce()
+ expect(mockReportCompletion).toHaveBeenCalledTimes(2)
+ expect(mockReportCompletion).toHaveBeenLastCalledWith(
+ toolCallId,
+ 'success',
+ 'Browser action completed',
+ { text: 'page content' }
+ )
+ expect(mockReportCompletionOnPageExit).toHaveBeenCalledTimes(2)
+ })
+
+ it('preserves an undelivered stateful result for a much later redelivery', async () => {
+ vi.useFakeTimers()
+ try {
+ const emittedAt = new Date('2026-01-01T00:00:00.000Z')
+ vi.setSystemTime(emittedAt)
+ mockExecuteBrowserTool.mockResolvedValue({ text: 'page content' })
+ mockReportCompletion.mockRejectedValueOnce(new Error('confirmation unavailable'))
+ mockReportCompletionOnPageExit.mockRejectedValueOnce(new Error('keepalive unavailable'))
+ const toolCallId = nextToolCallId()
+
+ executeBrowserToolOnClient(
+ toolCallId,
+ 'browser_click',
+ { elementId: 1 },
+ CHAT_SCOPE,
+ emittedAt.toISOString()
+ )
+ const firstFlush = flush()
+ await vi.advanceTimersByTimeAsync(0)
+ await firstFlush
+ expect(mockReportCompletionOnPageExit).toHaveBeenCalledOnce()
+ vi.setSystemTime(emittedAt.getTime() + 5 * 60_000 + 1)
+ mockReportCompletion.mockResolvedValueOnce(undefined)
+
+ executeBrowserToolOnClient(
+ toolCallId,
+ 'browser_click',
+ { elementId: 1 },
+ CHAT_SCOPE,
+ emittedAt.toISOString()
+ )
+ const secondFlush = flush()
+ await vi.advanceTimersByTimeAsync(0)
+ await secondFlush
+
+ expect(mockExecuteBrowserTool).toHaveBeenCalledOnce()
+ expect(mockReportCompletion).toHaveBeenCalledTimes(2)
+ expect(mockReportCompletion).toHaveBeenLastCalledWith(
+ toolCallId,
+ 'success',
+ 'Browser action completed',
+ { text: 'page content' }
+ )
+ expect(mockReportCompletionOnPageExit).toHaveBeenCalledOnce()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
it('preserves a takeover instruction and waits without a renderer deadline', async () => {
mockExecuteBrowserTool.mockResolvedValue({
completed: true,
@@ -425,7 +1040,13 @@ describe('executeBrowserToolOnClient', () => {
'uses keepalive fallback for known success when the page-exit beacon %s',
async (_label, send) => {
mockExecuteBrowserTool.mockResolvedValue({ text: 'page content' })
- mockReportCompletion.mockImplementation(() => new Promise(() => {}))
+ let finishReport: () => void = () => {}
+ mockReportCompletion.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ finishReport = resolve
+ })
+ )
Object.defineProperty(navigator, 'sendBeacon', {
configurable: true,
value: vi.fn(send),
@@ -444,6 +1065,8 @@ describe('executeBrowserToolOnClient', () => {
'Browser action completed',
{ text: 'page content' }
)
+ finishReport()
+ await flush()
}
)
@@ -582,19 +1205,36 @@ describe('executeBrowserToolOnClient', () => {
expect(reported.note).toContain('could not be encoded')
})
+ it('gives restored-tab switching the renderer navigation budget', async () => {
+ mockExecuteBrowserTool.mockResolvedValue({ tabId: '2', url: 'https://example.com' })
+ const toolCallId = nextToolCallId()
+
+ executeBrowserToolOnClient(toolCallId, 'browser_switch_tab', { tabId: '2' })
+ await flush()
+
+ expect(mockExecuteBrowserTool).toHaveBeenCalledWith(
+ toolCallId,
+ 'browser_switch_tab',
+ { tabId: '2' },
+ 130_000,
+ CHAT_SCOPE,
+ expect.any(Function)
+ )
+ })
+
/**
* Shared normalization coerces numeric strings and caps the requested wait
* at 120 seconds. The renderer adds delivery grace so it cannot abandon the
* native queue while the desktop is still honoring that same wait.
*/
it.each([
- ['number', 30_000, 45_000],
- ['numeric string', '30000', 45_000],
- ['absent', undefined, 25_000],
- ['non-numeric', 'soon', 25_000],
- ['zero', 0, 25_000],
- ['negative', -5_000, 25_000],
- ['above the desktop clamp', 500_000, 135_000],
+ ['number', 30_000, 105_000],
+ ['numeric string', '30000', 105_000],
+ ['absent', undefined, 85_000],
+ ['non-numeric', 'soon', 85_000],
+ ['zero', 0, 85_000],
+ ['negative', -5_000, 85_000],
+ ['above the desktop clamp', 500_000, 195_000],
])(
'budgets browser_wait_for above the desktop wait (%s)',
async (_label, timeoutMs, expected) => {
@@ -677,7 +1317,7 @@ describe('executeBrowserToolOnClient', () => {
toolCallId,
'browser_navigate',
{ url: 'https://example.com' },
- 45_000,
+ 130_000,
CHAT_SCOPE,
expect.any(Function)
)
@@ -700,7 +1340,7 @@ describe('executeBrowserToolOnClient', () => {
toolCallId,
'browser_list_sessions',
{},
- 30_000,
+ 90_000,
CHAT_SCOPE,
expect.any(Function)
)
@@ -739,15 +1379,23 @@ describe('executeBrowserToolOnClient', () => {
})
})
- it('preserves structured do-not-retry guidance for an outcome-unknown timeout', async () => {
+ it('keeps the renderer navigation margin and preserves outcome-unknown guidance', async () => {
mockExecuteBrowserTool.mockRejectedValue(
Object.assign(new Error('The browser outcome is unknown.'), { outcomeUnknown: true })
)
const toolCallId = nextToolCallId()
- executeBrowserToolOnClient(toolCallId, 'browser_click', { ref: 'e12' })
+ executeBrowserToolOnClient(toolCallId, 'browser_navigate', { url: 'https://example.com' })
await flush()
+ expect(mockExecuteBrowserTool).toHaveBeenCalledWith(
+ toolCallId,
+ 'browser_navigate',
+ { url: 'https://example.com' },
+ 130_000,
+ CHAT_SCOPE,
+ expect.any(Function)
+ )
expect(mockReportCompletion).toHaveBeenCalledWith(
toolCallId,
'error',
@@ -775,7 +1423,7 @@ describe('executeBrowserToolOnClient', () => {
toolCallId,
'browser_snapshot',
{},
- 30_000,
+ 90_000,
'chat-b',
expect.any(Function)
)
@@ -789,6 +1437,41 @@ describe('pre-dispatch drops still resolve the waiter', () => {
beforeEach(() => {
vi.clearAllMocks()
mockReportCompletion.mockResolvedValue(undefined)
+ mockReportCompletionOnPageExit.mockResolvedValue(undefined)
+ })
+
+ it.each([
+ [
+ 'stale event',
+ (toolCallId: string) =>
+ executeBrowserToolOnClient(
+ toolCallId,
+ 'browser_list_sessions',
+ {},
+ 'chat-scope-1',
+ new Date(Date.now() - 10 * 60_000).toISOString()
+ ),
+ ],
+ [
+ 'missing scope',
+ (toolCallId: string) =>
+ executeBrowserToolOnClient(toolCallId, 'browser_list_sessions', {}, null),
+ ],
+ ])('retains and retries the unload-safe terminal error for a %s', async (_label, execute) => {
+ mockReportCompletion.mockRejectedValue(new Error('confirmation unavailable'))
+ mockReportCompletionOnPageExit
+ .mockRejectedValueOnce(new Error('keepalive unavailable'))
+ .mockResolvedValueOnce(undefined)
+ const toolCallId = nextToolCallId()
+
+ execute(toolCallId)
+ await flush()
+ execute(toolCallId)
+ await flush()
+
+ expect(mockExecuteBrowserTool).not.toHaveBeenCalled()
+ expect(mockReportCompletion).toHaveBeenCalledTimes(2)
+ expect(mockReportCompletionOnPageExit).toHaveBeenCalledTimes(2)
})
it('reports an error confirmation for a stale event instead of hanging the turn', async () => {
@@ -805,6 +1488,30 @@ describe('pre-dispatch drops still resolve the waiter', () => {
)
})
+ it('marks a stale stateful event outcome unknown and unsafe to retry', async () => {
+ const staleTs = new Date(Date.now() - 10 * 60 * 1000).toISOString()
+ executeBrowserToolOnClient(
+ 'stale-stateful-call-1',
+ 'browser_click',
+ { elementId: 1 },
+ 'chat-scope-1',
+ staleTs
+ )
+ await sleep(0)
+
+ expect(mockExecuteBrowserTool).not.toHaveBeenCalled()
+ expect(mockReportCompletion).toHaveBeenCalledWith(
+ 'stale-stateful-call-1',
+ 'error',
+ expect.stringContaining('may already have taken effect'),
+ expect.objectContaining({
+ doNotRetry: true,
+ outcomeUnknown: true,
+ staleEvent: true,
+ })
+ )
+ })
+
it('reports an error confirmation when no chat scope exists', async () => {
useBrowserSessionStore.setState({ activeScopeId: null })
executeBrowserToolOnClient('no-scope-1', 'browser_list_sessions', {}, undefined)
diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts b/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts
index a2174c6df9e..2ae56e08bba 100644
--- a/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts
+++ b/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts
@@ -8,6 +8,8 @@
* server-side waiter.
*/
import {
+ BROWSER_NAVIGATION_RENDERER_TIMEOUT_MS,
+ BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS,
BROWSER_WAIT_FOR_RENDERER_GRACE_MS,
type BrowserToolName,
normalizeBrowserWaitForTimeoutMs,
@@ -27,6 +29,7 @@ import {
type AsyncConfirmationStatus,
} from '@/lib/copilot/async-runs/lifecycle'
import { COPILOT_CONFIRM_API_PATH } from '@/lib/copilot/constants'
+import { BrowserToolReplayLedger } from '@/lib/copilot/tools/client/browser-tool-replay-ledger'
import {
reportClientToolCompletion,
reportClientToolCompletionOnPageExit,
@@ -35,8 +38,7 @@ import { getBrowserSession, useBrowserSessionStore } from '@/stores/browser-sess
const logger = createLogger('CopilotBrowserToolExecution')
-const DEFAULT_TOOL_TIMEOUT_MS = 30_000
-const NAVIGATION_TOOL_TIMEOUT_MS = 45_000
+const DEFAULT_TOOL_TIMEOUT_MS = BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS + 30_000
/**
* Tools that do not require an existing live page. Most create a new page;
@@ -52,6 +54,39 @@ const LIVE_PAGE_OPTIONAL_TOOLS: ReadonlySet = new Set>
+
const SESSION_CLOSED_MESSAGE =
'The agent browser session is closed, so this browser tool cannot run. ' +
'Call browser_navigate or browser_open_tab to start a new session, or report the situation to the user. ' +
@@ -59,9 +94,23 @@ const SESSION_CLOSED_MESSAGE =
/** Tool events older than this are replays, not live instructions — never act on them. */
const MAX_EVENT_AGE_MS = 120_000
const EXECUTED_STORAGE_PREFIX = 'sim:copilot:browser-tool-executed:'
+const EXECUTED_LEDGER_STORAGE_KEY = 'sim:copilot:browser-tool-executed-ledger:v1'
+const EXECUTED_LEDGER_MAX_ENTRIES = 2_048
+const EXECUTED_LEDGER_TTL_MS = 5 * 60_000
const PAGE_EXIT_COMPLETION_MAX_BYTES = 48 * 1024
+const RETAINED_COMPLETION_MAX_BYTES = 8 * 1024
+const TERMINAL_COMPLETION_MAX_ACTIVE = 4
+const TERMINAL_COMPLETION_MAX_QUEUED = 64
const OUTCOME_UNKNOWN_MESSAGE =
'The Sim window closed while this browser action was in flight. It may already have taken effect. Do not retry it automatically; take a fresh browser snapshot before deciding what to do.'
+const REPLAY_OUTCOME_UNKNOWN_MESSAGE =
+ 'This browser action was recorded before the Sim page reloaded, but its terminal result could not be recovered. It may already have taken effect. Do not retry it automatically; take a fresh browser snapshot before deciding what to do.'
+const STALE_STATEFUL_OUTCOME_UNKNOWN_MESSAGE =
+ 'This browser action was delivered too late to recover its exact result. It may already have taken effect. Do not retry it automatically; take a fresh browser snapshot before deciding what to do.'
+const REPLAY_GUARD_CAPACITY_MESSAGE =
+ 'This browser action could not run safely because the recent-action replay guard is full. Wait a few minutes, then ask again.'
+const REPLAY_GUARD_STORAGE_MESSAGE =
+ 'This browser action could not run safely because reload-safe replay protection is unavailable. Check that browser storage is enabled, then ask again.'
interface PendingTerminalCompletion {
status: AsyncConfirmationStatus
@@ -69,6 +118,24 @@ interface PendingTerminalCompletion {
data?: AsyncCompletionData
}
+type TerminalCompletionPriority = 'executed' | 'guard'
+
+interface RetainedTerminalCompletion {
+ completion?: PendingTerminalCompletion
+ lastSeenAt: number
+ deliveryState:
+ | 'reserved'
+ | 'pending'
+ | 'queued'
+ | 'in-flight'
+ | 'awaiting-redelivery'
+ | 'delivered'
+ priority: TerminalCompletionPriority
+ failureLog: string
+ onPendingChange?: (completion: PendingTerminalCompletion | null) => void
+ onRelease?: () => void
+}
+
function compactCompletionForPageExit(
toolCallId: string,
completion: PendingTerminalCompletion
@@ -90,32 +157,372 @@ function compactCompletionForPageExit(
}
}
+function compactCompletionForRetry(
+ toolCallId: string,
+ completion: PendingTerminalCompletion
+): PendingTerminalCompletion {
+ const serialized = JSON.stringify({ toolCallId, ...completion })
+ if (new Blob([serialized]).size <= RETAINED_COMPLETION_MAX_BYTES) return completion
+
+ const data = isRecordLike(completion.data) ? completion.data : {}
+ return {
+ status: completion.status,
+ message: truncate(completion.message, 1024),
+ data: {
+ ...(data.outcomeUnknown === true ? { outcomeUnknown: true } : {}),
+ ...(data.doNotRetry === true ? { doNotRetry: true } : {}),
+ ...(data.sessionClosed === true ? { sessionClosed: true } : {}),
+ resultOmittedDuringRecovery: true,
+ note: 'The browser action reached a known terminal state, but its full result was too large to retain for delivery recovery. Do not repeat a side-effecting action. Take a fresh browser snapshot to recover current page state.',
+ },
+ }
+}
+
+async function deliverTerminalCompletion(
+ toolCallId: string,
+ completion: PendingTerminalCompletion,
+ failureLog: string,
+ onPendingChange?: (completion: PendingTerminalCompletion | null) => void
+): Promise {
+ onPendingChange?.(completion)
+ try {
+ await reportClientToolCompletion(
+ toolCallId,
+ completion.status,
+ completion.message,
+ completion.data
+ )
+ onPendingChange?.(null)
+ return true
+ } catch (error) {
+ logger.error(failureLog, {
+ toolCallId,
+ error: toError(error).message,
+ })
+ }
+
+ const compactCompletion = compactCompletionForPageExit(toolCallId, completion)
+ onPendingChange?.(compactCompletion)
+ try {
+ await reportClientToolCompletionOnPageExit(
+ toolCallId,
+ compactCompletion.status,
+ compactCompletion.message,
+ compactCompletion.data
+ )
+ onPendingChange?.(null)
+ return true
+ } catch (fallbackError) {
+ logger.error('Failed to enqueue browser completion with unload-safe fallback', {
+ toolCallId,
+ error: toError(fallbackError).message,
+ })
+ return false
+ }
+}
+
/**
- * Exactly-once guard. Stream recovery and tab reloads replay persisted tool
- * events; a browser action must never run twice (re-opening tabs, re-clicking
- * buttons). In-memory set for the fast path, sessionStorage so a reload of the
- * same tab cannot re-execute what it already did.
+ * Exactly-once guard for stream recovery and renderer reloads. The ledger is
+ * bounded while refusing to evict anything inside the full accepted-event
+ * window, so capacity pressure fails closed instead of making an action
+ * replayable.
*/
-const executedToolCallIds = new Set()
+const executedToolCalls = new BrowserToolReplayLedger({
+ storageKey: EXECUTED_LEDGER_STORAGE_KEY,
+ legacyStoragePrefix: EXECUTED_STORAGE_PREFIX,
+ maxEntries: EXECUTED_LEDGER_MAX_ENTRIES,
+ ttlMs: EXECUTED_LEDGER_TTL_MS,
+ protectedWindowMs: MAX_EVENT_AGE_MS,
+})
-function hasAlreadyExecuted(toolCallId: string): boolean {
- if (executedToolCallIds.has(toolCallId)) return true
- if (typeof window === 'undefined') return false
- try {
- return window.sessionStorage.getItem(`${EXECUTED_STORAGE_PREFIX}${toolCallId}`) !== null
- } catch {
+const retainedTerminalCompletions = new Map()
+/** Distinguishes a same-runtime redelivery from a durable claim recovered after reload. */
+const runningBrowserToolCalls = new Set()
+const terminalCompletionQueues: Record = {
+ executed: [],
+ guard: [],
+}
+let activeTerminalCompletionDeliveries = 0
+
+function removeFromTerminalCompletionQueues(toolCallId: string): void {
+ for (const priority of ['executed', 'guard'] as const) {
+ const index = terminalCompletionQueues[priority].indexOf(toolCallId)
+ if (index >= 0) terminalCompletionQueues[priority].splice(index, 1)
+ }
+}
+
+function releaseRetainedTerminalCompletion(entry: RetainedTerminalCompletion): void {
+ entry.completion = undefined
+ entry.onPendingChange?.(null)
+ entry.onPendingChange = undefined
+ entry.onRelease?.()
+ entry.onRelease = undefined
+}
+
+function deleteRetainedTerminalCompletion(
+ toolCallId: string,
+ entry: RetainedTerminalCompletion
+): void {
+ removeFromTerminalCompletionQueues(toolCallId)
+ releaseRetainedTerminalCompletion(entry)
+ retainedTerminalCompletions.delete(toolCallId)
+}
+
+function pruneRetainedTerminalCompletions(now: number): void {
+ for (const [toolCallId, entry] of retainedTerminalCompletions) {
+ if (entry.deliveryState === 'in-flight') continue
+ if (now - entry.lastSeenAt <= EXECUTED_LEDGER_TTL_MS) continue
+ if (entry.priority === 'executed' && entry.deliveryState !== 'delivered') continue
+ deleteRetainedTerminalCompletion(toolCallId, entry)
+ }
+}
+
+function retryRetainedTerminalCompletion(toolCallId: string): boolean {
+ const entry = retainedTerminalCompletions.get(toolCallId)
+ if (!entry) return false
+ const now = Date.now()
+ if (entry.deliveryState === 'in-flight') {
+ entry.lastSeenAt = now
+ retainedTerminalCompletions.delete(toolCallId)
+ retainedTerminalCompletions.set(toolCallId, entry)
+ return true
+ }
+ const preserveUndeliveredResult =
+ entry.priority === 'executed' && entry.deliveryState !== 'delivered'
+ if (now - entry.lastSeenAt > EXECUTED_LEDGER_TTL_MS && !preserveUndeliveredResult) {
+ deleteRetainedTerminalCompletion(toolCallId, entry)
return false
}
+ entry.lastSeenAt = now
+ retainedTerminalCompletions.delete(toolCallId)
+ retainedTerminalCompletions.set(toolCallId, entry)
+ if (entry.deliveryState === 'reserved') return true
+ if (entry.deliveryState === 'awaiting-redelivery') entry.deliveryState = 'pending'
+ scheduleTerminalCompletion(toolCallId)
+ return true
}
-function markExecuted(toolCallId: string): void {
- executedToolCallIds.add(toolCallId)
- if (typeof window === 'undefined') return
- try {
- window.sessionStorage.setItem(`${EXECUTED_STORAGE_PREFIX}${toolCallId}`, '1')
- } catch {
- // Best-effort; the in-memory set still covers this tab's lifetime.
+function terminalCompletionQueueSize(): number {
+ return terminalCompletionQueues.executed.length + terminalCompletionQueues.guard.length
+}
+
+function enqueueTerminalCompletion(toolCallId: string, entry: RetainedTerminalCompletion): boolean {
+ if (entry.deliveryState !== 'pending') return false
+ if (terminalCompletionQueueSize() >= TERMINAL_COMPLETION_MAX_QUEUED) {
+ if (entry.priority !== 'executed') return false
+ const displacedToolCallId = terminalCompletionQueues.guard.shift()
+ if (!displacedToolCallId) return false
+ const displacedEntry = retainedTerminalCompletions.get(displacedToolCallId)
+ if (displacedEntry?.deliveryState === 'queued') displacedEntry.deliveryState = 'pending'
+ }
+ terminalCompletionQueues[entry.priority].push(toolCallId)
+ entry.deliveryState = 'queued'
+ return true
+}
+
+function refillTerminalCompletionQueues(): void {
+ for (const priority of ['executed', 'guard'] as const) {
+ for (const [toolCallId, entry] of retainedTerminalCompletions) {
+ if (entry.priority === priority && entry.deliveryState === 'pending') {
+ const enqueued = enqueueTerminalCompletion(toolCallId, entry)
+ if (!enqueued && priority === 'guard') return
+ }
+ }
+ }
+}
+
+function startTerminalCompletionDelivery(
+ toolCallId: string,
+ entry: RetainedTerminalCompletion,
+ completion: PendingTerminalCompletion
+): void {
+ entry.deliveryState = 'in-flight'
+ activeTerminalCompletionDeliveries += 1
+ entry.onPendingChange?.(completion)
+ void deliverTerminalCompletion(toolCallId, completion, entry.failureLog, (pending) =>
+ entry.onPendingChange?.(pending)
+ )
+ .then((delivered) => {
+ if (retainedTerminalCompletions.get(toolCallId) === entry) {
+ if (delivered) {
+ entry.deliveryState = 'delivered'
+ releaseRetainedTerminalCompletion(entry)
+ } else {
+ entry.deliveryState = 'awaiting-redelivery'
+ entry.onPendingChange?.(entry.completion ?? null)
+ }
+ }
+ })
+ .catch((error) => {
+ logger.error('Unexpected browser terminal-completion delivery failure', {
+ toolCallId,
+ error: toError(error).message,
+ })
+ if (retainedTerminalCompletions.get(toolCallId) === entry) {
+ entry.deliveryState = 'awaiting-redelivery'
+ entry.onPendingChange?.(entry.completion ?? null)
+ }
+ })
+ .finally(() => {
+ activeTerminalCompletionDeliveries -= 1
+ drainTerminalCompletionQueue()
+ })
+}
+
+function drainTerminalCompletionQueue(): void {
+ refillTerminalCompletionQueues()
+ while (activeTerminalCompletionDeliveries < TERMINAL_COMPLETION_MAX_ACTIVE) {
+ const toolCallId =
+ terminalCompletionQueues.executed.shift() ?? terminalCompletionQueues.guard.shift()
+ if (!toolCallId) return
+ const entry = retainedTerminalCompletions.get(toolCallId)
+ if (!entry || entry.deliveryState !== 'queued' || !entry.completion) continue
+ startTerminalCompletionDelivery(toolCallId, entry, entry.completion)
+ }
+}
+
+function hasPendingExecutedTerminalCompletion(): boolean {
+ if (terminalCompletionQueues.executed.length > 0) return true
+ for (const entry of retainedTerminalCompletions.values()) {
+ if (entry.priority === 'executed' && entry.deliveryState === 'pending') return true
+ }
+ return false
+}
+
+function scheduleTerminalCompletion(
+ toolCallId: string,
+ initialCompletion?: PendingTerminalCompletion
+): void {
+ const entry = retainedTerminalCompletions.get(toolCallId)
+ if (!entry || entry.deliveryState !== 'pending' || !entry.completion) return
+
+ const hasExecutedAhead = hasPendingExecutedTerminalCompletion()
+ if (
+ activeTerminalCompletionDeliveries < TERMINAL_COMPLETION_MAX_ACTIVE &&
+ (entry.priority === 'executed' || !hasExecutedAhead)
+ ) {
+ startTerminalCompletionDelivery(toolCallId, entry, initialCompletion ?? entry.completion)
+ return
+ }
+
+ enqueueTerminalCompletion(toolCallId, entry)
+ drainTerminalCompletionQueue()
+}
+
+/** Never sacrifices an undelivered result from a browser action to admit overflow work. */
+function selectRetainedCompletionForEviction(): [string, RetainedTerminalCompletion] | undefined {
+ for (const candidate of retainedTerminalCompletions) {
+ if (candidate[1].deliveryState === 'delivered') return candidate
+ }
+ for (const candidate of retainedTerminalCompletions) {
+ if (candidate[1].priority === 'guard' && candidate[1].deliveryState !== 'in-flight') {
+ return candidate
+ }
+ }
+ return undefined
+}
+
+function reserveTerminalCompletion(
+ toolCallId: string,
+ priority: TerminalCompletionPriority,
+ failureLog: string,
+ onPendingChange?: (completion: PendingTerminalCompletion | null) => void,
+ onRelease?: () => void
+): RetainedTerminalCompletion | null {
+ const now = Date.now()
+ pruneRetainedTerminalCompletions(now)
+ if (retainedTerminalCompletions.has(toolCallId)) return null
+ if (retainedTerminalCompletions.size >= EXECUTED_LEDGER_MAX_ENTRIES) {
+ const candidate = selectRetainedCompletionForEviction()
+ if (!candidate) {
+ logger.error('Browser terminal-completion retention is full', {
+ toolCallId,
+ priority,
+ })
+ onPendingChange?.(null)
+ onRelease?.()
+ return null
+ }
+ deleteRetainedTerminalCompletion(candidate[0], candidate[1])
+ }
+ const entry: RetainedTerminalCompletion = {
+ lastSeenAt: now,
+ deliveryState: 'reserved',
+ priority,
+ failureLog,
+ onPendingChange,
+ onRelease,
+ }
+ retainedTerminalCompletions.set(toolCallId, entry)
+ return entry
+}
+
+function completeTerminalCompletionReservation(
+ toolCallId: string,
+ entry: RetainedTerminalCompletion,
+ completion: PendingTerminalCompletion,
+ priority: TerminalCompletionPriority,
+ failureLog: string
+): RetainedTerminalCompletion | null {
+ if (retainedTerminalCompletions.get(toolCallId) !== entry || entry.deliveryState !== 'reserved') {
+ return null
}
+ entry.completion = compactCompletionForRetry(toolCallId, completion)
+ entry.lastSeenAt = Date.now()
+ entry.deliveryState = 'pending'
+ entry.priority = priority
+ entry.failureLog = failureLog
+ retainedTerminalCompletions.delete(toolCallId)
+ retainedTerminalCompletions.set(toolCallId, entry)
+ entry.onPendingChange?.(entry.completion ?? null)
+ return entry
+}
+
+function retainTerminalCompletion(
+ toolCallId: string,
+ completion: PendingTerminalCompletion,
+ priority: TerminalCompletionPriority,
+ failureLog: string,
+ onPendingChange?: (completion: PendingTerminalCompletion | null) => void,
+ onRelease?: () => void
+): RetainedTerminalCompletion | null {
+ const entry = reserveTerminalCompletion(
+ toolCallId,
+ priority,
+ failureLog,
+ onPendingChange,
+ onRelease
+ )
+ if (!entry) return null
+ return completeTerminalCompletionReservation(toolCallId, entry, completion, priority, failureLog)
+}
+
+function completeAndReportTerminalCompletionReservation(
+ toolCallId: string,
+ entry: RetainedTerminalCompletion,
+ completion: PendingTerminalCompletion,
+ priority: TerminalCompletionPriority,
+ failureLog: string
+): void {
+ const retained = completeTerminalCompletionReservation(
+ toolCallId,
+ entry,
+ completion,
+ priority,
+ failureLog
+ )
+ if (!retained) return
+ scheduleTerminalCompletion(toolCallId, completion)
+}
+
+function retainAndReportTerminalCompletion(
+ toolCallId: string,
+ completion: PendingTerminalCompletion,
+ failureLog: string
+): void {
+ const retained = retainTerminalCompletion(toolCallId, completion, 'guard', failureLog)
+ if (!retained) return
+ scheduleTerminalCompletion(toolCallId, completion)
}
/** Milliseconds since the event was emitted, or null when unparsable. */
@@ -141,13 +548,14 @@ function timeoutForTool(toolName: BrowserToolName, params: Record {
- logger.error('Failed to report missing-scope browser tool error', {
- toolCallId,
- error: toError(reportErr).message,
- })
- })
- return
- }
- if (hasAlreadyExecuted(toolCallId)) {
- // Same-page re-delivery: the original dispatch is in flight (or done) and
- // owns the result. Reporting here would race it — the server claims each
- // resume exactly once, so an error now would discard the genuine result.
- logger.info('Skipping already-executed browser tool (replay)', { toolCallId, toolName })
+ retainAndReportTerminalCompletion(
+ toolCallId,
+ {
+ status: ASYNC_TOOL_CONFIRMATION_STATUS.error,
+ message,
+ data: { error: message },
+ },
+ 'Failed to report missing-scope browser tool error'
+ )
return
}
const age = eventAgeMs(eventTs)
if (age !== null && age > MAX_EVENT_AGE_MS) {
logger.info('Skipping stale browser tool event', { toolCallId, toolName, age })
- // Usually a replay of an action that already ran and resumed in a previous
- // page lifetime — the server claims each resume exactly once, so this
- // duplicate confirmation is simply discarded. When it is NOT a replay
- // (the event was delivered late, e.g. a backgrounded tab with throttled
- // timers), this error unblocks the turn instead of leaving it hanging
- // forever on a tool that will never execute.
- const message =
- 'This browser action was delivered too late to run safely. Ask again to retry it.'
- void reportClientToolCompletion(toolCallId, ASYNC_TOOL_CONFIRMATION_STATUS.error, message, {
- error: message,
- staleEvent: true,
- }).catch((reportErr) => {
- logger.error('Failed to report stale browser tool error', {
- toolCallId,
- error: toError(reportErr).message,
- })
+ const observationOnly = OBSERVATION_ONLY_BROWSER_TOOLS[toolName]
+ const message = observationOnly
+ ? 'This browser observation was delivered too late to run safely. Ask again to retry it.'
+ : STALE_STATEFUL_OUTCOME_UNKNOWN_MESSAGE
+ retainAndReportTerminalCompletion(
+ toolCallId,
+ {
+ status: ASYNC_TOOL_CONFIRMATION_STATUS.error,
+ message,
+ data: {
+ error: message,
+ staleEvent: true,
+ ...(!observationOnly ? { outcomeUnknown: true, doNotRetry: true } : {}),
+ },
+ },
+ 'Failed to report stale browser tool error'
+ )
+ return
+ }
+ const completionReservation = reserveTerminalCompletion(
+ toolCallId,
+ 'executed',
+ 'Failed to report browser tool completion'
+ )
+ if (!completionReservation) {
+ logger.error('Rejecting browser tool before dispatch because result retention is full', {
+ toolCallId,
+ toolName,
})
return
}
- markExecuted(toolCallId)
- void doExecuteBrowserTool(toolCallId, toolName, params, scopeId, abortSignal).catch((err) => {
- logger.error('Unhandled error in client-side browser tool execution', {
+ const reportGuardCompletion = (
+ completion: PendingTerminalCompletion,
+ failureLog: string
+ ): void => {
+ completeAndReportTerminalCompletionReservation(
+ toolCallId,
+ completionReservation,
+ completion,
+ 'guard',
+ failureLog
+ )
+ }
+ const replayClaim = executedToolCalls.claim(toolCallId)
+ if (replayClaim === 'duplicate') {
+ if (runningBrowserToolCalls.has(toolCallId)) {
+ deleteRetainedTerminalCompletion(toolCallId, completionReservation)
+ logger.info('Skipping in-flight browser tool replay', { toolCallId, toolName })
+ return
+ }
+ logger.warn('Recovering browser tool replay without a local result owner', {
+ toolCallId,
+ toolName,
+ })
+ reportGuardCompletion(
+ {
+ status: ASYNC_TOOL_CONFIRMATION_STATUS.error,
+ message: REPLAY_OUTCOME_UNKNOWN_MESSAGE,
+ data: {
+ error: REPLAY_OUTCOME_UNKNOWN_MESSAGE,
+ outcomeUnknown: true,
+ doNotRetry: true,
+ replayRecoveredWithoutResult: true,
+ },
+ },
+ 'Failed to report browser replay with an unknown outcome'
+ )
+ return
+ }
+ if (replayClaim === 'capacity-exhausted') {
+ logger.error('Rejecting browser tool because the replay guard is full', {
toolCallId,
toolName,
- error: toError(err).message,
})
- })
+ reportGuardCompletion(
+ {
+ status: ASYNC_TOOL_CONFIRMATION_STATUS.error,
+ message: REPLAY_GUARD_CAPACITY_MESSAGE,
+ data: {
+ error: REPLAY_GUARD_CAPACITY_MESSAGE,
+ replayGuardCapacityExceeded: true,
+ },
+ },
+ 'Failed to report replay-guard capacity error'
+ )
+ return
+ }
+ if (replayClaim === 'storage-unavailable') {
+ if (OBSERVATION_ONLY_BROWSER_TOOLS[toolName]) {
+ logger.warn('Executing observation-only browser tool without durable replay protection', {
+ toolCallId,
+ toolName,
+ })
+ } else {
+ logger.error('Rejecting browser tool because durable replay protection is unavailable', {
+ toolCallId,
+ toolName,
+ })
+ reportGuardCompletion(
+ {
+ status: ASYNC_TOOL_CONFIRMATION_STATUS.error,
+ message: REPLAY_GUARD_STORAGE_MESSAGE,
+ data: {
+ error: REPLAY_GUARD_STORAGE_MESSAGE,
+ replayGuardStorageUnavailable: true,
+ },
+ },
+ 'Failed to report replay-guard storage error'
+ )
+ return
+ }
+ }
+ runningBrowserToolCalls.add(toolCallId)
+ void doExecuteBrowserTool(
+ toolCallId,
+ toolName,
+ params,
+ scopeId,
+ completionReservation,
+ abortSignal
+ )
+ .catch((err) => {
+ logger.error('Unhandled error in client-side browser tool execution', {
+ toolCallId,
+ toolName,
+ error: toError(err).message,
+ })
+ })
+ .finally(() => {
+ runningBrowserToolCalls.delete(toolCallId)
+ })
}
/** True when the desktop app has reported the agent browser session closed. */
@@ -282,47 +795,28 @@ async function doExecuteBrowserTool(
toolName: BrowserToolName,
params: Record,
scopeId: string,
+ completionReservation: RetainedTerminalCompletion,
abortSignal?: AbortSignal
): Promise {
let cancelled = abortSignal?.aborted === true
let nativeActionPending = true
let nativeDispatchStarted = false
let pendingTerminalCompletion: PendingTerminalCompletion | null = null
- const reportTerminalCompletion = async (
+ const reportTerminalCompletion = (
completion: PendingTerminalCompletion,
- failureLog: string
- ): Promise => {
- pendingTerminalCompletion = completion
- try {
- await reportClientToolCompletion(
- toolCallId,
- completion.status,
- completion.message,
- completion.data
- )
- pendingTerminalCompletion = null
- } catch (error) {
- logger.error(failureLog, {
- toolCallId,
- error: toError(error).message,
- })
- const compactCompletion = compactCompletionForPageExit(toolCallId, completion)
- pendingTerminalCompletion = compactCompletion
- try {
- await reportClientToolCompletionOnPageExit(
- toolCallId,
- compactCompletion.status,
- compactCompletion.message,
- compactCompletion.data
- )
- pendingTerminalCompletion = null
- } catch (fallbackError) {
- logger.error('Failed to enqueue browser completion with unload-safe fallback', {
- toolCallId,
- error: toError(fallbackError).message,
- })
- }
- }
+ failureLog: string,
+ priority: TerminalCompletionPriority = 'executed'
+ ): void => {
+ const retainedCompletion = completeTerminalCompletionReservation(
+ toolCallId,
+ completionReservation,
+ completion,
+ priority,
+ failureLog
+ )
+ if (!retainedCompletion) return
+ pendingTerminalCompletion = retainedCompletion.completion ?? null
+ scheduleTerminalCompletion(toolCallId, completion)
}
const cancelNativeTool = async () => {
cancelled = true
@@ -412,6 +906,15 @@ async function doExecuteBrowserTool(
reportFallback()
}
}
+ completionReservation.onPendingChange = (pending) => {
+ pendingTerminalCompletion = pending
+ }
+ completionReservation.onRelease = () => {
+ pendingTerminalCompletion = null
+ if (typeof window !== 'undefined') {
+ window.removeEventListener('pagehide', onPageHide)
+ }
+ }
if (cancelled) {
void cancelNativeTool()
} else {
@@ -441,13 +944,14 @@ async function doExecuteBrowserTool(
toolName,
})
if (cancelled) return
- await reportTerminalCompletion(
+ reportTerminalCompletion(
{
status: ASYNC_TOOL_CONFIRMATION_STATUS.error,
message: SESSION_CLOSED_MESSAGE,
data: { error: SESSION_CLOSED_MESSAGE, sessionClosed: true },
},
- 'Failed to report browser session-closed error'
+ 'Failed to report browser session-closed error',
+ 'guard'
)
return
}
@@ -478,7 +982,7 @@ async function doExecuteBrowserTool(
? `${toError(err).message} ${SESSION_CLOSED_MESSAGE}`
: toError(err).message
logger.warn('Browser tool failed', { toolCallId, toolName, error: message, sessionClosed })
- await reportTerminalCompletion(
+ reportTerminalCompletion(
{
status: ASYNC_TOOL_CONFIRMATION_STATUS.error,
message,
@@ -494,7 +998,7 @@ async function doExecuteBrowserTool(
}
nativeActionPending = false
if (cancelled) return
- await reportTerminalCompletion(
+ reportTerminalCompletion(
{
status: ASYNC_TOOL_CONFIRMATION_STATUS.success,
message: 'Browser action completed',
@@ -504,6 +1008,12 @@ async function doExecuteBrowserTool(
)
} finally {
abortSignal?.removeEventListener('abort', onAbort)
+ if (
+ retainedTerminalCompletions.get(toolCallId) === completionReservation &&
+ completionReservation.deliveryState === 'reserved'
+ ) {
+ deleteRetainedTerminalCompletion(toolCallId, completionReservation)
+ }
if (typeof window !== 'undefined' && !pendingTerminalCompletion) {
window.removeEventListener('pagehide', onPageHide)
}
diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-replay-ledger.test.ts b/apps/sim/lib/copilot/tools/client/browser-tool-replay-ledger.test.ts
new file mode 100644
index 00000000000..5c5bbaa12a7
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/client/browser-tool-replay-ledger.test.ts
@@ -0,0 +1,417 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { beforeEach, describe, expect, it } from 'vitest'
+import { BrowserToolReplayLedger } from '@/lib/copilot/tools/client/browser-tool-replay-ledger'
+
+const STORAGE_KEY = 'test:browser-tool-ledger:v1'
+const LEGACY_PREFIX = 'test:browser-tool-executed:'
+const PROTECTED_WINDOW_MS = 120_000
+const TTL_MS = 300_000
+
+interface CreateLedgerOptions {
+ maxEntries?: number
+ now: () => number
+ storage?: Storage
+}
+
+function createLedger({
+ maxEntries = 3,
+ now,
+ storage = window.sessionStorage,
+}: CreateLedgerOptions) {
+ return new BrowserToolReplayLedger({
+ storageKey: STORAGE_KEY,
+ legacyStoragePrefix: LEGACY_PREFIX,
+ maxEntries,
+ ttlMs: TTL_MS,
+ protectedWindowMs: PROTECTED_WINDOW_MS,
+ now,
+ getStorage: () => storage,
+ })
+}
+
+function persistedEntries(): Array<{ toolCallId: string; lastSeenAt: number }> {
+ const serialized = window.sessionStorage.getItem(STORAGE_KEY)
+ if (!serialized) return []
+ return JSON.parse(serialized).entries
+}
+
+class WriteFailingStorage implements Storage {
+ private readonly values = new Map()
+ keyReads = 0
+
+ get length(): number {
+ return this.values.size
+ }
+
+ clear(): void {
+ this.values.clear()
+ }
+
+ getItem(key: string): string | null {
+ return this.values.get(key) ?? null
+ }
+
+ key(index: number): string | null {
+ this.keyReads += 1
+ return Array.from(this.values.keys())[index] ?? null
+ }
+
+ removeItem(key: string): void {
+ this.values.delete(key)
+ }
+
+ setItem(key: string, value: string): void {
+ if (key === STORAGE_KEY) throw new DOMException('Quota exceeded', 'QuotaExceededError')
+ this.values.set(key, value)
+ }
+}
+
+class ReadFailingStorage implements Storage {
+ readonly writes: Array<{ key: string; value: string }> = []
+ private readonly failure: 'ledger' | 'legacy-iteration'
+
+ constructor(failure: 'ledger' | 'legacy-iteration') {
+ this.failure = failure
+ }
+
+ get length(): number {
+ if (this.failure === 'legacy-iteration') throw new DOMException('Read blocked', 'SecurityError')
+ return 0
+ }
+
+ clear(): void {}
+
+ getItem(): string | null {
+ if (this.failure === 'ledger') throw new DOMException('Read blocked', 'SecurityError')
+ return null
+ }
+
+ key(): string | null {
+ return null
+ }
+
+ removeItem(): void {}
+
+ setItem(key: string, value: string): void {
+ this.writes.push({ key, value })
+ }
+}
+
+class RecordingStorage implements Storage {
+ private readonly values = new Map()
+ ledgerWrites = 0
+
+ get length(): number {
+ return this.values.size
+ }
+
+ clear(): void {
+ this.values.clear()
+ }
+
+ getItem(key: string): string | null {
+ return this.values.get(key) ?? null
+ }
+
+ key(index: number): string | null {
+ return Array.from(this.values.keys())[index] ?? null
+ }
+
+ removeItem(key: string): void {
+ this.values.delete(key)
+ }
+
+ setItem(key: string, value: string): void {
+ if (key === STORAGE_KEY) this.ledgerWrites += 1
+ this.values.set(key, value)
+ }
+}
+
+describe('BrowserToolReplayLedger', () => {
+ beforeEach(() => {
+ window.sessionStorage.clear()
+ })
+
+ it('never evicts an entry inside the accepted event window', () => {
+ let now = 1_000
+ const ledger = createLedger({ maxEntries: 2, now: () => now })
+
+ expect(ledger.claim('call-a')).toBe('claimed')
+ now += 1
+ expect(ledger.claim('call-b')).toBe('claimed')
+ now = 1_000 + PROTECTED_WINDOW_MS
+
+ expect(ledger.claim('call-c')).toBe('capacity-exhausted')
+ expect(ledger.claim('call-a')).toBe('duplicate')
+ expect(ledger.claim('call-b')).toBe('duplicate')
+ expect(persistedEntries()).toHaveLength(2)
+ })
+
+ it('evicts the least recently seen eligible entry after the protected window', () => {
+ let now = 1_000
+ const ledger = createLedger({ maxEntries: 2, now: () => now })
+
+ expect(ledger.claim('call-a')).toBe('claimed')
+ now += 1
+ expect(ledger.claim('call-b')).toBe('claimed')
+ now += PROTECTED_WINDOW_MS
+ expect(ledger.claim('call-a')).toBe('duplicate')
+ now += PROTECTED_WINDOW_MS
+
+ expect(ledger.claim('call-c')).toBe('claimed')
+ expect(persistedEntries().map(({ toolCallId }) => toolCallId)).toEqual(['call-a', 'call-c'])
+ expect(ledger.claim('call-a')).toBe('duplicate')
+ expect(persistedEntries().map(({ toolCallId }) => toolCallId)).not.toContain('call-b')
+ })
+
+ it('keeps persistent storage bounded under sustained use', () => {
+ let now = 1_000
+ const maxEntries = 32
+ const ledger = createLedger({ maxEntries, now: () => now })
+
+ for (let index = 0; index < 10_000; index += 1) {
+ if (index > 0 && index % maxEntries === 0) now += PROTECTED_WINDOW_MS + 1
+ expect(ledger.claim(`call-${index}`)).toBe('claimed')
+ }
+
+ const entries = persistedEntries()
+ expect(entries).toHaveLength(maxEntries)
+ expect(entries.at(-1)?.toolCallId).toBe('call-9999')
+ })
+
+ it('rejects an oversized serialized payload before writing it to storage', () => {
+ const storage = new RecordingStorage()
+ const ledger = createLedger({ maxEntries: 4, now: () => 1_000, storage })
+ const escapedId = (prefix: string) => `${prefix}${'\0'.repeat(255)}`
+
+ expect(ledger.claim(escapedId('\u0001'))).toBe('claimed')
+ expect(ledger.claim(escapedId('\u0002'))).toBe('claimed')
+ expect(storage.ledgerWrites).toBe(2)
+
+ expect(ledger.claim(escapedId('\u0003'))).toBe('storage-unavailable')
+ expect(storage.ledgerWrites).toBe(2)
+ })
+
+ it('expires entries only after the configured TTL boundary', () => {
+ let now = 1_000
+ const ledger = createLedger({ now: () => now })
+
+ expect(ledger.claim('call-a')).toBe('claimed')
+ now += TTL_MS + 1
+ expect(ledger.claim('call-a')).toBe('claimed')
+ })
+
+ it('transactionally migrates legacy keys and survives a fresh ledger instance', () => {
+ let now = 1_000
+ window.sessionStorage.setItem(`${LEGACY_PREFIX}legacy-call`, '1')
+ const firstLedger = createLedger({ now: () => now })
+
+ expect(firstLedger.claim('legacy-call')).toBe('duplicate')
+ expect(window.sessionStorage.getItem(`${LEGACY_PREFIX}legacy-call`)).toBeNull()
+ expect(persistedEntries()).toEqual([{ toolCallId: 'legacy-call', lastSeenAt: now }])
+
+ now += 1
+ const reloadedLedger = createLedger({ now: () => now })
+ expect(reloadedLedger.claim('legacy-call')).toBe('duplicate')
+ })
+
+ it('retains overflow legacy protection until more than the replay TTL has elapsed', () => {
+ let now = 1_000
+ window.sessionStorage.setItem(`${LEGACY_PREFIX}legacy-a`, '1')
+ window.sessionStorage.setItem(`${LEGACY_PREFIX}legacy-b`, '1')
+ window.sessionStorage.setItem(`${LEGACY_PREFIX}legacy-c`, '1')
+ const ledger = createLedger({ maxEntries: 2, now: () => now })
+
+ expect(ledger.claim('legacy-a')).toBe('duplicate')
+ expect(ledger.claim('legacy-b')).toBe('duplicate')
+ expect(ledger.claim('new-call')).toBe('claimed')
+ expect(window.sessionStorage.getItem(`${LEGACY_PREFIX}legacy-c`)).toBe('1')
+
+ now += TTL_MS
+ expect(ledger.claim('legacy-c')).toBe('duplicate')
+ now += 1
+ expect(ledger.claim('after-cleanup')).toBe('claimed')
+ expect(window.sessionStorage.getItem(`${LEGACY_PREFIX}legacy-c`)).toBeNull()
+ })
+
+ it('fails closed while keeping legacy protection and in-memory dedup when writes fail', () => {
+ let now = 1_000
+ const storage = new WriteFailingStorage()
+ storage.setItem(`${LEGACY_PREFIX}legacy-call`, '1')
+ const ledger = createLedger({ now: () => now, storage })
+
+ expect(ledger.claim('legacy-call')).toBe('duplicate')
+ expect(storage.getItem(`${LEGACY_PREFIX}legacy-call`)).toBe('1')
+ expect(ledger.claim('new-call')).toBe('storage-unavailable')
+ now += 1
+ expect(ledger.claim('new-call')).toBe('duplicate')
+ })
+
+ it('fails closed and suppresses same-lifetime redelivery when storage is unavailable', () => {
+ let now = 1_000
+ const ledger = new BrowserToolReplayLedger({
+ storageKey: STORAGE_KEY,
+ legacyStoragePrefix: LEGACY_PREFIX,
+ maxEntries: 2,
+ ttlMs: TTL_MS,
+ protectedWindowMs: PROTECTED_WINDOW_MS,
+ now: () => now,
+ getStorage: () => {
+ throw new DOMException('Blocked', 'SecurityError')
+ },
+ })
+
+ expect(ledger.claim('call-a')).toBe('storage-unavailable')
+ now += 1
+ expect(ledger.claim('call-a')).toBe('duplicate')
+ })
+
+ it.each(['ledger', 'legacy-iteration'] as const)(
+ 'fails closed when %s reads fail even though writes would succeed',
+ (failure) => {
+ let now = 1_000
+ const storage = new ReadFailingStorage(failure)
+ const ledger = createLedger({ now: () => now, storage })
+
+ expect(ledger.claim('call-a')).toBe('storage-unavailable')
+ expect(storage.writes).toEqual([])
+ now += 1
+ expect(ledger.claim('call-a')).toBe('duplicate')
+ }
+ )
+
+ it('rejects an oversized serialized ledger before parsing or materializing entries', () => {
+ const serialized = JSON.stringify({
+ version: 1,
+ entries: [{ toolCallId: 'persisted-call', lastSeenAt: 1_000 }],
+ padding: 'x'.repeat(5_000),
+ })
+ window.sessionStorage.setItem(STORAGE_KEY, serialized)
+ const ledger = createLedger({ now: () => 1_000 })
+
+ expect(ledger.claim('new-call')).toBe('storage-unavailable')
+ expect(ledger.claim('new-call')).toBe('duplicate')
+ expect(window.sessionStorage.getItem(STORAGE_KEY)).toBe(serialized)
+ })
+
+ it('rejects a persisted ledger with 2049 entries before hydrating its map', () => {
+ window.sessionStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({
+ version: 1,
+ entries: Array.from({ length: 2_049 }, (_, index) => ({
+ toolCallId: `call-${index}`,
+ lastSeenAt: 1_000,
+ })),
+ })
+ )
+ const ledger = createLedger({ maxEntries: 2_048, now: () => 1_000 })
+
+ expect(ledger.claim('new-call')).toBe('storage-unavailable')
+ expect(ledger.claim('new-call')).toBe('duplicate')
+ })
+
+ it.each([
+ ['empty', ''],
+ ['257-character', 'x'.repeat(257)],
+ ])('rejects a persisted ledger containing a %s tool-call id', (_label, toolCallId) => {
+ window.sessionStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({ version: 1, entries: [{ toolCallId, lastSeenAt: 1_000 }] })
+ )
+ const ledger = createLedger({ now: () => 1_000 })
+
+ expect(ledger.claim('new-call')).toBe('storage-unavailable')
+ expect(ledger.claim('new-call')).toBe('duplicate')
+ })
+
+ it('rejects persisted entries with non-finite timestamps', () => {
+ window.sessionStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({ version: 1, entries: [{ toolCallId: 'call-a', lastSeenAt: null }] })
+ )
+ const ledger = createLedger({ now: () => 1_000 })
+
+ expect(ledger.claim('new-call')).toBe('storage-unavailable')
+ expect(ledger.claim('new-call')).toBe('duplicate')
+ })
+
+ it('clamps a persisted future timestamp after the system clock moves backward', () => {
+ let now = 1_000
+ window.sessionStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({
+ version: 1,
+ entries: [{ toolCallId: 'future-call', lastSeenAt: 1_000_000_000 }],
+ })
+ )
+ const ledger = createLedger({ now: () => now })
+
+ expect(ledger.claim('new-call')).toBe('claimed')
+ expect(persistedEntries()).toContainEqual({ toolCallId: 'future-call', lastSeenAt: now })
+
+ now += TTL_MS + 1
+ expect(ledger.claim('future-call')).toBe('claimed')
+ })
+
+ it('bounds a future legacy-cleanup deadline to one TTL after hydration', () => {
+ let now = 1_000
+ window.sessionStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({
+ version: 1,
+ entries: [
+ { toolCallId: 'call-a', lastSeenAt: now },
+ { toolCallId: 'call-b', lastSeenAt: now },
+ ],
+ legacyCleanupAt: 1_000_000_000,
+ })
+ )
+ window.sessionStorage.setItem(`${LEGACY_PREFIX}legacy-call`, '1')
+ const ledger = createLedger({ maxEntries: 2, now: () => now })
+
+ expect(ledger.claim('new-call')).toBe('capacity-exhausted')
+ expect(window.sessionStorage.getItem(`${LEGACY_PREFIX}legacy-call`)).toBe('1')
+
+ now += TTL_MS + 1
+ expect(ledger.claim('after-cleanup')).toBe('claimed')
+ expect(window.sessionStorage.getItem(`${LEGACY_PREFIX}legacy-call`)).toBeNull()
+ })
+
+ it('bounds legacy discovery while preserving exact-key duplicate checks on overflow', () => {
+ const storage = new WriteFailingStorage()
+ for (let index = 0; index < 100; index += 1) {
+ storage.setItem(`${LEGACY_PREFIX}legacy-${index}`, '1')
+ }
+ const ledger = createLedger({ maxEntries: 2, now: () => 1_000, storage })
+
+ expect(ledger.claim('legacy-99')).toBe('duplicate')
+ expect(storage.keyReads).toBeLessThanOrEqual(32)
+ expect(storage.getItem(`${LEGACY_PREFIX}legacy-99`)).toBe('1')
+ })
+
+ it('rotates bounded legacy cleanup scans without rescanning on every claim', () => {
+ let now = 1_000
+ const storage = new WriteFailingStorage()
+ for (let index = 0; index < 4_096; index += 1) {
+ storage.setItem(`unrelated-${index}`, '1')
+ }
+ storage.setItem(`${LEGACY_PREFIX}late-legacy-call`, '1')
+ const ledger = createLedger({ maxEntries: 2_048, now: () => now, storage })
+
+ expect(ledger.claim('new-call')).toBe('storage-unavailable')
+ expect(storage.getItem(`${LEGACY_PREFIX}late-legacy-call`)).toBe('1')
+ expect(storage.keyReads).toBe(4_096)
+
+ now += TTL_MS + 1
+ expect(ledger.claim('after-cleanup')).toBe('storage-unavailable')
+ expect(storage.getItem(`${LEGACY_PREFIX}late-legacy-call`)).toBeNull()
+ expect(storage.keyReads).toBe(8_192)
+
+ now += 1
+ expect(ledger.claim('next-call')).toBe('storage-unavailable')
+ expect(storage.keyReads).toBe(8_192)
+ })
+})
diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-replay-ledger.ts b/apps/sim/lib/copilot/tools/client/browser-tool-replay-ledger.ts
new file mode 100644
index 00000000000..8289a4312c2
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/client/browser-tool-replay-ledger.ts
@@ -0,0 +1,335 @@
+const LEDGER_VERSION = 1
+const MAX_TOOL_CALL_ID_LENGTH = 256
+const MIN_SERIALIZED_LEDGER_BYTES = 4 * 1024
+const SERIALIZED_BYTES_PER_ENTRY = 1_152
+const MIN_LEGACY_SCAN_LIMIT = 32
+
+interface PersistedReplayLedgerEntry {
+ toolCallId: string
+ lastSeenAt: number
+}
+
+interface PersistedReplayLedger {
+ version: typeof LEDGER_VERSION
+ entries: PersistedReplayLedgerEntry[]
+ legacyCleanupAt?: number
+ legacyScanCursor?: number
+}
+
+type StorageRead = { ok: true; value: T } | { ok: false }
+
+interface LegacyKeysRead {
+ keys: string[]
+ overflow: boolean
+ nextCursor: number
+}
+
+export type BrowserToolReplayClaim =
+ | 'claimed'
+ | 'duplicate'
+ | 'capacity-exhausted'
+ | 'storage-unavailable'
+
+interface BrowserToolReplayLedgerOptions {
+ storageKey: string
+ legacyStoragePrefix: string
+ maxEntries: number
+ ttlMs: number
+ protectedWindowMs: number
+ now?: () => number
+ getStorage?: () => Storage | null
+}
+
+/**
+ * Bounded replay ledger for side-effecting client browser tools.
+ *
+ * Entries inside `protectedWindowMs` are never evicted to make room. When that
+ * window is full, claiming fails closed so a newly executed action cannot
+ * become replayable after a renderer reload. Older entries remain useful for
+ * replay suppression until `ttlMs`, but may be evicted in least-recently-seen
+ * order when capacity is needed.
+ */
+export class BrowserToolReplayLedger {
+ private readonly entries = new Map()
+ private readonly storageKey: string
+ private readonly legacyStoragePrefix: string
+ private readonly maxEntries: number
+ private readonly ttlMs: number
+ private readonly protectedWindowMs: number
+ private readonly maxSerializedBytes: number
+ private readonly legacyScanLimit: number
+ private readonly now: () => number
+ private readonly getStorage: () => Storage | null
+ private hydrated = false
+ private legacyCleanupAt: number | undefined
+ private legacyScanCursor = 0
+
+ constructor(options: BrowserToolReplayLedgerOptions) {
+ if (options.maxEntries < 1) throw new Error('Replay ledger maxEntries must be positive')
+ if (options.protectedWindowMs < 1) {
+ throw new Error('Replay ledger protectedWindowMs must be positive')
+ }
+ if (options.ttlMs < options.protectedWindowMs) {
+ throw new Error('Replay ledger ttlMs must cover protectedWindowMs')
+ }
+ this.storageKey = options.storageKey
+ this.legacyStoragePrefix = options.legacyStoragePrefix
+ this.maxEntries = options.maxEntries
+ this.ttlMs = options.ttlMs
+ this.protectedWindowMs = options.protectedWindowMs
+ this.maxSerializedBytes = Math.max(
+ MIN_SERIALIZED_LEDGER_BYTES,
+ options.maxEntries * SERIALIZED_BYTES_PER_ENTRY
+ )
+ this.legacyScanLimit = Math.max(MIN_LEGACY_SCAN_LIMIT, options.maxEntries * 2)
+ this.now = options.now ?? (() => Date.now())
+ this.getStorage =
+ options.getStorage ??
+ (() => {
+ if (typeof window === 'undefined') return null
+ return window.sessionStorage
+ })
+ }
+
+ /** Atomically claims an action, or fails closed while the protected window is full. */
+ claim(toolCallId: string): BrowserToolReplayClaim {
+ if (!isValidToolCallId(toolCallId)) return 'storage-unavailable'
+
+ const now = this.now()
+ const storage = this.getStorageSafely()
+ const hydrationCertain = this.hydrate(storage, now)
+ this.pruneExpired(now)
+ const cleanupCertain = hydrationCertain && this.cleanupLegacyKeysIfDue(storage, now)
+
+ if (this.entries.has(toolCallId)) {
+ this.touch(toolCallId, now)
+ if (hydrationCertain && cleanupCertain) this.persist(storage)
+ return 'duplicate'
+ }
+ const legacyRead =
+ storage && hydrationCertain && cleanupCertain
+ ? this.readLegacyKey(storage, toolCallId)
+ : { ok: false as const }
+ if (legacyRead.ok && legacyRead.value) return 'duplicate'
+
+ while (this.entries.size >= this.maxEntries) {
+ const oldest = this.entries.entries().next().value as [string, number] | undefined
+ if (!oldest || now - oldest[1] <= this.protectedWindowMs) {
+ return 'capacity-exhausted'
+ }
+ this.entries.delete(oldest[0])
+ }
+
+ this.entries.set(toolCallId, now)
+ if (!hydrationCertain || !cleanupCertain || !legacyRead.ok) return 'storage-unavailable'
+ return this.persist(storage) ? 'claimed' : 'storage-unavailable'
+ }
+
+ private hydrate(storage: Storage | null, now: number): boolean {
+ if (this.hydrated) return true
+ if (!storage) return false
+
+ const persistedRead = this.readPersistedLedger(storage)
+ if (!persistedRead.ok) return false
+ const persisted = persistedRead.value
+ if (persisted) {
+ this.legacyCleanupAt =
+ persisted.legacyCleanupAt === undefined
+ ? undefined
+ : Math.min(persisted.legacyCleanupAt, now + this.ttlMs)
+ this.legacyScanCursor = persisted.legacyScanCursor ?? 0
+ for (const entry of persisted.entries) {
+ const lastSeenAt = Math.min(entry.lastSeenAt, now)
+ if (now - lastSeenAt > this.ttlMs) continue
+ this.touch(entry.toolCallId, lastSeenAt)
+ }
+ this.pruneToCapacity(now)
+ }
+ const legacyKeysRead = this.readLegacyKeys(storage)
+ if (!legacyKeysRead.ok) return false
+
+ this.hydrated = true
+
+ const { keys: legacyKeys, overflow: legacyOverflow, nextCursor } = legacyKeysRead.value
+ this.legacyScanCursor = nextCursor
+ if (legacyKeys.length === 0 && !legacyOverflow) return true
+
+ const availableEntries = this.maxEntries - this.entries.size
+ if (!legacyOverflow && legacyKeys.length <= availableEntries) {
+ for (const key of legacyKeys) {
+ this.touch(key.slice(this.legacyStoragePrefix.length), now)
+ }
+ if (this.persist(storage) && !this.removeStorageKeys(storage, legacyKeys)) {
+ this.legacyCleanupAt = now + this.ttlMs
+ this.persist(storage)
+ }
+ return true
+ }
+
+ this.legacyCleanupAt ??= now + this.ttlMs
+ this.persist(storage)
+ return true
+ }
+
+ private pruneExpired(now: number): void {
+ for (const [toolCallId, lastSeenAt] of this.entries) {
+ if (now - lastSeenAt > this.ttlMs) this.entries.delete(toolCallId)
+ }
+ }
+
+ private pruneToCapacity(now: number): void {
+ this.pruneExpired(now)
+ while (this.entries.size > this.maxEntries) {
+ const oldest = this.entries.keys().next().value as string | undefined
+ if (!oldest) return
+ this.entries.delete(oldest)
+ }
+ }
+
+ private touch(toolCallId: string, timestamp: number): void {
+ this.entries.delete(toolCallId)
+ this.entries.set(toolCallId, timestamp)
+ }
+
+ private getStorageSafely(): Storage | null {
+ try {
+ return this.getStorage()
+ } catch {
+ return null
+ }
+ }
+
+ private readPersistedLedger(storage: Storage): StorageRead {
+ try {
+ const serialized = storage.getItem(this.storageKey)
+ if (!serialized) return { ok: true, value: null }
+ if (new TextEncoder().encode(serialized).byteLength > this.maxSerializedBytes) {
+ return { ok: false }
+ }
+ const value: unknown = JSON.parse(serialized)
+ if (!isPersistedReplayLedger(value, this.maxEntries)) return { ok: false }
+ return { ok: true, value }
+ } catch {
+ return { ok: false }
+ }
+ }
+
+ private readLegacyKeys(storage: Storage): StorageRead {
+ const keys: string[] = []
+ try {
+ const storageLength = storage.length
+ const inspectedKeys = Math.min(storageLength, this.legacyScanLimit)
+ let overflow = storageLength > inspectedKeys
+ const startIndex = storageLength === 0 ? 0 : this.legacyScanCursor % storageLength
+ for (let offset = 0; offset < inspectedKeys; offset += 1) {
+ const index = (startIndex + offset) % storageLength
+ const key = storage.key(index)
+ if (!key?.startsWith(this.legacyStoragePrefix) || key === this.storageKey) continue
+ if (!isValidToolCallId(key.slice(this.legacyStoragePrefix.length))) {
+ overflow = true
+ continue
+ }
+ keys.push(key)
+ }
+ const nextCursor = storageLength === 0 ? 0 : (startIndex + inspectedKeys) % storageLength
+ return { ok: true, value: { keys, overflow, nextCursor } }
+ } catch {
+ return { ok: false }
+ }
+ }
+
+ private readLegacyKey(storage: Storage, toolCallId: string): StorageRead {
+ try {
+ return {
+ ok: true,
+ value: storage.getItem(`${this.legacyStoragePrefix}${toolCallId}`) !== null,
+ }
+ } catch {
+ return { ok: false }
+ }
+ }
+
+ private cleanupLegacyKeysIfDue(storage: Storage | null, now: number): boolean {
+ if (!storage) return false
+ if (this.legacyCleanupAt === undefined || now <= this.legacyCleanupAt) return true
+ const keysRead = this.readLegacyKeys(storage)
+ if (!keysRead.ok) return false
+ this.legacyScanCursor = keysRead.value.nextCursor
+ if (!this.removeStorageKeys(storage, keysRead.value.keys)) {
+ this.legacyCleanupAt = now + this.ttlMs
+ this.persist(storage)
+ return false
+ }
+ this.legacyCleanupAt = keysRead.value.overflow ? now + this.ttlMs : undefined
+ return this.persist(storage)
+ }
+
+ private removeStorageKeys(storage: Storage, keys: string[]): boolean {
+ for (const key of keys) {
+ try {
+ storage.removeItem(key)
+ } catch {
+ return false
+ }
+ }
+ return true
+ }
+
+ private persist(storage: Storage | null): boolean {
+ if (!storage) return false
+ const payload: PersistedReplayLedger = {
+ version: LEDGER_VERSION,
+ entries: Array.from(this.entries, ([toolCallId, lastSeenAt]) => ({
+ toolCallId,
+ lastSeenAt,
+ })),
+ ...(this.legacyCleanupAt !== undefined ? { legacyCleanupAt: this.legacyCleanupAt } : {}),
+ ...(this.legacyScanCursor > 0 ? { legacyScanCursor: this.legacyScanCursor } : {}),
+ }
+ try {
+ const serialized = JSON.stringify(payload)
+ if (new TextEncoder().encode(serialized).byteLength > this.maxSerializedBytes) return false
+ storage.setItem(this.storageKey, serialized)
+ return true
+ } catch {
+ return false
+ }
+ }
+}
+
+function isValidToolCallId(value: unknown): value is string {
+ return typeof value === 'string' && value.length >= 1 && value.length <= MAX_TOOL_CALL_ID_LENGTH
+}
+
+function isPersistedReplayLedger(
+ value: unknown,
+ maxEntries: number
+): value is PersistedReplayLedger {
+ if (typeof value !== 'object' || value === null) return false
+ const candidate = value as Record
+ if (candidate.version !== LEDGER_VERSION || !Array.isArray(candidate.entries)) return false
+ if (candidate.entries.length > maxEntries) return false
+ if (
+ candidate.legacyCleanupAt !== undefined &&
+ (typeof candidate.legacyCleanupAt !== 'number' || !Number.isFinite(candidate.legacyCleanupAt))
+ ) {
+ return false
+ }
+ if (
+ candidate.legacyScanCursor !== undefined &&
+ (typeof candidate.legacyScanCursor !== 'number' ||
+ !Number.isSafeInteger(candidate.legacyScanCursor) ||
+ candidate.legacyScanCursor < 0)
+ ) {
+ return false
+ }
+ return candidate.entries.every((entry: unknown) => {
+ if (typeof entry !== 'object' || entry === null) return false
+ const record = entry as Record
+ return (
+ isValidToolCallId(record.toolCallId) &&
+ typeof record.lastSeenAt === 'number' &&
+ Number.isFinite(record.lastSeenAt)
+ )
+ })
+}
diff --git a/apps/sim/lib/copilot/tools/client/completion.test.ts b/apps/sim/lib/copilot/tools/client/completion.test.ts
index 00fd511e67b..fcd7a1db286 100644
--- a/apps/sim/lib/copilot/tools/client/completion.test.ts
+++ b/apps/sim/lib/copilot/tools/client/completion.test.ts
@@ -4,10 +4,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
CompletionReportError,
+ reportClientToolCompletion,
reportClientToolCompletionOnPageExit,
} from '@/lib/copilot/tools/client/completion'
-describe('reportClientToolCompletionOnPageExit', () => {
+describe('client tool completion reporting', () => {
const fetchMock = vi.fn()
beforeEach(() => {
@@ -16,10 +17,34 @@ describe('reportClientToolCompletionOnPageExit', () => {
})
afterEach(() => {
+ vi.useRealTimers()
vi.unstubAllGlobals()
vi.clearAllMocks()
})
+ it('bounds every normal confirmation attempt with an abortable deadline', async () => {
+ vi.useFakeTimers()
+ const signals: AbortSignal[] = []
+ fetchMock.mockImplementation((_input, init) => {
+ const signal = init?.signal
+ if (!signal) throw new Error('Expected an abort signal')
+ signals.push(signal)
+ return new Promise((_resolve, reject) => {
+ const rejectOnAbort = () => reject(signal.reason)
+ signal.addEventListener('abort', rejectOnAbort, { once: true })
+ })
+ })
+
+ const report = reportClientToolCompletion('tool-1', 'success')
+ const rejection = expect(report).rejects.toBeInstanceOf(CompletionReportError)
+ await vi.runAllTimersAsync()
+ await rejection
+
+ expect(fetchMock).toHaveBeenCalledTimes(5)
+ expect(signals).toHaveLength(5)
+ expect(signals.every((signal) => signal.aborted)).toBe(true)
+ })
+
it('uses a keepalive request with the exact terminal payload', async () => {
await reportClientToolCompletionOnPageExit('tool-1', 'success', 'Browser action completed', {
url: 'https://example.com',
@@ -40,6 +65,31 @@ describe('reportClientToolCompletionOnPageExit', () => {
)
})
+ it('bounds the unload-safe fallback with the same abortable deadline', async () => {
+ vi.useFakeTimers()
+ let signal: AbortSignal | null = null
+ fetchMock.mockImplementation((_input, init) => {
+ signal = init?.signal ?? null
+ if (!signal) throw new Error('Expected an abort signal')
+ return new Promise((_resolve, reject) => {
+ const rejectOnAbort = () => reject(signal?.reason)
+ signal?.addEventListener('abort', rejectOnAbort, { once: true })
+ })
+ })
+
+ const report = reportClientToolCompletionOnPageExit(
+ 'tool-1',
+ 'success',
+ 'Browser action completed'
+ )
+ const rejection = expect(report).rejects.toBeInstanceOf(DOMException)
+ await vi.runAllTimersAsync()
+ await rejection
+
+ expect(signal).not.toBeNull()
+ expect(signal?.aborted).toBe(true)
+ })
+
it('rejects a non-success response', async () => {
fetchMock.mockResolvedValue(new Response(null, { status: 503 }))
diff --git a/apps/sim/lib/copilot/tools/client/completion.ts b/apps/sim/lib/copilot/tools/client/completion.ts
index 7457de9e14e..ec095db8e8e 100644
--- a/apps/sim/lib/copilot/tools/client/completion.ts
+++ b/apps/sim/lib/copilot/tools/client/completion.ts
@@ -11,6 +11,7 @@ import { COPILOT_CONFIRM_API_PATH } from '@/lib/copilot/constants'
import { traceparentHeader } from '@/lib/copilot/tools/client/trace-context'
const logger = createLogger('CopilotClientToolCompletion')
+const COMPLETION_REPORT_ATTEMPT_TIMEOUT_MS = 15_000
export class CompletionReportError extends Error {
constructor(message: string) {
@@ -19,6 +20,18 @@ export class CompletionReportError extends Error {
}
}
+async function fetchCompletion(input: RequestInfo | URL, init: RequestInit): Promise {
+ const controller = new AbortController()
+ const timeout = setTimeout(() => {
+ controller.abort(new DOMException('Completion report timed out', 'TimeoutError'))
+ }, COMPLETION_REPORT_ATTEMPT_TIMEOUT_MS)
+ try {
+ return await fetch(input, { ...init, signal: controller.signal })
+ } finally {
+ clearTimeout(timeout)
+ }
+}
+
/**
* Persist a client-executed tool result and wake the server-side async waiter.
* Shared by workflow execution and desktop-native client tools.
@@ -38,7 +51,7 @@ export async function reportClientToolCompletion(
...(data !== undefined ? { data } : {}),
}
const send = async (body: string) =>
- fetch(COPILOT_CONFIRM_API_PATH, {
+ fetchCompletion(COPILOT_CONFIRM_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...traceparentHeader() },
body,
@@ -51,9 +64,9 @@ export async function reportClientToolCompletion(
// A lost confirmation strands the server-side waiter forever (the turn shows
// the tool as running indefinitely), so ride out multi-second network blips:
- // 5 attempts with jittered exponential backoff (~15s total) instead of a
- // sub-second give-up. The confirm endpoint claims each resume exactly once,
- // so duplicate deliveries from retries are discarded server-side.
+ // five bounded 15-second attempts with jittered exponential backoff. The
+ // confirm endpoint claims each resume exactly once, so duplicate deliveries
+ // from retries are discarded server-side.
const maxAttempts = 5
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
@@ -108,7 +121,7 @@ export async function reportClientToolCompletionOnPageExit(
data?: AsyncCompletionData
): Promise {
// boundary-raw-fetch: keepalive is required so a terminal desktop result survives page unload
- const response = await fetch(COPILOT_CONFIRM_API_PATH, {
+ const response = await fetchCompletion(COPILOT_CONFIRM_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...traceparentHeader() },
body: JSON.stringify({
diff --git a/apps/sim/stores/browser-session/store.test.ts b/apps/sim/stores/browser-session/store.test.ts
index b76387be92e..50efa8e2efa 100644
--- a/apps/sim/stores/browser-session/store.test.ts
+++ b/apps/sim/stores/browser-session/store.test.ts
@@ -161,6 +161,38 @@ describe('browser session store', () => {
expect(getBrowserSession('chat-test').pageState?.mediaPermissionRequest).toBe(retained)
})
+ it('retains and clears the exact pending site request from native page state', () => {
+ const store = useBrowserSessionStore.getState()
+ const request = {
+ requestId: 'site-request-1',
+ tabId: '2',
+ origin: 'https://outside.example',
+ }
+ const page = {
+ tabId: '1',
+ scopeId: 'chat-test',
+ title: 'Current page',
+ url: 'https://inside.example',
+ loading: false,
+ canGoBack: false,
+ canGoForward: false,
+ sitePermissionRequest: request,
+ }
+ store.setPageState(page)
+ const retained = getBrowserSession('chat-test').pageState?.sitePermissionRequest
+
+ store.setPageState({
+ ...page,
+ title: 'Updated title',
+ sitePermissionRequest: { ...request },
+ })
+ expect(getBrowserSession('chat-test').pageState?.sitePermissionRequest).toBe(retained)
+
+ const { sitePermissionRequest: _sitePermissionRequest, ...withoutRequest } = page
+ store.setPageState(withoutRequest)
+ expect(getBrowserSession('chat-test').pageState?.sitePermissionRequest).toBeUndefined()
+ })
+
it('reorders tabs optimistically without changing the active page', () => {
const store = useBrowserSessionStore.getState()
store.setTabsState({
diff --git a/apps/sim/stores/browser-session/store.ts b/apps/sim/stores/browser-session/store.ts
index cfbec3f0084..dd69f9f8eae 100644
--- a/apps/sim/stores/browser-session/store.ts
+++ b/apps/sim/stores/browser-session/store.ts
@@ -2,6 +2,7 @@ import type {
BrowserMediaPermissionRequest,
BrowserPageIssue,
BrowserPageState,
+ BrowserSitePermissionRequest,
BrowserTabState,
BrowserTabsState,
} from '@sim/browser-protocol'
@@ -116,6 +117,23 @@ function retainMediaPermissionRequest(
return mediaPermissionRequestEqual(current, incoming) ? current : incoming
}
+function sitePermissionRequestEqual(
+ a: BrowserSitePermissionRequest | undefined,
+ b: BrowserSitePermissionRequest | undefined
+): boolean {
+ return Boolean(
+ a === b ||
+ (a && b && a.requestId === b.requestId && a.tabId === b.tabId && a.origin === b.origin)
+ )
+}
+
+function retainSitePermissionRequest(
+ current: BrowserSitePermissionRequest | undefined,
+ incoming: BrowserSitePermissionRequest | undefined
+): BrowserSitePermissionRequest | undefined {
+ return sitePermissionRequestEqual(current, incoming) ? current : incoming
+}
+
function tabFieldsEqual(a: BrowserTabState, b: BrowserTabState): boolean {
return (
a.tabId === b.tabId &&
@@ -171,7 +189,8 @@ function pageStateEqual(a: BrowserPageState | null, b: BrowserPageState | null):
a.canGoBack === b.canGoBack &&
a.canGoForward === b.canGoForward &&
pageIssueEqual(a.issue, b.issue) &&
- mediaPermissionRequestEqual(a.mediaPermissionRequest, b.mediaPermissionRequest)
+ mediaPermissionRequestEqual(a.mediaPermissionRequest, b.mediaPermissionRequest) &&
+ sitePermissionRequestEqual(a.sitePermissionRequest, b.sitePermissionRequest)
)
}
@@ -238,6 +257,10 @@ export const useBrowserSessionStore = create()(
current.pageState?.mediaPermissionRequest,
pageState.mediaPermissionRequest
),
+ sitePermissionRequest: retainSitePermissionRequest(
+ current.pageState?.sitePermissionRequest,
+ pageState.sitePermissionRequest
+ ),
}
const nextTabs = current.tabs.map((tab) =>
tab.tabId === nextPageState.tabId
diff --git a/bun.lock b/bun.lock
index d37d3b332fe..de0537afb0f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -54,7 +54,7 @@
"@sim/tsconfig": "workspace:*",
"@types/micromatch": "4.0.10",
"@types/node": "24.2.1",
- "electron": "43.1.1",
+ "electron": "43.5.0",
"electron-builder": "26.15.3",
"esbuild": "0.28.1",
"typescript": "^7.0.2",
@@ -2882,7 +2882,7 @@
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
- "electron": ["electron@43.1.1", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-I5c5vfuVvaXpWx3IZdwvXgxQW44+e7OP1wXGVQkogLeSFSkUZ6sLCcWV05AdEcs65AO5tAIJJwbp7ixw+LdarA=="],
+ "electron": ["electron@43.5.0", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-nV2aWuKatmUrxrAWP2pNIynNX7dy83TCAz+6KnsbK7hDVLE+6fa2iouOtir41AboZTa+J5w2UgicWHAqUaaUJw=="],
"electron-builder": ["electron-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.3", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA=="],
diff --git a/packages/browser-protocol/src/index.ts b/packages/browser-protocol/src/index.ts
index cc6689eff14..3b5a0e3bd6f 100644
--- a/packages/browser-protocol/src/index.ts
+++ b/packages/browser-protocol/src/index.ts
@@ -60,6 +60,15 @@ export type BrowserToolName = (typeof BROWSER_TOOL_NAMES)[number]
export const BROWSER_WAIT_FOR_DEFAULT_TIMEOUT_MS = 10_000
export const BROWSER_WAIT_FOR_MAX_TIMEOUT_MS = 120_000
export const BROWSER_WAIT_FOR_RENDERER_GRACE_MS = 15_000
+export const BROWSER_TOOL_AUTHORIZATION_TIMEOUT_MS = 8_000
+export const BROWSER_NAVIGATION_NATIVE_WATCHDOG_MS = 60_000
+export const BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS = BROWSER_NAVIGATION_NATIVE_WATCHDOG_MS
+const BROWSER_RENDERER_TRANSPORT_GRACE_MS = 2_000
+export const BROWSER_NAVIGATION_RENDERER_TIMEOUT_MS =
+ BROWSER_TOOL_AUTHORIZATION_TIMEOUT_MS +
+ BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS +
+ BROWSER_NAVIGATION_NATIVE_WATCHDOG_MS +
+ BROWSER_RENDERER_TRANSPORT_GRACE_MS
/**
* Normalizes the model-visible `browser_wait_for.timeoutMs` consistently in
@@ -195,6 +204,7 @@ export interface BrowserPanelAction {
| 'zoom-out'
| 'zoom-reset'
| 'respond-media-permission'
+ | 'respond-site-permission'
| 'takeover-done'
/** Absolute URL for `navigate` (typed into the panel's URL bar). */
url?: string
@@ -202,9 +212,9 @@ export interface BrowserPanelAction {
tabId?: string
/** Optional free-text instruction submitted with `takeover-done`. */
takeoverResponse?: string
- /** Exact pending media request being answered. */
+ /** Exact pending permission request being answered. */
requestId?: string
- /** User decision for `respond-media-permission`. */
+ /** User decision for a permission response. */
allowed?: boolean
}
@@ -217,6 +227,15 @@ export interface BrowserMediaPermissionRequest {
devices: BrowserMediaDevice[]
}
+/** One ungranted top-level origin transition awaiting explicit user consent. */
+export interface BrowserSitePermissionRequest {
+ requestId: string
+ /** Exact tab whose suspended request will be resumed or cancelled. */
+ tabId: string
+ /** Destination origin only; credentials, paths, query strings, and fragments are excluded. */
+ origin: string
+}
+
/** Live state of the active page, pushed to the panel header. */
export interface BrowserPageState {
tabId: string
@@ -231,6 +250,8 @@ export interface BrowserPageState {
issue?: BrowserPageIssue
/** Main-frame media request awaiting a renderer-owned permission prompt. */
mediaPermissionRequest?: BrowserMediaPermissionRequest
+ /** Ungranted top-level origin transition awaiting a renderer-owned permission prompt. */
+ sitePermissionRequest?: BrowserSitePermissionRequest
}
/** A recoverable top-level page problem rendered by Sim instead of a blank native view. */
diff --git a/packages/desktop-bridge/contract-snapshot.ts b/packages/desktop-bridge/contract-snapshot.ts
index 8b61c52b0e2..b4f37265219 100644
--- a/packages/desktop-bridge/contract-snapshot.ts
+++ b/packages/desktop-bridge/contract-snapshot.ts
@@ -210,6 +210,7 @@ export interface BrowserPanelAction {
| 'zoom-out'
| 'zoom-reset'
| 'respond-media-permission'
+ | 'respond-site-permission'
| 'takeover-done'
/** Absolute URL for `navigate` (typed into the panel's URL bar). */
url?: string
@@ -217,9 +218,9 @@ export interface BrowserPanelAction {
tabId?: string
/** Optional free-text instruction submitted with `takeover-done`. */
takeoverResponse?: string
- /** Exact pending media request being answered. */
+ /** Exact pending permission request being answered. */
requestId?: string
- /** User decision for `respond-media-permission`. */
+ /** User decision for a permission response. */
allowed?: boolean
}
@@ -232,6 +233,15 @@ export interface BrowserMediaPermissionRequest {
devices: BrowserMediaDevice[]
}
+/** One ungranted top-level origin transition awaiting explicit user consent. */
+export interface BrowserSitePermissionRequest {
+ requestId: string
+ /** Exact tab whose suspended request will be resumed or cancelled. */
+ tabId: string
+ /** Destination origin only; credentials, paths, query strings, and fragments are excluded. */
+ origin: string
+}
+
/** Live state of the active page, pushed to the panel header. */
export interface BrowserPageState {
tabId: string
@@ -246,6 +256,8 @@ export interface BrowserPageState {
issue?: BrowserPageIssue
/** Main-frame media request awaiting a renderer-owned permission prompt. */
mediaPermissionRequest?: BrowserMediaPermissionRequest
+ /** Ungranted top-level origin transition awaiting a renderer-owned permission prompt. */
+ sitePermissionRequest?: BrowserSitePermissionRequest
}
/** A recoverable top-level page problem rendered by Sim instead of a blank native view. */
@@ -990,6 +1002,11 @@ export interface SimDesktopTerminalApi {
export interface SimDesktopBrowserAgentApi {
/** New shells can atomically force-hide a native page before renderer effects paint. */
readonly supportsAtomicPanelOcclusion?: true
+ /**
+ * Confirms that this renderer can present and answer site-origin prompts.
+ * Optional for compatibility with installed shells that predate site consent.
+ */
+ registerSitePermissionPromptSupport?(): void
/**
* Execute one browser tool. Resolves with the tool's outcome; never
* rejects for tool-level failures (those ride `ok: false`).
@@ -1011,6 +1028,8 @@ export interface SimDesktopBrowserAgentApi {
* Optional for compatibility with installed shells that predate acknowledged tab creation.
*/
openTab?(scopeId: string): Promise
+ /** Atomically creates a user-owned tab and grants/navigates its exact destination origin. */
+ openUrl?(url: string, scopeId: string): Promise
/** Makes a chat's browser tab set the renderer-visible set. */
activateScope(scopeId: string): Promise
/** Materializes a lazily activated chat's persisted tabs without showing its panel. */
diff --git a/packages/desktop-bridge/src/index.ts b/packages/desktop-bridge/src/index.ts
index 9792b74d4b1..77f3f6208d9 100644
--- a/packages/desktop-bridge/src/index.ts
+++ b/packages/desktop-bridge/src/index.ts
@@ -139,6 +139,11 @@ export interface SimDesktopTerminalApi {
export interface SimDesktopBrowserAgentApi {
/** New shells can atomically force-hide a native page before renderer effects paint. */
readonly supportsAtomicPanelOcclusion?: true
+ /**
+ * Confirms that this renderer can present and answer site-origin prompts.
+ * Optional for compatibility with installed shells that predate site consent.
+ */
+ registerSitePermissionPromptSupport?(): void
/**
* Execute one browser tool. Resolves with the tool's outcome; never
* rejects for tool-level failures (those ride `ok: false`).
@@ -160,6 +165,8 @@ export interface SimDesktopBrowserAgentApi {
* Optional for compatibility with installed shells that predate acknowledged tab creation.
*/
openTab?(scopeId: string): Promise
+ /** Atomically creates a user-owned tab and grants/navigates its exact destination origin. */
+ openUrl?(url: string, scopeId: string): Promise
/** Makes a chat's browser tab set the renderer-visible set. */
activateScope(scopeId: string): Promise
/** Materializes a lazily activated chat's persisted tabs without showing its panel. */