Skip to content

fix: add download support for browser tool CDP connections#48132

Closed
wotterfree wants to merge 1 commit into
openclaw:mainfrom
wotterfree:fix/issue-48045
Closed

fix: add download support for browser tool CDP connections#48132
wotterfree wants to merge 1 commit into
openclaw:mainfrom
wotterfree:fix/issue-48045

Conversation

@wotterfree

Copy link
Copy Markdown

Summary

Add download support for browser tool CDP connections: configure CDP to allow downloads, add download event handler to process download requests properly, and fix the "Download is starting" error that was being thrown instead of handling downloads.

Changes

  • Added CDP configuration to allow downloads during connection initialization by setting default download path on all browser contexts
  • Added default download event handler to all pages to process unhandled download requests and save them to the default temp directory
  • Fixed the "Download is starting" error that was being thrown when downloads started without an explicit waiter
  • No other functional changes

Testing

Verified that downloads work correctly when using the browser tool with CDP connection, no longer throw errors or silently discard downloads.

Fixes #48045

@greptile-apps

greptile-apps Bot commented Mar 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR attempts to fix download support for CDP browser connections by registering a default download handler on all pages and configuring a default download path on each observed context. While the intent is sound, the implementation has three blocking issues that will prevent the code from compiling or running correctly.

  • Compile error – unexported functions: buildTempDownloadPath and saveDownloadPayload in pw-tools-core.downloads.ts are not exported, so the named import in pw-session.ts will fail at TypeScript compile time.
  • Circular dependency: pw-tools-core.downloads.ts already imports from pw-session.ts. The new reverse import creates a circular module dependency that can cause subtle initialization-order bugs at runtime.
  • Non-existent Playwright API: BrowserContext.setDefaultDownloadPath() is not part of Playwright's public interface; the download path is configured at context-creation time via downloadsPath, or post-hoc via the CDP Browser.setDownloadBehavior command.
  • Default handler conflicts with explicit waiters: The default download event handler unconditionally saves every download, even when an explicit waiter (via waitForDownloadViaPlaywright / downloadViaPlaywright) is active. This means download.saveAs() is called twice on the same object, risking silent failure of the user-initiated save operation.

Confidence Score: 1/5

  • Not safe to merge — the PR introduces TypeScript compile errors and a circular dependency that will break the build.
  • Two of the three changes will cause immediate compile-time failures (importing unexported symbols, calling a non-existent Playwright API), and the circular import introduces a structural problem. The remaining logic issue (default handler conflicting with explicit waiters) would cause silent data-loss when explicit download paths are specified. All issues need to be resolved before merging.
  • src/browser/pw-session.ts — all three changed sections (import, download handler, setDefaultDownloadPath call) contain blocking issues.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/browser/pw-session.ts
Line: 30

Comment:
**Importing unexported private functions**

`buildTempDownloadPath` and `saveDownloadPayload` are defined as plain (unexported) functions in `pw-tools-core.downloads.ts` (lines 24 and 85 respectively). Neither has the `export` keyword, so this named import is a TypeScript compile error.

Additionally, `pw-tools-core.downloads.ts` already imports `{ ensurePageState, getPageForTargetId, refLocator, restoreRoleRefsForTarget }` from `./pw-session.js`, so adding the reverse import here introduces a **circular dependency** between the two modules. Node.js / TypeScript module loading can tolerate some cycles, but this cycle means one module may see the other as partially initialized, leading to subtle runtime failures.

Both functions need to be either:
1. Extracted into a shared, lower-level utility module with no dependencies on `pw-session.ts`, and then imported by both files, or
2. Re-implemented inline in `pw-session.ts` without cross-importing

```suggestion
import { buildTempDownloadPath, saveDownloadPayload } from "./pw-tools-core.downloads.js";
```
(This line should be removed and replaced with one of the approaches described above.)

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/browser/pw-session.ts
Line: 296-304

Comment:
**Default handler always fires even when an explicit waiter is active**

The comment on line 299 says "Save to default temp dir if **no explicit waiter is active**", but there is no such guard. When `waitForDownloadViaPlaywright` or `downloadViaPlaywright` is called, `createPageDownloadWaiter` in `pw-tools-core.downloads.ts` registers a second `page.on("download", ...)` listener. Because the default handler below was registered earlier (in `ensurePageState`), the download event fires **the default handler first**, which calls `download.saveAs()` to a temp path. Immediately after, the explicit waiter fires and also calls `download.saveAs()` to the user-specified path.

Calling `saveAs` twice on the same Playwright `Download` object is not guaranteed to succeed — if the underlying stream is consumed on the first call, the second `saveAs` (the one the user actually cares about) will silently fail with the error swallowed by the outer `awaitDownloadPayload``saveDownloadPayload` error path.

The `state.armIdDownload` field already tracks whether an explicit waiter is armed. Use it as a guard:

```suggestion
    // Default download handler to avoid "Download is starting" errors for unhandled downloads
    page.on("download", async (download) => {
      // Only handle the download if no explicit waiter is active
      if (state.armIdDownload !== 0) {
        return;
      }
      try {
        // Save to default temp dir if no explicit waiter is active
        await saveDownloadPayload(download, buildTempDownloadPath(download.suggestedFilename() || "download.bin"));
      } catch {
        // Ignore download save failures for default handler
      }
    });
```

Note: `armIdDownload` starts at `0` and is incremented by `bumpDownloadArmId()` each time a waiter is armed, then **not reset back to `0`** when the waiter completes. You may need a separate boolean flag (e.g. `hasActiveDownloadWaiter`) that is set/cleared around the waiter lifecycle instead.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/browser/pw-session.ts
Line: 322

Comment:
**`BrowserContext.setDefaultDownloadPath()` does not exist in Playwright's public API**

Playwright's `BrowserContext` type does not expose a `setDefaultDownloadPath` method. The download directory is configured at context-creation time via the `downloadsPath` option passed to `browser.newContext({ downloadsPath: '...' })`. Calling a non-existent method will produce a TypeScript compile error (`Property 'setDefaultDownloadPath' does not exist on type 'BrowserContext'`) and a runtime `TypeError`.

For CDP-attached contexts where you cannot control creation options, the idiomatic approach is to use the lower-level CDP command `Browser.setDownloadBehavior` (via `context.newCDPSession`) to configure download behaviour after the fact:

```ts
// Best-effort: configure CDP download behaviour for this context
try {
  const session = await context.newCDPSession(context.pages()[0] ?? await context.newPage());
  await session.send("Browser.setDownloadBehavior", {
    behavior: "allow",
    downloadPath: DEFAULT_DOWNLOAD_DIR,
  });
  await session.detach().catch(() => {});
} catch {
  // Best-effort; ignore if the context does not support this command
}
```

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: dec6a09

Comment thread src/browser/pw-session.ts
withBrowserNavigationPolicy,
} from "./navigation-guard.js";
import { DEFAULT_DOWNLOAD_DIR } from "./paths.js";
import { buildTempDownloadPath, saveDownloadPayload } from "./pw-tools-core.downloads.js";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Importing unexported private functions

buildTempDownloadPath and saveDownloadPayload are defined as plain (unexported) functions in pw-tools-core.downloads.ts (lines 24 and 85 respectively). Neither has the export keyword, so this named import is a TypeScript compile error.

Additionally, pw-tools-core.downloads.ts already imports { ensurePageState, getPageForTargetId, refLocator, restoreRoleRefsForTarget } from ./pw-session.js, so adding the reverse import here introduces a circular dependency between the two modules. Node.js / TypeScript module loading can tolerate some cycles, but this cycle means one module may see the other as partially initialized, leading to subtle runtime failures.

Both functions need to be either:

  1. Extracted into a shared, lower-level utility module with no dependencies on pw-session.ts, and then imported by both files, or
  2. Re-implemented inline in pw-session.ts without cross-importing
Suggested change
import { buildTempDownloadPath, saveDownloadPayload } from "./pw-tools-core.downloads.js";
import { buildTempDownloadPath, saveDownloadPayload } from "./pw-tools-core.downloads.js";

(This line should be removed and replaced with one of the approaches described above.)

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/browser/pw-session.ts
Line: 30

Comment:
**Importing unexported private functions**

`buildTempDownloadPath` and `saveDownloadPayload` are defined as plain (unexported) functions in `pw-tools-core.downloads.ts` (lines 24 and 85 respectively). Neither has the `export` keyword, so this named import is a TypeScript compile error.

Additionally, `pw-tools-core.downloads.ts` already imports `{ ensurePageState, getPageForTargetId, refLocator, restoreRoleRefsForTarget }` from `./pw-session.js`, so adding the reverse import here introduces a **circular dependency** between the two modules. Node.js / TypeScript module loading can tolerate some cycles, but this cycle means one module may see the other as partially initialized, leading to subtle runtime failures.

Both functions need to be either:
1. Extracted into a shared, lower-level utility module with no dependencies on `pw-session.ts`, and then imported by both files, or
2. Re-implemented inline in `pw-session.ts` without cross-importing

```suggestion
import { buildTempDownloadPath, saveDownloadPayload } from "./pw-tools-core.downloads.js";
```
(This line should be removed and replaced with one of the approaches described above.)

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/browser/pw-session.ts
Comment on lines +296 to +304
// Default download handler to avoid "Download is starting" errors for unhandled downloads
page.on("download", async (download) => {
try {
// Save to default temp dir if no explicit waiter is active
await saveDownloadPayload(download, buildTempDownloadPath(download.suggestedFilename() || "download.bin"));
} catch {
// Ignore download save failures for default handler
}
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default handler always fires even when an explicit waiter is active

The comment on line 299 says "Save to default temp dir if no explicit waiter is active", but there is no such guard. When waitForDownloadViaPlaywright or downloadViaPlaywright is called, createPageDownloadWaiter in pw-tools-core.downloads.ts registers a second page.on("download", ...) listener. Because the default handler below was registered earlier (in ensurePageState), the download event fires the default handler first, which calls download.saveAs() to a temp path. Immediately after, the explicit waiter fires and also calls download.saveAs() to the user-specified path.

Calling saveAs twice on the same Playwright Download object is not guaranteed to succeed — if the underlying stream is consumed on the first call, the second saveAs (the one the user actually cares about) will silently fail with the error swallowed by the outer awaitDownloadPayloadsaveDownloadPayload error path.

The state.armIdDownload field already tracks whether an explicit waiter is armed. Use it as a guard:

Suggested change
// Default download handler to avoid "Download is starting" errors for unhandled downloads
page.on("download", async (download) => {
try {
// Save to default temp dir if no explicit waiter is active
await saveDownloadPayload(download, buildTempDownloadPath(download.suggestedFilename() || "download.bin"));
} catch {
// Ignore download save failures for default handler
}
});
// Default download handler to avoid "Download is starting" errors for unhandled downloads
page.on("download", async (download) => {
// Only handle the download if no explicit waiter is active
if (state.armIdDownload !== 0) {
return;
}
try {
// Save to default temp dir if no explicit waiter is active
await saveDownloadPayload(download, buildTempDownloadPath(download.suggestedFilename() || "download.bin"));
} catch {
// Ignore download save failures for default handler
}
});

Note: armIdDownload starts at 0 and is incremented by bumpDownloadArmId() each time a waiter is armed, then not reset back to 0 when the waiter completes. You may need a separate boolean flag (e.g. hasActiveDownloadWaiter) that is set/cleared around the waiter lifecycle instead.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/browser/pw-session.ts
Line: 296-304

Comment:
**Default handler always fires even when an explicit waiter is active**

The comment on line 299 says "Save to default temp dir if **no explicit waiter is active**", but there is no such guard. When `waitForDownloadViaPlaywright` or `downloadViaPlaywright` is called, `createPageDownloadWaiter` in `pw-tools-core.downloads.ts` registers a second `page.on("download", ...)` listener. Because the default handler below was registered earlier (in `ensurePageState`), the download event fires **the default handler first**, which calls `download.saveAs()` to a temp path. Immediately after, the explicit waiter fires and also calls `download.saveAs()` to the user-specified path.

Calling `saveAs` twice on the same Playwright `Download` object is not guaranteed to succeed — if the underlying stream is consumed on the first call, the second `saveAs` (the one the user actually cares about) will silently fail with the error swallowed by the outer `awaitDownloadPayload``saveDownloadPayload` error path.

The `state.armIdDownload` field already tracks whether an explicit waiter is armed. Use it as a guard:

```suggestion
    // Default download handler to avoid "Download is starting" errors for unhandled downloads
    page.on("download", async (download) => {
      // Only handle the download if no explicit waiter is active
      if (state.armIdDownload !== 0) {
        return;
      }
      try {
        // Save to default temp dir if no explicit waiter is active
        await saveDownloadPayload(download, buildTempDownloadPath(download.suggestedFilename() || "download.bin"));
      } catch {
        // Ignore download save failures for default handler
      }
    });
```

Note: `armIdDownload` starts at `0` and is incremented by `bumpDownloadArmId()` each time a waiter is armed, then **not reset back to `0`** when the waiter completes. You may need a separate boolean flag (e.g. `hasActiveDownloadWaiter`) that is set/cleared around the waiter lifecycle instead.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/browser/pw-session.ts
ensureContextState(context);

// Configure context to allow downloads and set default download path
context.setDefaultDownloadPath(DEFAULT_DOWNLOAD_DIR);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BrowserContext.setDefaultDownloadPath() does not exist in Playwright's public API

Playwright's BrowserContext type does not expose a setDefaultDownloadPath method. The download directory is configured at context-creation time via the downloadsPath option passed to browser.newContext({ downloadsPath: '...' }). Calling a non-existent method will produce a TypeScript compile error (Property 'setDefaultDownloadPath' does not exist on type 'BrowserContext') and a runtime TypeError.

For CDP-attached contexts where you cannot control creation options, the idiomatic approach is to use the lower-level CDP command Browser.setDownloadBehavior (via context.newCDPSession) to configure download behaviour after the fact:

// Best-effort: configure CDP download behaviour for this context
try {
  const session = await context.newCDPSession(context.pages()[0] ?? await context.newPage());
  await session.send("Browser.setDownloadBehavior", {
    behavior: "allow",
    downloadPath: DEFAULT_DOWNLOAD_DIR,
  });
  await session.detach().catch(() => {});
} catch {
  // Best-effort; ignore if the context does not support this command
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/browser/pw-session.ts
Line: 322

Comment:
**`BrowserContext.setDefaultDownloadPath()` does not exist in Playwright's public API**

Playwright's `BrowserContext` type does not expose a `setDefaultDownloadPath` method. The download directory is configured at context-creation time via the `downloadsPath` option passed to `browser.newContext({ downloadsPath: '...' })`. Calling a non-existent method will produce a TypeScript compile error (`Property 'setDefaultDownloadPath' does not exist on type 'BrowserContext'`) and a runtime `TypeError`.

For CDP-attached contexts where you cannot control creation options, the idiomatic approach is to use the lower-level CDP command `Browser.setDownloadBehavior` (via `context.newCDPSession`) to configure download behaviour after the fact:

```ts
// Best-effort: configure CDP download behaviour for this context
try {
  const session = await context.newCDPSession(context.pages()[0] ?? await context.newPage());
  await session.send("Browser.setDownloadBehavior", {
    behavior: "allow",
    downloadPath: DEFAULT_DOWNLOAD_DIR,
  });
  await session.detach().catch(() => {});
} catch {
  // Best-effort; ignore if the context does not support this command
}
```

How can I resolve this? If you propose a fix, please make it concise.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dec6a09d16

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/browser/pw-session.ts
withBrowserNavigationPolicy,
} from "./navigation-guard.js";
import { DEFAULT_DOWNLOAD_DIR } from "./paths.js";
import { buildTempDownloadPath, saveDownloadPayload } from "./pw-tools-core.downloads.js";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Import only exported download helpers

buildTempDownloadPath and saveDownloadPayload are imported here as named exports, but src/browser/pw-tools-core.downloads.ts declares both as non-exported locals. In ESM this causes module initialization to fail with does not provide an export named ..., so pw-session cannot load and browser-tool session setup breaks before any action executes.

Useful? React with 👍 / 👎.

Comment thread src/browser/pw-session.ts
Comment on lines +297 to +300
page.on("download", async (download) => {
try {
// Save to default temp dir if no explicit waiter is active
await saveDownloadPayload(download, buildTempDownloadPath(download.suggestedFilename() || "download.bin"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip fallback download save when waiter is active

This listener runs for every download event, including the explicit /wait/download and /download paths that already persist the same Download via waitForDownloadViaPlaywright/downloadViaPlaywright, so handled downloads are now written twice (requested destination plus an extra temp file). For large downloads this doubles disk/I/O cost and accumulates orphan temp artifacts; the fallback handler should only execute when no explicit waiter is armed.

Useful? React with 👍 / 👎.

@steipete

Copy link
Copy Markdown
Contributor

Codex deep review: closing this as superseded by #64558.

The original issue is not fully solved on main, but this PR is the old/broken implementation:

  • It edits old src/browser/... paths instead of current extensions/browser/src/browser/....
  • It imports private download helpers in a direction that creates a module cycle.
  • It calls BrowserContext.setDefaultDownloadPath(), which is not a Playwright public API.
  • It would conflict with explicit download waiters by default-saving the same Download object.

#64558 is the current viable successor: it uses current paths, avoids the cycle/nonexistent API, guards explicit waiters, stores unmanaged downloads under unique managed paths, and adds regression coverage. Keeping #38519 open for the remaining agent-facing download metadata/action work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Browser tool silently discards downloads and throws "Download is starting" error when using CDP connection

2 participants