orca/src/main/ipc/browser.ts

623 lines
22 KiB
TypeScript

/* eslint-disable max-lines -- Why: browser IPC handlers must be registered together so the
trust boundary (isTrustedBrowserRenderer) and handler teardown stay consistent. */
import { BrowserWindow, dialog, ipcMain } from 'electron'
import { browserManager } from '../browser/browser-manager'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import { browserSessionRegistry } from '../browser/browser-session-registry'
import {
pickCookieFile,
importCookiesFromFile,
detectInstalledBrowsers,
selectBrowserProfile,
importCookiesFromBrowser
} from '../browser/browser-cookie-import'
import type {
BrowserSetGrabModeArgs,
BrowserSetGrabModeResult,
BrowserAwaitGrabSelectionArgs,
BrowserGrabResult,
BrowserCancelGrabArgs,
BrowserCaptureSelectionScreenshotArgs,
BrowserCaptureSelectionScreenshotResult,
BrowserExtractHoverArgs,
BrowserExtractHoverResult
} from '../../shared/browser-grab-types'
import type {
BrowserCookieImportResult,
BrowserSessionProfile,
BrowserSessionProfileScope,
BrowserViewportOverride
} from '../../shared/types'
import {
isValidBrowserAnnotationViewportBridgeMarkers,
isValidBrowserAnnotationViewportBridgeToken,
type BrowserSetAnnotationViewportBridgeArgs
} from '../../shared/browser-annotation-viewport-bridge'
let trustedBrowserRendererWebContentsId: number | null = null
let agentBrowserBridgeRef: AgentBrowserBridge | null = null
// Why: CLI-driven tab creation must wait until the renderer mounts the webview
// and calls registerGuest, so the tab has a webContentsId and is operable by
// subsequent commands. Multiple commands can wait for the same page during
// startup, so keep all one-shot resolvers keyed by browserPageId.
const pendingTabRegistrations = new Map<string, Set<() => void>>()
const pendingWorktreeTabRegistrations = new Map<string, Set<() => void>>()
const pendingAnyTabRegistrations = new Set<() => void>()
function waitForRegistrationSet(
registrationResolvers: Set<() => void>,
timeoutMs: number,
onEmpty: () => void
): Promise<void> {
return new Promise<void>((resolve, reject) => {
const resolveRegistration = (): void => {
clearTimeout(timer)
resolve()
}
const timer = setTimeout(() => {
registrationResolvers.delete(resolveRegistration)
if (registrationResolvers.size === 0) {
onEmpty()
}
reject(new Error('Tab registration timed out'))
}, timeoutMs)
registrationResolvers.add(resolveRegistration)
})
}
function resolvePendingRegistrations(registrationResolvers: Set<() => void> | undefined): void {
if (!registrationResolvers) {
return
}
for (const pendingResolve of registrationResolvers) {
pendingResolve()
}
}
function hasRegisteredTabForWorktree(worktreeId: string): boolean {
for (const browserPageId of browserManager.getWebContentsIdByTabId().keys()) {
if (browserManager.getWorktreeIdForTab(browserPageId) === worktreeId) {
return true
}
}
return false
}
export function waitForTabRegistration(browserPageId: string, timeoutMs = 8_000): Promise<void> {
if (browserManager.getGuestWebContentsId(browserPageId) !== null) {
return Promise.resolve()
}
let registrationResolvers = pendingTabRegistrations.get(browserPageId)
if (!registrationResolvers) {
registrationResolvers = new Set()
pendingTabRegistrations.set(browserPageId, registrationResolvers)
}
return waitForRegistrationSet(registrationResolvers, timeoutMs, () => {
pendingTabRegistrations.delete(browserPageId)
})
}
export function waitForWorktreeTabRegistration(
worktreeId: string | undefined,
timeoutMs = 8_000
): Promise<void> {
if (!worktreeId) {
return waitForAnyTabRegistration(timeoutMs)
}
if (hasRegisteredTabForWorktree(worktreeId)) {
return Promise.resolve()
}
let registrationResolvers = pendingWorktreeTabRegistrations.get(worktreeId)
if (!registrationResolvers) {
registrationResolvers = new Set()
pendingWorktreeTabRegistrations.set(worktreeId, registrationResolvers)
}
return waitForRegistrationSet(registrationResolvers, timeoutMs, () => {
pendingWorktreeTabRegistrations.delete(worktreeId)
})
}
export function waitForAnyTabRegistration(timeoutMs = 8_000): Promise<void> {
if (browserManager.getWebContentsIdByTabId().size > 0) {
return Promise.resolve()
}
return waitForRegistrationSet(pendingAnyTabRegistrations, timeoutMs, () => {})
}
export function setTrustedBrowserRendererWebContentsId(webContentsId: number | null): void {
trustedBrowserRendererWebContentsId = webContentsId
}
export function setAgentBrowserBridgeRef(bridge: AgentBrowserBridge | null): void {
agentBrowserBridgeRef = bridge
}
function isTrustedBrowserRenderer(sender: Electron.WebContents): boolean {
if (sender.isDestroyed() || sender.getType() !== 'window') {
return false
}
if (trustedBrowserRendererWebContentsId != null) {
return sender.id === trustedBrowserRendererWebContentsId
}
const senderUrl = sender.getURL()
if (process.env.ELECTRON_RENDERER_URL) {
try {
return new URL(senderUrl).origin === new URL(process.env.ELECTRON_RENDERER_URL).origin
} catch {
return false
}
}
return senderUrl.startsWith('file://')
}
export function registerBrowserHandlers(): void {
ipcMain.removeHandler('browser:registerGuest')
ipcMain.removeHandler('browser:unregisterGuest')
ipcMain.removeHandler('browser:openDevTools')
ipcMain.removeHandler('browser:setViewportOverride')
ipcMain.removeHandler('browser:setAnnotationViewportBridge')
ipcMain.removeHandler('browser:acceptDownload')
ipcMain.removeHandler('browser:cancelDownload')
ipcMain.removeHandler('browser:setGrabMode')
ipcMain.removeHandler('browser:awaitGrabSelection')
ipcMain.removeHandler('browser:cancelGrab')
ipcMain.removeHandler('browser:captureSelectionScreenshot')
ipcMain.removeHandler('browser:extractHoverPayload')
ipcMain.removeHandler('browser:activeTabChanged')
ipcMain.handle(
'browser:registerGuest',
(
event,
args: {
browserPageId: string
workspaceId: string
worktreeId: string
sessionProfileId?: string | null
webContentsId: number
}
) => {
if (!isTrustedBrowserRenderer(event.sender)) {
return false
}
// Why: when Chromium swaps a guest's renderer process (navigation,
// crash recovery), the renderer re-registers the same browserPageId
// with a new webContentsId. The bridge must destroy the old session's
// proxy (its webContents is gone) and let the next command recreate it.
const previousWcId = browserManager.getGuestWebContentsId(args.browserPageId)
browserManager.registerGuest({
...args,
rendererWebContentsId: event.sender.id
})
if (agentBrowserBridgeRef && previousWcId !== null && previousWcId !== args.webContentsId) {
agentBrowserBridgeRef.onProcessSwap(args.browserPageId, args.webContentsId, previousWcId)
}
const pendingResolves = pendingTabRegistrations.get(args.browserPageId)
pendingTabRegistrations.delete(args.browserPageId)
resolvePendingRegistrations(pendingResolves)
const pendingWorktreeResolves = pendingWorktreeTabRegistrations.get(args.worktreeId)
pendingWorktreeTabRegistrations.delete(args.worktreeId)
resolvePendingRegistrations(pendingWorktreeResolves)
const pendingAnyResolves = new Set(pendingAnyTabRegistrations)
pendingAnyTabRegistrations.clear()
resolvePendingRegistrations(pendingAnyResolves)
return true
}
)
ipcMain.handle('browser:unregisterGuest', (event, args: { browserPageId: string }) => {
if (!isTrustedBrowserRenderer(event.sender)) {
return false
}
// Why: notify bridge before unregistering so it can destroy the session
// process and proxy. Must happen before unregisterGuest clears the mapping.
const wcId = browserManager.getGuestWebContentsId(args.browserPageId)
if (wcId !== null && agentBrowserBridgeRef) {
agentBrowserBridgeRef.onTabClosed(wcId)
}
browserManager.unregisterGuest(args.browserPageId)
return true
})
// Why: keeps the bridge's active tab in sync with the renderer's UI state.
// Without this, a user switching tabs in the UI would leave the agent operating
// on the previous tab, which is confusing.
ipcMain.handle('browser:activeTabChanged', (event, args: { browserPageId: string }) => {
if (!isTrustedBrowserRenderer(event.sender)) {
return false
}
if (!agentBrowserBridgeRef) {
return false
}
const wcId = browserManager.getGuestWebContentsId(args.browserPageId)
if (wcId !== null) {
// Why: renderer tab changes are scoped to a worktree. If we only update
// the global active guest, later worktree-scoped commands can still
// resolve to the previously active page inside that worktree.
agentBrowserBridgeRef.onTabChanged(
wcId,
browserManager.getWorktreeIdForTab(args.browserPageId)
)
}
return true
})
ipcMain.handle('browser:openDevTools', (event, args: { browserPageId: string }) => {
if (!isTrustedBrowserRenderer(event.sender)) {
return false
}
return browserManager.openDevTools(args.browserPageId)
})
ipcMain.handle(
'browser:setViewportOverride',
(
event,
args: {
browserPageId: string
override: BrowserViewportOverride | null
}
) => {
if (!isTrustedBrowserRenderer(event.sender)) {
return false
}
// Why: CDP misbehaves on non-finite/negative metrics (NaN/Infinity can
// wedge Emulation.setDeviceMetricsOverride and leave the page in a
// broken state). Validate at the main-process trust boundary so a buggy
// or compromised renderer cannot corrupt CDP state.
if (args.override !== null) {
const { width, height, deviceScaleFactor, mobile } = args.override
const isFinitePositive = (n: unknown): n is number =>
typeof n === 'number' && Number.isFinite(n) && n > 0
if (!isFinitePositive(width) || width < 1 || width > 10000) {
return false
}
if (!isFinitePositive(height) || height < 1 || height > 10000) {
return false
}
if (
!isFinitePositive(deviceScaleFactor) ||
deviceScaleFactor < 0.1 ||
deviceScaleFactor > 5
) {
return false
}
if (typeof mobile !== 'boolean') {
return false
}
}
return browserManager.setViewportOverride(args.browserPageId, args.override)
}
)
ipcMain.handle(
'browser:setAnnotationViewportBridge',
(event, args: BrowserSetAnnotationViewportBridgeArgs): Promise<boolean> | boolean => {
if (!isTrustedBrowserRenderer(event.sender)) {
return false
}
if (
typeof args?.browserPageId !== 'string' ||
typeof args.enabled !== 'boolean' ||
typeof args.emitViewport !== 'boolean' ||
!isValidBrowserAnnotationViewportBridgeMarkers(args.markers) ||
!isValidBrowserAnnotationViewportBridgeToken(args.token)
) {
return false
}
return browserManager.setAnnotationViewportBridge(args.browserPageId, {
enabled: args.enabled,
emitViewport: args.emitViewport,
markers: args.markers,
token: args.token
})
}
)
ipcMain.handle('browser:acceptDownload', async (event, args: { downloadId: string }) => {
if (!isTrustedBrowserRenderer(event.sender)) {
return { ok: false, reason: 'not-authorized' as const }
}
const prompt = browserManager.getDownloadPrompt(args.downloadId, event.sender.id)
if (!prompt) {
return { ok: false, reason: 'not-ready' as const }
}
const parent = BrowserWindow.fromWebContents(event.sender)
const result = parent
? await dialog.showSaveDialog(parent, { defaultPath: prompt.filename })
: await dialog.showSaveDialog({ defaultPath: prompt.filename })
if (result.canceled || !result.filePath) {
browserManager.cancelDownload({
downloadId: args.downloadId,
senderWebContentsId: event.sender.id
})
return { ok: false, reason: 'canceled' as const }
}
return browserManager.acceptDownload({
downloadId: args.downloadId,
senderWebContentsId: event.sender.id,
savePath: result.filePath
})
})
ipcMain.handle('browser:cancelDownload', (event, args: { downloadId: string }) => {
if (!isTrustedBrowserRenderer(event.sender)) {
return false
}
return browserManager.cancelDownload({
downloadId: args.downloadId,
senderWebContentsId: event.sender.id
})
})
// --- Browser Context Grab IPC ---
ipcMain.handle(
'browser:setGrabMode',
async (event, args: BrowserSetGrabModeArgs): Promise<BrowserSetGrabModeResult> => {
if (!isTrustedBrowserRenderer(event.sender)) {
return { ok: false, reason: 'not-authorized' }
}
const guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id)
if (!guest) {
return { ok: false, reason: 'not-ready' }
}
const success = await browserManager.setGrabMode(args.browserPageId, args.enabled, guest)
return success ? { ok: true } : { ok: false, reason: 'not-ready' }
}
)
ipcMain.handle(
'browser:awaitGrabSelection',
async (event, args: BrowserAwaitGrabSelectionArgs): Promise<BrowserGrabResult> => {
if (!isTrustedBrowserRenderer(event.sender)) {
return { opId: args.opId, kind: 'error', reason: 'Not authorized' }
}
const guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id)
if (!guest) {
return { opId: args.opId, kind: 'error', reason: 'Guest not ready' }
}
// Why: no hasActiveGrabOp guard here — awaitGrabSelection already handles
// the conflict by cancelling the previous op. Blocking at the IPC layer
// would create a race window where rearm() fails if the previous IPC call
// hasn't fully resolved yet.
return browserManager.awaitGrabSelection(args.browserPageId, args.opId, guest)
}
)
ipcMain.handle('browser:cancelGrab', (event, args: BrowserCancelGrabArgs): boolean => {
if (!isTrustedBrowserRenderer(event.sender)) {
return false
}
// Why: verify the sender actually owns this tab, consistent with the
// authorization check in setGrabMode/awaitGrabSelection/captureScreenshot.
const guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id)
if (!guest) {
return false
}
browserManager.cancelGrabOp(args.browserPageId, 'user')
return true
})
ipcMain.handle(
'browser:captureSelectionScreenshot',
async (
event,
args: BrowserCaptureSelectionScreenshotArgs
): Promise<BrowserCaptureSelectionScreenshotResult> => {
if (!isTrustedBrowserRenderer(event.sender)) {
return { ok: false, reason: 'Not authorized' }
}
const guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id)
if (!guest) {
return { ok: false, reason: 'Guest not ready' }
}
const screenshot = await browserManager.captureSelectionScreenshot(
args.browserPageId,
args.rect,
guest
)
if (!screenshot) {
return { ok: false, reason: 'Screenshot capture failed' }
}
return { ok: true, screenshot }
}
)
ipcMain.handle(
'browser:extractHoverPayload',
async (event, args: BrowserExtractHoverArgs): Promise<BrowserExtractHoverResult> => {
if (!isTrustedBrowserRenderer(event.sender)) {
return { ok: false, reason: 'Not authorized' }
}
const guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id)
if (!guest) {
return { ok: false, reason: 'Guest not ready' }
}
const payload = await browserManager.extractHoverPayload(args.browserPageId, guest)
if (!payload) {
return { ok: false, reason: 'No element hovered' }
}
return { ok: true, payload }
}
)
// --- Browser Session Profile IPC ---
ipcMain.removeHandler('browser:session:listProfiles')
ipcMain.removeHandler('browser:session:createProfile')
ipcMain.removeHandler('browser:session:deleteProfile')
ipcMain.removeHandler('browser:session:importCookies')
ipcMain.removeHandler('browser:session:resolvePartition')
ipcMain.handle('browser:session:listProfiles', (event): BrowserSessionProfile[] => {
if (!isTrustedBrowserRenderer(event.sender)) {
return []
}
return browserSessionRegistry.listProfiles()
})
ipcMain.handle(
'browser:session:createProfile',
(
event,
args: { scope: BrowserSessionProfileScope; label: string }
): BrowserSessionProfile | null => {
if (!isTrustedBrowserRenderer(event.sender)) {
return null
}
return browserSessionRegistry.createProfile(args.scope, args.label)
}
)
ipcMain.handle(
'browser:session:deleteProfile',
async (event, args: { profileId: string }): Promise<boolean> => {
if (!isTrustedBrowserRenderer(event.sender)) {
return false
}
return browserSessionRegistry.deleteProfile(args.profileId)
}
)
ipcMain.handle(
'browser:session:importCookies',
async (event, args: { profileId: string }): Promise<BrowserCookieImportResult> => {
if (!isTrustedBrowserRenderer(event.sender)) {
return { ok: false, reason: 'Not authorized' }
}
const profile = browserSessionRegistry.getProfile(args.profileId)
if (!profile) {
return { ok: false, reason: 'Session profile not found.' }
}
const parent = BrowserWindow.fromWebContents(event.sender)
const filePath = await pickCookieFile(parent)
if (!filePath) {
return { ok: false, reason: 'canceled' }
}
const result = await importCookiesFromFile(filePath, profile.partition)
if (result.ok) {
browserSessionRegistry.updateProfileSource(args.profileId, {
browserFamily: 'manual',
importedAt: Date.now()
})
return { ...result, profileId: args.profileId }
}
return result
}
)
ipcMain.handle(
'browser:session:resolvePartition',
(event, args: { profileId: string | null }): string | null => {
if (!isTrustedBrowserRenderer(event.sender)) {
return null
}
return browserSessionRegistry.resolvePartition(args.profileId)
}
)
ipcMain.removeHandler('browser:session:clearDefaultCookies')
ipcMain.handle('browser:session:clearDefaultCookies', async (event): Promise<boolean> => {
if (!isTrustedBrowserRenderer(event.sender)) {
return false
}
return browserSessionRegistry.clearDefaultSessionCookies()
})
ipcMain.removeHandler('browser:session:detectBrowsers')
ipcMain.removeHandler('browser:session:importFromBrowser')
ipcMain.handle(
'browser:session:detectBrowsers',
(
event
): {
family: string
label: string
profiles: { name: string; directory: string }[]
selectedProfile: string
}[] => {
if (!isTrustedBrowserRenderer(event.sender)) {
return []
}
// Why: the renderer only needs family/label/profiles for the UI picker.
// Strip cookiesPath, keychainService, and keychainAccount to avoid
// exposing filesystem paths and credential store identifiers to the renderer.
return detectInstalledBrowsers().map((b) => ({
family: b.family,
label: b.label,
profiles: b.profiles,
selectedProfile: b.selectedProfile
}))
}
)
ipcMain.handle(
'browser:session:importFromBrowser',
async (
event,
args: { profileId: string; browserFamily: string; browserProfile?: string }
): Promise<BrowserCookieImportResult> => {
if (!isTrustedBrowserRenderer(event.sender)) {
return { ok: false, reason: 'Not authorized' }
}
const profile = browserSessionRegistry.getProfile(args.profileId)
if (!profile) {
return { ok: false, reason: 'Session profile not found.' }
}
// Why: browserProfile comes from the renderer and is used to construct
// a filesystem path. Reject traversal characters to prevent a compromised
// renderer from reading arbitrary files via the cookie import pipeline.
if (
args.browserProfile &&
(/[/\\]/.test(args.browserProfile) || args.browserProfile.includes('..'))
) {
return { ok: false, reason: 'Invalid browser profile name.' }
}
const browsers = detectInstalledBrowsers()
let browser = browsers.find((b) => b.family === args.browserFamily)
if (!browser) {
return { ok: false, reason: 'Browser not found on this system.' }
}
// Why: if the user selected a non-default profile from the picker,
// resolve the cookies path for that specific profile.
if (args.browserProfile && args.browserProfile !== browser.selectedProfile) {
const reselected = selectBrowserProfile(browser, args.browserProfile)
if (!reselected) {
return {
ok: false,
reason: `No cookies database found for profile "${args.browserProfile}".`
}
}
browser = reselected
}
const result = await importCookiesFromBrowser(browser, profile.partition)
if (result.ok) {
const profileName =
browser.profiles.find((p) => p.directory === browser.selectedProfile)?.name ??
browser.selectedProfile
browserSessionRegistry.updateProfileSource(args.profileId, {
browserFamily: browser.family,
profileName,
importedAt: Date.now()
})
return { ...result, profileId: args.profileId }
}
return result
}
)
}