Skip to content

[Feature] Opt-in to preserve browser-default download behavior when connectOverCDP attaches to a user-owned browser #40158

@sidsarasvati

Description

@sidsarasvati

[Feature] Opt-in to preserve browser-default download behavior when connectOverCDP attaches to a user-owned browser

Context

This issue is a follow-up to #40152, which I filed as a PR and @pavelfeldman rightly closed with: "Most users would want Playwright to automate and see the downloads after connecting to the browser. This deserves an issue first."

Pavel is right on both counts. I skipped the issue step, and the behavior-change default I proposed breaks the historical automation-focused use case. Filing here to discuss the API shape before iterating the code.

The new use case that motivated this

In 2021, connectOverCDP users were automation folks: CI, headless Chrome, Selenoid, remote test runners. For them, Playwright intercepting downloads into artifactsDir is exactly what they want.

Today, connectOverCDP has a second audience: AI browser agents attaching to the human's daily-driver Chrome to observe/act on their real tabs. Examples:

  • Anthropic's Claude Chrome extension — attaches via CDP for MCP tool use
  • Playwright-MCP when used against --cdp-endpoint
  • remorses/playwriter — Chrome extension exposing the user's browser as a Playwright session
  • Selenoid-style "my real browser with my cookies, remote-driven"

For these users, Playwright's Browser.setDownloadBehavior call silently reconfigures the entire browser context — including tabs the human opened manually, hours after the automation session ended. Downloads land in /tmp/playwright-artifacts-XXXXX/<uuid> instead of ~/Downloads, with no filename from Content-Disposition. Chrome's download popup still says "Done". The human has no idea anything is wrong.

How I discovered this

A 15 KB Read.ai transcript disappeared from my ~/Downloads. Chrome said "Done". The file wasn't there. I queried Chrome's SQLite download history DB directly:

SELECT target_path, received_bytes, datetime(start_time/1000000-11644473600,'unixepoch','localtime')
FROM downloads ORDER BY start_time DESC LIMIT 5;
('/var/folders/.../T/playwright-artifacts-GKCPC0/a1957412-...',    15332, '2026-04-10 12:14:54')
('/var/folders/.../T/playwright-artifacts-GKCPC0/2d420415-...',    64140, '2026-04-08 19:37:47')
('/var/folders/.../T/playwright-artifacts-GKCPC0/d7e05cb8-...',    64140, '2026-04-08 19:37:32')
('/var/folders/.../T/playwright-artifacts-GKCPC0/db927849-...', 1409325857, '2026-04-08 14:08:16')  ← 1.4 GB video
('/var/folders/.../T/playwright-artifacts-GKCPC0/838b1bde-...', 1409325857, '2026-04-08 14:02:30')  ← SAME 1.4 GB, dup

Every download from my normal browsing over the past week — including a 1.4 GB video I'd downloaded twice — had been silently rerouted into Playwright's temp folder. 2.6 GB of orphan files in /tmp/playwright-artifacts-GKCPC0/, accumulated because an AI browser agent had attached via CDP at some point and never detached.

The root cause is chromium.ts:_connectOverCDPImpl setting downloadsPath to artifactsDir and acceptDownloads defaulting to 'accept', which then fires Browser.setDownloadBehavior with allowAndName + the temp path — globally, for the whole browser context.

Proposal: opt-in API instead of behavior change

I agree with Pavel's comment on #40152changing the default is wrong for the existing automation use case. Instead, I propose a new option:

await chromium.connectOverCDP(endpointURL, {
  preserveBrowserDownloads: true,  // ← NEW, default false (current behavior)
});

When preserveBrowserDownloads: true:

  • The persistent context created on connect uses acceptDownloads: 'internal-browser-default' (an existing Playwright value that skips the Browser.setDownloadBehavior CDP call entirely — see crBrowser.ts:353 and launchApp.ts:62)
  • Chrome handles downloads natively: real filename from Content-Disposition, user's own download folder
  • Playwright's page.waitForEvent('download') will not fire on the default context, because events were never enabled
  • Callers who need automation-managed downloads can still create a new context via browser.newContext({ acceptDownloads: true }) — that context gets full Playwright download handling, unchanged

Default behavior (preserveBrowserDownloads omitted or false) is exactly the current behavior. Zero breakage for automation users.

Alternative shapes I considered

  1. Change the default (what fix(connectOverCDP): preserve browser-default downloads in the default context #40152 did) — Pavel rightly rejected this. Behavior break.
  2. Auto-detect based on whether the browser was launched by Playwright (isLocal flag) — fragile, and users who launch Chrome themselves for local debugging would still get the old behavior.
  3. New option on the context, not the browserbrowser.contexts()[0] is already created by the time the caller can touch it, so you can't retroactively change acceptDownloads. Has to be at connect time.
  4. New method entirelychromium.attachToUserBrowser(url) as a separate API from connectOverCDP. Cleaner semantics but more API surface.

I think option 1 in the proposal (opt-in flag on connectOverCDP) is the minimum change for the maximum compatibility. Happy to discuss whichever shape you prefer.

Happy to re-PR

If this shape works for you, I'll:

  • Update my fork with the opt-in preserveBrowserDownloads option
  • Add a test that verifies both paths (default = current behavior, opt-in = new behavior)
  • Update connect-over-cdp.spec.ts to document both patterns
  • Link this issue in the new PR

If you'd prefer a different shape, tell me what and I'll build to that.

Related

Environment

  • Playwright: main @ 19e0abb7c
  • OS: macOS 25.3 Tahoe
  • Discovered via: Anthropic Claude Desktop's built-in chrome-control MCP extension, which uses playwright.chromium.connectOverCDP() under the hood to attach to the user's running Chrome

Filed by @sidsarasvati.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions