From 12e7939204e419dce924712c50bf44b023599f0c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 00:38:24 -0700 Subject: [PATCH] fix(desktop): let sites copy to the clipboard in the browser tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent partition denied every site permission, which included clipboard-sanitized-write — the permission Chromium routes navigator.clipboard.writeText through. Copy buttons rejected with NotAllowedError and did nothing at all: no error, no copied text. Sites still on document.execCommand('copy') kept working, which is why only some looked broken. Granting it hands the page no reach it lacked. Chromium still requires the document to be focused and holding a transient user activation, and a sanitized write only places text the page already renders onto the clipboard. Reading stays denied, along with media, geolocation, and notifications. --- .../src/main/browser-agent/session.test.ts | 26 +++++++++++++----- .../desktop/src/main/browser-agent/session.ts | 27 ++++++++++++++++--- 2 files changed, 43 insertions(+), 10 deletions(-) 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