diff --git a/src/client.ts b/src/client.ts index 03d9227..fc4a1d6 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1361,7 +1361,7 @@ export class BetterWright { synced: 0, selected: extracted.selected, skipped: extracted.skipped, - source: extracted.source, + source: "path" in extracted.source ? { path: "selected" } : extracted.source, target: consentTarget || (this.hostTarget ? "host" : "local"), warnings: extracted.warnings, }; diff --git a/src/cookie-sync.ts b/src/cookie-sync.ts index 37b9cab..9dfe19e 100644 --- a/src/cookie-sync.ts +++ b/src/cookie-sync.ts @@ -205,6 +205,9 @@ export function normalizeCookieSyncOptions( if (includeSessionValue !== undefined && !isBoolean(includeSessionValue)) { throw new TypeError("syncCookies includeSession must be a boolean."); } + if (includeSessionValue === true && "path" in source) { + throw new TypeError("includeSession is unavailable for explicit cookie paths."); + } const appBoundValue = untrustedField(value, "windowsAppBound"); const windowsAppBound = appBoundValue === undefined ? "disabled" diff --git a/src/daemon-client.ts b/src/daemon-client.ts index 7d99463..25d716b 100644 --- a/src/daemon-client.ts +++ b/src/daemon-client.ts @@ -15,6 +15,7 @@ import { spawn } from "node:child_process"; import fs from "node:fs"; import net from "node:net"; +import path from "node:path"; import readline from "node:readline"; import { @@ -27,7 +28,7 @@ import { } from "./daemon.js"; import { profileLabel, resolveProfileName } from "./profile-name.js"; import { bunInheritedExecArgv } from "./runtime.js"; -import type { UntrustedValue } from "./untrusted-value.js"; +import { isRecord, isString, type UntrustedValue, untrustedField } from "./untrusted-value.js"; const CONNECT_TIMEOUT_MS = 1_000; const SPAWN_WAIT_MS = 8_000; @@ -250,6 +251,17 @@ async function waitForSocketGone(socketPath, timeoutMs = 5_000) { * | {ok: false, reason: string} * >} */ +// The daemon keeps the cwd of whichever CLI spawned it, so a relative +// cookie path must be resolved here, against the caller's directory. +function callerRelativeCookieSource(options: UntrustedValue) { + const source = isRecord(options) ? untrustedField(options, "source") : undefined; + const sourcePath = isRecord(source) ? untrustedField(source, "path") : undefined; + if (!isRecord(options) || !isRecord(source) || !isString(sourcePath) || !sourcePath.trim() || path.isAbsolute(sourcePath.trim())) { + return options; + } + return { ...options, source: { ...source, path: path.resolve(sourcePath.trim()) } }; +} + export async function connectSessionDaemon({ home = defaultDaemonHome(), cliPath, @@ -376,7 +388,7 @@ export function createDaemonBrowser(channel, { session = "default" }: any = {}) syncCookies: (options?: any) => call( "syncCookies", - [options], + [callerRelativeCookieSource(options)], Math.min( Math.max(Number(options?.timeoutMs) || 30_000, 1_000) + 45_000, 180_000, diff --git a/src/launch-config.ts b/src/launch-config.ts index 7598faf..532061b 100644 --- a/src/launch-config.ts +++ b/src/launch-config.ts @@ -75,7 +75,13 @@ export function launchOptionsFromEnv(home = defaultHome(), env = process.env): L } export function resolveLaunchOptions(options: BetterWrightOptions): BetterWrightOptions { - const defaults = launchOptionsFromEnv(options.home || defaultHome()); + // Lower-priority variables cannot reject a setting the caller supplied. + const env = { ...process.env }; + if (options.headless !== undefined) delete env.BETTERWRIGHT_HEADLESS; + if (options.adBlock !== undefined) delete env.BETTERWRIGHT_AD_BLOCK; + if (options.profile !== undefined) delete env.BETTERWRIGHT_PROFILE; + if (options.downloadPolicy !== undefined) delete env.BETTERWRIGHT_DOWNLOAD_POLICY; + const defaults = launchOptionsFromEnv(options.home || defaultHome(), env); // A forced display mode must also cancel an inherited off-screen window. if (options.headless !== undefined && options.headedInvisible === undefined) defaults.headedInvisible = false; return { ...options, ...defaults, ...Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined)) }; diff --git a/tests/node/client.test.ts b/tests/node/client.test.ts index 9d38d06..c500472 100644 --- a/tests/node/client.test.ts +++ b/tests/node/client.test.ts @@ -373,6 +373,24 @@ test("the worker rechecks Cookie Sync consent before opening a remote CDP target } }); +test("an empty Cookie Sync result does not echo an explicit source path", async () => { + const hostTarget = { + connect: async () => ({ provider: { cdpUrl: "ws://127.0.0.1:9/devtools/browser/unreachable" }, close: async () => {} }), + }; + const browser = new BetterWright({ hostTarget, vault: false }); + browser._extractCookieSync = async (normalized) => ({ + cookies: [], selected: 0, skipped: 1, warnings: [], source: { ...normalized.source }, + }); + try { + const empty = await browser.syncCookies({ source: { path: "/private/fixture/Cookies" }, domains: ["example.test"] }); + assert.equal(empty.ok, true); + assert.deepEqual(empty.source, { path: "selected" }); + assert.equal(JSON.stringify(empty).includes("/private/fixture"), false); + } finally { + await browser.close(); + } +}); + test("Cookie Sync to a host-owned target needs no cloud consent and reports the granted domains", async () => { const hostTarget = { connect: async () => ({ provider: { cdpUrl: "ws://127.0.0.1:9/devtools/browser/unreachable" }, close: async () => {} }), diff --git a/tests/node/cookie-sync.test.ts b/tests/node/cookie-sync.test.ts index 523fabe..c42d4b9 100644 --- a/tests/node/cookie-sync.test.ts +++ b/tests/node/cookie-sync.test.ts @@ -82,7 +82,10 @@ test("explicit Chromium sources read plaintext cookies outside the registry with } assert.throws(() => normalizeCookieSyncOptions({ source: { browser: "chrome", path: profile } }), /cannot be combined/); assert.throws(() => normalizeCookieSyncOptions({ source: { path: "\n" } }), /single-line/); - await assert.rejects(extractCookieSync(normalizeCookieSyncOptions({ source: { path: profile }, includeSession: true })), /includeSession/); + assert.throws( + () => normalizeCookieSyncOptions({ source: { path: profile }, includeSession: true }), + /includeSession is unavailable for explicit cookie paths/, + ); }); test("Cookie Sync normalizes source, domains, timeout, and secure defaults", () => { diff --git a/tests/node/daemon.test.ts b/tests/node/daemon.test.ts index 2971d57..d7c06c4 100644 --- a/tests/node/daemon.test.ts +++ b/tests/node/daemon.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import net from "node:net"; +import path from "node:path"; import readline from "node:readline"; import test from "node:test"; import { @@ -355,6 +356,12 @@ test("Cookie Sync crosses the daemon socket as source options, not cookie plaint assert.equal(result.synced, 4); assert.deepEqual(browser.calls.at(-1), ["syncCookies", options]); assert.equal(JSON.stringify(browser.calls.at(-1)).includes('"cookies"'), false); + const relative = { source: { path: path.join("profiles", "Default") }, domains: ["example.com"] }; + assert.equal((await proxy.syncCookies(relative)).ok, true); + assert.deepEqual(browser.calls.at(-1), ["syncCookies", { + ...relative, + source: { path: path.resolve("profiles", "Default") }, + }]); await proxy.close(); } finally { await cleanup(); diff --git a/tests/node/launch-config.test.ts b/tests/node/launch-config.test.ts index 86509ab..e3d3e4b 100644 --- a/tests/node/launch-config.test.ts +++ b/tests/node/launch-config.test.ts @@ -65,6 +65,29 @@ test("environment and explicit constructor options override saved launch default } }); +test("invalid environment values do not reject explicitly supplied constructor options", async () => { + const home = makeTempDir("betterwright-launch-explicit-"); + const invalid = { BETTERWRIGHT_HEADLESS: "maybe", BETTERWRIGHT_AD_BLOCK: "invalid", + BETTERWRIGHT_PROFILE: "../escape", BETTERWRIGHT_DOWNLOAD_POLICY: "sometimes" }; + const previous = Object.fromEntries(Object.keys(invalid).map(key => [key, process.env[key]])); + Object.assign(process.env, invalid); + try { + const browser = new BetterWright({ home, vault: false, headless: true, adBlock: false, + profile: "explicit", downloadPolicy: "ask" }); + assert.equal(browser.headless, true); + assert.equal(browser.adBlock, false); + assert.equal(browser.profile, "explicit"); + assert.equal(browser.downloadPolicy, "ask"); + await browser.close(); + assert.throws(() => new BetterWright({ home, vault: false, headless: true }), /BETTERWRIGHT_AD_BLOCK/); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +}); + test("CLI daemon config includes saved identity, display and download policy in its signature", () => { const home = makeTempDir("betterwright-launch-daemon-"); const file = path.join(home, "config.json");