diff --git a/README.md b/README.md index 7f6553f..160e0dc 100644 --- a/README.md +++ b/README.md @@ -417,6 +417,8 @@ asyncio.run(main()) Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions. +A persistent profile keeps its fingerprint seed across launches (stored in `.cloakbrowser-seed` inside the profile; an explicit `--fingerprint=` in `args` overrides it, and a corrupt file is replaced with a warning). To rotate the identity, delete `.cloakbrowser-seed` and the next launch picks a new seed. + Use this when you need to: - **Stay logged in** across runs (cookies/sessions survive restarts) diff --git a/cloakbrowser/browser.py b/cloakbrowser/browser.py index c20e0b0..0e3ff03 100644 --- a/cloakbrowser/browser.py +++ b/cloakbrowser/browser.py @@ -28,6 +28,7 @@ from .config import ( binary_supports_http_proxy_inline_auth, binary_supports_maximized_window, get_default_stealth_args, + persistent_seed_args, ) from .download import ensure_binary from .license import ( @@ -642,6 +643,7 @@ def launch_persistent_context( proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key, release_channel) args = _resolve_webrtc_args(args, proxy) args = _append_webrtc_exit_ip(args, exit_ip) + args = persistent_seed_args(user_data_dir, stealth_args, args) 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, release_channel) and viewport is _VIEWPORT_UNSET and "viewport" not in kwargs and "no_viewport" not in kwargs) _maybe_warn_windows_fonts(chrome_args) @@ -804,6 +806,7 @@ async def launch_persistent_context_async( proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key, release_channel) args = _resolve_webrtc_args(args, proxy) args = _append_webrtc_exit_ip(args, exit_ip) + args = persistent_seed_args(user_data_dir, stealth_args, args) 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, release_channel) and viewport is _VIEWPORT_UNSET and "viewport" not in kwargs and "no_viewport" not in kwargs) _maybe_warn_windows_fonts(chrome_args) diff --git a/cloakbrowser/config.py b/cloakbrowser/config.py index d6ed83c..097aaec 100644 --- a/cloakbrowser/config.py +++ b/cloakbrowser/config.py @@ -2,12 +2,17 @@ from __future__ import annotations +import logging import os import platform import random import re +import time +import uuid from pathlib import Path +logger = logging.getLogger("cloakbrowser") + # --------------------------------------------------------------------------- # Chromium version shipped with this release. @@ -51,13 +56,109 @@ IGNORE_DEFAULT_ARGS = ["--enable-automation", "--enable-unsafe-swiftshader"] # Default stealth arguments passed to the patched Chromium binary. # These activate source-level fingerprint patches compiled into the binary. # --------------------------------------------------------------------------- +SEED_MIN, SEED_MAX = 10000, 99999 + +# Shared with the JS and .NET wrappers: decimal seed + newline, so a profile +# keeps its seed whichever wrapper opens it. +PROFILE_SEED_FILE = ".cloakbrowser-seed" + + +def _random_seed() -> int: + return random.randint(SEED_MIN, SEED_MAX) + + +def _read_profile_seed(path: Path) -> int | None: + """Stored seed, or None if the file is missing or corrupt (corrupt is removed).""" + try: + data = path.read_bytes() + # Empty = a concurrent launch's exclusive create (no-hard-link fallback) + # hasn't written yet; give it ~1s before calling the file corrupt. + for _ in range(20): + if data: + break + time.sleep(0.05) + data = path.read_bytes() + except FileNotFoundError: + return None + text = data.strip() + if text.isdigit() and text.isascii() and SEED_MIN <= int(text) <= SEED_MAX: + return int(text) + # A corrupt file gets a fresh seed (= new identity), so say so loudly. + logger.warning("Corrupt fingerprint seed file %s (%r); generating a new seed", path, data[:32]) + try: + path.unlink() + except FileNotFoundError: + pass # a concurrent launch already removed it + return None + + +def _write_synced(path: Path, mode: str, content: str) -> None: + # newline="\n": same bytes as the JS/.NET wrappers (no \r\n on Windows) + with open(path, mode, encoding="ascii", newline="\n") as f: + f.write(content) + f.flush() + os.fsync(f.fileno()) + + +def persistent_seed_args( + user_data_dir: str | os.PathLike, stealth_args: bool, args: list[str] | None +) -> list[str] | None: + """Pin a persistent profile's fingerprint seed in ``/.cloakbrowser-seed``. + + The first launch creates the seed; later launches reuse it. Returned as a + user arg, so it overrides the random stealth default via build_args dedup. + An explicit ``--fingerprint`` in ``args`` (including ``=off``) or + ``stealth_args=False`` leaves the file alone. + """ + if not stealth_args or not user_data_dir: + return args + if any(a.split("=", 1)[0] == "--fingerprint" for a in args or []): + return args + + profile = Path(os.fspath(user_data_dir)) + path = profile / PROFILE_SEED_FILE + seed = _read_profile_seed(path) + if seed is None: + profile.mkdir(parents=True, exist_ok=True) + tmp = profile / f"{PROFILE_SEED_FILE}.{uuid.uuid4().hex}.tmp" + content = f"{_random_seed()}\n" + try: + _write_synced(tmp, "w", content) + # Publish without replacing: concurrent first launches all end up + # with whichever seed was linked first. + try: + os.link(tmp, path) + except FileExistsError: + pass + except OSError as e: + # No hard links (FAT/exFAT, some network mounts): exclusive create + # still never overwrites. ponytail: a concurrent reader can catch + # the file empty here and regenerate; link() avoids that window. + logger.debug("Hard link unsupported for %s (%s); using exclusive create", path, e) + try: + _write_synced(path, "x", content) + except FileExistsError: + pass + finally: + try: + tmp.unlink() + except OSError as e: + logger.warning("Failed to remove temp seed file %s: %s", tmp, e) + # ponytail: if the winner's file is corrupt here (only via a concurrent + # corrupt-file race), fail loudly rather than loop. + seed = _read_profile_seed(path) + if seed is None: + raise RuntimeError(f"Could not create fingerprint seed file {path}") + return [f"--fingerprint={seed}", *(args or [])] + + def get_default_stealth_args() -> list[str]: """Build stealth args with a random fingerprint seed per launch. On macOS, skips platform/GPU spoofing — runs as a native Mac browser. Spoofing Windows on Mac creates detectable mismatches (fonts, GPU, etc.). """ - seed = random.randint(10000, 99999) + seed = _random_seed() system = platform.system() base = [ diff --git a/dotnet/src/CloakBrowser/CloakLauncher.cs b/dotnet/src/CloakBrowser/CloakLauncher.cs index 9f9c3c0..9a94bfb 100644 --- a/dotnet/src/CloakBrowser/CloakLauncher.cs +++ b/dotnet/src/CloakBrowser/CloakLauncher.cs @@ -175,6 +175,7 @@ public static class CloakLauncher options.Proxy, options.BrowserVersion, options.LicenseKey, options.ReleaseChannel); var args = await ResolveWebRtcArgsAsync(options.Args, options.Proxy).ConfigureAwait(false); args = MaybeAppendWebRtcExitIp(args, exitIp); + args = Config.PersistentSeedArgs(userDataDir, options.StealthArgs, args); var combined = new List(args ?? new List()); combined.AddRange(proxyResolution.ExtraArgs); diff --git a/dotnet/src/CloakBrowser/Config.cs b/dotnet/src/CloakBrowser/Config.cs index 091c844..07aed3c 100644 --- a/dotnet/src/CloakBrowser/Config.cs +++ b/dotnet/src/CloakBrowser/Config.cs @@ -62,6 +62,137 @@ public static class Config private static readonly Random _rng = new(); + public const int SeedMin = 10000; + public const int SeedMax = 99999; + + /// + /// Per-profile seed file, shared with the Python and JS wrappers (decimal seed + + /// newline), so a profile keeps its seed whichever wrapper opens it. + /// + public const string ProfileSeedFile = ".cloakbrowser-seed"; + + internal static int RandomSeed() + { + lock (_rng) { return _rng.Next(SeedMin, SeedMax + 1); } + } + + /// Stored seed, or null if the file is missing or corrupt (corrupt is removed). + private static int? ReadProfileSeed(string file) + { + byte[] data; + try + { + data = File.ReadAllBytes(file); + // Empty = a concurrent launch's exclusive create (no-hard-link fallback) + // hasn't written yet; give it ~1s before calling the file corrupt. + for (int i = 0; i < 20 && data.Length == 0; i++) + { + Thread.Sleep(50); + data = File.ReadAllBytes(file); + } + } + catch (Exception e) when (e is FileNotFoundException or DirectoryNotFoundException) + { + return null; // DirectoryNotFound: the profile dir doesn't exist yet + } + var text = System.Text.Encoding.Latin1.GetString(data).Trim(); + if (text.Length > 0 && text.All(c => c is >= '0' and <= '9') + && int.TryParse(text, out var seed) && seed is >= SeedMin and <= SeedMax) + return seed; + // A corrupt file gets a fresh seed (= new identity), so say so loudly. + CloakLog.Warning("Corrupt fingerprint seed file {0} ({1}); generating a new seed", + file, text.Length > 32 ? text[..32] : text); + File.Delete(file); // no-op if a concurrent launch already removed it + return null; + } + + [DllImport("libc", EntryPoint = "link", SetLastError = true)] + private static extern int PosixLink(string oldPath, string newPath); + + private const int EEXIST = 17; // same value on Linux and macOS + + /// + /// Atomically create from , keeping an + /// existing file. On Unix, File.Move(overwrite: false) checks then renames, so + /// concurrent callers can replace each other (seen on macOS); POSIX link() fails + /// atomically with EEXIST instead. Windows MoveFileEx without REPLACE_EXISTING is atomic. + /// + private static void PublishNoReplace(string tmp, string file, byte[] content) + { + if (OperatingSystem.IsWindows()) + { + try { File.Move(tmp, file, overwrite: false); } + catch (IOException) when (File.Exists(file)) { } + return; + } + if (PosixLink(tmp, file) == 0) + return; + int errno = Marshal.GetLastPInvokeError(); + if (errno == EEXIST) + return; + // No hard links (FAT/exFAT, some network mounts): exclusive create still + // never overwrites. ponytail: a concurrent reader can catch the file empty + // here and regenerate; link() avoids that window. + CloakLog.Debug("Hard link unsupported for {0} (errno {1}); using exclusive create", file, errno); + try { WriteSynced(file, content); } + catch (IOException) when (File.Exists(file)) { } + } + + /// Create exclusively (throws if present), write, flush to disk. + private static void WriteSynced(string file, byte[] content) + { + using var fs = new FileStream(file, FileMode.CreateNew, FileAccess.Write); + fs.Write(content, 0, content.Length); + fs.Flush(flushToDisk: true); + } + + /// + /// Prepend the persistent profile's stored --fingerprint=<seed> to + /// , creating it on first launch. It is a user arg, so it + /// overrides the random stealth default via BuildArgs dedup. An explicit + /// --fingerprint (including =off) or stealthArgs: false + /// leaves the file alone. Mirrors Python config.persistent_seed_args. + /// + internal static List? PersistentSeedArgs(string userDataDir, bool stealthArgs, List? args) + { + if (!stealthArgs || string.IsNullOrEmpty(userDataDir)) + return args; + if (args != null && args.Any(a => a.Split('=', 2)[0] == "--fingerprint")) + return args; + + var file = Path.Combine(userDataDir, ProfileSeedFile); + var seed = ReadProfileSeed(file); + if (seed == null) + { + Directory.CreateDirectory(userDataDir); + var tmp = Path.Combine(userDataDir, $"{ProfileSeedFile}.{Guid.NewGuid():N}.tmp"); + try + { + var content = System.Text.Encoding.ASCII.GetBytes($"{RandomSeed()}\n"); + WriteSynced(tmp, content); + // Publish without replacing: concurrent first launches all end up + // with whichever seed was published first. + PublishNoReplace(tmp, file, content); + } + finally + { + try + { + File.Delete(tmp); + } + catch (Exception e) + { + CloakLog.Warning("Failed to remove temp seed file {0}: {1}", tmp, e.Message); + } + } + seed = ReadProfileSeed(file) + ?? throw new IOException($"Could not create fingerprint seed file {file}"); + } + var result = new List { $"--fingerprint={seed}" }; + if (args != null) result.AddRange(args); + return result; + } + /// /// Build stealth args with a random fingerprint seed per launch. /// On macOS, skips platform/GPU spoofing - runs as a native Mac browser. @@ -69,8 +200,7 @@ public static class Config /// public static List GetDefaultStealthArgs() { - int seed; - lock (_rng) { seed = _rng.Next(10000, 100000); } + int seed = RandomSeed(); var baseArgs = new List { diff --git a/dotnet/tests/CloakBrowser.Tests/ProfileSeedTests.cs b/dotnet/tests/CloakBrowser.Tests/ProfileSeedTests.cs new file mode 100644 index 0000000..f79a169 --- /dev/null +++ b/dotnet/tests/CloakBrowser.Tests/ProfileSeedTests.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CloakBrowser; +using Xunit; + +namespace CloakBrowser.Tests; + +/// +/// Fingerprint seed persistence for persistent profiles (mirrors Python +/// tests/test_profile_seed.py and js/tests/profile-seed.test.ts). Playwright launch +/// isn't mocked in this suite, so these drive +/// and , the two calls LaunchPersistentContextAsync chains. +/// +[Collection("env-serial")] // swaps the global CloakLog.Sink +public sealed class ProfileSeedTests : IDisposable +{ + private readonly string _tmp = + Path.Combine(Path.GetTempPath(), "cb-seed-" + Guid.NewGuid().ToString("N")); + + private string SeedFile => Path.Combine(_tmp, Config.ProfileSeedFile); + + public ProfileSeedTests() => Directory.CreateDirectory(_tmp); + + public void Dispose() + { + CloakLog.Sink = null; + Directory.Delete(_tmp, recursive: true); + } + + private static List ChromeArgs(string dir, bool stealth, List? args) => + CloakLauncher.BuildArgs(stealth, Config.PersistentSeedArgs(dir, stealth, args), headless: true); + + private static List Seeds(List args) => + args.Where(a => a.StartsWith("--fingerprint=")).ToList(); + + [Fact] + public void FirstLaunch_WritesSeed_SecondReuses() + { + var profile = Path.Combine(_tmp, "missing", "profile"); // created on demand + var first = Seeds(ChromeArgs(profile, true, null)); + var stored = File.ReadAllText(Path.Combine(profile, Config.ProfileSeedFile)); + Assert.Matches(@"^\d{5}\n$", stored); + Assert.Equal(new[] { $"--fingerprint={stored.Trim()}" }, first); + Assert.Equal(first, Seeds(ChromeArgs(profile, true, null))); + } + + /// + /// Call-site check through the real launcher: a fake "chrome" records its argv and + /// exits, so the launch fails but the args Playwright passed are on disk. + /// + [Fact] + public async Task LaunchPersistentContextAsync_WritesAndPassesSeed() + { + if (OperatingSystem.IsWindows()) return; // the fake binary is a shell script + var argvFile = Path.Combine(_tmp, "argv.txt"); + var fake = Path.Combine(_tmp, "fake-chrome"); + File.WriteAllText(fake, $"#!/bin/sh\nprintf '%s\\n' \"$@\" > '{argvFile}'\nexit 1\n"); + File.SetUnixFileMode(fake, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + var profile = Path.Combine(_tmp, "profile"); + + var saved = new[] { "CLOAKBROWSER_BINARY_PATH", "CLOAKBROWSER_LICENSE_KEY", "CLOAKBROWSER_VERSION" } + .ToDictionary(k => k, Environment.GetEnvironmentVariable); + Environment.SetEnvironmentVariable("CLOAKBROWSER_BINARY_PATH", fake); + Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", null); + Environment.SetEnvironmentVariable("CLOAKBROWSER_VERSION", null); + try + { + await Assert.ThrowsAnyAsync(() => CloakLauncher.LaunchPersistentContextAsync(profile)); + } + finally + { + foreach (var (k, v) in saved) Environment.SetEnvironmentVariable(k, v); + } + + var stored = File.ReadAllText(Path.Combine(profile, Config.ProfileSeedFile)); + Assert.Equal(new[] { $"--fingerprint={stored.Trim()}" }, Seeds(File.ReadAllLines(argvFile).ToList())); + } + + [Fact] + public void Reads_PythonWritten_File() + { + File.WriteAllText(SeedFile, "12345\n"); // exact Python output + Assert.Equal(new[] { "--fingerprint=12345" }, Seeds(ChromeArgs(_tmp, true, null))); + } + + [Theory] + [InlineData("--fingerprint=4242")] + [InlineData("--fingerprint=off")] + public void ExplicitFingerprint_Wins_FileUntouched(string flag) + { + Assert.Equal(new[] { flag }, Seeds(ChromeArgs(_tmp, true, new List { flag }))); + Assert.False(File.Exists(SeedFile)); + File.WriteAllText(SeedFile, "12345\n"); + Assert.Equal(new[] { flag }, Seeds(ChromeArgs(_tmp, true, new List { flag }))); + Assert.Equal("12345\n", File.ReadAllText(SeedFile)); + } + + [Fact] + public void StealthArgsFalse_WritesNothing() + { + Assert.Empty(Seeds(ChromeArgs(_tmp, false, null))); + Assert.Empty(Directory.GetFileSystemEntries(_tmp)); + } + + [Fact] + public void EmptyUserDataDir_Ignored() + { + var args = new List { "--x" }; + Assert.Same(args, Config.PersistentSeedArgs("", true, args)); + } + + [Fact] + public void NonPersistent_StaysRandom() + { + var seeds = Enumerable.Range(0, 5) + .Select(_ => Seeds(CloakLauncher.BuildArgs(true, null, headless: true))[0]) + .ToHashSet(); + Assert.True(seeds.Count > 1); + } + + [Theory] + [InlineData("")] + [InlineData("abc\n")] + [InlineData("5\n")] + [InlineData("123456\n")] + [InlineData("-12345\n")] + public void CorruptFile_Logs_And_Regenerates(string content) + { + File.WriteAllText(SeedFile, content); + var warnings = new ConcurrentQueue(); + CloakLog.Sink = (level, msg) => { if (level == CloakLogLevel.Warning) warnings.Enqueue(msg); }; + + var args = Config.PersistentSeedArgs(_tmp, true, null)!; + + Assert.Contains(warnings, w => w.Contains(SeedFile)); + var stored = File.ReadAllText(SeedFile); + Assert.Matches(@"^\d{5}\n$", stored); + Assert.Equal(new[] { $"--fingerprint={stored.Trim()}" }, args); + } + + [Fact] + public async Task ConcurrentFirstLaunches_Agree() + { + using var start = new ManualResetEventSlim(); + var tasks = Enumerable.Range(0, 16).Select(_ => Task.Run(() => + { + start.Wait(); + return Config.PersistentSeedArgs(_tmp, true, null)![0]; + })).ToArray(); + start.Set(); + var seeds = await Task.WhenAll(tasks); + + Assert.Single(seeds.Distinct()); + // macOS adds ._ AppleDouble files on FAT volumes, where these tests also run. + Assert.Equal(new[] { SeedFile }, + Directory.GetFileSystemEntries(_tmp).Where(p => !Path.GetFileName(p).StartsWith("._"))); + } +} diff --git a/js/src/config.ts b/js/src/config.ts index 9e18e80..1a17ae8 100644 --- a/js/src/config.ts +++ b/js/src/config.ts @@ -418,8 +418,15 @@ export const IGNORE_DEFAULT_ARGS = ["--enable-automation", "--enable-unsafe-swif // innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks). export const DEFAULT_VIEWPORT = { width: 1920, height: 947 }; +export const SEED_MIN = 10000; +export const SEED_MAX = 99999; + +export function randomSeed(): number { + return Math.floor(Math.random() * (SEED_MAX - SEED_MIN + 1)) + SEED_MIN; +} + export function getDefaultStealthArgs(): string[] { - const seed = Math.floor(Math.random() * 90000) + 10000; // 10000-99999 + const seed = randomSeed(); const isMac = process.platform === "darwin"; const base = [ diff --git a/js/src/playwright.ts b/js/src/playwright.ts index a22aefd..91e59da 100644 --- a/js/src/playwright.ts +++ b/js/src/playwright.ts @@ -23,6 +23,7 @@ import { resolveLicenseKey, } from "./license.js"; import { seedWidevineHint } from "./widevine.js"; +import { persistentSeedArgs } from "./profile-seed.js"; /** @internal Accept both timezone and timezoneId — either works, no warning. Exported for testing. */ export function resolveTimezone(options: T): T { @@ -355,6 +356,7 @@ export async function launchPersistentContext( ); let resolvedArgs = await resolveWebrtcArgs(options); resolvedArgs = appendWebrtcExitIp(resolvedArgs, exitIp); + resolvedArgs = persistentSeedArgs(options.userDataDir, options.stealthArgs, resolvedArgs); const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] }); maybeWarnWindowsFonts(args); diff --git a/js/src/profile-seed.ts b/js/src/profile-seed.ts new file mode 100644 index 0000000..ac559a2 --- /dev/null +++ b/js/src/profile-seed.ts @@ -0,0 +1,109 @@ +/** + * Fingerprint seed persistence for persistent profiles. + * Mirrors Python cloakbrowser/config.py::persistent_seed_args. + * + * The first launch of a profile stores its seed in `/.cloakbrowser-seed` + * (decimal + newline, same file in all wrappers); later launches reuse it, so the + * profile keeps one fingerprint identity across sessions. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +import { SEED_MAX, SEED_MIN, randomSeed } from "./config.js"; + +export const PROFILE_SEED_FILE = ".cloakbrowser-seed"; + +/** Stored seed, or null if the file is missing or corrupt (corrupt is removed). */ +function readProfileSeed(file: string): number | null { + let data: Buffer; + try { + data = fs.readFileSync(file); + // Empty = a concurrent launch's exclusive create (no-hard-link fallback) + // hasn't written yet; give it ~1s before calling the file corrupt. + for (let i = 0; i < 20 && data.length === 0; i++) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50); // sync sleep + data = fs.readFileSync(file); + } + } catch (e: any) { + if (e?.code === "ENOENT") return null; + throw e; + } + const text = data.toString("latin1").trim(); + if (/^[0-9]+$/.test(text) && Number(text) >= SEED_MIN && Number(text) <= SEED_MAX) { + return Number(text); + } + // A corrupt file gets a fresh seed (= new identity), so say so loudly. + console.warn( + `[cloakbrowser] Corrupt fingerprint seed file ${file} (${JSON.stringify(text.slice(0, 32))}); generating a new seed`, + ); + try { + fs.unlinkSync(file); + } catch (e: any) { + if (e?.code !== "ENOENT") throw e; // ENOENT: a concurrent launch already removed it + } + return null; +} + +/** Create `file` exclusively (EEXIST if present), write `content`, fsync. */ +function writeSynced(file: string, content: string): void { + const fd = fs.openSync(file, "wx"); + try { + fs.writeSync(fd, content); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } +} + +/** + * Prepend the profile's stored `--fingerprint=` to `args`, creating it on + * first launch. It is a user arg, so it overrides the random stealth default via + * buildArgs dedup. An explicit `--fingerprint` (including `=off`) or + * `stealthArgs: false` leaves the file alone. + */ +export function persistentSeedArgs( + userDataDir: string | undefined, + stealthArgs: boolean | undefined, + args: string[] | undefined, +): string[] | undefined { + if (stealthArgs === false || !userDataDir) return args; + if ((args ?? []).some((a) => a.split("=")[0] === "--fingerprint")) return args; + + const file = path.join(userDataDir, PROFILE_SEED_FILE); + let seed = readProfileSeed(file); + if (seed === null) { + fs.mkdirSync(userDataDir, { recursive: true }); + const tmp = path.join(userDataDir, `${PROFILE_SEED_FILE}.${randomUUID()}.tmp`); + const content = `${randomSeed()}\n`; + try { + writeSynced(tmp, content); + // Publish without replacing: concurrent first launches all end up with + // whichever seed was linked first. + try { + fs.linkSync(tmp, file); + } catch (e: any) { + if (e?.code !== "EEXIST") { + // No hard links (FAT/exFAT, some network mounts): exclusive create + // still never overwrites. ponytail: a concurrent reader can catch the + // file empty here and regenerate; link() avoids that window. + try { + writeSynced(file, content); + } catch (e2: any) { + if (e2?.code !== "EEXIST") throw e2; + } + } + } + } finally { + try { + fs.unlinkSync(tmp); + } catch (e) { + console.warn(`[cloakbrowser] Failed to remove temp seed file ${tmp}:`, e); + } + } + seed = readProfileSeed(file); + if (seed === null) throw new Error(`Could not create fingerprint seed file ${file}`); + } + return [`--fingerprint=${seed}`, ...(args ?? [])]; +} diff --git a/js/src/puppeteer.ts b/js/src/puppeteer.ts index 7162743..d4f48d5 100644 --- a/js/src/puppeteer.ts +++ b/js/src/puppeteer.ts @@ -25,6 +25,7 @@ import { resolveLicenseKey, } from "./license.js"; import { seedWidevineHint } from "./widevine.js"; +import { persistentSeedArgs } from "./profile-seed.js"; export { CloakBrowserLicenseError } from "./license.js"; @@ -60,7 +61,9 @@ function resolveDefaultViewport(options: LaunchOptions): { width: number; height } /** Resolve binary path, geoip, webrtc, and build final Chrome args. */ -async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string; args: string[] }> { +async function resolveArgs( + options: LaunchOptions & { userDataDir?: string }, +): Promise<{ binaryPath: string; args: string[] }> { const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary( @@ -72,6 +75,8 @@ async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string let resolvedArgs = (await resolveWebrtcArgs(options)) ?? options.args; resolvedArgs = appendWebrtcExitIp(resolvedArgs, exitIp); + // Only launchPersistentContext passes userDataDir; plain launch() stays random. + resolvedArgs = persistentSeedArgs(options.userDataDir, options.stealthArgs, resolvedArgs); const args = buildArgs({ ...options, ...resolved, args: resolvedArgs }); maybeWarnWindowsFonts(args); return { binaryPath, args }; diff --git a/js/tests/launch.test.ts b/js/tests/launch.test.ts index 16b1a39..ff63337 100644 --- a/js/tests/launch.test.ts +++ b/js/tests/launch.test.ts @@ -461,6 +461,8 @@ describe("launchPersistentContext (unit)", () => { }; vi.doMock("playwright-core", () => ({ chromium: mockChromium })); + // Fixed fake profile paths: keep the seed file off disk (covered in profile-seed.test.ts). + vi.doMock("../src/profile-seed.js", () => ({ persistentSeedArgs: (_d: any, _s: any, a: any) => a })); }); afterEach(() => { diff --git a/js/tests/profile-seed.test.ts b/js/tests/profile-seed.test.ts new file mode 100644 index 0000000..f52cceb --- /dev/null +++ b/js/tests/profile-seed.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +import { PROFILE_SEED_FILE, persistentSeedArgs } from "../src/profile-seed.js"; + +// macOS adds ._ AppleDouble files on FAT volumes, where these tests also run. +const listing = (d: string) => fs.readdirSync(d).filter((n) => !n.startsWith("._")); +const seedOf = (args: string[]) => args.filter((a) => a.startsWith("--fingerprint=")); + +let dir: string; +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "cb-seed-")); +}); +afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe("persistentSeedArgs", () => { + it("writes the seed on first call and reuses it", () => { + const profile = path.join(dir, "missing", "profile"); // created on demand + const first = persistentSeedArgs(profile, undefined, ["--x"])!; + const stored = fs.readFileSync(path.join(profile, PROFILE_SEED_FILE), "utf8"); + expect(stored).toMatch(/^\d{5}\n$/); + expect(first).toEqual([`--fingerprint=${stored.trim()}`, "--x"]); + expect(persistentSeedArgs(profile, true, ["--x"])).toEqual(first); + }); + + it("reads a Python-written seed file", () => { + fs.writeFileSync(path.join(dir, PROFILE_SEED_FILE), "12345\n"); // exact Python output + expect(persistentSeedArgs(dir, true, undefined)).toEqual(["--fingerprint=12345"]); + }); + + it.each(["--fingerprint=4242", "--fingerprint=off"])("explicit %s wins, file untouched", (flag) => { + expect(persistentSeedArgs(dir, true, [flag])).toEqual([flag]); + expect(fs.existsSync(path.join(dir, PROFILE_SEED_FILE))).toBe(false); + fs.writeFileSync(path.join(dir, PROFILE_SEED_FILE), "12345\n"); + expect(persistentSeedArgs(dir, true, [flag])).toEqual([flag]); + expect(fs.readFileSync(path.join(dir, PROFILE_SEED_FILE), "utf8")).toBe("12345\n"); + }); + + it("stealthArgs false writes nothing", () => { + expect(persistentSeedArgs(dir, false, ["--x"])).toEqual(["--x"]); + expect(fs.readdirSync(dir)).toEqual([]); + }); + + it("empty userDataDir is ignored", () => { + expect(persistentSeedArgs("", true, ["--x"])).toEqual(["--x"]); + }); + + it.each(["", "abc\n", "5\n", "123456\n", "-12345\n"])("corrupt %j logs and regenerates", (content) => { + const file = path.join(dir, PROFILE_SEED_FILE); + fs.writeFileSync(file, content); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const args = persistentSeedArgs(dir, true, undefined)!; + expect(warn.mock.calls[0][0]).toContain(file); + const stored = fs.readFileSync(file, "utf8"); + expect(stored).toMatch(/^\d{5}\n$/); + expect(args).toEqual([`--fingerprint=${stored.trim()}`]); + }); + + it("falls back to exclusive create when hard links are unsupported", () => { + // FAT/exFAT and some network mounts reject link() with ENOTSUP/EPERM. + const link = vi.spyOn(fs, "linkSync").mockImplementation(() => { + throw Object.assign(new Error("operation not supported"), { code: "ENOTSUP" }); + }); + const first = persistentSeedArgs(dir, true, undefined)!; + expect(persistentSeedArgs(dir, true, undefined)).toEqual(first); + const stored = fs.readFileSync(path.join(dir, PROFILE_SEED_FILE), "utf8"); + expect(first).toEqual([`--fingerprint=${stored.trim()}`]); + expect(listing(dir)).toEqual([PROFILE_SEED_FILE]); + expect(link).toHaveBeenCalledTimes(1); // the mock really intercepted the publish + }); + + it("concurrent first launches in separate processes agree", async () => { + // Runs the built dist/ (npm run build first, as CI does). + const moduleUrl = new URL("../dist/profile-seed.js", import.meta.url).href; + const script = + `const { persistentSeedArgs } = await import(${JSON.stringify(moduleUrl)});` + + `console.log(persistentSeedArgs(${JSON.stringify(dir)}, true, [])[0]);`; + const outs = await Promise.all( + Array.from({ length: 8 }, () => + promisify(execFile)(process.execPath, ["--input-type=module", "-e", script]), + ), + ); + const seeds = new Set(outs.map((o) => o.stdout.trim())); + expect(seeds.size).toBe(1); + expect(listing(dir)).toEqual([PROFILE_SEED_FILE]); + }); +}); + +describe("launchers use the stored seed", () => { + const origEnv = process.env.CLOAKBROWSER_BINARY_PATH; + beforeEach(() => { + process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome"; + }); + afterEach(() => { + vi.resetModules(); + if (origEnv) process.env.CLOAKBROWSER_BINARY_PATH = origEnv; + else delete process.env.CLOAKBROWSER_BINARY_PATH; + }); + + it("playwright launchPersistentContext reuses the seed, launch() stays random", async () => { + const chromium = { + launchPersistentContext: vi.fn().mockResolvedValue({ close: vi.fn(), pages: () => [] }), + launch: vi.fn().mockResolvedValue({ close: vi.fn(), on: vi.fn(), contexts: () => [] }), + }; + vi.doMock("playwright-core", () => ({ chromium })); + const { launchPersistentContext, launch } = await import("../src/playwright.js"); + + await launchPersistentContext({ userDataDir: dir }); + await launchPersistentContext({ userDataDir: dir }); + const [a, b] = chromium.launchPersistentContext.mock.calls.map((c: any) => seedOf(c[1].args)); + const stored = fs.readFileSync(path.join(dir, PROFILE_SEED_FILE), "utf8").trim(); + expect(a).toEqual([`--fingerprint=${stored}`]); + expect(b).toEqual(a); + + for (let i = 0; i < 5; i++) await launch(); + const plain = new Set(chromium.launch.mock.calls.map((c: any) => seedOf(c[0].args)[0])); + expect(plain.size).toBeGreaterThan(1); + }); + + it("puppeteer launchPersistentContext uses the stored seed", async () => { + const launch = vi.fn().mockResolvedValue({ pages: vi.fn().mockResolvedValue([]), close: vi.fn(), on: vi.fn() }); + vi.doMock("puppeteer-core", () => ({ default: { launch } })); + fs.writeFileSync(path.join(dir, PROFILE_SEED_FILE), "12345\n"); + const { launchPersistentContext } = await import("../src/puppeteer.js"); + + await launchPersistentContext({ userDataDir: dir }); + expect(seedOf(launch.mock.calls[0][0].args)).toEqual(["--fingerprint=12345"]); + }); + + it("puppeteer launch() shares resolveArgs but stays random and writes nothing", async () => { + const launch = vi.fn().mockResolvedValue({ pages: vi.fn().mockResolvedValue([]), close: vi.fn(), on: vi.fn() }); + vi.doMock("puppeteer-core", () => ({ default: { launch } })); + const cwd = process.cwd(); + process.chdir(dir); // a stray relative seed file would land here + try { + const pptr = await import("../src/puppeteer.js"); + for (let i = 0; i < 5; i++) await pptr.launch(); + } finally { + process.chdir(cwd); + } + const seeds = new Set(launch.mock.calls.map((c: any) => seedOf(c[0].args)[0])); + expect(seeds.size).toBeGreaterThan(1); + expect(listing(dir)).toEqual([]); + }); + + it("playwright launchContext stays random and writes nothing", async () => { + const context = { close: vi.fn(), pages: () => [], on: vi.fn() }; + const chromium = { + launch: vi.fn().mockResolvedValue({ close: vi.fn(), on: vi.fn(), contexts: () => [], newContext: vi.fn().mockResolvedValue(context) }), + }; + vi.doMock("playwright-core", () => ({ chromium })); + const cwd = process.cwd(); + process.chdir(dir); + try { + const { launchContext } = await import("../src/playwright.js"); + for (let i = 0; i < 5; i++) await launchContext(); + } finally { + process.chdir(cwd); + } + const seeds = new Set(chromium.launch.mock.calls.map((c: any) => seedOf(c[0].args)[0])); + expect(seeds.size).toBeGreaterThan(1); + expect(listing(dir)).toEqual([]); + }); +}); diff --git a/js/tests/puppeteer.test.ts b/js/tests/puppeteer.test.ts index e133e57..646a395 100644 --- a/js/tests/puppeteer.test.ts +++ b/js/tests/puppeteer.test.ts @@ -11,6 +11,11 @@ vi.mock("../src/download.js", () => ({ ensureBinary: vi.fn().mockResolvedValue("/fake/chrome"), })); +// "./my-profile" is a fake path: keep the seed file off disk (covered in profile-seed.test.ts). +vi.mock("../src/profile-seed.js", () => ({ + persistentSeedArgs: (_d: any, _s: any, a: any) => a, +})); + vi.mock("../src/geoip.js", () => ({ resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }), maybeResolveGeoip: vi.fn().mockResolvedValue({}), diff --git a/js/tests/widevine-integration.test.ts b/js/tests/widevine-integration.test.ts index 4993686..8d9b677 100644 --- a/js/tests/widevine-integration.test.ts +++ b/js/tests/widevine-integration.test.ts @@ -8,6 +8,9 @@ vi.mock("../src/widevine.js", () => ({ seedWidevineHint: vi.fn(), resolveWidevineCdmDir: vi.fn(), })); +vi.mock("../src/profile-seed.js", () => ({ + persistentSeedArgs: (_d: any, _s: any, a: any) => a, +})); vi.mock("../src/download.js", () => ({ ensureBinary: vi.fn().mockResolvedValue("/fake/chrome"), })); diff --git a/tests/test_persistent_context.py b/tests/test_persistent_context.py index 5f28119..094fbfb 100644 --- a/tests/test_persistent_context.py +++ b/tests/test_persistent_context.py @@ -10,6 +10,14 @@ import pytest from cloakbrowser.config import DEFAULT_VIEWPORT +@pytest.fixture(autouse=True) +def _no_seed_file(): + """These tests use fixed fake profile paths; keep the seed file off disk. + Seed persistence itself is covered in test_profile_seed.py.""" + with patch("cloakbrowser.browser.persistent_seed_args", side_effect=lambda _d, _s, a: a): + yield + + def _make_mock_pw_and_context(): """Create mock sync_playwright chain returning a mock context.""" context = MagicMock() diff --git a/tests/test_profile_seed.py b/tests/test_profile_seed.py new file mode 100644 index 0000000..5797ef7 --- /dev/null +++ b/tests/test_profile_seed.py @@ -0,0 +1,144 @@ +"""Fingerprint seed persistence for persistent profiles (.cloakbrowser-seed).""" + +import logging +import threading +from unittest.mock import MagicMock, patch + +import pytest + +from cloakbrowser.config import PROFILE_SEED_FILE, persistent_seed_args + + +def _listing(d): + # macOS adds ._ AppleDouble files on FAT volumes, where these tests also run. + return [p.name for p in d.iterdir() if not p.name.startswith("._")] + + +def _seed(args): + return [a for a in args if a.startswith("--fingerprint=")] + + +def _launch_args(launcher, *a, **kw): + """Run launcher with mocked playwright; return the args passed to Chromium.""" + pw = MagicMock() + pw_cm = MagicMock() + pw_cm.start.return_value = pw + with patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome"), \ + patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)), \ + patch("playwright.sync_api.sync_playwright", return_value=pw_cm): + launcher(*a, **kw) + if pw.chromium.launch_persistent_context.called: + return pw.chromium.launch_persistent_context.call_args[1]["args"] + return pw.chromium.launch.call_args[1]["args"] + + +def test_first_launch_writes_seed_and_second_reuses(tmp_path): + from cloakbrowser.browser import launch_persistent_context + + profile = tmp_path / "profile" # missing dir is created + first = _seed(_launch_args(launch_persistent_context, str(profile))) + stored = (profile / PROFILE_SEED_FILE).read_text() + assert first == [f"--fingerprint={stored.strip()}"] + assert stored.endswith("\n") and 10000 <= int(stored) <= 99999 + + second = _seed(_launch_args(launch_persistent_context, str(profile))) + assert second == first + + +@pytest.mark.asyncio +async def test_async_launch_uses_stored_seed(tmp_path): + from unittest.mock import AsyncMock + + from cloakbrowser.browser import launch_persistent_context_async + + (tmp_path / PROFILE_SEED_FILE).write_text("12345\n") + pw = MagicMock() + pw.chromium.launch_persistent_context = AsyncMock() + pw_cm = MagicMock() + pw_cm.start = AsyncMock(return_value=pw) + with patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome"), \ + patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)), \ + patch("playwright.async_api.async_playwright", return_value=pw_cm): + await launch_persistent_context_async(str(tmp_path)) + assert _seed(pw.chromium.launch_persistent_context.call_args[1]["args"]) == ["--fingerprint=12345"] + + +@pytest.mark.parametrize("flag", ["--fingerprint=4242", "--fingerprint=off"]) +def test_explicit_fingerprint_wins_and_file_untouched(tmp_path, flag): + from cloakbrowser.browser import launch_persistent_context + + assert _seed(_launch_args(launch_persistent_context, str(tmp_path), args=[flag])) == [flag] + assert not (tmp_path / PROFILE_SEED_FILE).exists() + + (tmp_path / PROFILE_SEED_FILE).write_text("12345\n") + assert _seed(_launch_args(launch_persistent_context, str(tmp_path), args=[flag])) == [flag] + assert (tmp_path / PROFILE_SEED_FILE).read_text() == "12345\n" + + +def test_stealth_args_false_writes_nothing(tmp_path): + from cloakbrowser.browser import launch_persistent_context + + args = _launch_args(launch_persistent_context, str(tmp_path), stealth_args=False) + assert _seed(args) == [] + assert list(tmp_path.iterdir()) == [] + + +def test_non_persistent_launch_stays_random(tmp_path, monkeypatch): + from cloakbrowser.browser import launch + + monkeypatch.chdir(tmp_path) + seeds = {_seed(_launch_args(launch))[0] for _ in range(5)} + assert len(seeds) > 1 + assert list(tmp_path.iterdir()) == [] + + +@pytest.mark.parametrize("content", [b"", b"abc\n", b"5\n", b"123456\n", b"-12345\n", b"\xff\xfe"]) +def test_corrupt_file_logs_and_regenerates(tmp_path, caplog, content): + path = tmp_path / PROFILE_SEED_FILE + path.write_bytes(content) + with caplog.at_level(logging.WARNING, logger="cloakbrowser"): + args = persistent_seed_args(tmp_path, True, None) + assert str(path) in caplog.text + stored = path.read_text() + assert args == [f"--fingerprint={stored.strip()}"] + assert 10000 <= int(stored) <= 99999 + + +def test_python_file_format_is_cross_wrapper(tmp_path): + """JS and .NET tests read this exact content; keep the format pinned.""" + persistent_seed_args(tmp_path, True, None) + assert (tmp_path / PROFILE_SEED_FILE).read_bytes().decode("ascii").rstrip("\n").isdigit() + (tmp_path / PROFILE_SEED_FILE).write_text("12345\n") + assert persistent_seed_args(tmp_path, True, ["--x"]) == ["--fingerprint=12345", "--x"] + + +def test_empty_user_data_dir_is_ignored(): + assert persistent_seed_args("", True, ["--x"]) == ["--x"] + + +def test_concurrent_first_launches_agree(tmp_path): + results, barrier = [], threading.Barrier(16) + + def run(): + barrier.wait() + results.append(persistent_seed_args(tmp_path, True, None)[0]) + + threads = [threading.Thread(target=run) for _ in range(16)] + for t in threads: + t.start() + for t in threads: + t.join() + assert len(set(results)) == 1 + assert _listing(tmp_path) == [PROFILE_SEED_FILE] + + +def test_no_hard_link_support_falls_back_to_exclusive_create(tmp_path): + """FAT/exFAT and some network mounts reject link() with ENOTSUP/EPERM.""" + import errno + + with patch("cloakbrowser.config.os.link", side_effect=OSError(errno.ENOTSUP, "not supported")): + first = persistent_seed_args(tmp_path, True, None) + assert persistent_seed_args(tmp_path, True, None) == first + stored = (tmp_path / PROFILE_SEED_FILE).read_text() + assert first == [f"--fingerprint={stored.strip()}"] + assert _listing(tmp_path) == [PROFILE_SEED_FILE]