fix(browser): route in-app webcam/mic requests through macOS TCC (#1291)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-04-30 17:19:07 -07:00 committed by GitHub
parent caee6f91f8
commit dcf46e367e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 203 additions and 78 deletions

View File

@ -0,0 +1,63 @@
import { systemPreferences } from 'electron'
import type { MediaAccessPermissionRequest } from 'electron'
// Why: macOS gates all camera/microphone access at the app-process level via
// TCC. Electron's per-session permission handlers run inside that envelope:
// if we call callback(true) but macOS has not granted the parent app, the
// stream is still empty. Conversely, if we deny at the session handler, pages
// never see the stream even when macOS has granted — which is the bug the user
// hit inside the in-app browser (#1273 only fixed Settings → Permissions, not
// the actual runtime getUserMedia() path).
//
// These helpers let both the main window session and the browser-tab sessions
// consult the same macOS-aware logic, so once a user has granted Camera or
// Microphone to Orca (via Settings → Permissions or directly in System
// Settings), a page inside an in-app browser tab actually receives the stream.
export function requestedMediaTypes(
details: MediaAccessPermissionRequest | undefined
): Set<'audio' | 'video'> {
return new Set(details?.mediaTypes ?? [])
}
export function hasSystemMediaAccess(mediaType: string | undefined): boolean {
if (process.platform !== 'darwin') {
return true
}
if (mediaType === 'audio') {
return systemPreferences.getMediaAccessStatus('microphone') === 'granted'
}
if (mediaType === 'video') {
return systemPreferences.getMediaAccessStatus('camera') === 'granted'
}
return false
}
export async function requestSystemMediaAccess(
details: MediaAccessPermissionRequest | undefined
): Promise<boolean> {
if (process.platform !== 'darwin') {
return true
}
const mediaTypes = requestedMediaTypes(details)
if (mediaTypes.size === 0) {
return false
}
if (mediaTypes.has('audio')) {
// Why: macOS only shows the TCC prompt from the app process, so Chromium's
// media grant is paired with the OS-level request at the actual media ask.
const granted = await systemPreferences.askForMediaAccess('microphone')
if (!granted) {
return false
}
}
if (mediaTypes.has('video')) {
const granted = await systemPreferences.askForMediaAccess('camera')
if (!granted) {
return false
}
}
return true
}

View File

@ -1,12 +1,20 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { sessionFromPartitionMock } = vi.hoisted(() => ({
sessionFromPartitionMock: vi.fn()
}))
const { sessionFromPartitionMock, askForMediaAccessMock, getMediaAccessStatusMock } = vi.hoisted(
() => ({
sessionFromPartitionMock: vi.fn(),
askForMediaAccessMock: vi.fn(),
getMediaAccessStatusMock: vi.fn()
})
)
vi.mock('electron', () => ({
session: {
fromPartition: sessionFromPartitionMock
},
systemPreferences: {
askForMediaAccess: askForMediaAccessMock,
getMediaAccessStatus: getMediaAccessStatusMock
}
}))
@ -24,6 +32,10 @@ import { ORCA_BROWSER_PARTITION } from '../../shared/constants'
describe('BrowserSessionRegistry', () => {
beforeEach(() => {
sessionFromPartitionMock.mockReset()
askForMediaAccessMock.mockReset()
getMediaAccessStatusMock.mockReset()
askForMediaAccessMock.mockResolvedValue(true)
getMediaAccessStatusMock.mockReturnValue('granted')
sessionFromPartitionMock.mockReturnValue({
setPermissionRequestHandler: vi.fn(),
setPermissionCheckHandler: vi.fn(),
@ -147,6 +159,25 @@ describe('BrowserSessionRegistry', () => {
expect(mockSession?.setPermissionCheckHandler).toHaveBeenCalled()
})
it('routes media permission requests through macOS TCC for isolated partitions', async () => {
// Why: verify the parallel fix to the default partition — isolated/imported
// profiles must also defer media permission checks to macOS instead of
// denying outright, otherwise pages inside them still hit NotAllowedError
// after the user grants Camera/Microphone to Orca.
browserSessionRegistry.createProfile('isolated', 'Media Test')
const mockSession = sessionFromPartitionMock.mock.results[0]?.value
const requestHandler = mockSession.setPermissionRequestHandler.mock.calls[0][0]
const checkHandler = mockSession.setPermissionCheckHandler.mock.calls[0][0]
const cb = vi.fn()
const guestWc = { id: 7, getURL: vi.fn(() => 'https://example.com/') }
requestHandler(guestWc, 'media', cb, { mediaTypes: ['video'] })
await vi.waitFor(() => expect(cb).toHaveBeenCalledWith(true))
expect(checkHandler(null, 'media', '', { mediaType: 'video' })).toBe(true)
expect(checkHandler(null, 'notifications', '', {})).toBe(false)
})
describe('setupClientHintsOverride', () => {
it('overrides sec-ch-ua headers for Edge UA', () => {
const onBeforeSendHeaders = vi.fn()

View File

@ -16,6 +16,7 @@ import { join } from 'node:path'
import { ORCA_BROWSER_PARTITION } from '../../shared/constants'
import type { BrowserSessionProfile, BrowserSessionProfileScope } from '../../shared/types'
import { browserManager } from './browser-manager'
import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access'
import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua'
type BrowserSessionMeta = {
@ -358,7 +359,39 @@ class BrowserSessionRegistry {
// clipboard commands to work. Without these, navigator.clipboard.writeText/readText
// throws NotAllowedError even when invoked via CDP with userGesture:true.
const autoGranted = new Set(['fullscreen', 'clipboard-read', 'clipboard-sanitized-write'])
sess.setPermissionRequestHandler((webContents, permission, callback) => {
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
// isolated browser profiles throw NotAllowedError even after the user
// granted Camera/Microphone to Orca — the same bug we fixed for the
// default partition. macOS TCC still gates the actual stream, so
// granting here only forwards what the OS has already authorized.
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 = autoGranted.has(permission)
if (!allowed) {
browserManager.notifyPermissionDenied({
@ -369,7 +402,10 @@ class BrowserSessionRegistry {
}
callback(allowed)
})
sess.setPermissionCheckHandler((_webContents, permission) => {
sess.setPermissionCheckHandler((_webContents, permission, _origin, details) => {
if (permission === 'media') {
return hasSystemMediaAccess(details?.mediaType)
}
return autoGranted.has(permission)
})
sess.setDisplayMediaRequestHandler((_request, callback) => {

View File

@ -196,7 +196,7 @@ describe('attachMainWindowServices', () => {
}
})
it('denies browser-session permissions, display capture, and downloads by default', () => {
it('denies browser-session permissions, display capture, and downloads by default', async () => {
const browserSessionOnMock = vi.fn()
sessionFromPartitionMock.mockReturnValue({
setPermissionRequestHandler: setPermissionRequestHandlerMock,
@ -214,35 +214,39 @@ describe('attachMainWindowServices', () => {
const browserPermissionHandler = setPermissionRequestHandlerMock.mock.calls[1][0] as (
wc: unknown,
permission: string,
callback: (allowed: boolean) => void
callback: (allowed: boolean) => void,
details?: unknown
) => void
const permissionCallback = vi.fn()
const guestWebContents = { id: 401, getURL: vi.fn(() => 'https://example.com/account') }
browserPermissionHandler(guestWebContents, 'fullscreen', permissionCallback)
browserPermissionHandler(guestWebContents, 'media', permissionCallback)
expect(permissionCallback.mock.calls).toEqual([[true], [false]])
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: 'media',
permission: 'notifications',
rawUrl: 'https://example.com/account'
})
const browserPermissionCheckHandler = setPermissionCheckHandlerMock.mock.calls[1][0] as (
const browserCheckHandler = setPermissionCheckHandlerMock.mock.calls[1][0] as (
wc: unknown,
permission: string
permission: string,
origin: string,
details?: { mediaType?: 'video' | 'audio' | 'unknown' }
) => boolean
expect(browserPermissionCheckHandler(null, 'fullscreen')).toBe(true)
expect(browserPermissionCheckHandler(null, 'notifications')).toBe(false)
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] as (
request: unknown,
callback: (streams: { video: null; audio: null }) => void
) => void
const displayCallback = vi.fn()
displayMediaHandler(null, displayCallback)
expect(displayCallback).toHaveBeenCalledWith({ video: undefined, audio: undefined })
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'

View File

@ -1,8 +1,8 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { app, clipboard, ipcMain, nativeImage, session, systemPreferences } from 'electron'
import type { BrowserWindow, MediaAccessPermissionRequest } from 'electron'
import { app, clipboard, ipcMain, nativeImage, session } from 'electron'
import type { BrowserWindow } from 'electron'
import type { Store } from '../persistence'
import type { CreateWorktreeResult } from '../../shared/types'
import { ORCA_BROWSER_PARTITION } from '../../shared/constants'
@ -11,6 +11,7 @@ import { registerWorktreeHandlers } from '../ipc/worktrees'
import { registerPtyHandlers } from '../ipc/pty'
import { registerSshHandlers } from '../ipc/ssh'
import { browserManager } from '../browser/browser-manager'
import { hasSystemMediaAccess, requestSystemMediaAccess } from '../browser/browser-media-access'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import {
checkForUpdatesFromMenu,
@ -106,10 +107,42 @@ export function attachMainWindowServices(
)
const browserSession = session.fromPartition(ORCA_BROWSER_PARTITION)
browserSession.setPermissionRequestHandler((webContents, permission, callback) => {
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({
@ -120,8 +153,14 @@ export function attachMainWindowServices(
}
callback(allowed)
})
browserSession.setPermissionCheckHandler((_webContents, permission) => {
return permission === 'fullscreen'
browserSession.setPermissionCheckHandler((_webContents, permission, _origin, details) => {
if (permission === 'fullscreen') {
return true
}
if (permission === 'media') {
return hasSystemMediaAccess(details?.mediaType)
}
return false
})
browserSession.setDisplayMediaRequestHandler((_request, callback) => {
// Why: arbitrary sites inside Orca should never be able to capture the
@ -147,54 +186,6 @@ export function attachMainWindowServices(
})
}
function requestedMediaTypes(
details: MediaAccessPermissionRequest | undefined
): Set<'audio' | 'video'> {
return new Set(details?.mediaTypes ?? [])
}
function hasSystemMediaAccess(mediaType: string | undefined): boolean {
if (process.platform !== 'darwin') {
return true
}
if (mediaType === 'audio') {
return systemPreferences.getMediaAccessStatus('microphone') === 'granted'
}
if (mediaType === 'video') {
return systemPreferences.getMediaAccessStatus('camera') === 'granted'
}
return false
}
async function requestSystemMediaAccess(
details: MediaAccessPermissionRequest | undefined
): Promise<boolean> {
if (process.platform !== 'darwin') {
return true
}
const mediaTypes = requestedMediaTypes(details)
if (mediaTypes.size === 0) {
return false
}
if (mediaTypes.has('audio')) {
// Why: macOS only shows the TCC prompt from the app process, so Chromium's
// media grant is paired with the OS-level request at the actual media ask.
const granted = await systemPreferences.askForMediaAccess('microphone')
if (!granted) {
return false
}
}
if (mediaTypes.has('video')) {
const granted = await systemPreferences.askForMediaAccess('camera')
if (!granted) {
return false
}
}
return true
}
function registerRuntimeWindowLifecycle(
mainWindow: BrowserWindow,
runtime: OrcaRuntimeService