diff --git a/apps/desktop/src/main/browser-agent/session.test.ts b/apps/desktop/src/main/browser-agent/session.test.ts index f54558bed97..f846cb90749 100644 --- a/apps/desktop/src/main/browser-agent/session.test.ts +++ b/apps/desktop/src/main/browser-agent/session.test.ts @@ -1770,7 +1770,7 @@ describe('browser-agent session', () => { expect(event.preventDefault).toHaveBeenCalledOnce() }) - it('permission handlers deny every request on the agent partition', () => { + it('permission handlers deny every request on the agent partition but the copy button', () => { const tab = session.ensureTab() const ses = (tab.view as unknown as MockView).webContents.session const requestHandler = ses.setPermissionRequestHandler.mock.calls[0][0] as ( @@ -1778,12 +1778,26 @@ describe('browser-agent session', () => { permission: string, callback: (granted: boolean) => void ) => void - const callback = vi.fn() - requestHandler(null, 'media', callback) - expect(callback).toHaveBeenCalledWith(false) + const checkHandler = ses.setPermissionCheckHandler.mock.calls[0][0] as ( + wc: unknown, + permission: string + ) => boolean + + // Reading the clipboard would leak whatever the user last copied anywhere + // else, so it stays denied alongside everything a page could spy through. + for (const permission of ['media', 'geolocation', 'notifications', 'clipboard-read']) { + const callback = vi.fn() + requestHandler(null, permission, callback) + expect(callback).toHaveBeenCalledWith(false) + expect(checkHandler(null, permission)).toBe(false) + } - const checkHandler = ses.setPermissionCheckHandler.mock.calls[0][0] as () => boolean - expect(checkHandler()).toBe(false) + // Chromium routes navigator.clipboard.writeText through this one; denying + // it silently broke every copy button that does not use execCommand. + const writeCallback = vi.fn() + requestHandler(null, 'clipboard-sanitized-write', writeCallback) + expect(writeCallback).toHaveBeenCalledWith(true) + expect(checkHandler(null, 'clipboard-sanitized-write')).toBe(true) }) it('leaves nothing of the signed-out user behind in the browser profile', async () => { diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts index 79e7f57b25c..fbedb905a3e 100644 --- a/apps/desktop/src/main/browser-agent/session.ts +++ b/apps/desktop/src/main/browser-agent/session.ts @@ -810,16 +810,35 @@ export async function importAgentCookies( return { imported, failed } } +/** + * The single site permission a browsing surface cannot withhold: the one every + * "Copy" button on the web goes through. Blanket-denying it made + * `navigator.clipboard.writeText` reject with `NotAllowedError`, so those + * buttons did nothing at all — no error, no copied text — while the legacy + * `document.execCommand('copy')` path kept working, which is why only some + * sites looked broken. + * + * Granting it hands the page no reach it lacked: Chromium still requires the + * document to be focused and to hold a transient user activation, and a + * sanitized write only places text the page already renders onto the clipboard. + * Reading stays denied — that is the direction that would leak whatever the + * user last copied from anywhere else. + */ +const ALLOWED_SITE_PERMISSIONS = new Set(['clipboard-sanitized-write']) + /** * Default-deny hardening for the agent partition. Site permissions remain - * denied, while uploads use Chromium's native file chooser and downloads are - * saved into the device-level browser download directory. + * denied apart from ALLOWED_SITE_PERMISSIONS, while uploads use Chromium's + * native file chooser and downloads are saved into the device-level browser + * download directory. */ function configureAgentPartition(ses: Session): void { if (configuredPartitions.has(ses)) return configuredPartitions.add(ses) - ses.setPermissionRequestHandler((_wc, _permission, callback) => callback(false)) - ses.setPermissionCheckHandler(() => false) + ses.setPermissionRequestHandler((_wc, permission, callback) => + callback(ALLOWED_SITE_PERMISSIONS.has(permission)) + ) + ses.setPermissionCheckHandler((_wc, permission) => ALLOWED_SITE_PERMISSIONS.has(permission)) // Service workers do not inherit a tab's user agent. With only the tab's set, // the document request carries the browser string while the worker's own // script request still announces Electron — and on a site that routes its