Fix browser webview permissions and wheel zoom (#4849)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-06-07 21:18:47 -07:00
committed by GitHub
co-authored by Orca
parent 2756d6f28f
commit cacd48da72
9 changed files with 389 additions and 14 deletions
+100
View File
@@ -0,0 +1,100 @@
import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
type PermissionQueryResult = {
state: string
onchange: null
}
type AntiDetectionContext = {
Notification: {
permission: string
requestPermission: (callback?: (permission: string) => void) => Promise<string>
}
navigator: {
permissions: {
query: (descriptor: { name: string }) => Promise<PermissionQueryResult>
}
}
}
function createContext(args: {
nativeNotificationPermission: string
requestedNotificationPermission: string
}): AntiDetectionContext & Record<string, unknown> {
class Permissions {
query(): Promise<PermissionQueryResult> {
return Promise.resolve({ state: 'denied', onchange: null })
}
}
const Notification = {
permission: args.nativeNotificationPermission,
requestPermission(callback?: (permission: string) => void): Promise<string> {
callback?.(args.requestedNotificationPermission)
return Promise.resolve(args.requestedNotificationPermission)
}
}
Object.defineProperty(Notification, 'permission', {
configurable: true,
get: () => args.nativeNotificationPermission
})
return {
Date,
Object,
Promise,
Set,
performance: { now: () => 0 },
window: {},
navigator: {
plugins: [],
languages: [],
permissions: new Permissions()
},
Permissions,
Notification
} as AntiDetectionContext & Record<string, unknown>
}
describe('ANTI_DETECTION_SCRIPT', () => {
it('reports notification permission as granted after a site permission request succeeds', async () => {
const context = createContext({
nativeNotificationPermission: 'denied',
requestedNotificationPermission: 'granted'
})
runInNewContext(ANTI_DETECTION_SCRIPT, context)
expect(context.Notification.permission).toBe('default')
await expect(context.navigator.permissions.query({ name: 'notifications' })).resolves.toEqual({
state: 'prompt',
onchange: null
})
await expect(context.Notification.requestPermission()).resolves.toBe('granted')
expect(context.Notification.permission).toBe('granted')
await expect(context.navigator.permissions.query({ name: 'notifications' })).resolves.toEqual({
state: 'granted',
onchange: null
})
})
it('preserves notification permission when Electron already reports a grant', async () => {
const context = createContext({
nativeNotificationPermission: 'granted',
requestedNotificationPermission: 'granted'
})
runInNewContext(ANTI_DETECTION_SCRIPT, context)
expect(context.Notification.permission).toBe('granted')
await expect(context.navigator.permissions.query({ name: 'notifications' })).resolves.toEqual({
state: 'granted',
onchange: null
})
})
})
+39 -2
View File
@@ -59,12 +59,32 @@ export const ANTI_DETECTION_SCRIPT = `(function() {
// but real Chrome returns 'prompt' for ungranted permissions. Returning
// 'denied' is a strong bot signal. Override the query result for common
// permissions that Turnstile and similar detectors probe.
var notificationPermission = 'default';
var setNotificationPermission = function(permission) {
if (permission === 'granted' || permission === 'denied') {
notificationPermission = permission;
return permission;
}
notificationPermission = 'default';
return 'default';
};
var notificationPermissionState = function() {
return notificationPermission === 'default' ? 'prompt' : notificationPermission;
};
try {
if (Notification.permission === 'granted') {
notificationPermission = 'granted';
}
} catch {}
const promptPerms = new Set([
'notifications', 'geolocation', 'camera', 'microphone',
'geolocation', 'camera', 'microphone',
'midi', 'idle-detection', 'storage-access'
]);
const origQuery = Permissions.prototype.query;
Permissions.prototype.query = function(desc) {
if (desc.name === 'notifications') {
return Promise.resolve({ state: notificationPermissionState(), onchange: null });
}
if (promptPerms.has(desc.name)) {
return Promise.resolve({ state: 'prompt', onchange: null });
}
@@ -75,8 +95,25 @@ export const ANTI_DETECTION_SCRIPT = `(function() {
// or blocked. Turnstile cross-references this with the Permissions API.
try {
Object.defineProperty(Notification, 'permission', {
get: () => 'default'
get: () => notificationPermission
});
const origRequestPermission = Notification.requestPermission;
if (typeof origRequestPermission === 'function') {
Notification.requestPermission = function(callback) {
var wrappedCallback = typeof callback === 'function'
? function(permission) {
callback(setNotificationPermission(permission));
}
: undefined;
var result = origRequestPermission.call(Notification, wrappedCallback);
if (result && typeof result.then === 'function') {
return result.then(function(permission) {
return setNotificationPermission(permission);
});
}
return result;
};
}
} catch {}
// Why: Electron webviews may have an empty languages array. Real Chrome
// always has at least one entry. An empty array is an automation signal.
+125 -1
View File
@@ -9,7 +9,12 @@ vi.mock('electron', () => ({
webContents: { fromId: vi.fn() }
}))
import { setupGuestContextMenu, setupGuestShortcutForwarding } from './browser-guest-ui'
import {
resolveGuestMouseWheelZoomDirection,
setupGuestContextMenu,
setupGuestMouseWheelZoomForwarding,
setupGuestShortcutForwarding
} from './browser-guest-ui'
describe('setupGuestContextMenu', () => {
const browserTabId = 'tab-1'
@@ -253,6 +258,125 @@ describe('setupGuestContextMenu', () => {
})
})
describe('guest mouse wheel browser zoom', () => {
const browserTabId = 'tab-1'
let rendererSendMock: ReturnType<typeof vi.fn>
let guestOnMock: ReturnType<typeof vi.fn>
let guestOffMock: ReturnType<typeof vi.fn>
function makeGuest() {
return {
on: guestOnMock,
off: guestOffMock
} as unknown as Electron.WebContents
}
function makeRenderer() {
return { send: rendererSendMock } as unknown as Electron.WebContents
}
function mouseWheel(
overrides: Partial<Electron.MouseWheelInputEvent> = {}
): Electron.MouseWheelInputEvent {
return {
type: 'mouseWheel',
x: 0,
y: 0,
deltaY: -120,
modifiers: ['ctrl'],
...overrides
}
}
function triggerBeforeMouse(mouse: Electron.MouseInputEvent): ReturnType<typeof vi.fn> {
const handler = guestOnMock.mock.calls.find((call) => call[0] === 'before-mouse-event')?.[1] as
| ((event: Electron.Event, mouse: Electron.MouseInputEvent) => void)
| undefined
expect(handler).toBeTypeOf('function')
const preventDefault = vi.fn()
handler!({ preventDefault } as unknown as Electron.Event, mouse)
return preventDefault
}
beforeEach(() => {
rendererSendMock = vi.fn()
guestOnMock = vi.fn()
guestOffMock = vi.fn()
})
it('resolves ctrl wheel direction from guest mouse input', () => {
expect(resolveGuestMouseWheelZoomDirection(mouseWheel({ deltaY: -120 }), 'win32')).toBe('in')
expect(resolveGuestMouseWheelZoomDirection(mouseWheel({ deltaY: 120 }), 'linux')).toBe('out')
})
it('allows command wheel only on macOS', () => {
const commandWheel = mouseWheel({ modifiers: ['cmd'], deltaY: -120 })
expect(resolveGuestMouseWheelZoomDirection(commandWheel, 'darwin')).toBe('in')
expect(resolveGuestMouseWheelZoomDirection(commandWheel, 'win32')).toBeNull()
})
it('ignores non-zoom wheel input', () => {
expect(
resolveGuestMouseWheelZoomDirection(mouseWheel({ modifiers: [], deltaY: -120 }), 'linux')
).toBeNull()
expect(
resolveGuestMouseWheelZoomDirection(mouseWheel({ modifiers: ['ctrl', 'alt'] }), 'linux')
).toBeNull()
expect(
resolveGuestMouseWheelZoomDirection(mouseWheel({ modifiers: ['ctrl', 'shift'] }), 'linux')
).toBeNull()
expect(resolveGuestMouseWheelZoomDirection(mouseWheel({ deltaY: 0 }), 'linux')).toBeNull()
expect(
resolveGuestMouseWheelZoomDirection(
{ type: 'mouseMove', x: 0, y: 0, modifiers: ['ctrl'] },
'linux'
)
).toBeNull()
})
it('forwards ctrl wheel to browser page zoom and consumes the guest wheel event', () => {
setupGuestMouseWheelZoomForwarding({
browserTabId,
guest: makeGuest(),
resolveRenderer: () => makeRenderer()
})
const preventDefault = triggerBeforeMouse(mouseWheel({ deltaY: -120 }))
const outPreventDefault = triggerBeforeMouse(mouseWheel({ deltaY: 120 }))
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(outPreventDefault).toHaveBeenCalledTimes(1)
expect(rendererSendMock).toHaveBeenNthCalledWith(1, 'ui:zoomBrowserPage', 'in')
expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:zoomBrowserPage', 'out')
})
it('consumes guest ctrl wheel even when the renderer is unavailable', () => {
setupGuestMouseWheelZoomForwarding({
browserTabId,
guest: makeGuest(),
resolveRenderer: () => null
})
const preventDefault = triggerBeforeMouse(mouseWheel())
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(rendererSendMock).not.toHaveBeenCalled()
})
it('cleans up the mouse wheel listener on teardown', () => {
const cleanup = setupGuestMouseWheelZoomForwarding({
browserTabId,
guest: makeGuest(),
resolveRenderer: () => makeRenderer()
})
cleanup()
expect(guestOffMock).toHaveBeenCalledWith('before-mouse-event', expect.any(Function))
})
})
describe('setupGuestShortcutForwarding', () => {
const browserTabId = 'tab-1'
let rendererSendMock: ReturnType<typeof vi.fn>
+59
View File
@@ -14,10 +14,42 @@ import {
} from '../../shared/window-shortcut-policy'
import { readGuestNavigationState } from './browser-guest-navigation-state'
import { keybindingMatchesAction, type KeybindingOverrides } from '../../shared/keybindings'
import type { BrowserPageZoomDirection } from '../../shared/browser-page-zoom'
type ResolveRenderer = (browserTabId: string) => Electron.WebContents | null
type ShouldForwardDictationShortcut = () => boolean
const CONTROL_MODIFIERS = new Set(['control', 'ctrl'])
const MAC_COMMAND_MODIFIERS = new Set(['meta', 'command', 'cmd'])
const WHEEL_ZOOM_BLOCKING_MODIFIERS = new Set(['alt', 'shift'])
function hasModifier(mouse: Electron.MouseInputEvent, modifiers: ReadonlySet<string>): boolean {
return mouse.modifiers?.some((modifier) => modifiers.has(modifier)) ?? false
}
export function resolveGuestMouseWheelZoomDirection(
mouse: Electron.MouseInputEvent,
platform: NodeJS.Platform = process.platform
): BrowserPageZoomDirection | null {
if (mouse.type !== 'mouseWheel') {
return null
}
if (hasModifier(mouse, WHEEL_ZOOM_BLOCKING_MODIFIERS)) {
return null
}
const hasZoomModifier =
hasModifier(mouse, CONTROL_MODIFIERS) ||
(platform === 'darwin' && hasModifier(mouse, MAC_COMMAND_MODIFIERS))
if (!hasZoomModifier) {
return null
}
const deltaY = (mouse as Electron.MouseWheelInputEvent).deltaY
if (typeof deltaY !== 'number' || deltaY === 0) {
return null
}
return deltaY < 0 ? 'in' : 'out'
}
function isControlKeyRelease(input: Electron.Input): boolean {
return input.type === 'keyUp' && (input.code === 'ControlLeft' || input.code === 'ControlRight')
}
@@ -424,6 +456,33 @@ export function setupGuestShortcutForwarding(args: {
}
}
export function setupGuestMouseWheelZoomForwarding(args: {
browserTabId: string
guest: Electron.WebContents
resolveRenderer: ResolveRenderer
}): () => void {
const { browserTabId, guest, resolveRenderer } = args
const handler = (event: Electron.Event, mouse: Electron.MouseInputEvent): void => {
const direction = resolveGuestMouseWheelZoomDirection(mouse)
if (!direction) {
return
}
// Why: wheel input over a focused webview does not reach renderer DOM
// handlers, so consume it here and forward to the existing page-zoom path.
event.preventDefault()
resolveRenderer(browserTabId)?.send('ui:zoomBrowserPage', direction)
}
guest.on('before-mouse-event', handler)
return () => {
try {
guest.off('before-mouse-event', handler)
} catch {
// Why: best-effort — guest may already be destroyed during teardown.
}
}
}
export function resolveRendererWebContents(
rendererWebContentsIdByTabId: ReadonlyMap<string, number>,
browserTabId: string
+27
View File
@@ -32,6 +32,7 @@ import {
resolveRendererWebContents,
setupGrabShortcutForwarding,
setupGuestContextMenu,
setupGuestMouseWheelZoomForwarding,
setupGuestShortcutForwarding
} from './browser-guest-ui'
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
@@ -184,6 +185,7 @@ export class BrowserManager {
private readonly contextMenuCleanupByTabId = new Map<string, () => void>()
private readonly grabShortcutCleanupByTabId = new Map<string, () => void>()
private readonly shortcutForwardingCleanupByTabId = new Map<string, () => void>()
private readonly mouseWheelZoomCleanupByTabId = new Map<string, () => void>()
private readonly annotationViewportBridgeOpsByTabId = new Map<string, Promise<unknown>>()
private readonly worktreeIdByTabId = new Map<string, string>()
private readonly policyAttachedGuestIds = new Set<number>()
@@ -751,6 +753,7 @@ export class BrowserManager {
this.setupContextMenu(browserTabId, guest)
this.setupGrabShortcut(browserTabId, guest)
this.setupShortcutForwarding(browserTabId, guest)
this.setupMouseWheelZoomForwarding(browserTabId, guest)
this.flushPendingLoadFailure(browserTabId, webContentsId)
this.flushPendingPermissionEvents(browserTabId, webContentsId)
this.flushPendingPopupEvents(browserTabId, webContentsId)
@@ -786,6 +789,11 @@ export class BrowserManager {
fwdCleanup()
this.shortcutForwardingCleanupByTabId.delete(browserTabId)
}
const mouseWheelZoomCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId)
if (mouseWheelZoomCleanup) {
mouseWheelZoomCleanup()
this.mouseWheelZoomCleanupByTabId.delete(browserTabId)
}
// Why: paused downloads wait for explicit product approval. If the owning
// browser tab disappears first, cancel the request so the app does not
// retain orphaned download items or write files after context is gone.
@@ -834,6 +842,7 @@ export class BrowserManager {
this.pendingPermissionEventsByGuestId.clear()
this.pendingPopupEventsByGuestId.clear()
this.pendingDownloadIdsByGuestId.clear()
this.mouseWheelZoomCleanupByTabId.clear()
this.annotationViewportBridgeOpsByTabId.clear()
}
@@ -1417,6 +1426,24 @@ export class BrowserManager {
)
}
private setupMouseWheelZoomForwarding(browserTabId: string, guest: Electron.WebContents): void {
const previousCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId)
if (previousCleanup) {
previousCleanup()
this.mouseWheelZoomCleanupByTabId.delete(browserTabId)
}
this.mouseWheelZoomCleanupByTabId.set(
browserTabId,
setupGuestMouseWheelZoomForwarding({
browserTabId,
guest,
resolveRenderer: (tabId) =>
resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId)
})
)
}
private forwardOrQueueGuestLoadFailure(
guestWebContentsId: number,
loadError: { code: number; description: string; validatedUrl: string }
@@ -0,0 +1,17 @@
const AUTO_GRANTED_BROWSER_PERMISSIONS = new Set([
'fullscreen',
// Agent-browser clipboard commands execute via CDP in this session; denying
// them breaks trusted runtime commands even when invoked with a user gesture.
'clipboard-read',
'clipboard-sanitized-write',
// User-opened browser pages need these profile-scoped grants to complete
// normal site flows like web push setup and durable app storage.
'notifications',
// Chromium can request this at runtime even though Electron's TS union does
// not list it; chatgpt.com uses it to keep browser storage from eviction.
'persistent-storage'
])
export function isAutoGrantedBrowserSessionPermission(permission: string): boolean {
return AUTO_GRANTED_BROWSER_PERMISSIONS.has(permission)
}
@@ -257,23 +257,35 @@ describe('BrowserSessionRegistry persistence', () => {
requestHandler(guestWc, 'clipboard-read', permissionCallback)
requestHandler(guestWc, 'clipboard-sanitized-write', permissionCallback)
requestHandler(guestWc, 'notifications', permissionCallback)
requestHandler(guestWc, 'persistent-storage', permissionCallback)
requestHandler(guestWc, 'geolocation', permissionCallback)
requestHandler(guestWc, 'media', permissionCallback, { mediaTypes: ['video'] })
await vi.waitFor(() =>
expect(permissionCallback.mock.calls).toEqual([[true], [true], [true], [false], [true]])
expect(permissionCallback.mock.calls).toEqual([
[true],
[true],
[true],
[true],
[true],
[false],
[true]
])
)
expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({
guestWebContentsId: 401,
permission: 'notifications',
permission: 'geolocation',
rawUrl: 'https://example.com/account'
})
expect(
browserManagerNotifyPermissionDeniedMock.mock.calls.map(([args]) => args.permission)
).toEqual(['notifications'])
).toEqual(['geolocation'])
expect(checkHandler(null, 'fullscreen', '')).toBe(true)
expect(checkHandler(null, 'clipboard-read', '')).toBe(true)
expect(checkHandler(null, 'clipboard-sanitized-write', '')).toBe(true)
expect(checkHandler(null, 'notifications', '')).toBe(false)
expect(checkHandler(null, 'notifications', '')).toBe(true)
expect(checkHandler(null, 'persistent-storage', '')).toBe(true)
expect(checkHandler(null, 'geolocation', '')).toBe(false)
expect(checkHandler(null, 'media', '', { mediaType: 'video' })).toBe(true)
expect(defaultSession.setDisplayMediaRequestHandler).toHaveBeenCalled()
const displayMediaHandler = defaultSession.setDisplayMediaRequestHandler.mock.calls[0][0]
@@ -206,7 +206,9 @@ describe('BrowserSessionRegistry', () => {
await vi.waitFor(() => expect(cb).toHaveBeenCalledWith(true))
expect(checkHandler(null, 'media', '', { mediaType: 'video' })).toBe(true)
expect(checkHandler(null, 'notifications', '', {})).toBe(false)
expect(checkHandler(null, 'notifications', '', {})).toBe(true)
expect(checkHandler(null, 'persistent-storage', '', {})).toBe(true)
expect(checkHandler(null, 'geolocation', '', {})).toBe(false)
})
it('wires WebAuthn device selection for isolated partitions', () => {
+3 -6
View File
@@ -20,6 +20,7 @@ import type { BrowserSessionProfile, BrowserSessionProfileScope } from '../../sh
import { browserManager } from './browser-manager'
import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access'
import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua'
import { isAutoGrantedBrowserSessionPermission } from './browser-session-permission-policy'
import {
allowsBrowserWebAuthnPermission,
clearBrowserWebAuthnAccessHandlers,
@@ -493,10 +494,6 @@ class BrowserSessionRegistry {
sess.setUserAgent(cleanUA)
setupClientHintsOverride(sess, cleanUA)
}
// Why: agent-browser clipboard commands execute via CDP in this session.
// Until there is a separate trusted bridge, denying clipboard-read breaks
// those runtime commands even when invoked with a user gesture.
const autoGranted = new Set(['fullscreen', 'clipboard-read', 'clipboard-sanitized-write'])
sess.setPermissionRequestHandler((webContents, permission, callback, details) => {
// Why: `media` (camera/mic) must defer to macOS TCC instead of being
// denied outright. Denying at the session layer would make pages inside
@@ -530,7 +527,7 @@ class BrowserSessionRegistry {
)
return
}
const allowed = autoGranted.has(permission)
const allowed = isAutoGrantedBrowserSessionPermission(permission)
if (!allowed) {
browserManager.notifyPermissionDenied({
guestWebContentsId: webContents.id,
@@ -547,7 +544,7 @@ class BrowserSessionRegistry {
if (allowsBrowserWebAuthnPermission(permission, details)) {
return true
}
return autoGranted.has(permission)
return isAutoGrantedBrowserSessionPermission(permission)
})
installBrowserWebAuthnAccessHandlers(sess)
sess.setDisplayMediaRequestHandler((_request, callback) => {