fix(desktop): let sites copy to the clipboard in the browser tab (#6696)

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.
This commit is contained in:
Waleed
2026-08-14 09:56:10 -07:00
committed by GitHub
parent 0239db80c6
commit 0650eab6e0
2 changed files with 43 additions and 10 deletions
@@ -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
const checkHandler = ses.setPermissionCheckHandler.mock.calls[0][0] as () => boolean
expect(checkHandler()).toBe(false)
// 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)
}
// 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 () => {
+23 -4
View File
@@ -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