feat: persist the fingerprint seed per persistent profile

launch_persistent_context() now stores its seed in <user_data_dir>/.cloakbrowser-seed
on first launch and reuses it, so a profile keeps one identity across sessions.
Same file format in Python, JS and .NET. An explicit --fingerprint in args wins
and leaves the file alone; stealth_args=False skips it; non-persistent launches
stay random. Concurrent first launches publish without overwriting (link), so
they agree on one seed. Filesystems without hard links (FAT/exFAT, some network
mounts) fall back to an exclusive create, and an empty file is re-read briefly
as in-progress. A corrupt file is logged and regenerated.
This commit is contained in:
CloakHQ committed 2026-10-08 05:00:29 +02:00
1 parent 58eead89d2
commit 5fb230d644
16 files changed
+860 -5

No files matched your search

+8 -1
View File
@@ -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 = [
+2
View File
@@ -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<T extends { timezone?: string; timezoneId?: string }>(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);
+109
View File
@@ -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 `<userDataDir>/.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=<seed>` 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 ?? [])];
}
+6 -1
View File
@@ -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 };
+2
View File
@@ -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(() => {
+171
View File
@@ -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([]);
});
});
+5
View File
@@ -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({}),
+3
View File
@@ -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"),
}));