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. */