diff --git a/CHANGELOG.md b/CHANGELOG.md index b3e4476..c6cba44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi ## [Unreleased] +- **[wrapper]** Authenticated HTTP/HTTPS proxies now use the browser's native proxy authentication on every platform whose binary supports it — resolved per platform and binary version — and fall back to the standard proxy path on older binaries that don't. Fixes credentialed HTTP/HTTPS proxies on macOS and ARM, which previously could not use the native path. Python, JavaScript, Puppeteer, and .NET. + ## [0.4.10] — 2026-07-09 - **[wrapper]** Fix JS CLI silently exiting with no output when run via `npx`/`node_modules/.bin` (#427). The entry-point guard compared `import.meta.url` to an unresolved `process.argv[1]`; symlinked bin installs (npm/pnpm/npx) never matched, so no subcommand ran. Now realpath-resolves the invoked path before comparing. diff --git a/cloakbrowser/browser.py b/cloakbrowser/browser.py index 56f17d7..4f9d92d 100644 --- a/cloakbrowser/browser.py +++ b/cloakbrowser/browser.py @@ -24,9 +24,9 @@ from .config import ( DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS, binary_supports_headless_no_viewport, + binary_supports_http_proxy_inline_auth, binary_supports_maximized_window, get_default_stealth_args, - normalize_requested_version, ) from .download import ensure_binary from .license import build_launch_env @@ -206,7 +206,7 @@ def launch( binary_path = ensure_binary(license_key=license_key, browser_version=browser_version) timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) - proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version) + proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key) args = _resolve_webrtc_args(args, proxy) args = _append_webrtc_exit_ip(args, exit_ip) @@ -312,7 +312,7 @@ async def launch_async( # noqa: C901 binary_path = ensure_binary(license_key=license_key, browser_version=browser_version) timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) - proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version) + proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key) args = _resolve_webrtc_args(args, proxy) args = _append_webrtc_exit_ip(args, exit_ip) chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version) and not _suppress_maximize) @@ -428,7 +428,7 @@ def launch_persistent_context( binary_path = ensure_binary(license_key=license_key, browser_version=browser_version) timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) - proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version) + proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key) args = _resolve_webrtc_args(args, proxy) args = _append_webrtc_exit_ip(args, exit_ip) chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version) and viewport is _VIEWPORT_UNSET and "viewport" not in kwargs and "no_viewport" not in kwargs) @@ -565,7 +565,7 @@ async def launch_persistent_context_async( binary_path = ensure_binary(license_key=license_key, browser_version=browser_version) timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) - proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version) + proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key) args = _resolve_webrtc_args(args, proxy) args = _append_webrtc_exit_ip(args, exit_ip) chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version) and viewport is _VIEWPORT_UNSET and "viewport" not in kwargs and "no_viewport" not in kwargs) @@ -1379,26 +1379,6 @@ def _normalize_http_string_url(url: str) -> str: return result -_HTTP_PROXY_INLINE_AUTH_MIN_VERSION = "146.0.7680.177.5" -_HTTP_PROXY_INLINE_AUTH_PLATFORMS = {"linux-x64", "windows-x64"} - - -def _supports_http_proxy_inline_auth(version: str | None = None) -> bool: - """Check if the running binary supports HTTP proxy inline credentials. - - Requires both a supported platform AND a binary version with preemptive proxy - auth. ``version`` is the pinned/resolved Chromium version actually being - launched; when None it falls back to the platform default. Passing the pin - matters because a rollback can run a binary older than the default (#182). - """ - from .config import get_platform_tag, get_chromium_version, _version_tuple - tag = get_platform_tag() - if tag not in _HTTP_PROXY_INLINE_AUTH_PLATFORMS: - return False - effective = version or get_chromium_version() - return _version_tuple(effective) >= _version_tuple(_HTTP_PROXY_INLINE_AUTH_MIN_VERSION) - - def _is_socks_proxy(proxy: str | ProxySettings | None) -> bool: """Check if the proxy uses SOCKS5 protocol.""" if proxy is None: @@ -1410,12 +1390,15 @@ def _is_socks_proxy(proxy: str | ProxySettings | None) -> bool: def _resolve_proxy_config( proxy: str | ProxySettings | None, browser_version: str | None = None, + license_key: str | None = None, ) -> tuple[dict[str, Any], list[str]]: """Resolve proxy into Playwright kwargs and Chrome args. - Proxies with credentials (SOCKS5 or HTTP/HTTPS) are passed via Chrome's - --proxy-server flag with inline credentials, bypassing Playwright's CDP - auth interceptor which breaks on some proxies and Google domains (#182). + Proxies with credentials (SOCKS5 always; HTTP/HTTPS only on binaries that + support inline proxy auth) are passed via Chrome's --proxy-server flag with + inline credentials, bypassing Playwright's CDP auth interceptor which breaks + on some proxies and Google domains (#182). HTTP/HTTPS creds on older binaries + fall back to Playwright's proxy dict. Returns: (proxy_kwargs, extra_chrome_args) — one or both will be empty. @@ -1436,11 +1419,13 @@ def _resolve_proxy_config( # passwords at '=' and other special chars (#157). return {}, [f"--proxy-server={_normalize_socks_string_url(proxy)}"] - # HTTP/HTTPS with credentials on supported platforms: use Chrome's native - # proxy authentication path instead of Playwright's CDP auth interceptor - # (#182). - requested_version = normalize_requested_version(browser_version) - if _has_credentials(proxy) and _supports_http_proxy_inline_auth(requested_version): + # HTTP/HTTPS with credentials, only on binaries that ship inline proxy auth: + # use Chrome's native proxy authentication path instead of Playwright's CDP + # auth interceptor (#182). Older binaries (free macOS/linux-arm64) can't parse + # inline credentials, so they fall through to the Playwright proxy dict below. + if _has_credentials(proxy) and binary_supports_http_proxy_inline_auth( + license_key, browser_version + ): if isinstance(proxy, dict): url = _reconstruct_http_url(proxy) extra_args = [f"--proxy-server={url}"] diff --git a/cloakbrowser/config.py b/cloakbrowser/config.py index 338462d..497b4f7 100644 --- a/cloakbrowser/config.py +++ b/cloakbrowser/config.py @@ -344,6 +344,70 @@ def binary_supports_headless_no_viewport( return False +# --------------------------------------------------------------------------- +# Inline HTTP proxy authentication by binary version +# --------------------------------------------------------------------------- +# First Chromium build, per platform, whose binary can take inline HTTP proxy +# credentials (Chromium-native ``--proxy-server=http://user:pass@host``). Below +# the floor the wrapper MUST route credentialed HTTP/HTTPS proxies through +# Playwright's proxy dict instead — an older binary treats the ``user:pass@`` +# authority as an invalid proxy host and drops proxy auth (#182). +# +# A single global threshold (like HEADLESS_NO_VIEWPORT_MIN_VERSION) can't model +# this: the capability landed in different build lineages at different points — +# linux-x64/windows-x64 have it at 146.0.7680.177.5, but the free macOS (145.x) +# and linux-arm64 (146.0.7680.177.3) floors predate it, so those only qualify +# once they resolve to a Pro/newer build (148+). Platforms absent from this map +# are treated as never-inline (unknown capability => safe fallback). +HTTP_PROXY_INLINE_AUTH_MIN_VERSION: dict[str, str] = { + "linux-x64": "146.0.7680.177.5", + "windows-x64": "146.0.7680.177.5", + "linux-arm64": "148.0.7778.215.3", + "darwin-arm64": "148.0.7778.215.3", + "darwin-x64": "148.0.7778.215.3", +} + + +def binary_supports_http_proxy_inline_auth( + license_key: str | None = None, browser_version: str | None = None +) -> bool: + """Whether the resolved binary accepts inline HTTP proxy credentials. + + The capability is a function of the binary version; ``license_key`` only + resolves *which* version launches (Pro build vs free default), exactly like + ``binary_supports_headless_no_viewport``. A declared pin wins, else a valid + Pro license resolves to the Pro build (which ships the patch), else the free + per-platform default — then it compares to this platform's floor. Below the + floor, credentialed HTTP proxies fall back to Playwright's proxy dict. A + local override with no declared version is unknown-version, so stay on the + safe fallback. Python, JS and .NET mirror this gate. + """ + floor = HTTP_PROXY_INLINE_AUTH_MIN_VERSION.get(get_platform_tag()) + if floor is None: + return False + try: + declared = normalize_requested_version(browser_version) + except ValueError: + declared = None + if declared: + version = declared + elif get_local_binary_override(): + return False + else: + from .license import resolve_license_key + + pro = bool(resolve_license_key(license_key)) + version = get_effective_version(pro=pro) + if version is None: + # Pro with no cached marker => resolve latest Pro from the server, which + # is >= the floor by construction and always ships the patch. + return True + try: + return not _version_newer(floor, version) + except (ValueError, AttributeError): + return False + + def binary_supports_maximized_window( license_key: str | None = None, browser_version: str | None = None ) -> bool: diff --git a/dotnet/src/CloakBrowser/CloakLauncher.cs b/dotnet/src/CloakBrowser/CloakLauncher.cs index d631d47..e4c6f38 100644 --- a/dotnet/src/CloakBrowser/CloakLauncher.cs +++ b/dotnet/src/CloakBrowser/CloakLauncher.cs @@ -26,7 +26,7 @@ public static class CloakLauncher string binaryPath = await Download.EnsureBinaryAsync(options.LicenseKey, options.BrowserVersion).ConfigureAwait(false); var (timezone, locale, exitIp) = await MaybeResolveGeoIpAsync( options.GeoIp, options.Proxy, options.Timezone, options.Locale).ConfigureAwait(false); - var proxyResolution = ProxyResolver.Resolve(options.Proxy, options.BrowserVersion); + var proxyResolution = ProxyResolver.Resolve(options.Proxy, options.BrowserVersion, options.LicenseKey); var args = await ResolveWebRtcArgsAsync(options.Args, options.Proxy).ConfigureAwait(false); args = MaybeAppendWebRtcExitIp(args, exitIp); @@ -136,7 +136,7 @@ public static class CloakLauncher string binaryPath = await Download.EnsureBinaryAsync(options.LicenseKey, options.BrowserVersion).ConfigureAwait(false); var (timezone, locale, exitIp) = await MaybeResolveGeoIpAsync( options.GeoIp, options.Proxy, options.Timezone, options.Locale).ConfigureAwait(false); - var proxyResolution = ProxyResolver.Resolve(options.Proxy, options.BrowserVersion); + var proxyResolution = ProxyResolver.Resolve(options.Proxy, options.BrowserVersion, options.LicenseKey); var args = await ResolveWebRtcArgsAsync(options.Args, options.Proxy).ConfigureAwait(false); args = MaybeAppendWebRtcExitIp(args, exitIp); diff --git a/dotnet/src/CloakBrowser/Config.cs b/dotnet/src/CloakBrowser/Config.cs index c2a014b..ff8ca1a 100644 --- a/dotnet/src/CloakBrowser/Config.cs +++ b/dotnet/src/CloakBrowser/Config.cs @@ -452,4 +452,81 @@ public static class Config /// public static bool BinarySupportsMaximizedWindow(string? licenseKey = null, string? browserVersion = null) => BinarySupportsHeadlessNoViewport(licenseKey, browserVersion); + + // First Chromium build, per platform, whose binary can take inline HTTP proxy + // credentials (Chromium-native --proxy-server=http://user:pass@host). Below the + // floor the wrapper MUST route credentialed HTTP/HTTPS proxies through + // Playwright's proxy object — an older binary treats the user:pass@ authority as + // an invalid proxy host and drops proxy auth (#182). A single global threshold + // can't model this: the capability landed in different build lineages at different + // points (linux-x64/windows-x64 at 146.0.7680.177.5, but the free macOS 145.x and + // linux-arm64 146.0.7680.177.3 floors predate it, qualifying only once they resolve + // to a Pro/newer 148+ build). Platforms absent from this map are never-inline. + public static readonly IReadOnlyDictionary HttpProxyInlineAuthMinVersion = + new Dictionary + { + ["linux-x64"] = "146.0.7680.177.5", + ["windows-x64"] = "146.0.7680.177.5", + ["linux-arm64"] = "148.0.7778.215.3", + ["darwin-arm64"] = "148.0.7778.215.3", + ["darwin-x64"] = "148.0.7778.215.3", + }; + + /// + /// Whether the resolved binary accepts inline HTTP proxy credentials. The + /// capability is a function of the binary version; + /// only resolves which version launches (Pro build vs free default), exactly like + /// . A declared pin wins, else a valid + /// Pro license resolves to the Pro build (which ships the patch), else the free + /// per-platform default — then it compares to this platform's floor. Below the + /// floor, credentialed HTTP proxies fall back to Playwright's proxy object. A local + /// override with no declared version is unknown-version, so stay on the safe + /// fallback. Python, JS and .NET mirror this gate. + /// + public static bool BinarySupportsHttpProxyInlineAuth(string? licenseKey = null, string? browserVersion = null) + { + string tag; + try + { + tag = GetPlatformTag(); + } + catch + { + return false; + } + if (!HttpProxyInlineAuthMinVersion.TryGetValue(tag, out var floor)) + return false; + string? declared; + try + { + declared = NormalizeRequestedVersion(browserVersion); + } + catch + { + declared = null; + } + string? version; + if (!string.IsNullOrEmpty(declared)) + { + version = declared!; + } + else if (GetLocalBinaryOverride() != null) + { + return false; + } + else + { + bool pro = !string.IsNullOrEmpty(License.ResolveLicenseKey(licenseKey)); + version = GetEffectiveVersion(pro); + } + if (version == null) return false; + try + { + return !VersionNewer(floor, version); + } + catch + { + return false; + } + } } diff --git a/dotnet/src/CloakBrowser/ProxyResolver.cs b/dotnet/src/CloakBrowser/ProxyResolver.cs index e22fb83..e8af008 100644 --- a/dotnet/src/CloakBrowser/ProxyResolver.cs +++ b/dotnet/src/CloakBrowser/ProxyResolver.cs @@ -7,10 +7,6 @@ namespace CloakBrowser; /// internal static class ProxyResolver { - private const string HttpProxyInlineAuthMinVersion = "146.0.7680.177.5"; - private static readonly IReadOnlySet HttpProxyInlineAuthPlatforms = - new HashSet { "linux-x64", "windows-x64" }; - /// Result of resolving a proxy: Playwright proxy (or null) plus extra Chrome args. public sealed record ProxyResolution(Microsoft.Playwright.Proxy? PlaywrightProxy, List ExtraArgs); @@ -267,28 +263,6 @@ internal static class ProxyResolver return result; } - private static bool SupportsHttpProxyInlineAuth(string? version = null) - { - string tag = Config.GetPlatformTag(); - if (!HttpProxyInlineAuthPlatforms.Contains(tag)) - return false; - return Compare(version ?? Config.GetChromiumVersion(), HttpProxyInlineAuthMinVersion) >= 0; - } - - private static int Compare(string a, string b) - { - var ta = Config.VersionTuple(a); - var tb = Config.VersionTuple(b); - int n = Math.Max(ta.Length, tb.Length); - for (int i = 0; i < n; i++) - { - int va = i < ta.Length ? ta[i] : 0; - int vb = i < tb.Length ? tb[i] : 0; - if (va != vb) return va.CompareTo(vb); - } - return 0; - } - /// Extract a normalized proxy URL string from a string or dict proxy (for geoip / webrtc). public static string? ExtractProxyUrl(object? proxy) { @@ -307,7 +281,7 @@ internal static class ProxyResolver } /// Resolve a proxy into Playwright options + extra Chrome args (one or both empty). - public static ProxyResolution Resolve(object? proxy, string? browserVersion = null) + public static ProxyResolution Resolve(object? proxy, string? browserVersion = null, string? licenseKey = null) { if (proxy == null) return new ProxyResolution(null, new List()); @@ -333,15 +307,16 @@ internal static class ProxyResolver return new ProxyResolution(null, new List { $"--proxy-server={NormalizeSocksStringUrl(sUrl)}" }); } - // HTTP/HTTPS with credentials on supported platforms: inline creds via --proxy-server. + // HTTP/HTTPS with credentials, only on binaries that ship inline proxy auth: + // inline creds via --proxy-server. Older binaries (free macOS/linux-arm64) + // can't parse inline credentials, so they fall through to Playwright's proxy. bool hasCreds = proxy switch { ProxySettings ps => HasCredentials(ps), string s => HasCredentials(s), _ => false, }; - var requestedVersion = Config.NormalizeRequestedVersion(browserVersion); - if (hasCreds && SupportsHttpProxyInlineAuth(requestedVersion)) + if (hasCreds && Config.BinarySupportsHttpProxyInlineAuth(licenseKey, browserVersion)) { if (proxy is ProxySettings psd) { diff --git a/dotnet/tests/CloakBrowser.Tests/ProxyResolverTests.cs b/dotnet/tests/CloakBrowser.Tests/ProxyResolverTests.cs index 51313ff..8ff4af3 100644 --- a/dotnet/tests/CloakBrowser.Tests/ProxyResolverTests.cs +++ b/dotnet/tests/CloakBrowser.Tests/ProxyResolverTests.cs @@ -66,21 +66,24 @@ public class ProxyResolverTests } [Fact] - public void Http_With_Creds_Parses_Into_PlaywrightFields_When_InlineAuth_Unsupported() + public void Http_With_Creds_Uses_InlineAuth_On_Binary_With_Patch() { - // On platforms NOT in the inline-auth set (e.g. linux-arm64, darwin-*), - // HTTP creds go to the Playwright proxy dict. On linux-x64/windows-x64 with - // a recent binary they go to --proxy-server. Accept either valid outcome. - var r = ProxyResolver.Resolve("http://user:pass@host:8080"); - bool inlineArg = r.ExtraArgs.Any(a => a.StartsWith("--proxy-server=")); - bool pwProxy = r.PlaywrightProxy != null; - Assert.True(inlineArg ^ pwProxy, "Exactly one of inline-arg or playwright-proxy should be set"); - if (pwProxy) - { - Assert.Equal("http://host:8080", r.PlaywrightProxy!.Server); - Assert.Equal("user", r.PlaywrightProxy.Username); - Assert.Equal("pass", r.PlaywrightProxy.Password); - } + // Pin a version at/above every platform floor → inline on any host. + var r = ProxyResolver.Resolve("http://user:pass@host:8080", "148.0.7778.215.3"); + Assert.Null(r.PlaywrightProxy); + Assert.Equal("--proxy-server=http://user:pass@host:8080", Assert.Single(r.ExtraArgs)); + } + + [Fact] + public void Http_With_Creds_Falls_Back_On_Binary_Without_Patch() + { + // Pin a version below every platform floor → Playwright proxy on any host. + var r = ProxyResolver.Resolve("http://user:pass@host:8080", "146.0.7680.177.3"); + Assert.Empty(r.ExtraArgs); + Assert.NotNull(r.PlaywrightProxy); + Assert.Equal("http://host:8080", r.PlaywrightProxy!.Server); + Assert.Equal("user", r.PlaywrightProxy.Username); + Assert.Equal("pass", r.PlaywrightProxy.Password); } [Fact] diff --git a/js/src/config.ts b/js/src/config.ts index 985636b..deb34d4 100644 --- a/js/src/config.ts +++ b/js/src/config.ts @@ -320,6 +320,74 @@ export function binarySupportsMaximizedWindow( return binarySupportsHeadlessNoViewport(licenseKey, browserVersion); } +// --------------------------------------------------------------------------- +// Inline HTTP proxy authentication by binary version +// --------------------------------------------------------------------------- +// First Chromium build, per platform, whose binary can take inline HTTP proxy +// credentials (Chromium-native `--proxy-server=http://user:pass@host`). Below +// the floor the wrapper MUST route credentialed HTTP/HTTPS proxies through +// Playwright's proxy dict / page.authenticate — an older binary treats the +// `user:pass@` authority as an invalid proxy host and drops proxy auth (#182). +// A single global threshold can't model this: the capability landed in +// different build lineages at different points (linux-x64/windows-x64 at +// 146.0.7680.177.5, but the free macOS 145.x and linux-arm64 146.0.7680.177.3 +// floors predate it, qualifying only once they resolve to a Pro/newer 148+ +// build). Platforms absent from this map are treated as never-inline. +export const HTTP_PROXY_INLINE_AUTH_MIN_VERSION: Record = { + "linux-x64": "146.0.7680.177.5", + "windows-x64": "146.0.7680.177.5", + "linux-arm64": "148.0.7778.215.3", + "darwin-arm64": "148.0.7778.215.3", + "darwin-x64": "148.0.7778.215.3", +}; + +/** + * Whether the resolved binary accepts inline HTTP proxy credentials. The + * capability is a function of the binary version; `licenseKey` only resolves + * which version launches (Pro build vs free default), exactly like + * binarySupportsHeadlessNoViewport. A declared pin wins, else a valid Pro + * license resolves to the Pro build (which ships the patch), else the free + * per-platform default — then it compares to this platform's floor. Below the + * floor, credentialed HTTP proxies fall back to Playwright's proxy dict. A + * local override with no declared version is unknown-version, so fail safe. + * Python, JS and .NET mirror this gate. + */ +export function binarySupportsHttpProxyInlineAuth( + licenseKey?: string, + browserVersion?: string, +): boolean { + let tag: string; + try { + tag = getPlatformTag(); + } catch { + return false; + } + const floor = HTTP_PROXY_INLINE_AUTH_MIN_VERSION[tag]; + if (!floor) return false; + let declared: string | undefined; + try { + declared = normalizeRequestedVersion(browserVersion); + } catch { + declared = undefined; + } + let version: string | null; + if (declared) { + version = declared; + } else if (getLocalBinaryOverride()) { + return false; + } else { + const pro = Boolean(resolveLicenseKey(licenseKey)); + version = getEffectiveVersion(pro); + } + if (version === null) return false; + try { + if (parseVersion(version).some(Number.isNaN)) return false; + return !versionNewer(floor, version); + } catch { + return false; + } +} + // --------------------------------------------------------------------------- // Playwright default args to suppress — these leak automation signals. // --enable-automation: exposes navigator.webdriver = true diff --git a/js/src/playwright.ts b/js/src/playwright.ts index 08efd35..09e3366 100644 --- a/js/src/playwright.ts +++ b/js/src/playwright.ts @@ -114,7 +114,7 @@ export async function buildLaunchOptions( process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary(options.licenseKey, options.browserVersion)); const { exitIp, ...resolved } = await maybeResolveGeoip(options); - const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy, options.browserVersion); + const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy, options.browserVersion, options.licenseKey); let resolvedArgs = await resolveWebrtcArgs(options); resolvedArgs = appendWebrtcExitIp(resolvedArgs, exitIp); const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] }); @@ -296,7 +296,7 @@ export async function launchPersistentContext( process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary(options.licenseKey, options.browserVersion)); const { exitIp, ...resolved } = await maybeResolveGeoip(options); - const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy, options.browserVersion); + const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy, options.browserVersion, options.licenseKey); let resolvedArgs = await resolveWebrtcArgs(options); resolvedArgs = appendWebrtcExitIp(resolvedArgs, exitIp); const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] }); diff --git a/js/src/proxy.ts b/js/src/proxy.ts index 9a9aad3..c676ba3 100644 --- a/js/src/proxy.ts +++ b/js/src/proxy.ts @@ -2,7 +2,7 @@ * Shared proxy URL parsing for Playwright and Puppeteer wrappers. */ -import { getChromiumVersion, getPlatformTag, normalizeRequestedVersion, parseVersion } from "./config.js"; +import { binarySupportsHttpProxyInlineAuth } from "./config.js"; export interface ParsedProxy { server: string; @@ -74,7 +74,7 @@ function assembleSocksUrl( * bare ``%`` not followed by two hex digits is left as a literal ``%``. */ function lenientDecodeURIComponent(s: string): string { - return s.replace(/%([0-9A-Fa-f]{2})|%/g, (match, hex) => + return s.replace(/%([0-9A-Fa-f]{2})|%/g, (_match, hex) => hex ? String.fromCharCode(parseInt(hex, 16)) : "%", ); } @@ -157,25 +157,6 @@ export function normalizeSocksStringUrl(urlStr: string): string { } } -const HTTP_PROXY_INLINE_AUTH_MIN_VERSION = "146.0.7680.177.5"; -const HTTP_PROXY_INLINE_AUTH_PLATFORMS = new Set(["linux-x64", "windows-x64"]); - -export function supportsHttpProxyInlineAuth(version?: string): boolean { - try { - const tag = getPlatformTag(); - if (!HTTP_PROXY_INLINE_AUTH_PLATFORMS.has(tag)) return false; - const current = parseVersion(version ?? getChromiumVersion()); - const minimum = parseVersion(HTTP_PROXY_INLINE_AUTH_MIN_VERSION); - for (let i = 0; i < Math.max(current.length, minimum.length); i++) { - if ((current[i] ?? 0) > (minimum[i] ?? 0)) return true; - if ((current[i] ?? 0) < (minimum[i] ?? 0)) return false; - } - return true; // equal = supported - } catch { - return false; - } -} - function hasCredentials(proxy: string | ProxyDict): boolean { if (typeof proxy === "string") return proxy.includes("@"); return !!proxy.username; @@ -260,7 +241,8 @@ export function normalizeHttpStringUrl(urlStr: string): string { */ export function resolveProxyConfig( proxy: string | ProxyDict | undefined, - browserVersion?: string + browserVersion?: string, + licenseKey?: string ): ProxyConfig { if (!proxy) return { proxyArgs: [] }; @@ -277,11 +259,11 @@ export function resolveProxyConfig( return { proxyArgs: args }; } - // HTTP/HTTPS with credentials on supported platforms: use Chrome's native - // proxy authentication path instead of Playwright's CDP auth interceptor - // (#182). - const requestedVersion = normalizeRequestedVersion(browserVersion); - if (hasCredentials(proxy) && supportsHttpProxyInlineAuth(requestedVersion)) { + // HTTP/HTTPS with credentials, only on binaries that ship inline proxy auth: + // use Chrome's native proxy authentication path instead of Playwright's CDP + // auth interceptor (#182). Older binaries (free macOS/linux-arm64) can't parse + // inline credentials, so they fall through to the Playwright proxy dict below. + if (hasCredentials(proxy) && binarySupportsHttpProxyInlineAuth(licenseKey, browserVersion)) { if (typeof proxy === "string") { return { proxyArgs: [`--proxy-server=${normalizeHttpStringUrl(proxy)}`] }; } diff --git a/js/src/puppeteer.ts b/js/src/puppeteer.ts index e192945..72e5ff2 100644 --- a/js/src/puppeteer.ts +++ b/js/src/puppeteer.ts @@ -10,11 +10,12 @@ import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS, binarySupportsHeadlessNoViewport, + binarySupportsHttpProxyInlineAuth, } from "./config.js"; import { buildArgs } from "./args.js"; import { maybeWarnWindowsFonts } from "./fonts.js"; import { ensureBinary } from "./download.js"; -import { isSocksProxy, normalizeHttpStringUrl, parseProxyUrl, reconstructHttpUrl, resolveProxyConfig, supportsHttpProxyInlineAuth } from "./proxy.js"; +import { isSocksProxy, normalizeHttpStringUrl, parseProxyUrl, reconstructHttpUrl, resolveProxyConfig } from "./proxy.js"; import { maybeResolveGeoip, resolveWebrtcArgs, appendWebrtcExitIp } from "./geoip.js"; import { buildLaunchEnv } from "./license.js"; import { seedWidevineHint } from "./widevine.js"; @@ -60,8 +61,9 @@ async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string /** * Resolve proxy into Chrome CLI args and optional HTTP auth credentials. * SOCKS5: Chrome handles inline credentials natively (RFC 1929 auth). - * HTTP on supported platforms: inline credentials via --proxy-server. - * HTTP on unsupported platforms: strip credentials, use page.authenticate() fallback. + * HTTP on binaries with inline proxy auth: inline credentials via --proxy-server. + * HTTP on older binaries (free macOS/linux-arm64): strip credentials, use + * page.authenticate() fallback. */ function resolveProxy(options: LaunchOptions, args: string[]): { username: string; password: string } | undefined { if (!options.proxy) return undefined; @@ -72,8 +74,8 @@ function resolveProxy(options: LaunchOptions, args: string[]): { username: strin return undefined; } - // On supported platforms: pass full URL with inline creds to --proxy-server - if (supportsHttpProxyInlineAuth()) { + // On binaries that ship inline proxy auth: pass full URL with inline creds. + if (binarySupportsHttpProxyInlineAuth(options.licenseKey, options.browserVersion)) { if (typeof options.proxy === "string") { args.push(`--proxy-server=${normalizeHttpStringUrl(options.proxy)}`); return undefined; @@ -88,7 +90,7 @@ function resolveProxy(options: LaunchOptions, args: string[]): { username: strin return undefined; } - // Unsupported platform: strip credentials, fall back to page.authenticate() + // Older binary: strip credentials, fall back to page.authenticate() if (typeof options.proxy === "string") { const { server, username, password } = parseProxyUrl(options.proxy); args.push(`--proxy-server=${server}`); @@ -105,7 +107,7 @@ function resolveProxy(options: LaunchOptions, args: string[]): { username: strin return username ? { username, password: password ?? "" } : undefined; } -/** Apply proxy auth fallback (unsupported platforms) and humanize patching. */ +/** Apply proxy auth fallback (older binaries) and humanize patching. */ async function applyPostLaunch( browser: Browser, options: LaunchOptions, diff --git a/js/tests/launch.test.ts b/js/tests/launch.test.ts index d458d55..bc6ac6d 100644 --- a/js/tests/launch.test.ts +++ b/js/tests/launch.test.ts @@ -149,8 +149,14 @@ describe("composable Playwright launch helpers", () => { }); it("buildLaunchOptions returns Playwright options without launching a browser", async () => { - const freshConfig = await import("../src/config.js"); - vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64"); + // Free macOS lacks inline proxy auth → credentialed HTTP proxy goes through + // Playwright's proxy dict. Drive the platform via process.platform/arch since + // getPlatformTag reads them at call time (a config spy can't reach the + // intra-config gate call). + const origPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; + const origArch = Object.getOwnPropertyDescriptor(process, "arch")!; + Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); + Object.defineProperty(process, "arch", { value: "arm64", configurable: true }); try { const { buildLaunchOptions } = await import("../src/index.js"); @@ -170,10 +176,15 @@ describe("composable Playwright launch helpers", () => { username: "user", password: "pass", }); + expect(options.args).not.toContain( + "--proxy-server=http://user:pass@proxy.example:8080", + ); expect(options.timeout).toBe(1234); // No license key -> env is not injected expect(options.env).toBeUndefined(); } finally { + Object.defineProperty(process, "platform", origPlatform); + Object.defineProperty(process, "arch", origArch); vi.restoreAllMocks(); } }); @@ -449,8 +460,12 @@ describe("launchPersistentContext (unit)", () => { }); it("forwards proxy string", async () => { - const freshConfig = await import("../src/config.js"); - vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64"); + // Free macOS → credentialed HTTP proxy via Playwright's proxy dict, not + // inline --proxy-server. Drive platform via process.platform/arch. + const origPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; + const origArch = Object.getOwnPropertyDescriptor(process, "arch")!; + Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); + Object.defineProperty(process, "arch", { value: "arm64", configurable: true }); try { const { launchPersistentContext } = await import("../src/playwright.js"); await launchPersistentContext({ @@ -459,10 +474,15 @@ describe("launchPersistentContext (unit)", () => { }); const args = mockChromium.launchPersistentContext.mock.calls[0][1]; - expect(args.proxy.server).toBe("http://proxy:8080"); - expect(args.proxy.username).toBe("user"); - expect(args.proxy.password).toBe("pass"); + expect(args.proxy).toEqual({ + server: "http://proxy:8080", + username: "user", + password: "pass", + }); + expect(args.args).not.toContain("--proxy-server=http://user:pass@proxy:8080"); } finally { + Object.defineProperty(process, "platform", origPlatform); + Object.defineProperty(process, "arch", origArch); vi.restoreAllMocks(); } }); diff --git a/js/tests/proxy.test.ts b/js/tests/proxy.test.ts index 9a497fb..d0c90a4 100644 --- a/js/tests/proxy.test.ts +++ b/js/tests/proxy.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { parseProxyUrl, isSocksProxy, resolveProxyConfig, reconstructHttpUrl, normalizeHttpStringUrl } from "../src/proxy.js"; +import { parseProxyUrl, isSocksProxy, resolveProxyConfig } from "../src/proxy.js"; import * as config from "../src/config.js"; import type { LaunchOptions } from "../src/types.js"; @@ -148,20 +148,35 @@ describe("isSocksProxy", () => { }); describe("resolveProxyConfig", () => { + // binarySupportsHttpProxyInlineAuth reads getPlatformTag() intra-config, which + // a vi.spyOn(config, ...) can't intercept (ESM live binding). getPlatformTag + // derives from process.platform/arch at call time, so override those instead — + // this correctly drives the per-platform floor lookup and version resolution. + const origPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; + const origArch = Object.getOwnPropertyDescriptor(process, "arch")!; + function setPlatform(platform: string, arch: string): void { + Object.defineProperty(process, "platform", { value: platform, configurable: true }); + Object.defineProperty(process, "arch", { value: arch, configurable: true }); + } + function restorePlatform(): void { + Object.defineProperty(process, "platform", origPlatform); + Object.defineProperty(process, "arch", origArch); + } + it("returns empty for undefined", () => { const { proxyOption, proxyArgs } = resolveProxyConfig(undefined); expect(proxyOption).toBeUndefined(); expect(proxyArgs).toEqual([]); }); - it("returns playwright dict for http string on unsupported platform", () => { - vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64"); + it("falls back to playwright dict for http string on free macOS", () => { + setPlatform("darwin", "arm64"); try { const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080"); expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" }); expect(proxyArgs).toEqual([]); } finally { - vi.restoreAllMocks(); + restorePlatform(); } }); @@ -330,93 +345,111 @@ describe("resolveProxyConfig", () => { // --- HTTP with credentials → --proxy-server (supported platform + version) --- + // Supported cases pass the version via the browserVersion param (declared-pin + // path) so resolution is deterministic; setPlatform drives the floor lookup. it("routes http string with creds through --proxy-server on linux-x64 v177.5", () => { - vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64"); - vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5"); + setPlatform("linux", "x64"); try { - const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080"); + const { proxyOption, proxyArgs } = resolveProxyConfig( + "http://user:pass@proxy:8080", + "146.0.7680.177.5", + ); expect(proxyOption).toBeUndefined(); expect(proxyArgs).toEqual(["--proxy-server=http://user:pass@proxy:8080"]); } finally { - vi.restoreAllMocks(); + restorePlatform(); } }); it("routes http dict with creds through --proxy-server on linux-x64 v177.5", () => { - vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64"); - vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5"); + setPlatform("linux", "x64"); try { - const { proxyOption, proxyArgs } = resolveProxyConfig({ - server: "http://proxy:8080", - username: "user", - password: "pass", - }); + const { proxyOption, proxyArgs } = resolveProxyConfig( + { + server: "http://proxy:8080", + username: "user", + password: "pass", + }, + "146.0.7680.177.5", + ); expect(proxyOption).toBeUndefined(); expect(proxyArgs).toEqual(["--proxy-server=http://user:pass@proxy:8080"]); } finally { - vi.restoreAllMocks(); + restorePlatform(); } }); it("includes bypass for http dict with creds on windows-x64 v177.5", () => { - vi.spyOn(config, "getPlatformTag").mockReturnValue("windows-x64"); - vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5"); + setPlatform("win32", "x64"); try { - const { proxyArgs } = resolveProxyConfig({ - server: "http://proxy:8080", - username: "user", - password: "pass", - bypass: ".google.com", - }); + const { proxyArgs } = resolveProxyConfig( + { + server: "http://proxy:8080", + username: "user", + password: "pass", + bypass: ".google.com", + }, + "146.0.7680.177.5", + ); expect(proxyArgs).toContain("--proxy-server=http://user:pass@proxy:8080"); expect(proxyArgs).toContain("--proxy-bypass-list=.google.com"); } finally { - vi.restoreAllMocks(); + restorePlatform(); } }); it("encodes special chars in http proxy password on supported platform v177.5", () => { - vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64"); - vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5"); + setPlatform("linux", "x64"); try { - const { proxyArgs } = resolveProxyConfig("http://user:pass=123@proxy:8080"); + const { proxyArgs } = resolveProxyConfig( + "http://user:pass=123@proxy:8080", + "146.0.7680.177.5", + ); expect(proxyArgs).toEqual(["--proxy-server=http://user:pass%3D123@proxy:8080"]); } finally { - vi.restoreAllMocks(); + restorePlatform(); } }); - it("falls back on linux-x64 with old version (pre-inline-auth)", () => { - vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64"); - vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.3"); + it("falls back to playwright dict with an old pinned version", () => { + setPlatform("linux", "x64"); try { - const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080"); - expect(proxyOption).toBeDefined(); - expect(proxyArgs).toEqual([]); - } finally { - vi.restoreAllMocks(); - } - }); - - it("falls back to playwright dict for http with creds on darwin-arm64", () => { - vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64"); - try { - const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080"); + const { proxyOption, proxyArgs } = resolveProxyConfig( + "http://user:pass@proxy:8080", + "146.0.7680.177.3", + ); expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" }); expect(proxyArgs).toEqual([]); } finally { - vi.restoreAllMocks(); + restorePlatform(); } }); - it("falls back to playwright dict for http with creds on linux-arm64", () => { - vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-arm64"); + it("inline auth for http with creds on a new pinned darwin-arm64 build", () => { + setPlatform("darwin", "arm64"); try { - const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080"); + const { proxyOption, proxyArgs } = resolveProxyConfig( + "http://user:pass@proxy:8080", + "148.0.7778.215.3", + ); + expect(proxyOption).toBeUndefined(); + expect(proxyArgs).toEqual(["--proxy-server=http://user:pass@proxy:8080"]); + } finally { + restorePlatform(); + } + }); + + it("falls back to playwright dict for http with creds on free linux-arm64", () => { + setPlatform("linux", "arm64"); + try { + const { proxyOption, proxyArgs } = resolveProxyConfig( + "http://user:pass@proxy:8080", + "146.0.7680.177.3", + ); expect(proxyOption).toBeDefined(); expect(proxyArgs).toEqual([]); } finally { - vi.restoreAllMocks(); + restorePlatform(); } }); }); diff --git a/js/tests/puppeteer.test.ts b/js/tests/puppeteer.test.ts index 3f100d1..c2fae49 100644 --- a/js/tests/puppeteer.test.ts +++ b/js/tests/puppeteer.test.ts @@ -132,30 +132,39 @@ describe("puppeteer launch", () => { expect(callArgs.args).toContain("--proxy-bypass-list=.google.com,localhost"); }); - it("uses page.authenticate fallback for http proxy on unsupported platform", async () => { - const config = await import("../src/config.js"); - vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64"); + it("uses page.authenticate fallback for http proxy on free macOS", async () => { + // Free macOS lacks inline proxy auth → strip creds, use page.authenticate. + // getPlatformTag reads process.platform/arch at call time. + const origPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; + const origArch = Object.getOwnPropertyDescriptor(process, "arch")!; + Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); + Object.defineProperty(process, "arch", { value: "arm64", configurable: true }); try { const { launch } = await import("../src/puppeteer.js"); const browser = await launch({ proxy: "http://user:pass@proxy:8080" }); + const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0]; + expect(callArgs.args).toContain("--proxy-server=http://proxy:8080"); const page = await browser.newPage(); - expect(page.authenticate).toHaveBeenCalledWith({ - username: "user", - password: "pass", - }); + expect(page.authenticate).toHaveBeenCalledWith({ username: "user", password: "pass" }); } finally { + Object.defineProperty(process, "platform", origPlatform); + Object.defineProperty(process, "arch", origArch); vi.restoreAllMocks(); } }); it("passes inline creds via --proxy-server on supported platform (no page.authenticate)", async () => { - const config = await import("../src/config.js"); - vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64"); - vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5"); + const origPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; + const origArch = Object.getOwnPropertyDescriptor(process, "arch")!; + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + Object.defineProperty(process, "arch", { value: "x64", configurable: true }); try { const { launch } = await import("../src/puppeteer.js"); - const browser = await launch({ proxy: "http://user:pass@proxy:8080" }); + const browser = await launch({ + proxy: "http://user:pass@proxy:8080", + browserVersion: "146.0.7680.177.5", + }); const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0]; expect(callArgs.args).toContain("--proxy-server=http://user:pass@proxy:8080"); @@ -163,6 +172,8 @@ describe("puppeteer launch", () => { const page = await browser.newPage(); expect(page.authenticate).not.toHaveBeenCalled(); } finally { + Object.defineProperty(process, "platform", origPlatform); + Object.defineProperty(process, "arch", origArch); vi.restoreAllMocks(); } }); @@ -302,9 +313,11 @@ describe("puppeteer launchPersistentContext", () => { ).toEqual(DEFAULT_VIEWPORT); }); - it("uses page.authenticate fallback for http proxy in persistent context on unsupported platform", async () => { - const config = await import("../src/config.js"); - vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64"); + it("uses page.authenticate fallback in persistent context on free macOS", async () => { + const origPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; + const origArch = Object.getOwnPropertyDescriptor(process, "arch")!; + Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); + Object.defineProperty(process, "arch", { value: "arm64", configurable: true }); try { const { launchPersistentContext } = await import("../src/puppeteer.js"); const browser = await launchPersistentContext({ @@ -312,12 +325,13 @@ describe("puppeteer launchPersistentContext", () => { proxy: "http://user:pass@proxy:8080", }); + const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0]; + expect(callArgs.args).toContain("--proxy-server=http://proxy:8080"); const page = await browser.newPage(); - expect(page.authenticate).toHaveBeenCalledWith({ - username: "user", - password: "pass", - }); + expect(page.authenticate).toHaveBeenCalledWith({ username: "user", password: "pass" }); } finally { + Object.defineProperty(process, "platform", origPlatform); + Object.defineProperty(process, "arch", origArch); vi.restoreAllMocks(); } }); diff --git a/tests/test_proxy.py b/tests/test_proxy.py index fd51617..f3137c9 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -68,12 +68,12 @@ class TestBuildProxyKwargs: assert kwargs == {"proxy": proxy_dict} assert args == [] - @patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5") @patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64") - def test_pinned_old_version_disables_inline_auth(self, *_): - # Pin a binary BELOW the inline-auth floor while the platform default is - # at/above it. The gate must read the pin (older = no preemptive auth), - # not the default — otherwise rolled-back binaries break proxy auth (#182). + def test_pinned_old_version_disables_inline_auth(self, _mock): + # Pin a binary BELOW the inline-auth floor (146.0.7680.177.5) on a + # platform that otherwise supports it. The gate must read the pin — an + # older binary lacks inline proxy auth — and fall back to Playwright's + # dict, else rolled-back binaries break proxy auth (#182). kwargs, args = _resolve_proxy_config( "http://user:pass@proxy:8080", browser_version="146.0.7680.177.3" ) @@ -465,14 +465,10 @@ class TestResolveProxyConfig: assert kwargs == {} assert args == ["--proxy-server=http://user:pass@proxy:8080"] - @patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.3") - @patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64") - def test_http_with_creds_old_version_falls_back(self, *_): - kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080") - assert "proxy" in kwargs - assert args == [] - - # --- HTTP with credentials on unsupported platform → fallback to Playwright --- + # --- HTTP with credentials on binaries without inline proxy auth → fallback --- + # Free macOS (145.x) and linux-arm64 (146.0.7680.177.3) predate the inline + # proxy-auth patch, so their credentialed HTTP proxies route through + # Playwright's proxy dict instead of --proxy-server. @patch("cloakbrowser.config.get_platform_tag", return_value="darwin-arm64") def test_http_string_with_creds_on_macos_falls_back(self, _mock): @@ -494,6 +490,16 @@ class TestResolveProxyConfig: assert "proxy" in kwargs assert args == [] + @patch("cloakbrowser.config.get_platform_tag", return_value="darwin-arm64") + def test_http_with_creds_on_macos_inline_when_pinned_new(self, _mock): + # A macOS binary at/above the floor (e.g. a pinned 148 build with the + # inline proxy-auth patch) uses --proxy-server, not the fallback. + kwargs, args = _resolve_proxy_config( + "http://user:pass@proxy:8080", browser_version="148.0.7778.215.3" + ) + assert kwargs == {} + assert args == ["--proxy-server=http://user:pass@proxy:8080"] + # --- HTTP without credentials (all platforms) --- def test_http_no_creds_returns_playwright_dict(self):