Fix browser clipboard permission handling (#4689)

* fix(browser): set up session policies for default browser partition

The default browser partition (persist:orca-browser) was created in the
registry constructor but never got session policies — permission handlers,
download handlers, WebAuthn handlers, etc. — because:

1. hydrateFromPersisted() explicitly skips the default partition
2. createProfile() never targets the default partition
3. restorePersistedUserAgent() only restored the user agent, not policies

This meant sites in the default browser partition that requested
clipboard-sanitized-write (or any other permission) were denied by
Electron's default behavior, since no handler was registered.

Fix: call setupSessionPolicies(ORCA_BROWSER_PARTITION) in
restorePersistedUserAgent(), which is the startup path that runs after
app is ready. The configuredPartitions guard makes this idempotent.

Also adds a test verifying the default partition gets its session
policies on restore.

Co-authored-by: Orca <help@stably.ai>

* fix(browser): preserve clipboard permissions for browser session

Co-authored-by: Orca <help@stably.ai>

* Initialize browser sessions at app startup

- Move cookie replay and persisted session setup out of renderer IPC wiring
- Install default browser partition policies once per process before webviews load
- Cover startup ordering, retry behavior, permission denial, downloads, and WebAuthn policies

---------

Co-authored-by: Prethish-Complyance <prethish@complyance.io>
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
Prethish Kumar 2026-06-06 01:50:52 +05:30 committed by GitHub
parent f0ea7a39a7
commit bae0a88567
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 333 additions and 350 deletions

View File

@ -24,6 +24,9 @@ function installModuleMocks(
): {
sessionFromPartitionMock: ReturnType<typeof vi.fn>
setupClientHintsOverrideMock: ReturnType<typeof vi.fn>
browserManagerHandleGuestWillDownloadMock: ReturnType<typeof vi.fn>
browserManagerNotifyPermissionDeniedMock: ReturnType<typeof vi.fn>
requestSystemMediaAccessMock: ReturnType<typeof vi.fn>
} {
const sessionFromPartitionMock = vi.fn((partition: string) => ({
partition,
@ -39,6 +42,9 @@ function installModuleMocks(
clearCache: vi.fn().mockResolvedValue(undefined)
}))
const setupClientHintsOverrideMock = vi.fn()
const browserManagerHandleGuestWillDownloadMock = vi.fn()
const browserManagerNotifyPermissionDeniedMock = vi.fn()
const requestSystemMediaAccessMock = vi.fn().mockResolvedValue(true)
vi.doMock('electron', () => ({
app: { getPath: vi.fn(() => USER_DATA) },
@ -92,20 +98,26 @@ function installModuleMocks(
vi.doMock('./browser-manager', () => ({
browserManager: {
notifyPermissionDenied: vi.fn(),
handleGuestWillDownload: vi.fn()
notifyPermissionDenied: browserManagerNotifyPermissionDeniedMock,
handleGuestWillDownload: browserManagerHandleGuestWillDownloadMock
}
}))
vi.doMock('./browser-media-access', () => ({
hasSystemMediaAccess: vi.fn(() => true),
requestSystemMediaAccess: vi.fn().mockResolvedValue(true)
requestSystemMediaAccess: requestSystemMediaAccessMock
}))
vi.doMock('./browser-session-ua', () => ({
cleanElectronUserAgent: vi.fn((ua: string) => ua.replace(/\s*Electron\/\S+/, '')),
setupClientHintsOverride: setupClientHintsOverrideMock
}))
return { sessionFromPartitionMock, setupClientHintsOverrideMock }
return {
sessionFromPartitionMock,
setupClientHintsOverrideMock,
browserManagerHandleGuestWillDownloadMock,
browserManagerNotifyPermissionDeniedMock,
requestSystemMediaAccessMock
}
}
describe('BrowserSessionRegistry persistence', () => {
@ -191,7 +203,7 @@ describe('BrowserSessionRegistry persistence', () => {
const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState)
const { browserSessionRegistry } = await import('./browser-session-registry')
browserSessionRegistry.restorePersistedUserAgent()
browserSessionRegistry.initializeBrowserSessionsFromPersistedState()
const importedSessions = sessionFromPartitionMock.mock.results
.filter((_, idx) => sessionFromPartitionMock.mock.calls[idx]?.[0] === importedPartition)
@ -211,6 +223,208 @@ describe('BrowserSessionRegistry persistence', () => {
).toBe(true)
})
it('sets up default-partition policies on restore', async () => {
const fsState = createFsState()
seedMeta(fsState, {
defaultSource: null,
userAgent: null,
userAgentByPartition: {},
pendingCookieDbPath: null,
pendingCookieImports: {},
profiles: []
})
const {
sessionFromPartitionMock,
browserManagerHandleGuestWillDownloadMock,
browserManagerNotifyPermissionDeniedMock
} = installModuleMocks(fsState)
const { browserSessionRegistry } = await import('./browser-session-registry')
browserSessionRegistry.initializeBrowserSessionsFromPersistedState()
const defaultSessions = sessionFromPartitionMock.mock.results
.filter((_, idx) => sessionFromPartitionMock.mock.calls[idx]?.[0] === 'persist:orca-browser')
.map((r) => r.value)
expect(defaultSessions.length).toBeGreaterThan(0)
const defaultSession = defaultSessions[0]
const requestHandler = defaultSession.setPermissionRequestHandler.mock.calls[0][0]
const checkHandler = defaultSession.setPermissionCheckHandler.mock.calls[0][0]
const guestWc = { id: 401, getURL: vi.fn(() => 'https://example.com/account') }
const permissionCallback = vi.fn()
requestHandler(guestWc, 'fullscreen', permissionCallback)
requestHandler(guestWc, 'clipboard-read', permissionCallback)
requestHandler(guestWc, 'clipboard-sanitized-write', permissionCallback)
requestHandler(guestWc, 'notifications', permissionCallback)
requestHandler(guestWc, 'media', permissionCallback, { mediaTypes: ['video'] })
await vi.waitFor(() =>
expect(permissionCallback.mock.calls).toEqual([[true], [true], [true], [false], [true]])
)
expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({
guestWebContentsId: 401,
permission: 'notifications',
rawUrl: 'https://example.com/account'
})
expect(
browserManagerNotifyPermissionDeniedMock.mock.calls.map(([args]) => args.permission)
).toEqual(['notifications'])
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, 'media', '', { mediaType: 'video' })).toBe(true)
expect(defaultSession.setDisplayMediaRequestHandler).toHaveBeenCalled()
const displayMediaHandler = defaultSession.setDisplayMediaRequestHandler.mock.calls[0][0]
const displayMediaCallback = vi.fn()
displayMediaHandler(null, displayMediaCallback)
expect(displayMediaCallback).toHaveBeenCalledWith({ video: undefined, audio: undefined })
const devicePermissionHandler = defaultSession.setDevicePermissionHandler.mock.calls[0][0]
expect(
devicePermissionHandler({
deviceType: 'hid',
origin: 'https://github.com',
device: { collections: [{ usagePage: 0xf1d0 }] }
})
).toBe(true)
expect(checkHandler(null, 'hid', '', { securityOrigin: 'https://github.com' })).toBe(true)
const selectHidHandler = defaultSession.on.mock.calls.find(
([eventName]: unknown[]) => eventName === 'select-hid-device'
)?.[1] as (
event: { preventDefault: () => void },
details: {
deviceList: { deviceId: string; collections?: { usagePage?: number }[] }[]
frame: { url: string }
},
callback: (deviceId?: string) => void
) => void
const hidCallback = vi.fn()
selectHidHandler(
{ preventDefault: vi.fn() },
{
frame: { url: 'https://github.com' },
deviceList: [
{ deviceId: 'keyboard', collections: [{ usagePage: 1 }] },
{ deviceId: 'security-key', collections: [{ usagePage: 0xf1d0 }] }
]
},
hidCallback
)
expect(hidCallback).toHaveBeenCalledWith('security-key')
const selectWebAuthnHandler = defaultSession.on.mock.calls.find(
([eventName]: unknown[]) => eventName === 'select-webauthn-account'
)?.[1] as (
event: { preventDefault: () => void },
details: { accounts: { credentialId: string }[] },
callback: (credentialId?: string | null) => void
) => void
const webAuthnCallback = vi.fn()
selectWebAuthnHandler(
{ preventDefault: vi.fn() },
{ accounts: [{ credentialId: 'credential-1' }] },
webAuthnCallback
)
expect(webAuthnCallback).toHaveBeenCalledWith('credential-1')
const willDownloadHandler = defaultSession.on.mock.calls.find(
([eventName]: unknown[]) => eventName === 'will-download'
)?.[1] as (
event: unknown,
item: { getFilename: () => string },
webContents: { id: number }
) => void
expect(willDownloadHandler).toBeTypeOf('function')
const item = { getFilename: vi.fn(() => 'report.pdf') }
willDownloadHandler({}, item, { id: 402 })
expect(browserManagerHandleGuestWillDownloadMock).toHaveBeenCalledWith({
guestWebContentsId: 402,
item
})
})
it('does not stack default-partition policy handlers on repeated restore', async () => {
const fsState = createFsState()
seedMeta(fsState, {
defaultSource: null,
userAgent: null,
userAgentByPartition: {},
pendingCookieDbPath: null,
pendingCookieImports: {},
profiles: []
})
const { sessionFromPartitionMock } = installModuleMocks(fsState)
const { browserSessionRegistry } = await import('./browser-session-registry')
browserSessionRegistry.initializeBrowserSessionsFromPersistedState()
browserSessionRegistry.initializeBrowserSessionsFromPersistedState()
const defaultSessions = sessionFromPartitionMock.mock.results
.filter((_, idx) => sessionFromPartitionMock.mock.calls[idx]?.[0] === 'persist:orca-browser')
.map((r) => r.value)
const policySessions = defaultSessions.filter(
(s) => s.setPermissionRequestHandler.mock.calls.length > 0
)
expect(policySessions).toHaveLength(1)
expect(
policySessions[0].on.mock.calls.filter(
([eventName]: unknown[]) => eventName === 'will-download'
)
).toHaveLength(1)
expect(
policySessions[0].on.mock.calls.filter(
([eventName]: unknown[]) => eventName === 'select-hid-device'
)
).toHaveLength(1)
expect(
policySessions[0].on.mock.calls.filter(
([eventName]: unknown[]) => eventName === 'select-webauthn-account'
)
).toHaveLength(1)
})
it('notifies when default-partition media permission is denied', async () => {
const fsState = createFsState()
seedMeta(fsState, {
defaultSource: null,
userAgent: null,
userAgentByPartition: {},
pendingCookieDbPath: null,
pendingCookieImports: {},
profiles: []
})
const {
sessionFromPartitionMock,
browserManagerNotifyPermissionDeniedMock,
requestSystemMediaAccessMock
} = installModuleMocks(fsState)
requestSystemMediaAccessMock.mockResolvedValue(false)
const { browserSessionRegistry } = await import('./browser-session-registry')
browserSessionRegistry.initializeBrowserSessionsFromPersistedState()
const defaultSession = sessionFromPartitionMock.mock.results.find(
(_, idx) => sessionFromPartitionMock.mock.calls[idx]?.[0] === 'persist:orca-browser'
)?.value
const requestHandler = defaultSession.setPermissionRequestHandler.mock.calls[0][0]
const guestWc = { id: 403, getURL: vi.fn(() => 'https://example.com/camera') }
const callback = vi.fn()
requestHandler(guestWc, 'media', callback, { mediaTypes: ['video'] })
await vi.waitFor(() => expect(callback).toHaveBeenCalledWith(false))
expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({
guestWebContentsId: 403,
permission: 'media',
rawUrl: 'https://example.com/camera'
})
})
it('keeps failed partition replay pending and removes unrelated missing entries', async () => {
const importedPartition = 'persist:orca-browser-session-22222222-2222-4222-8222-222222222222'
const fsState = createFsState()

View File

@ -140,16 +140,17 @@ class BrowserSessionRegistry {
}
}
// Why: the User-Agent must be set on the session BEFORE any webview loads,
// otherwise the first request uses Electron's default UA and the server may
// invalidate the imported session cookies.
// Why: browser sessions must be initialized BEFORE any webview loads.
// Permission/download policies must exist before sites request capabilities,
// and the User-Agent must be set before the first request so imported session
// cookies are not invalidated by Electron's default UA.
//
// Why this also refreshes defaultSource: the singleton constructor runs at
// module-import time, which may be before app.isReady(). app.getPath('userData')
// is not guaranteed before ready, so the constructor's loadPersistedSource()
// silently returns null. Re-reading here (called from registerCoreHandlers,
// after app is ready) ensures the default profile's source is populated.
restorePersistedUserAgent(): void {
// silently returns null. Re-reading here after app readiness ensures the
// default profile's source is populated.
initializeBrowserSessionsFromPersistedState(): void {
const meta = this.loadPersistedMeta()
if (meta.defaultSource) {
const current = this.profiles.get('default')
@ -161,6 +162,13 @@ class BrowserSessionRegistry {
this.hydrateFromPersisted(meta.profiles)
}
// Why: the default partition is created in the constructor but never gets
// session policies (permission handlers, download handlers, etc.) because
// hydrateFromPersisted skips the default partition and createProfile never
// targets it. Without this, clipboard permissions and other guest policies
// are denied by default in the default browser partition.
this.setupSessionPolicies(ORCA_BROWSER_PARTITION)
const partitions = new Set([
ORCA_BROWSER_PARTITION,
...this.listProfiles().map((p) => p.partition)
@ -462,10 +470,9 @@ class BrowserSessionRegistry {
}
}
// Why: each non-default partition needs the same deny-by-default permission
// and download policies as the shared partition. Without this, newly created
// session partitions would silently allow permissions and downloads that the
// shared partition correctly denies.
// Why: every browser partition needs the same deny-by-default permission
// and download policies. Keeping the installer here prevents the default
// partition and imported/isolated partitions from drifting apart.
private readonly configuredPartitions = new Set<string>()
private readonly handleWillDownload = (
_event: Electron.Event,
@ -479,7 +486,6 @@ class BrowserSessionRegistry {
if (this.configuredPartitions.has(partition)) {
return
}
this.configuredPartitions.add(partition)
const sess = session.fromPartition(partition)
if (typeof sess.getUserAgent === 'function') {
@ -487,9 +493,9 @@ class BrowserSessionRegistry {
sess.setUserAgent(cleanUA)
setupClientHintsOverride(sess, cleanUA)
}
// Why: clipboard-read and clipboard-sanitized-write are required for agent-browser's
// clipboard commands to work. Without these, navigator.clipboard.writeText/readText
// throws NotAllowedError even when invoked via CDP with userGesture:true.
// 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
@ -549,6 +555,7 @@ class BrowserSessionRegistry {
})
sess.removeListener('will-download', this.handleWillDownload)
sess.on('will-download', this.handleWillDownload)
this.configuredPartitions.add(partition)
}
private clearSessionPolicies(partition: string, sess: Session): void {

View File

@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
function installRegistryMock(): {
applyPendingCookieImportMock: ReturnType<typeof vi.fn>
initializeBrowserSessionsFromPersistedStateMock: ReturnType<typeof vi.fn>
} {
const applyPendingCookieImportMock = vi.fn()
const initializeBrowserSessionsFromPersistedStateMock = vi.fn()
vi.doMock('./browser-session-registry', () => ({
browserSessionRegistry: {
applyPendingCookieImport: applyPendingCookieImportMock,
initializeBrowserSessionsFromPersistedState: initializeBrowserSessionsFromPersistedStateMock
}
}))
return {
applyPendingCookieImportMock,
initializeBrowserSessionsFromPersistedStateMock
}
}
describe('initializeBrowserSessionsForApp', () => {
beforeEach(() => {
vi.resetModules()
vi.restoreAllMocks()
})
it('replays pending cookie imports before initializing browser sessions', async () => {
const { applyPendingCookieImportMock, initializeBrowserSessionsFromPersistedStateMock } =
installRegistryMock()
const { initializeBrowserSessionsForApp } = await import('./browser-session-startup')
initializeBrowserSessionsForApp()
expect(applyPendingCookieImportMock).toHaveBeenCalledOnce()
expect(initializeBrowserSessionsFromPersistedStateMock).toHaveBeenCalledOnce()
expect(applyPendingCookieImportMock.mock.invocationCallOrder[0]).toBeLessThan(
initializeBrowserSessionsFromPersistedStateMock.mock.invocationCallOrder[0]
)
})
it('initializes browser sessions once per app process', async () => {
const { applyPendingCookieImportMock, initializeBrowserSessionsFromPersistedStateMock } =
installRegistryMock()
const { initializeBrowserSessionsForApp } = await import('./browser-session-startup')
initializeBrowserSessionsForApp()
initializeBrowserSessionsForApp()
expect(applyPendingCookieImportMock).toHaveBeenCalledOnce()
expect(initializeBrowserSessionsFromPersistedStateMock).toHaveBeenCalledOnce()
})
it('retries if initialization fails before completion', async () => {
const { applyPendingCookieImportMock, initializeBrowserSessionsFromPersistedStateMock } =
installRegistryMock()
initializeBrowserSessionsFromPersistedStateMock.mockImplementationOnce(() => {
throw new Error('session init failed')
})
const { initializeBrowserSessionsForApp } = await import('./browser-session-startup')
expect(() => initializeBrowserSessionsForApp()).toThrow('session init failed')
initializeBrowserSessionsForApp()
expect(applyPendingCookieImportMock).toHaveBeenCalledTimes(2)
expect(initializeBrowserSessionsFromPersistedStateMock).toHaveBeenCalledTimes(2)
})
})

View File

@ -0,0 +1,15 @@
import { browserSessionRegistry } from './browser-session-registry'
let initialized = false
export function initializeBrowserSessionsForApp(): void {
if (initialized) {
return
}
// Why: cookie replay must happen before the first session.fromPartition()
// call, otherwise Chromium opens the stale live cookie DB before import.
browserSessionRegistry.applyPendingCookieImport()
browserSessionRegistry.initializeBrowserSessionsFromPersistedState()
initialized = true
}

View File

@ -100,6 +100,7 @@ import {
} from './ipc/pty'
import { AgentBrowserBridge } from './browser/agent-browser-bridge'
import { browserManager } from './browser/browser-manager'
import { initializeBrowserSessionsForApp } from './browser/browser-session-startup'
import { setUnreadDockBadgeCount } from './dock/unread-badge'
import { AutomationService } from './automations/service'
import { AgentAwakeService } from './agent-awake-service'
@ -1064,6 +1065,9 @@ app.whenReady().then(async () => {
} catch {
console.warn('[proxy] Failed to apply network proxy settings')
}
// Why: browser sessions are used by desktop webviews and runtime profile
// commands, so initialize them at app startup instead of a renderer IPC path.
initializeBrowserSessionsForApp()
agentAwakeService = new AgentAwakeService()
agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun)
// Why: disk-hydrated status rows are UI continuity only. The service starts

View File

@ -39,7 +39,6 @@ import { registerAutomationHandlers } from './automations'
import { registerKeybindingHandlers } from './keybindings'
import { registerTelemetryHandlers } from './telemetry'
import { registerBrowserHandlers } from './browser'
import { browserSessionRegistry } from '../browser/browser-session-registry'
import { registerShellHandlers } from './shell'
import { registerPetHandlers } from './pet'
import { registerUIHandlers } from './ui'
@ -140,12 +139,6 @@ export function registerCoreHandlers(
}
registerTelemetryHandlers(store)
registerBrowserHandlers()
// Why: applyPendingCookieImport MUST run before restorePersistedUserAgent
// because the latter calls session.fromPartition() which initializes
// CookieMonster. The pending import replaces the live DB file so
// CookieMonster reads the imported cookies on first access.
browserSessionRegistry.applyPendingCookieImport()
browserSessionRegistry.restorePersistedUserAgent()
registerShellHandlers()
registerPetHandlers()
registerSessionHandlers(store)

View File

@ -7,8 +7,6 @@ const {
removeListenerMock,
setPermissionRequestHandlerMock,
setPermissionCheckHandlerMock,
setDevicePermissionHandlerMock,
setDisplayMediaRequestHandlerMock,
handleMock,
removeHandlerMock,
systemPreferencesAskForMediaAccessMock,
@ -17,18 +15,13 @@ const {
registerWorktreeHandlersMock,
registerPtyHandlersMock,
setupAutoUpdaterMock,
sessionFromPartitionMock,
browserManagerUnregisterAllMock,
browserManagerNotifyPermissionDeniedMock,
browserManagerHandleGuestWillDownloadMock
browserManagerUnregisterAllMock
} = vi.hoisted(() => ({
onMock: vi.fn(),
removeAllListenersMock: vi.fn(),
removeListenerMock: vi.fn(),
setPermissionRequestHandlerMock: vi.fn(),
setPermissionCheckHandlerMock: vi.fn(),
setDevicePermissionHandlerMock: vi.fn(),
setDisplayMediaRequestHandlerMock: vi.fn(),
handleMock: vi.fn(),
removeHandlerMock: vi.fn(),
systemPreferencesAskForMediaAccessMock: vi.fn(),
@ -37,18 +30,12 @@ const {
registerWorktreeHandlersMock: vi.fn(),
registerPtyHandlersMock: vi.fn(),
setupAutoUpdaterMock: vi.fn(),
sessionFromPartitionMock: vi.fn(),
browserManagerUnregisterAllMock: vi.fn(),
browserManagerNotifyPermissionDeniedMock: vi.fn(),
browserManagerHandleGuestWillDownloadMock: vi.fn()
browserManagerUnregisterAllMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {},
clipboard: {},
session: {
fromPartition: sessionFromPartitionMock
},
systemPreferences: {
askForMediaAccess: systemPreferencesAskForMediaAccessMock,
getMediaAccessStatus: systemPreferencesGetMediaAccessStatusMock
@ -81,8 +68,6 @@ vi.mock('../ipc/pty', () => ({
vi.mock('../browser/browser-manager', () => ({
browserManager: {
notifyPermissionDenied: browserManagerNotifyPermissionDeniedMock,
handleGuestWillDownload: browserManagerHandleGuestWillDownloadMock,
unregisterAll: browserManagerUnregisterAllMock
}
}))
@ -170,26 +155,13 @@ describe('attachMainWindowServices', () => {
removeHandlerMock.mockReset()
setPermissionRequestHandlerMock.mockReset()
setPermissionCheckHandlerMock.mockReset()
setDevicePermissionHandlerMock.mockReset()
setDisplayMediaRequestHandlerMock.mockReset()
systemPreferencesAskForMediaAccessMock.mockReset()
systemPreferencesGetMediaAccessStatusMock.mockReset()
registerRepoHandlersMock.mockReset()
registerWorktreeHandlersMock.mockReset()
registerPtyHandlersMock.mockReset()
setupAutoUpdaterMock.mockReset()
sessionFromPartitionMock.mockReset()
browserManagerUnregisterAllMock.mockReset()
browserManagerNotifyPermissionDeniedMock.mockReset()
browserManagerHandleGuestWillDownloadMock.mockReset()
sessionFromPartitionMock.mockReturnValue({
setPermissionRequestHandler: setPermissionRequestHandlerMock,
setPermissionCheckHandler: setPermissionCheckHandlerMock,
setDevicePermissionHandler: setDevicePermissionHandlerMock,
setDisplayMediaRequestHandler: setDisplayMediaRequestHandlerMock,
on: vi.fn(),
removeListener: vi.fn()
})
systemPreferencesAskForMediaAccessMock.mockResolvedValue(true)
systemPreferencesGetMediaAccessStatusMock.mockReturnValue('granted')
})
@ -334,7 +306,7 @@ describe('attachMainWindowServices', () => {
it('only allows the explicit permission allowlist', async () => {
attachMainWindowServices(createMainWindow() as never, createStore(), createRuntime() as never)
expect(setPermissionRequestHandlerMock).toHaveBeenCalledTimes(2)
expect(setPermissionRequestHandlerMock).toHaveBeenCalledTimes(1)
const permissionHandler = setPermissionRequestHandlerMock.mock.calls[0][0]
const callback = vi.fn()
@ -369,205 +341,7 @@ describe('attachMainWindowServices', () => {
}
})
it('denies browser-session permissions, display capture, and downloads by default', async () => {
const browserSessionOnMock = vi.fn()
sessionFromPartitionMock.mockReturnValue({
setPermissionRequestHandler: setPermissionRequestHandlerMock,
setPermissionCheckHandler: setPermissionCheckHandlerMock,
setDevicePermissionHandler: setDevicePermissionHandlerMock,
setDisplayMediaRequestHandler: setDisplayMediaRequestHandlerMock,
on: browserSessionOnMock,
removeListener: vi.fn()
})
const mainWindowOnMock = vi.fn()
const mainWindow = createMainWindow()
mainWindow.on = mainWindowOnMock
attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never)
const browserPermissionHandler = setPermissionRequestHandlerMock.mock.calls[1][0] as (
wc: unknown,
permission: string,
callback: (allowed: boolean) => void,
details?: unknown
) => void
const cb = vi.fn()
const guestWc = { id: 401, getURL: vi.fn(() => 'https://example.com/account') }
browserPermissionHandler(guestWc, 'fullscreen', cb)
browserPermissionHandler(guestWc, 'notifications', cb)
// Why: `media` routes through macOS TCC instead of being denied outright,
// so pages inside the in-app browser can use camera/mic once Orca has been
// granted Camera/Microphone at the OS level.
browserPermissionHandler(guestWc, 'media', cb, { mediaTypes: ['video'] })
await vi.waitFor(() => expect(cb.mock.calls).toEqual([[true], [false], [true]]))
expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledTimes(1)
expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({
guestWebContentsId: 401,
permission: 'notifications',
rawUrl: 'https://example.com/account'
})
const browserCheckHandler = setPermissionCheckHandlerMock.mock.calls[1][0] as (
wc: unknown,
permission: string,
origin: string,
details?: { mediaType?: 'video' | 'audio' | 'unknown' }
) => boolean
expect(browserCheckHandler(null, 'fullscreen', '')).toBe(true)
expect(browserCheckHandler(null, 'notifications', '')).toBe(false)
expect(browserCheckHandler(null, 'media', '', { mediaType: 'video' })).toBe(true)
const displayMediaHandler = setDisplayMediaRequestHandlerMock.mock.calls[0][0]
const displayCb = vi.fn()
displayMediaHandler(null, displayCb)
expect(displayCb).toHaveBeenCalledWith({ video: undefined, audio: undefined })
const willDownloadHandler = browserSessionOnMock.mock.calls.find(
([eventName]) => eventName === 'will-download'
)?.[1] as (
event: unknown,
item: { getFilename: () => string },
webContents: { id: number }
) => void
const item = { getFilename: vi.fn(() => 'report.pdf') }
willDownloadHandler({}, item, { id: 402 })
expect(browserManagerHandleGuestWillDownloadMock).toHaveBeenCalledTimes(1)
expect(browserManagerHandleGuestWillDownloadMock).toHaveBeenCalledWith({
guestWebContentsId: 402,
item
})
})
it('wires browser-session WebAuthn device selection for security keys', () => {
const browserSessionOnMock = vi.fn()
sessionFromPartitionMock.mockReturnValue({
setPermissionRequestHandler: setPermissionRequestHandlerMock,
setPermissionCheckHandler: setPermissionCheckHandlerMock,
setDevicePermissionHandler: setDevicePermissionHandlerMock,
setDisplayMediaRequestHandler: setDisplayMediaRequestHandlerMock,
on: browserSessionOnMock,
removeListener: vi.fn()
})
attachMainWindowServices(createMainWindow() as never, createStore(), createRuntime() as never)
expect(setDevicePermissionHandlerMock).toHaveBeenCalledWith(expect.any(Function))
const devicePermissionHandler = setDevicePermissionHandlerMock.mock.calls[0][0] as (details: {
deviceType: string
origin: string
device: { collections?: { usagePage?: number }[] }
}) => boolean
expect(
devicePermissionHandler({
deviceType: 'hid',
origin: 'https://github.com',
device: { collections: [{ usagePage: 0xf1d0 }] }
})
).toBe(true)
expect(
devicePermissionHandler({
deviceType: 'hid',
origin: 'http://[::1]:5173',
device: { collections: [{ usagePage: 0xf1d0 }] }
})
).toBe(true)
expect(
devicePermissionHandler({
deviceType: 'hid',
origin: 'https://github.com',
device: { collections: [{ usagePage: 1 }] }
})
).toBe(false)
const browserCheckHandler = setPermissionCheckHandlerMock.mock.calls[1][0] as (
wc: unknown,
permission: string,
origin: string,
details?: { securityOrigin?: string }
) => boolean
expect(browserCheckHandler(null, 'hid', '', { securityOrigin: 'https://github.com' })).toBe(
true
)
const selectHidHandler = browserSessionOnMock.mock.calls.find(
([eventName]) => eventName === 'select-hid-device'
)?.[1] as (
event: { preventDefault: () => void },
details: {
deviceList: { deviceId: string; collections?: { usagePage?: number }[] }[]
frame: { url: string }
},
callback: (deviceId?: string) => void
) => void
const preventDefault = vi.fn()
const callback = vi.fn()
selectHidHandler(
{ preventDefault },
{
frame: { url: 'https://github.com' },
deviceList: [
{ deviceId: 'keyboard', collections: [{ usagePage: 1 }] },
{ deviceId: 'security-key', collections: [{ usagePage: 0xf1d0 }] }
]
},
callback
)
expect(preventDefault).toHaveBeenCalled()
expect(callback).toHaveBeenCalledWith('security-key')
const selectWebAuthnHandler = browserSessionOnMock.mock.calls.find(
([eventName]) => eventName === 'select-webauthn-account'
)?.[1] as (
event: { preventDefault: () => void },
details: { accounts: { credentialId: string }[] },
callback: (credentialId?: string | null) => void
) => void
const webAuthnCallback = vi.fn()
selectWebAuthnHandler(
{ preventDefault: vi.fn() },
{ accounts: [{ credentialId: 'credential-1' }] },
webAuthnCallback
)
expect(webAuthnCallback).toHaveBeenCalledWith('credential-1')
})
it('replaces the persistent browser-session download handler on re-attach', () => {
const browserSessionOnMock = vi.fn()
const browserSessionRemoveListenerMock = vi.fn()
sessionFromPartitionMock.mockReturnValue({
setPermissionRequestHandler: setPermissionRequestHandlerMock,
setPermissionCheckHandler: setPermissionCheckHandlerMock,
setDevicePermissionHandler: setDevicePermissionHandlerMock,
setDisplayMediaRequestHandler: setDisplayMediaRequestHandlerMock,
on: browserSessionOnMock,
removeListener: browserSessionRemoveListenerMock
})
attachMainWindowServices(createMainWindow() as never, createStore(), createRuntime() as never)
attachMainWindowServices(createMainWindow() as never, createStore(), createRuntime() as never)
const downloadOnCalls = browserSessionOnMock.mock.calls.filter(
([eventName]) => eventName === 'will-download'
)
const downloadRemoveCalls = browserSessionRemoveListenerMock.mock.calls.filter(
([eventName]) => eventName === 'will-download'
)
expect(downloadOnCalls).toHaveLength(2)
expect(downloadRemoveCalls).toHaveLength(2)
expect(downloadRemoveCalls[1][1]).toBe(downloadOnCalls[0][1])
})
it('clears browser guest registrations when the main window closes', () => {
sessionFromPartitionMock.mockReturnValue({
setPermissionRequestHandler: setPermissionRequestHandlerMock,
setPermissionCheckHandler: setPermissionCheckHandlerMock,
setDevicePermissionHandler: setDevicePermissionHandlerMock,
setDisplayMediaRequestHandler: setDisplayMediaRequestHandlerMock,
on: vi.fn(),
removeListener: vi.fn()
})
const mainWindowOnMock = vi.fn()
const mainWindow = createMainWindow()
mainWindow.on = mainWindowOnMock

View File

@ -1,11 +1,10 @@
/* eslint-disable max-lines -- Why: this file is the central main-window IPC wiring point; splitting it during the mobile release compatibility rebase would increase release risk. */
import { randomUUID } from 'node:crypto'
import { app, ipcMain, session } from 'electron'
import type { BrowserWindow, Session } from 'electron'
import { app, ipcMain } from 'electron'
import type { BrowserWindow } from 'electron'
import type { Store } from '../persistence'
import type { CreateWorktreeResult, WorktreeStartupLaunch } from '../../shared/types'
import { ORCA_BROWSER_PARTITION } from '../../shared/constants'
import { registerRepoHandlers } from '../ipc/repos'
import { registerWorktreeHandlers } from '../ipc/worktrees'
import { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup'
@ -15,10 +14,6 @@ import { registerSshHandlers } from '../ipc/ssh'
import { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace'
import { browserManager } from '../browser/browser-manager'
import { hasSystemMediaAccess, requestSystemMediaAccess } from '../browser/browser-media-access'
import {
allowsBrowserWebAuthnPermission,
installBrowserWebAuthnAccessHandlers
} from '../browser/browser-webauthn-access'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import {
checkForUpdatesFromMenu,
@ -146,76 +141,6 @@ export function attachMainWindowServices(
}
)
const browserSession = session.fromPartition(ORCA_BROWSER_PARTITION)
browserSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
// Why: the in-app browser is for dev previews and lightweight browsing, not
// trusted desktop-app privileges. Denying by default keeps arbitrary sites
// from silently escalating into camera/mic/notification prompts inside Orca.
// Why `media` is allowed through: camera/mic are still gated by macOS TCC
// at the app-process level, so granting here only *permits* Chromium to
// use whatever the OS has already authorized for Orca. Denying at this
// layer would make pages inside the in-app browser throw NotAllowedError
// even after the user granted Camera/Microphone via Settings → Permissions
// or System Settings — the bug #1273 partially addressed.
if (permission === 'media') {
void requestSystemMediaAccess(
details as Electron.MediaAccessPermissionRequest | undefined
).then(
(granted) => {
if (!granted) {
browserManager.notifyPermissionDenied({
guestWebContentsId: webContents.id,
permission,
rawUrl: webContents.getURL()
})
}
callback(granted)
},
(error: unknown) => {
console.error('[permissions] Browser media access failed:', error)
browserManager.notifyPermissionDenied({
guestWebContentsId: webContents.id,
permission,
rawUrl: webContents.getURL()
})
callback(false)
}
)
return
}
const allowed = permission === 'fullscreen'
if (!allowed) {
browserManager.notifyPermissionDenied({
guestWebContentsId: webContents.id,
permission,
rawUrl: webContents.getURL()
})
}
callback(allowed)
})
browserSession.setPermissionCheckHandler((_webContents, permission, _origin, details) => {
if (permission === 'fullscreen') {
return true
}
if (permission === 'media') {
return hasSystemMediaAccess(details?.mediaType)
}
if (allowsBrowserWebAuthnPermission(permission, details)) {
return true
}
return false
})
installBrowserWebAuthnAccessHandlers(browserSession)
browserSession.setDisplayMediaRequestHandler((_request, callback) => {
// Why: arbitrary sites inside Orca should never be able to capture the
// desktop or application windows until there is explicit product UX for
// selecting a source and surfacing that choice to the user.
// Why: pass undefined (not null) to satisfy Electron's typed callback
// signature while still denying the request.
callback({ video: undefined, audio: undefined })
})
registerBrowserDownloadHandler(browserSession)
mainWindow.on('closed', () => {
// Why: browser webviews are renderer-owned guest surfaces. Clearing
// main-owned guest registrations on window close prevents stale
@ -224,24 +149,6 @@ export function attachMainWindowServices(
})
}
function handleBrowserWillDownload(
_event: Electron.Event,
item: Electron.DownloadItem,
webContents: Electron.WebContents
): void {
// Why: browser-tab downloads need explicit product UX before arbitrary sites
// can write files through Orca. Pause the item and route it through
// BrowserManager so the user must explicitly accept the save path first.
browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item })
}
function registerBrowserDownloadHandler(browserSession: Session): void {
// Why: browser sessions are process-persistent while main windows can be
// recreated; replace the named handler so re-attach does not stack listeners.
browserSession.removeListener('will-download', handleBrowserWillDownload)
browserSession.on('will-download', handleBrowserWillDownload)
}
function registerAppReloadHandler(
mainWindow: BrowserWindow,
onBeforeRendererReload?: (args: { webContentsId: number; ignoreCache: boolean }) => void