diff --git a/CHANGELOG.md b/CHANGELOG.md index e18e9fdd81a..fa153b89c45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - Heartbeat: clamp oversized scheduler delays through the shared safe timer helper, preventing `every` values over Node's timeout cap from becoming a 1 ms crash loop. Fixes #71414. (#71478) Thanks @hclsys. - Telegram: remove the startup persisted-offset `getUpdates` preflight so polling restarts do not self-conflict before the runner starts. Fixes #69304. (#69779) Thanks @chinar-amrutkar. - Browser/Playwright: ignore benign already-handled route races during guarded navigation so browser-page tasks no longer fail when Playwright tears down a route mid-flight. (#68708) Thanks @Steady-ai. +- Browser/downloads: seed managed Chrome profiles with OpenClaw download prefs and capture unmanaged click-triggered downloads under the guarded downloads directory, while explicit download waiters still own their target file. (#64558) Thanks @Pearcekieser. - Browser/aria snapshots: bind `format=aria` `axN` refs to live DOM nodes through backend DOM ids when Playwright is available, so follow-up browser actions can use those refs without timing out. (#62434) Thanks @MrKipler. - Telegram: prevent duplicate in-process long pollers for the same bot token and add clearer `getUpdates` conflict diagnostics for external duplicate pollers. Fixes #56230. - Browser/Linux: detect Chromium-based installs under `/opt/google`, `/opt/brave.com`, `/usr/lib/chromium`, and `/usr/lib/chromium-browser` before asking users to set `browser.executablePath`. (#48563) Thanks @lupuletic. diff --git a/docs/cli/browser.md b/docs/cli/browser.md index 8f47e892a10..790d74479b7 100644 --- a/docs/cli/browser.md +++ b/docs/cli/browser.md @@ -185,6 +185,11 @@ openclaw browser download report.pdf openclaw browser dialog --accept ``` +Managed Chrome profiles save ordinary click-triggered downloads into the OpenClaw +downloads directory (`/tmp/openclaw/downloads` by default, or the configured temp +root). Use `waitfordownload` or `download` when the agent needs to wait for a +specific file and return its path; those explicit waiters own the next download. + ## State and storage Viewport + emulation: diff --git a/extensions/browser/src/browser/chrome.profile-decoration.ts b/extensions/browser/src/browser/chrome.profile-decoration.ts index dd15dca9d5d..ebacb7580c3 100644 --- a/extensions/browser/src/browser/chrome.profile-decoration.ts +++ b/extensions/browser/src/browser/chrome.profile-decoration.ts @@ -67,6 +67,7 @@ export function isProfileDecorated( userDataDir: string, desiredName: string, desiredColorHex: string, + desiredDownloadDir?: string, ): boolean { const desiredColorInt = parseHexRgbToSignedArgbInt(desiredColorHex); @@ -80,12 +81,20 @@ export function isProfileDecorated( const prefs = safeReadJson(preferencesPath); const browserTheme = readNestedRecord(prefs?.browser, "theme"); const autogeneratedTheme = readNestedRecord(prefs?.autogenerated, "theme"); + const download = readNestedRecord(prefs, "download"); + const savefile = readNestedRecord(prefs, "savefile"); const nameOk = typeof info?.name === "string" ? info.name === desiredName : true; + const downloadOk = desiredDownloadDir + ? download?.default_directory === desiredDownloadDir && + download.prompt_for_download === false && + download.directory_upgrade === true && + savefile?.default_directory === desiredDownloadDir + : true; if (desiredColorInt == null) { // If the user provided a non-#RRGGBB value, we can only do best-effort. - return nameOk; + return nameOk && downloadOk; } const localSeedOk = @@ -98,7 +107,7 @@ export function isProfileDecorated( browserTheme.user_color2 === desiredColorInt) || (typeof autogeneratedTheme?.color === "number" && autogeneratedTheme.color === desiredColorInt); - return nameOk && localSeedOk && prefOk; + return nameOk && localSeedOk && prefOk && downloadOk; } /** @@ -107,7 +116,7 @@ export function isProfileDecorated( */ export function decorateOpenClawProfile( userDataDir: string, - opts?: { name?: string; color?: string }, + opts?: { name?: string; color?: string; downloadDir?: string }, ) { const desiredName = opts?.name ?? DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME; const desiredColor = (opts?.color ?? DEFAULT_OPENCLAW_BROWSER_COLOR).toUpperCase(); @@ -159,6 +168,12 @@ export function decorateOpenClawProfile( // User-selected browser theme color (pref name: browser.theme.user_color2). setDeep(prefs, ["browser", "theme", "user_color2"], desiredColorInt); } + if (opts?.downloadDir) { + setDeep(prefs, ["download", "default_directory"], opts.downloadDir); + setDeep(prefs, ["download", "prompt_for_download"], false); + setDeep(prefs, ["download", "directory_upgrade"], true); + setDeep(prefs, ["savefile", "default_directory"], opts.downloadDir); + } safeWriteJson(preferencesPath, prefs); try { diff --git a/extensions/browser/src/browser/chrome.test.ts b/extensions/browser/src/browser/chrome.test.ts index 43aef226cb4..d0c4db6fa23 100644 --- a/extensions/browser/src/browser/chrome.test.ts +++ b/extensions/browser/src/browser/chrome.test.ts @@ -21,6 +21,7 @@ import { formatChromeCdpDiagnostic, buildOpenClawChromeLaunchArgs, getChromeWebSocketUrl, + isProfileDecorated, isChromeCdpReady, isChromeReachable, resolveBrowserExecutableForPlatform, @@ -31,6 +32,7 @@ import { DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, } from "./constants.js"; import { BrowserCdpEndpointBlockedError } from "./errors.js"; +import { DEFAULT_DOWNLOAD_DIR } from "./paths.js"; type StopChromeTarget = Parameters[0]; @@ -161,6 +163,8 @@ describe("browser chrome profile decoration", () => { expect(theme.user_color2).toBe(expectedSignedArgb); expect(autogeneratedTheme.color).toBe(expectedSignedArgb); + expect(prefs.download).toBeUndefined(); + expect(prefs.savefile).toBeUndefined(); const marker = await fsp.readFile( path.join(userDataDir, ".openclaw-profile-decorated"), @@ -169,6 +173,45 @@ describe("browser chrome profile decoration", () => { expect(marker.trim()).toMatch(/^\d+$/); }); + it("writes managed download prefs when a download dir is provided", async () => { + const userDataDir = await createUserDataDir(); + decorateOpenClawProfile(userDataDir, { + color: DEFAULT_OPENCLAW_BROWSER_COLOR, + downloadDir: DEFAULT_DOWNLOAD_DIR, + }); + + const prefs = await readJson(path.join(userDataDir, "Default", "Preferences")); + const download = prefs.download as Record; + const savefile = prefs.savefile as Record; + + expect(download.default_directory).toBe(DEFAULT_DOWNLOAD_DIR); + expect(download.prompt_for_download).toBe(false); + expect(download.directory_upgrade).toBe(true); + expect(savefile.default_directory).toBe(DEFAULT_DOWNLOAD_DIR); + expect( + isProfileDecorated( + userDataDir, + DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, + DEFAULT_OPENCLAW_BROWSER_COLOR, + DEFAULT_DOWNLOAD_DIR, + ), + ).toBe(true); + }); + + it("treats missing managed download prefs as undecorated when required", async () => { + const userDataDir = await createUserDataDir(); + decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR }); + + expect( + isProfileDecorated( + userDataDir, + DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, + DEFAULT_OPENCLAW_BROWSER_COLOR, + DEFAULT_DOWNLOAD_DIR, + ), + ).toBe(false); + }); + it("best-effort writes name when color is invalid", async () => { const userDataDir = await createUserDataDir(); decorateOpenClawProfile(userDataDir, { color: "lobster-orange" }); diff --git a/extensions/browser/src/browser/chrome.ts b/extensions/browser/src/browser/chrome.ts index 086f460fcb5..767515420db 100644 --- a/extensions/browser/src/browser/chrome.ts +++ b/extensions/browser/src/browser/chrome.ts @@ -51,6 +51,7 @@ import { DEFAULT_OPENCLAW_BROWSER_COLOR, DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, } from "./constants.js"; +import { DEFAULT_DOWNLOAD_DIR } from "./paths.js"; const log = createSubsystemLogger("browser").child("chrome"); const CHROME_SINGLETON_LOCK_PATHS = [ @@ -393,11 +394,13 @@ export async function launchOpenClawChrome( const userDataDir = resolveOpenClawUserDataDir(profile.name); fs.mkdirSync(userDataDir, { recursive: true }); + fs.mkdirSync(DEFAULT_DOWNLOAD_DIR, { recursive: true }); const needsDecorate = !isProfileDecorated( userDataDir, profile.name, (profile.color ?? DEFAULT_OPENCLAW_BROWSER_COLOR).toUpperCase(), + DEFAULT_DOWNLOAD_DIR, ); // First launch to create preference files if missing, then decorate and relaunch. @@ -460,6 +463,7 @@ export async function launchOpenClawChrome( decorateOpenClawProfile(userDataDir, { name: profile.name, color: profile.color, + downloadDir: DEFAULT_DOWNLOAD_DIR, }); log.info(`🦞 openclaw browser profile decorated (${profile.color})`); } catch (err) { diff --git a/extensions/browser/src/browser/pw-session.test.ts b/extensions/browser/src/browser/pw-session.test.ts index 14a9c1858c4..b4ea01a59db 100644 --- a/extensions/browser/src/browser/pw-session.test.ts +++ b/extensions/browser/src/browser/pw-session.test.ts @@ -1,5 +1,8 @@ +import fs from "node:fs/promises"; +import path from "node:path"; import type { Page } from "playwright-core"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_DOWNLOAD_DIR } from "./paths.js"; import { ensurePageState, refLocator, @@ -8,6 +11,16 @@ import { } from "./pw-session.js"; import { BROWSER_REF_MARKER_ATTRIBUTE } from "./pw-session.page-cdp.js"; +type MutableDownload = { + suggestedFilename: () => string; + saveAs: ReturnType; + path?: () => Promise; +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + function fakePage(): { page: Page; handlers: Map void>>; @@ -123,6 +136,80 @@ describe("pw-session role refs cache", () => { }); describe("pw-session ensurePageState", () => { + it("stores unmanaged downloads under unique managed paths", async () => { + const { page, handlers } = fakePage(); + const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined); + ensurePageState(page); + + const saveAsA = vi.fn(async () => {}); + const saveAsB = vi.fn(async () => {}); + const downloadA: MutableDownload = { + suggestedFilename: () => "report.pdf", + saveAs: saveAsA, + }; + const downloadB: MutableDownload = { + suggestedFilename: () => "report.pdf", + saveAs: saveAsB, + }; + + handlers.get("download")?.[0]?.(downloadA); + handlers.get("download")?.[0]?.(downloadB); + + const managedPathA = await downloadA.path?.(); + const managedPathB = await downloadB.path?.(); + + expect(managedPathA).not.toBe(managedPathB); + expect(path.dirname(managedPathA ?? "")).toBe(DEFAULT_DOWNLOAD_DIR); + expect(path.dirname(managedPathB ?? "")).toBe(DEFAULT_DOWNLOAD_DIR); + expect(path.basename(managedPathA ?? "")).toMatch(/-report\.pdf$/); + expect(path.basename(managedPathB ?? "")).toMatch(/-report\.pdf$/); + expect(saveAsA).toHaveBeenCalledWith(managedPathA); + expect(saveAsB).toHaveBeenCalledWith(managedPathB); + expect(mkdirSpy).toHaveBeenCalledWith(DEFAULT_DOWNLOAD_DIR, { recursive: true }); + }); + + it("suppresses unmanaged download save rejections until path is awaited", async () => { + const { page, handlers } = fakePage(); + vi.spyOn(fs, "mkdir").mockResolvedValue(undefined); + ensurePageState(page); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on("unhandledRejection", onUnhandled); + + const err = new Error("save failed"); + const download: MutableDownload = { + suggestedFilename: () => "report.pdf", + saveAs: vi.fn(async () => { + throw err; + }), + }; + + try { + handlers.get("download")?.[0]?.(download); + await new Promise((resolve) => setImmediate(resolve)); + + expect(unhandled).toEqual([]); + await expect(download.path?.()).rejects.toThrow("save failed"); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); + + it("leaves unmanaged download handling to explicit waiters while armed", () => { + const { page, handlers } = fakePage(); + const state = ensurePageState(page); + state.downloadWaiterDepth = 1; + const download = { + suggestedFilename: () => "report.pdf", + saveAs: vi.fn(async () => {}), + }; + + handlers.get("download")?.[0]?.(download); + + expect(download).not.toHaveProperty("path"); + expect(download.saveAs).not.toHaveBeenCalled(); + }); + it("tracks page errors and network requests (best-effort)", () => { const { page, handlers } = fakePage(); const state = ensurePageState(page); diff --git a/extensions/browser/src/browser/pw-session.ts b/extensions/browser/src/browser/pw-session.ts index b80a8a1a02a..bfb002f9999 100644 --- a/extensions/browser/src/browser/pw-session.ts +++ b/extensions/browser/src/browser/pw-session.ts @@ -1,3 +1,6 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import type { Browser, @@ -31,7 +34,9 @@ import { InvalidBrowserNavigationUrlError, withBrowserNavigationPolicy, } from "./navigation-guard.js"; +import { DEFAULT_DOWNLOAD_DIR } from "./paths.js"; import { BROWSER_REF_MARKER_ATTRIBUTE, withPageScopedCdpClient } from "./pw-session.page-cdp.js"; +import { sanitizeUntrustedFileName } from "./safe-filename.js"; export type BrowserConsoleMessage = { type: string; @@ -79,6 +84,7 @@ type PageState = { armIdUpload: number; armIdDialog: number; armIdDownload: number; + downloadWaiterDepth: number; /** * Role-based refs from the last role snapshot (e.g. e1/e2). * Mode "role" refs are generated from ariaSnapshot and resolved via getByRole. @@ -123,6 +129,12 @@ function normalizeCdpUrl(raw: string) { return raw.replace(/\/$/, ""); } +function buildManagedDownloadPath(fileName: string): string { + const id = crypto.randomUUID(); + const safeName = sanitizeUntrustedFileName(fileName, "download.bin"); + return path.join(DEFAULT_DOWNLOAD_DIR, `${id}-${safeName}`); +} + function hasCachedPlaywrightBrowserConnection(cdpUrl: string): boolean { return cachedByCdpUrl.has(normalizeCdpUrl(cdpUrl)); } @@ -334,6 +346,7 @@ export function ensurePageState(page: Page): PageState { armIdUpload: 0, armIdDialog: 0, armIdDownload: 0, + downloadWaiterDepth: 0, }; pageStates.set(page, state); @@ -402,6 +415,30 @@ export function ensurePageState(page: Page): PageState { rec.failureText = req.failure()?.errorText; rec.ok = false; }); + page.on( + "download", + (download: { + suggestedFilename?: () => string; + saveAs?: (outPath: string) => Promise; + path?: () => Promise; + }) => { + if (state.downloadWaiterDepth > 0) { + return; + } + const suggested = sanitizeUntrustedFileName( + download.suggestedFilename?.() || "download.bin", + "download.bin", + ); + const managedPath = buildManagedDownloadPath(suggested); + const managedSave = (async () => { + await fs.mkdir(DEFAULT_DOWNLOAD_DIR, { recursive: true }); + await download.saveAs?.(managedPath); + return managedPath; + })(); + managedSave.catch(() => {}); + download.path = async () => await managedSave; + }, + ); page.on("close", () => { pageStates.delete(page); observedPages.delete(page); diff --git a/extensions/browser/src/browser/pw-tools-core.downloads.ts b/extensions/browser/src/browser/pw-tools-core.downloads.ts index 3563f23231f..abecd679d69 100644 --- a/extensions/browser/src/browser/pw-tools-core.downloads.ts +++ b/extensions/browser/src/browser/pw-tools-core.downloads.ts @@ -28,11 +28,18 @@ function buildTempDownloadPath(fileName: string): string { } function createPageDownloadWaiter(page: Page, timeoutMs: number) { + const state = ensurePageState(page); + state.downloadWaiterDepth += 1; let done = false; let timer: NodeJS.Timeout | undefined; let handler: ((download: unknown) => void) | undefined; + let depthReleased = false; const cleanup = () => { + if (!depthReleased) { + depthReleased = true; + state.downloadWaiterDepth = Math.max(0, state.downloadWaiterDepth - 1); + } if (timer) { clearTimeout(timer); } diff --git a/extensions/browser/src/browser/pw-tools-core.test-harness.ts b/extensions/browser/src/browser/pw-tools-core.test-harness.ts index 2d71558cc42..421ed5649e6 100644 --- a/extensions/browser/src/browser/pw-tools-core.test-harness.ts +++ b/extensions/browser/src/browser/pw-tools-core.test-harness.ts @@ -7,11 +7,13 @@ let pageState: { armIdUpload: number; armIdDialog: number; armIdDownload: number; + downloadWaiterDepth: number; } = { console: [], armIdUpload: 0, armIdDialog: 0, armIdDownload: 0, + downloadWaiterDepth: 0, }; const sessionMocks = vi.hoisted(() => ({ @@ -81,6 +83,7 @@ export function installPwToolsCoreTestHooks() { armIdUpload: 0, armIdDialog: 0, armIdDownload: 0, + downloadWaiterDepth: 0, }; for (const fn of Object.values(sessionMocks)) { diff --git a/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts b/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts index 38600433ee8..ead9088439c 100644 --- a/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts @@ -102,21 +102,28 @@ describe("pw-tools-core", () => { } function createDownloadEventHarness() { - let downloadHandler: ((download: unknown) => void) | undefined; + const downloadHandlers = new Set<(download: unknown) => void>(); const on = vi.fn((event: string, handler: (download: unknown) => void) => { if (event === "download") { - downloadHandler = handler; + downloadHandlers.add(handler); + } + }); + const off = vi.fn((event: string, handler: (download: unknown) => void) => { + if (event === "download") { + downloadHandlers.delete(handler); } }); - const off = vi.fn(); setPwToolsCoreCurrentPage({ on, off }); return { trigger: (download: unknown) => { - downloadHandler?.(download); + for (const handler of downloadHandlers) { + handler(download); + } }, expectArmed: () => { - expect(downloadHandler).toBeDefined(); + expect(downloadHandlers.size).toBeGreaterThan(0); }, + activeHandlerCount: () => downloadHandlers.size, }; } @@ -169,6 +176,31 @@ describe("pw-tools-core", () => { await expect(fs.realpath(res.path)).resolves.toBe(await fs.realpath(targetPath)); }); }); + + it("marks explicit download waiters as owning the next download until cleanup", async () => { + const harness = createDownloadEventHarness(); + const state = sessionMocks.ensurePageState(); + expect(state.downloadWaiterDepth).toBe(0); + + const p = mod.waitForDownloadViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "T1", + timeoutMs: 1000, + }); + + await Promise.resolve(); + harness.expectArmed(); + expect(state.downloadWaiterDepth).toBe(1); + harness.trigger({ + url: () => "https://example.com/file.bin", + suggestedFilename: () => "file.bin", + saveAs: vi.fn(async () => {}), + }); + + await p; + expect(state.downloadWaiterDepth).toBe(0); + expect(harness.activeHandlerCount()).toBe(0); + }); it("clicks a ref and atomically finalizes explicit download paths", async () => { await withTempDir(async (tempDir) => { const harness = createDownloadEventHarness();