diff --git a/CHANGELOG.md b/CHANGELOG.md index a0bc89c..d7d4ee2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,12 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi --- -## [0.3.8] — 2026-03-05 +## [0.3.9] — 2026-03-05 - **[binary]** Upgrade Chromium base to 145.0.7632.159 (Linux x64). macOS and Windows remain on 145.0.7632.109.2 - **[binary]** WebGPU adapter spoofing for headless/Docker, timezone multi-context fix, stealth audit phase 2 (6 detection vector fixes), font auto-hide for cross-platform fingerprints +- **[wrapper]** Deduplicate CLI flags when user args overlap with stealth defaults — user values win cleanly instead of passing both to Chromium +- **[wrapper]** Extract shared `buildArgs` into `js/src/args.ts` (JS DRY fix), guard debug logging behind `DEBUG=cloakbrowser` env var ## [0.3.7] — 2026-03-05 diff --git a/cloakbrowser/_version.py b/cloakbrowser/_version.py index 4ad67eb..771bc6e 100644 --- a/cloakbrowser/_version.py +++ b/cloakbrowser/_version.py @@ -1 +1 @@ -__version__ = "0.3.8" +__version__ = "0.3.9" diff --git a/cloakbrowser/browser.py b/cloakbrowser/browser.py index a081a27..44221c7 100644 --- a/cloakbrowser/browser.py +++ b/cloakbrowser/browser.py @@ -496,18 +496,39 @@ def _build_args( timezone: str | None = None, locale: str | None = None, ) -> list[str]: - """Combine stealth args with user-provided args and locale flags.""" - result = [] + """Combine stealth args with user-provided args and locale flags. + + Deduplicates by flag key (everything before '='). + Priority: stealth defaults < user args < dedicated params (timezone/locale). + """ + seen: dict[str, str] = {} + if stealth_args: - result.extend(get_default_stealth_args()) + for arg in get_default_stealth_args(): + seen[arg.split("=", 1)[0]] = arg + if extra_args: - result.extend(extra_args) + for arg in extra_args: + key = arg.split("=", 1)[0] + if key in seen: + logger.debug("Arg override: %s -> %s", seen[key], arg) + seen[key] = arg + # Timezone/locale flags are independent of stealth_args — always inject when set if timezone: - result.append(f"--fingerprint-timezone={timezone}") + key = "--fingerprint-timezone" + flag = f"{key}={timezone}" + if key in seen: + logger.debug("Arg override: %s -> %s", seen[key], flag) + seen[key] = flag if locale: - result.append(f"--lang={locale}") - return result + key = "--lang" + flag = f"{key}={locale}" + if key in seen: + logger.debug("Arg override: %s -> %s", seen[key], flag) + seen[key] = flag + + return list(seen.values()) def _parse_proxy_url(proxy: str) -> dict[str, Any]: diff --git a/js/package.json b/js/package.json index e20713f..6e820be 100644 --- a/js/package.json +++ b/js/package.json @@ -1,6 +1,6 @@ { "name": "cloakbrowser", - "version": "0.3.8", + "version": "0.3.9", "description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.", "type": "module", "main": "dist/index.js", diff --git a/js/src/args.ts b/js/src/args.ts new file mode 100644 index 0000000..0df410a --- /dev/null +++ b/js/src/args.ts @@ -0,0 +1,49 @@ +/** + * Shared argument builder for Playwright and Puppeteer wrappers. + */ + +import type { LaunchOptions } from "./types.js"; +import { getDefaultStealthArgs } from "./config.js"; + +const DEBUG = /\bcloakbrowser\b/.test(process.env.DEBUG ?? ""); + +/** + * Build deduplicated Chromium CLI args from stealth defaults + user overrides. + * + * Priority: stealth defaults < user args < dedicated params (timezone/locale). + */ +export function buildArgs(options: LaunchOptions): string[] { + const seen = new Map(); + + if (options.stealthArgs !== false) { + for (const arg of getDefaultStealthArgs()) { + seen.set(arg.split("=")[0], arg); + } + } + if (options.args) { + for (const arg of options.args) { + const key = arg.split("=")[0]; + if (seen.has(key)) { + if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(key)} -> ${arg}`); + } + seen.set(key, arg); + } + } + if (options.timezone) { + const key = "--fingerprint-timezone"; + const flag = `${key}=${options.timezone}`; + if (seen.has(key)) { + if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(key)} -> ${flag}`); + } + seen.set(key, flag); + } + if (options.locale) { + const key = "--lang"; + const flag = `${key}=${options.locale}`; + if (seen.has(key)) { + if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(key)} -> ${flag}`); + } + seen.set(key, flag); + } + return [...seen.values()]; +} diff --git a/js/src/playwright.ts b/js/src/playwright.ts index 3eecd8f..3c57461 100644 --- a/js/src/playwright.ts +++ b/js/src/playwright.ts @@ -5,7 +5,8 @@ import type { Browser, BrowserContext } from "playwright-core"; import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js"; -import { DEFAULT_VIEWPORT, getDefaultStealthArgs } from "./config.js"; +import { DEFAULT_VIEWPORT } from "./config.js"; +import { buildArgs } from "./args.js"; import { ensureBinary } from "./download.js"; import { parseProxyUrl } from "./proxy.js"; @@ -176,24 +177,4 @@ async function maybeResolveGeoip( } /** @internal Exposed for unit tests only. */ -export function _buildArgsForTest(options: LaunchOptions): string[] { - return buildArgs(options); -} - -function buildArgs(options: LaunchOptions): string[] { - const args: string[] = []; - if (options.stealthArgs !== false) { - args.push(...getDefaultStealthArgs()); - } - if (options.args) { - args.push(...options.args); - } - // Timezone/locale flags — always inject when set - if (options.timezone) { - args.push(`--fingerprint-timezone=${options.timezone}`); - } - if (options.locale) { - args.push(`--lang=${options.locale}`); - } - return args; -} +export { buildArgs as _buildArgsForTest } from "./args.js"; diff --git a/js/src/puppeteer.ts b/js/src/puppeteer.ts index 4ff917f..15a596e 100644 --- a/js/src/puppeteer.ts +++ b/js/src/puppeteer.ts @@ -5,7 +5,7 @@ import type { Browser } from "puppeteer-core"; import type { LaunchOptions } from "./types.js"; -import { getDefaultStealthArgs } from "./config.js"; +import { buildArgs } from "./args.js"; import { ensureBinary } from "./download.js"; import { parseProxyUrl } from "./proxy.js"; @@ -99,19 +99,3 @@ async function maybeResolveGeoip( }; } -function buildArgs(options: LaunchOptions): string[] { - const args: string[] = []; - if (options.stealthArgs !== false) { - args.push(...getDefaultStealthArgs()); - } - if (options.args) { - args.push(...options.args); - } - if (options.timezone) { - args.push(`--fingerprint-timezone=${options.timezone}`); - } - if (options.locale) { - args.push(`--lang=${options.locale}`); - } - return args; -} diff --git a/js/tests/config.test.ts b/js/tests/config.test.ts index fb013f6..a79e011 100644 --- a/js/tests/config.test.ts +++ b/js/tests/config.test.ts @@ -123,6 +123,59 @@ describe("buildArgs timezone/locale", () => { }); }); +describe("buildArgs deduplication", () => { + it("user --fingerprint overrides default seed", () => { + const args = _buildArgsForTest({ args: ["--fingerprint=99887"] }); + const fpArgs = args.filter(a => a.startsWith("--fingerprint=")); + expect(fpArgs).toHaveLength(1); + expect(fpArgs[0]).toBe("--fingerprint=99887"); + }); + + it("user --fingerprint-platform overrides default", () => { + const args = _buildArgsForTest({ args: ["--fingerprint-platform=linux"] }); + const platArgs = args.filter(a => a.startsWith("--fingerprint-platform=")); + expect(platArgs).toHaveLength(1); + expect(platArgs[0]).toBe("--fingerprint-platform=linux"); + }); + + it("timezone param overrides user --fingerprint-timezone arg", () => { + const args = _buildArgsForTest({ + args: ["--fingerprint-timezone=Europe/London"], + timezone: "America/New_York", + }); + const tzArgs = args.filter(a => a.startsWith("--fingerprint-timezone=")); + expect(tzArgs).toHaveLength(1); + expect(tzArgs[0]).toBe("--fingerprint-timezone=America/New_York"); + }); + + it("locale param overrides user --lang arg", () => { + const args = _buildArgsForTest({ + args: ["--lang=de-DE"], + locale: "en-US", + }); + const langArgs = args.filter(a => a.startsWith("--lang=")); + expect(langArgs).toHaveLength(1); + expect(langArgs[0]).toBe("--lang=en-US"); + }); + + it("no duplicate flag keys in output", () => { + const args = _buildArgsForTest({ + args: ["--fingerprint=99887", "--fingerprint-timezone=UTC", "--lang=fr-FR"], + timezone: "Europe/Berlin", + locale: "de-DE", + }); + const keys = args.map(a => a.split("=")[0]); + expect(new Set(keys).size).toBe(keys.length); + }); + + it("non-value flags preserved without dedup issues", () => { + const args = _buildArgsForTest({ args: ["--disable-gpu", "--no-zygote"] }); + expect(args).toContain("--disable-gpu"); + expect(args).toContain("--no-zygote"); + expect(args).toContain("--no-sandbox"); + }); +}); + describe("migrateTimezoneId deprecation", () => { it("migrates timezoneId to timezone", () => { const result = migrateTimezoneId({ timezoneId: "Europe/Paris" }); diff --git a/tests/test_build_args.py b/tests/test_build_args.py index 201c671..9ab588f 100644 --- a/tests/test_build_args.py +++ b/tests/test_build_args.py @@ -92,3 +92,75 @@ def test_migrate_both_none(): result = _migrate_timezone_id(None, kwargs) assert result is None assert len(w) == 0 + + +# --- Deduplication tests --- + + +def test_user_fingerprint_overrides_default(): + """User --fingerprint should override the random default seed.""" + args = _build_args(stealth_args=True, extra_args=["--fingerprint=99887"]) + fingerprint_args = [a for a in args if a.startswith("--fingerprint=")] + assert len(fingerprint_args) == 1 + assert fingerprint_args[0] == "--fingerprint=99887" + + +def test_user_platform_overrides_default(): + """User --fingerprint-platform should override the default.""" + args = _build_args(stealth_args=True, extra_args=["--fingerprint-platform=linux"]) + platform_args = [a for a in args if a.startswith("--fingerprint-platform=")] + assert len(platform_args) == 1 + assert platform_args[0] == "--fingerprint-platform=linux" + + +def test_timezone_param_overrides_user_arg(): + """Dedicated timezone param should override user arg.""" + args = _build_args( + stealth_args=True, + extra_args=["--fingerprint-timezone=Europe/London"], + timezone="America/New_York", + ) + tz_args = [a for a in args if a.startswith("--fingerprint-timezone=")] + assert len(tz_args) == 1 + assert tz_args[0] == "--fingerprint-timezone=America/New_York" + + +def test_locale_param_overrides_user_arg(): + """Dedicated locale param should override user --lang arg.""" + args = _build_args( + stealth_args=True, + extra_args=["--lang=de-DE"], + locale="en-US", + ) + lang_args = [a for a in args if a.startswith("--lang=")] + assert len(lang_args) == 1 + assert lang_args[0] == "--lang=en-US" + + +def test_no_duplicate_flags(): + """No flag key should appear more than once in the output.""" + args = _build_args( + stealth_args=True, + extra_args=["--fingerprint=99887", "--fingerprint-timezone=UTC", "--lang=fr-FR"], + timezone="Europe/Berlin", + locale="de-DE", + ) + keys = [a.split("=", 1)[0] for a in args] + assert len(keys) == len(set(keys)), f"Duplicate keys found: {keys}" + + +def test_non_value_flags_preserved(): + """Flags without = should be preserved without dedup issues.""" + args = _build_args(stealth_args=True, extra_args=["--disable-gpu", "--no-zygote"]) + assert "--disable-gpu" in args + assert "--no-zygote" in args + assert "--no-sandbox" in args + + +def test_override_logs_debug(caplog): + """Should log debug message when an override happens.""" + import logging + + with caplog.at_level(logging.DEBUG, logger="cloakbrowser"): + _build_args(stealth_args=True, extra_args=["--fingerprint=99887"]) + assert any("--fingerprint=" in r.message and "99887" in r.message for r in caplog.records)