From 448111427e534bec1f6781c9d0aa618dbb6b4cd1 Mon Sep 17 00:00:00 2001 From: namvippro Date: Sun, 4 Oct 2026 20:08:53 +0700 Subject: [PATCH 1/5] feat(launcher): launch real profiles with Chrome's own identity A profile with fingerprint { mode: 'real' } resolves to engine "real": no generated fingerprint, no UA/Client Hints override, no injected script, no auto-attach, no kernel or anti-detect flags, and TZ/--lang only when the profile pins them. Asking a real profile for engine kernel or inject throws an actionable INVALID_CONFIG error. Lock files, reuse and launch results carry the resolved engine. A real profile gets no default timezone or GPU at create, and entering real mode drops the stored timezone. resolveTimezone moves to proxy.ts to keep launch.ts from growing. Co-Authored-By: Claude Opus 5.5 --- src/commands/browser-launch.ts | 4 +- src/commands/browser-open.ts | 12 ++-- src/commands/profile-get.ts | 5 +- src/index.ts | 3 + src/integrations/playwright.ts | 17 +++-- src/integrations/puppeteer.ts | 9 +-- src/launcher/chrome-flags.ts | 34 +++++++--- src/launcher/kernel.ts | 8 +-- src/launcher/launch.e2e.test.ts | 23 +++++++ src/launcher/launch.test.ts | 117 ++++++++++++++++++++++++++++++++ src/launcher/launch.ts | 77 ++++++++++----------- src/launcher/protections.ts | 22 +++--- src/launcher/proxy.ts | 20 +++++- src/launcher/real.test.ts | 75 ++++++++++++++++++++ src/launcher/real.ts | 69 +++++++++++++++++++ src/launcher/reuse.ts | 6 +- src/launcher/standalone.ts | 4 +- src/profile-fingerprint.ts | 38 +++++++++++ src/profile-manager.ts | 17 +---- src/storage.ts | 9 ++- src/types.ts | 34 ++++++++-- 21 files changed, 497 insertions(+), 106 deletions(-) create mode 100644 src/launcher/launch.test.ts create mode 100644 src/launcher/real.test.ts create mode 100644 src/launcher/real.ts create mode 100644 src/profile-fingerprint.ts diff --git a/src/commands/browser-launch.ts b/src/commands/browser-launch.ts index 98d92a4..03bfb40 100644 --- a/src/commands/browser-launch.ts +++ b/src/commands/browser-launch.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { Err, Ok } from '../types'; -import type { BrowserError, ProxyConfig, Result } from '../types'; +import type { BrowserError, ProxyConfig, ResolvedEngine, Result } from '../types'; import { launchChromeStandalone } from '../chrome-launcher'; import { generateFingerprint } from '../fingerprint'; import { defineCommand } from './define'; @@ -18,7 +18,7 @@ export interface BrowserLaunched { /** True when Chrome was started to outlive the opener (reduced protection). */ detached: boolean; /** kernel spoofs inside Chromium. inject patches from JavaScript. */ - engine: 'kernel' | 'inject'; + engine: ResolvedEngine; } export const browserLaunch = defineCommand({ diff --git a/src/commands/browser-open.ts b/src/commands/browser-open.ts index daa984a..e006529 100644 --- a/src/commands/browser-open.ts +++ b/src/commands/browser-open.ts @@ -1,7 +1,8 @@ import { z } from 'zod'; import { Err, Ok } from '../types'; -import type { BrowserError, Result } from '../types'; +import type { BrowserError, ResolvedEngine, Result } from '../types'; import { DETACHED_PROXY_MESSAGE } from '../launcher/proxy'; +import { REAL_ENGINE_ADVICE } from '../launcher/real'; import { defineCommand, resolveProfile } from './define'; import { detachedField, engineField, headlessField, idOrNameField } from './fields'; @@ -14,13 +15,13 @@ export interface BrowserOpened { reused: boolean; /** True when Chrome was started to outlive the opener (reduced protection). */ detached: boolean; - /** kernel spoofs inside Chromium. inject patches from JavaScript. */ - engine: 'kernel' | 'inject'; + /** kernel spoofs inside Chromium. inject patches from JavaScript. real changes nothing. */ + engine: ResolvedEngine; } /** The launcher reports every problem as a thrown Error; keep the codes honest. */ export function launchErrorCode(message: string): BrowserError['code'] { - if (message.includes(DETACHED_PROXY_MESSAGE)) return 'INVALID_CONFIG'; + if (message.includes(DETACHED_PROXY_MESSAGE) || message.includes(REAL_ENGINE_ADVICE)) return 'INVALID_CONFIG'; if (/not found/i.test(message) && /chrom/i.test(message)) return 'CHROME_NOT_FOUND'; return 'LAUNCH_FAILED'; } @@ -35,7 +36,8 @@ export const browserOpen = defineCommand({ name: 'browser.open', description: 'Open Chrome with a stored profile and return its CDP wsEndpoint. Calling it again for the same ' + - 'profile returns the running browser with reused=true. Close it with browser.close.', + 'profile returns the running browser with reused=true. engine reports real when the profile ' + + 'ran with Chrome\'s own fingerprint. Close it with browser.close.', cli: { positional: ['idOrName'], aliases: ['open'], keepAlive: true }, input: z.object({ idOrName: idOrNameField, diff --git a/src/commands/profile-get.ts b/src/commands/profile-get.ts index 214ef10..bda2e6e 100644 --- a/src/commands/profile-get.ts +++ b/src/commands/profile-get.ts @@ -28,7 +28,10 @@ export function renderProfile(profile: StoredProfile): string { if (profile.notes) lines.push(`Notes: ${profile.notes}`); const fingerprint = profile.fingerprint; - if (fingerprint && Object.keys(fingerprint).length > 0) { + if (fingerprint?.mode === 'real') { + lines.push('', 'Fingerprint: real (Chrome\'s own identity, nothing spoofed)'); + if (fingerprint.language) lines.push(` Language: ${fingerprint.language}`); + } else if (fingerprint && Object.keys(fingerprint).length > 0) { lines.push('', 'Fingerprint:'); if (fingerprint.language) lines.push(` Language: ${fingerprint.language}`); if (fingerprint.platform) lines.push(` Platform: ${fingerprint.platform}`); diff --git a/src/index.ts b/src/index.ts index 2e45897..518fd42 100644 --- a/src/index.ts +++ b/src/index.ts @@ -120,6 +120,7 @@ export type { LaunchResult, FingerprintEngine, ResolvedEngine, + SpoofEngine, // Proxy & Cookie types ProxyConfig, @@ -127,6 +128,8 @@ export type { // Fingerprint types FingerprintConfig, + ProfileFingerprint, + RealFingerprint, FingerprintGenerateOptions, ScreenConfig, WebGLConfig, diff --git a/src/integrations/playwright.ts b/src/integrations/playwright.ts index 3a40773..42be023 100644 --- a/src/integrations/playwright.ts +++ b/src/integrations/playwright.ts @@ -2,6 +2,7 @@ // @aitofy/browser-profiles - Playwright Integration // ============================================================================ +import { spoofSettings } from '../types'; import type { StoredProfile, LaunchOptions, LaunchResult, ProxyConfig, ProfileConfig } from '../types'; import { BrowserProfiles } from '../profile-manager'; import { getProfileProtectionScripts } from '../fingerprint'; @@ -11,7 +12,7 @@ import { getProfileProtectionScripts } from '../fingerprint'; * created over connectOverCDP, so the same bundle is re-injected here. */ async function applyProtection(context: PlaywrightContextType, fingerprint?: StoredProfile['fingerprint']): Promise { - await context.addInitScript(getProfileProtectionScripts(fingerprint)); + await context.addInitScript(getProfileProtectionScripts(spoofSettings(fingerprint))); } // ============================================================================ @@ -185,16 +186,18 @@ export async function withPlaywright(options: WithPlaywrightOptions): Promise 0 ? contexts[0] : await browser.newContext(); - if (launch.engine !== 'kernel') await applyProtection(context, profile.fingerprint); + if (launch.engine === 'inject') await applyProtection(context, profile.fingerprint); const pages = context.pages(); page = pages.length > 0 ? pages[0] : await context.newPage(); } @@ -259,12 +262,12 @@ export async function quickLaunchPlaywright(options: QuickLaunchPlaywrightOption timezoneId: options.timezone || 'America/New_York', viewport: options.defaultViewport || null, }); - if (launch.engine !== 'kernel') await applyProtection(context, profile.fingerprint); + if (launch.engine === 'inject') await applyProtection(context, profile.fingerprint); page = await context.newPage(); } else { const contexts = browser.contexts(); context = contexts.length > 0 ? contexts[0] : await browser.newContext(); - if (launch.engine !== 'kernel') await applyProtection(context, profile.fingerprint); + if (launch.engine === 'inject') await applyProtection(context, profile.fingerprint); const pages = context.pages(); page = pages.length > 0 ? pages[0] : await context.newPage(); } diff --git a/src/integrations/puppeteer.ts b/src/integrations/puppeteer.ts index 1107509..5efa9be 100644 --- a/src/integrations/puppeteer.ts +++ b/src/integrations/puppeteer.ts @@ -2,6 +2,7 @@ // @aitofy/browser-profiles - Puppeteer Integration // ============================================================================ +import { spoofSettings } from '../types'; import type { StoredProfile, LaunchOptions, LaunchResult, ProxyConfig, ProfileConfig } from '../types'; import { BrowserProfiles } from '../profile-manager'; import { createLogger } from '../log'; @@ -291,8 +292,8 @@ export async function withPuppeteer(options: WithPuppeteerOptions): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any await (targetPage as any).evaluateOnNewDocument(bundle); @@ -400,9 +401,9 @@ export async function quickLaunch(options: QuickLaunchOptions = {}): Promise 0 ? pages[0] : await browser.newPage(); - if (launch.engine !== 'kernel') { + if (launch.engine === 'inject') { // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (page as any).evaluateOnNewDocument(getProfileProtectionScripts(profile.fingerprint)); + await (page as any).evaluateOnNewDocument(getProfileProtectionScripts(spoofSettings(profile.fingerprint))); } // Close function - by default only closes this session's page diff --git a/src/launcher/chrome-flags.ts b/src/launcher/chrome-flags.ts index 154d8d3..26ca4d0 100644 --- a/src/launcher/chrome-flags.ts +++ b/src/launcher/chrome-flags.ts @@ -17,6 +17,8 @@ export interface ChromeFlagOptions { proxyServer?: string; /** fingerprint-chromium switches. Empty on the inject engine. */ kernelFlags?: string[]; + /** Real profile: no anti-detect flags, and --lang only when the profile pins a language. */ + real?: boolean; } const BASE_FLAGS = [ @@ -36,16 +38,22 @@ const BASE_FLAGS = [ '--disable-dev-shm-usage', ]; +// Drops the automation-controlled bit navigator.webdriver reads. +const NOT_WEBDRIVER_FLAG = '--disable-blink-features=AutomationControlled'; + +// WebRTC must not reach the network outside the proxy. +const WEBRTC_PROXY_FLAGS = [ + '--webrtc-ip-handling-policy=disable_non_proxied_udp', + '--force-webrtc-ip-handling-policy', +]; + const ANTI_DETECT_FLAGS = [ - // Drops the automation-controlled bit navigator.webdriver reads. - '--disable-blink-features=AutomationControlled', + NOT_WEBDRIVER_FLAG, '--disable-infobars', '--disable-extensions-file-access-check', '--enable-features=NetworkService,NetworkServiceInProcess', '--disable-features=IsolateOrigins,site-per-process', - // WebRTC must not reach the network outside the proxy. - '--webrtc-ip-handling-policy=disable_non_proxied_udp', - '--force-webrtc-ip-handling-policy', + ...WEBRTC_PROXY_FLAGS, ]; const HEADLESS_FLAGS = ['--headless=new', '--mute-audio', '--hide-scrollbars']; @@ -59,14 +67,24 @@ function extensionFlags(extensions: string[]): string[] { return [`--disable-extensions-except=${joined}`, `--load-extension=${joined}`]; } +/** A hand-opened Chrome is not webdriver, so that bit stays off; the proxy must not leak either. */ +function realIdentityFlags(language: string | undefined, proxyServer: string | undefined): string[] { + return [ + ...(language ? [`--lang=${language}`] : []), + NOT_WEBDRIVER_FLAG, + ...(proxyServer ? WEBRTC_PROXY_FLAGS : []), + ]; +} + export function buildChromeFlags(options: ChromeFlagOptions): string[] { const { profile, userDataDir, headless, args, extensions, proxyServer, kernelFlags = [] } = options; + const language = profile.fingerprint?.language; return [ ...BASE_FLAGS, - `--lang=${profile.fingerprint?.language || 'en-US'}`, - ...ANTI_DETECT_FLAGS, - ...kernelFlags, + ...(options.real + ? realIdentityFlags(language, proxyServer) + : [`--lang=${language || 'en-US'}`, ...ANTI_DETECT_FLAGS, ...kernelFlags]), ...(userDataDir ? [`--user-data-dir=${userDataDir}`] : []), ...(headless ? HEADLESS_FLAGS : []), ...(proxyServer ? [`--proxy-server=${proxyServer}`] : []), diff --git a/src/launcher/kernel.ts b/src/launcher/kernel.ts index 676abec..13dece0 100644 --- a/src/launcher/kernel.ts +++ b/src/launcher/kernel.ts @@ -8,8 +8,8 @@ import fs from 'fs'; import path from 'path'; -import type { FingerprintConfig, StoredProfile } from '../types'; -import type { FingerprintEngine, ResolvedEngine } from '../types'; +import { spoofSettings } from '../types'; +import type { FingerprintConfig, FingerprintEngine, SpoofEngine, StoredProfile } from '../types'; import { FINGERPRINT_DEFAULTS } from '../fingerprint'; export const KERNEL_SWITCH = '--fingerprint-platform'; @@ -113,7 +113,7 @@ function fileContains(filePath: string, size: number): boolean { * kernel without the switch throws: skipping the hooks on stock Chrome would * launch a browser with no spoof at all. */ -export function resolveEngine(requested: FingerprintEngine, binaryIsKernel: boolean): ResolvedEngine { +export function resolveEngine(requested: FingerprintEngine, binaryIsKernel: boolean): SpoofEngine { if (requested === 'inject') return 'inject'; if (requested === 'kernel') { if (!binaryIsKernel) throw new Error(KERNEL_REQUIRED_MESSAGE); @@ -175,7 +175,7 @@ export function buildKernelFlags( timezone: string, host: KernelPlatform = hostKernelPlatform() ): string[] { - const fingerprint = profile.fingerprint; + const fingerprint = spoofSettings(profile.fingerprint); const language = fingerprint?.language || FINGERPRINT_DEFAULTS.language; const platform = fingerprint?.platform ? kernelPlatform(fingerprint.platform) : host; const flags = [ diff --git a/src/launcher/launch.e2e.test.ts b/src/launcher/launch.e2e.test.ts index 3a930ab..be1b641 100644 --- a/src/launcher/launch.e2e.test.ts +++ b/src/launcher/launch.e2e.test.ts @@ -179,6 +179,29 @@ e2e('a browser launched for a profile', () => { } }, 60_000); + it('leaves a real profile with Chrome\'s own user agent and this host\'s clock', async () => { + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browser-profiles-e2e-')); + const real: StoredProfile = { + id: 'e2e-real', name: 'Real', createdAt: 0, updatedAt: 0, fingerprint: { mode: 'real' }, + }; + launched = await launchChrome({ profile: real, userDataDir, headless: true }); + expect(launched.engine).toBe('real'); + + const browser = await puppeteer.connect({ browserWSEndpoint: launched.wsEndpoint }); + try { + const page = await browser.newPage(); + await page.goto('data:text/html,e2e'); + const seen = await page.evaluate(() => ({ + userAgent: navigator.userAgent, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + })); + expect(seen.userAgent).toBe(await browser.userAgent()); + expect(seen.timezone).toBe(Intl.DateTimeFormat().resolvedOptions().timeZone); + } finally { + browser.disconnect(); + } + }, 60_000); + // Needs a fingerprint-chromium build: KERNEL_PATH=/path/to/Chromium. it.skipIf(!process.env.KERNEL_PATH)('gives Intl the profile locale in kernel mode', async () => { userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browser-profiles-e2e-')); diff --git a/src/launcher/launch.test.ts b/src/launcher/launch.test.ts new file mode 100644 index 0000000..63c8e03 --- /dev/null +++ b/src/launcher/launch.test.ts @@ -0,0 +1,117 @@ +import fs from 'fs'; +import http from 'http'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { LaunchResult, StoredProfile } from '../types'; +import { readLockFile } from '../storage'; +import type { CdpParams } from './deps'; +import { launchChrome } from './launch'; + +// Chrome and its DevTools socket are faked; everything between them is the real launcher. +const fake = vi.hoisted(() => ({ + port: 0, + launches: [] as Array<{ chromeFlags: string[]; envVars?: Record }>, + cdpCalls: [] as string[], +})); + +vi.mock('./deps', () => ({ + loadChromeLauncher: async () => ({ + launch: async (options: { chromeFlags: string[]; envVars?: Record }) => { + fake.launches.push(options); + return { port: fake.port, pid: 2_000_000_000, kill: async () => undefined }; + }, + }), + loadCdp: async () => async () => ({ + send: async (method: string): Promise => { + fake.cdpCalls.push(method); + if (method === 'Browser.getVersion') return { product: 'Chrome/150.0.7000.1' }; + if (method === 'Target.getTargets') return { targetInfos: [] }; + return {}; + }, + on: () => undefined, + close: async () => undefined, + }), +})); + +let server: http.Server; +let dir: string; +let chromePath: string; +let launched: LaunchResult | null = null; + +beforeEach(async () => { + fake.launches.length = 0; + fake.cdpCalls.length = 0; + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'launch-')); + chromePath = path.join(dir, 'chrome'); + fs.writeFileSync(chromePath, 'stock chrome'); + server = http.createServer((_request, response) => { + response.end(JSON.stringify({ webSocketDebuggerUrl: `ws://127.0.0.1:${fake.port}/devtools/browser/fake` })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + fake.port = (server.address() as { port: number }).port; +}); + +afterEach(async () => { + if (launched) await launched.close(); + launched = null; + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +const realProfile: StoredProfile = { + id: 'real-1', name: 'Google Main', createdAt: 0, updatedAt: 0, fingerprint: { mode: 'real' }, +}; + +function launch(profile: StoredProfile, engine?: 'auto' | 'kernel' | 'inject'): Promise { + return launchChrome({ profile, userDataDir: path.join(dir, 'data'), chromePath, engine }); +} + +const SPOOF_FLAG = /^--(fingerprint|lang=|accept-lang|timezone|disable-features=IsolateOrigins|webrtc-ip)/; + +describe('launchChrome with a real profile', () => { + it('passes no fingerprint flag, no TZ and sends no override to any tab', async () => { + launched = await launch(realProfile); + + expect(launched.engine).toBe('real'); + const [{ chromeFlags, envVars }] = fake.launches; + expect(chromeFlags.filter((flag) => SPOOF_FLAG.test(flag))).toEqual([]); + expect(envVars).toBeUndefined(); + expect(fake.cdpCalls).toEqual([]); + expect(readLockFile(path.join(dir, 'data'))?.engine).toBe('real'); + }); + + it('applies only the timezone and language the profile pins itself', async () => { + launched = await launch({ + ...realProfile, timezone: 'Asia/Ho_Chi_Minh', fingerprint: { mode: 'real', language: 'vi-VN' }, + }); + + const [{ chromeFlags, envVars }] = fake.launches; + expect(envVars).toEqual({ TZ: 'Asia/Ho_Chi_Minh' }); + expect(chromeFlags).toContain('--lang=vi-VN'); + expect(fake.cdpCalls).toEqual([]); + }); + + it('refuses the kernel engine before Chrome starts and releases the profile', async () => { + await expect(launch(realProfile, 'kernel')).rejects.toThrow(/real.*engine "kernel".*--fingerprint generated/s); + await expect(launch(realProfile, 'inject')).rejects.toThrow(/engine "inject"/); + expect(fake.launches).toEqual([]); + expect(readLockFile(path.join(dir, 'data'))).toBeNull(); + }); +}); + +describe('launchChrome with a generated profile', () => { + it('still spoofs: TZ, --lang, anti-detect flags and auto-attach', async () => { + launched = await launch({ id: 'gen-1', name: 'Gen', createdAt: 0, updatedAt: 0, timezone: 'Europe/Paris' }); + + expect(launched.engine).toBe('inject'); + const [{ chromeFlags, envVars }] = fake.launches; + expect(envVars).toEqual({ TZ: 'Europe/Paris' }); + expect(chromeFlags).toEqual(expect.arrayContaining([ + '--lang=en-US', + '--disable-features=IsolateOrigins,site-per-process', + '--webrtc-ip-handling-policy=disable_non_proxied_udp', + ])); + expect(fake.cdpCalls).toEqual(expect.arrayContaining(['Browser.getVersion', 'Target.setAutoAttach'])); + }); +}); diff --git a/src/launcher/launch.ts b/src/launcher/launch.ts index ca199b6..2699640 100644 --- a/src/launcher/launch.ts +++ b/src/launcher/launch.ts @@ -2,13 +2,14 @@ // @aitofy/browser-profiles - Launch Chrome for a profile // ============================================================================ -import type { LaunchOptions, LaunchResult, ResolvedEngine, StoredProfile } from '../types'; +import { spoofSettings } from '../types'; +import type { LaunchOptions, LaunchResult, SpoofEngine, StoredProfile } from '../types'; import { createLogger } from '../log'; import { deleteLockFile, lockFilePath, probeDevToolsPort, writeLockFile } from '../storage'; import { startAutoAttach } from './auto-attach'; import { buildChromeFlags, clearStaleSingletonLocks } from './chrome-flags'; import { getChromePath } from './chrome-path'; -import { binaryLooksLikeKernel, buildKernelFlags, resolveEngine } from './kernel'; +import { binaryLooksLikeKernel, buildKernelFlags } from './kernel'; import { claimProfile } from './claim'; import { loadCdp, loadChromeLauncher } from './deps'; import type { CdpClient, CdpConnectOptions, LaunchedChrome } from './deps'; @@ -17,10 +18,10 @@ import type { ProtectionPlan } from './protections'; import { assertDetachedLaunchAllowed, closeProxyRelay, - detectExitLocation, - detectTimezoneFromIP, + resolveTimezone, startProxyRelay, } from './proxy'; +import { resolveProfileEngine, startRealSession } from './real'; import { reuseExisting } from './reuse'; import { trackBrowser, untrackBrowser } from './running'; import { parseChromeVersion, FALLBACK_CHROME_MAJOR, FALLBACK_CHROME_VERSION } from '../user-agent'; @@ -42,23 +43,10 @@ export interface ChromeLaunchOptions extends LaunchOptions { const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); -/** Timezone for Chrome's TZ, from the profile, else the proxy exit IP, else this host. */ -async function resolveTimezone(profile: StoredProfile, relayUrl: string | undefined): Promise { - if (profile.timezone) return profile.timezone; - - if (profile.proxy) { - const exit = relayUrl ? await detectExitLocation(relayUrl) : null; - const geo = exit ?? await detectTimezoneFromIP(profile.proxy.host); - if (geo) { - log.info(`Auto-detected timezone: ${geo.timezone} (${geo.city}, ${geo.country})`); - return geo.timezone; - } - } - - const systemTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - log.debug(`Using system timezone: ${systemTimezone}`); - return systemTimezone; -} +/** A real profile keeps Chrome's clock unless it names a timezone; a spoof always sets one. */ +type LaunchIdentity = + | { engine: 'real'; timezone?: string } + | { engine: SpoofEngine; timezone: string }; async function browserEndpoint(port: number): Promise { for (let attempt = 0; attempt < ENDPOINT_ATTEMPTS; attempt++) { @@ -165,15 +153,17 @@ async function launchClaimed(options: ChromeLaunchOptions, userDataDir: string): } const executablePath = getChromePath(chromePath); - const engine = resolveEngine(options.engine ?? 'auto', binaryLooksLikeKernel(executablePath)); - if (engine === 'kernel' && profile.fingerprint?.userAgent) { + const engine = resolveProfileEngine(profile, options.engine, () => binaryLooksLikeKernel(executablePath)); + if (engine === 'kernel' && spoofSettings(profile.fingerprint)?.userAgent) { log.warn( 'fingerprint.userAgent is ignored in kernel mode. The binary sets the user agent from its ' + 'own version; a pinned string would disagree with it.' ); } const relayUrl = profile.proxy ? await startProxyRelay(profile.proxy) : undefined; - const timezone = await resolveTimezone(profile, relayUrl); + const identity: LaunchIdentity = engine === 'real' + ? { engine, timezone: profile.timezone } + : { engine, timezone: await resolveTimezone(profile, relayUrl) }; log.debug(`Launching Chrome: ${executablePath}`); log.debug(`User data dir: ${userDataDir}`); @@ -192,14 +182,16 @@ async function launchClaimed(options: ChromeLaunchOptions, userDataDir: string): args: flagArgs, extensions, proxyServer: relayUrl, - kernelFlags: engine === 'kernel' ? buildKernelFlags(profile, timezone) : undefined, + real: identity.engine === 'real', + kernelFlags: identity.engine === 'kernel' ? buildKernelFlags(profile, identity.timezone) : undefined, }), ...(startingUrl ? { startingUrl } : {}), userDataDir, ignoreDefaultFlags: true, // Our own SIGINT handlers must run: chrome-launcher's kills and exits first. handleSIGINT: false, - envVars: { TZ: timezone }, + // Without envVars chrome-launcher passes this process's environment through. + envVars: identity.timezone ? { TZ: identity.timezone } : undefined, }); if (detached) { // chrome-launcher already spawns with detached:true on POSIX; unref frees our event loop. @@ -219,11 +211,22 @@ async function launchClaimed(options: ChromeLaunchOptions, userDataDir: string): } let releaseSession: (() => Promise) | undefined; + // The browser died on its own: drop the state that claims it is alive. + const forget = (): void => { + untrackBrowser(profile.id); + deleteLockFile(userDataDir); + void closeProxyRelay(relayUrl); + }; try { - releaseSession = detached - ? await protectFirstTabOnly(chrome.port, profile, timezone, engine) - : await protectEveryTab(wsEndpoint, profile, timezone, engine, userDataDir, relayUrl); + if (identity.engine === 'real') { + const connect = () => connectCdp({ target: wsEndpoint }); + releaseSession = await startRealSession({ connect, profile, detached, onDisconnect: forget }); + } else { + releaseSession = detached + ? await protectFirstTabOnly(chrome.port, profile, identity.timezone, identity.engine) + : await protectEveryTab(wsEndpoint, profile, identity.timezone, identity.engine, forget); + } } catch (error) { await abortLaunch(chrome, relayUrl); throw error; @@ -281,7 +284,7 @@ async function planFor( client: CdpClient, profile: StoredProfile, timezone: string, - engine: ResolvedEngine + engine: SpoofEngine ): Promise { if (engine === 'kernel') return buildProtectionPlan(profile, timezone, FALLBACK_CHROME_VERSION, 'kernel'); @@ -304,7 +307,7 @@ async function protectFirstTabOnly( port: number, profile: StoredProfile, timezone: string, - engine: ResolvedEngine + engine: SpoofEngine ): Promise { const client = await connectCdp({ port }); try { @@ -323,9 +326,8 @@ async function protectEveryTab( wsEndpoint: string, profile: StoredProfile, timezone: string, - engine: ResolvedEngine, - userDataDir: string, - relayUrl: string | undefined + engine: SpoofEngine, + onDisconnect: () => void ): Promise<() => Promise> { const client = await connectCdp({ target: wsEndpoint }); const plan = await planFor(client, profile, timezone, engine); @@ -335,12 +337,7 @@ async function protectEveryTab( client, plan, log, - onDisconnect: () => { - // The browser died on its own: drop the state that claims it is alive. - untrackBrowser(profile.id); - deleteLockFile(userDataDir); - void closeProxyRelay(relayUrl); - }, + onDisconnect, }); return async () => { diff --git a/src/launcher/protections.ts b/src/launcher/protections.ts index e69318e..6f5ab17 100644 --- a/src/launcher/protections.ts +++ b/src/launcher/protections.ts @@ -2,7 +2,8 @@ // @aitofy/browser-profiles - Anti-detect injections, applied per CDP session // ============================================================================ -import type { ProfileCookie, ResolvedEngine, StoredProfile } from '../types'; +import { spoofSettings } from '../types'; +import type { ProfileCookie, SpoofEngine, StoredProfile } from '../types'; import { FINGERPRINT_DEFAULTS, getProfileProtectionScripts, getProfileWorkerScript } from '../fingerprint'; import { buildUserAgentMetadata, resolveUserAgent, FALLBACK_CHROME_VERSION } from '../user-agent'; import type { ChromeVersion } from '../user-agent'; @@ -35,10 +36,10 @@ export interface ProtectionPlan { /** Set when the profile pins a UA claiming another Chrome major than the browser running it. */ userAgentMismatch: { claimed: number; running: number } | null; /** kernel: the binary owns the fingerprint; only the locale override is sent. */ - engine: ResolvedEngine; + engine: SpoofEngine; } -function cookieParams(cookie: ProfileCookie): CdpParams { +export function cookieParams(cookie: ProfileCookie): CdpParams { return { url: `https://${cookie.domain}`, name: cookie.name, @@ -65,12 +66,13 @@ export function buildProtectionPlan( profile: StoredProfile, timezoneId: string = profile.timezone || FALLBACK_TIMEZONE, chromeVersion: ChromeVersion = FALLBACK_CHROME_VERSION, - engine: ResolvedEngine = 'inject' + engine: SpoofEngine = 'inject' ): ProtectionPlan { - const platform = profile.fingerprint?.platform || FINGERPRINT_DEFAULTS.platform; - const language = profile.fingerprint?.language || FINGERPRINT_DEFAULTS.language; + const fingerprint = spoofSettings(profile.fingerprint); + const platform = fingerprint?.platform || FINGERPRINT_DEFAULTS.platform; + const language = fingerprint?.language || FINGERPRINT_DEFAULTS.language; const { userAgent, version, mismatch } = resolveUserAgent( - profile.fingerprint?.userAgent, + fingerprint?.userAgent, platform, chromeVersion ); @@ -82,8 +84,8 @@ export function buildProtectionPlan( acceptLanguage: acceptLanguageHeader(language), userAgentMetadata: buildUserAgentMetadata(platform, version), }, - initScript: engine === 'kernel' ? '' : getProfileProtectionScripts(profile.fingerprint), - workerScript: engine === 'kernel' ? '' : getProfileWorkerScript(profile.fingerprint, userAgent), + initScript: engine === 'kernel' ? '' : getProfileProtectionScripts(fingerprint), + workerScript: engine === 'kernel' ? '' : getProfileWorkerScript(fingerprint, userAgent), timezoneId, locale: language, cookies: (profile.cookies ?? []).map(cookieParams), @@ -130,7 +132,7 @@ export async function applyWorkerProtections(session: CdpSession, plan: Protecti * Cookies belong to the browser's cookie store, not to one tab, so they are * installed once per launch and every later tab inherits them. */ -export async function applyCookies(session: CdpSession, plan: ProtectionPlan): Promise { +export async function applyCookies(session: CdpSession, plan: Pick): Promise { if (plan.cookies.length === 0) return; // A cookie the browser rejects must not cost the rest of the launch. await session.send('Storage.setCookies', { cookies: plan.cookies }).catch(() => undefined); diff --git a/src/launcher/proxy.ts b/src/launcher/proxy.ts index b35689b..cfdabdb 100644 --- a/src/launcher/proxy.ts +++ b/src/launcher/proxy.ts @@ -3,7 +3,7 @@ // ============================================================================ import http from 'http'; -import type { ProxyConfig } from '../types'; +import type { ProxyConfig, StoredProfile } from '../types'; import { createLogger } from '../log'; import { loadProxyChain } from './deps'; @@ -122,6 +122,24 @@ export async function autoDetectTimezone(proxy: ProxyConfig): Promise { return FALLBACK_TIMEZONE; } +/** Timezone for Chrome's TZ, from the profile, else the proxy exit IP, else this host. */ +export async function resolveTimezone(profile: StoredProfile, relayUrl: string | undefined): Promise { + if (profile.timezone) return profile.timezone; + + if (profile.proxy) { + const exit = relayUrl ? await detectExitLocation(relayUrl) : null; + const geo = exit ?? await detectTimezoneFromIP(profile.proxy.host); + if (geo) { + log.info(`Auto-detected timezone: ${geo.timezone} (${geo.city}, ${geo.country})`); + return geo.timezone; + } + } + + const systemTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + log.debug(`Using system timezone: ${systemTimezone}`); + return systemTimezone; +} + /** * Start a local relay that answers the proxy challenge for Chrome. * @returns The local URL to hand to `--proxy-server`. diff --git a/src/launcher/real.test.ts b/src/launcher/real.test.ts new file mode 100644 index 0000000..6de6863 --- /dev/null +++ b/src/launcher/real.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import type { StoredProfile } from '../types'; +import type { CdpClient, CdpEventListener } from './deps'; +import { launchErrorCode } from '../commands/browser-open'; +import { realEngineConflictMessage, resolveProfileEngine, startRealSession } from './real'; + +const real: StoredProfile = { id: 'r', name: 'Real', createdAt: 0, updatedAt: 0, fingerprint: { mode: 'real' } }; +const generated: StoredProfile = { id: 'g', name: 'Gen', createdAt: 0, updatedAt: 0 }; + +function fakeClient() { + const calls: string[] = []; + const listeners = new Map(); + const client: CdpClient = { + send: async (method) => { + calls.push(method); + return {}; + }, + on: (event, listener) => void listeners.set(event, listener), + close: async () => undefined, + }; + return { client, calls, emit: (event: string) => listeners.get(event)?.({}) }; +} + +describe('resolveProfileEngine', () => { + it('opens a real profile real without reading the binary', () => { + const probe = () => { + throw new Error('binary must not be read'); + }; + expect(resolveProfileEngine(real, undefined, probe)).toBe('real'); + expect(resolveProfileEngine(real, 'auto', probe)).toBe('real'); + }); + + it('resolves a generated profile exactly as before', () => { + expect(resolveProfileEngine(generated, undefined, () => false)).toBe('inject'); + expect(resolveProfileEngine(generated, undefined, () => true)).toBe('kernel'); + expect(resolveProfileEngine(generated, 'inject', () => true)).toBe('inject'); + }); +}); + +describe('startRealSession', () => { + it('only watches for exit: no command reaches the browser', async () => { + const fake = fakeClient(); + let gone = 0; + const release = await startRealSession({ + connect: async () => fake.client, profile: real, detached: false, onDisconnect: () => gone++, + }); + expect(fake.calls).toEqual([]); + fake.emit('disconnect'); + expect(gone).toBe(1); + await release?.(); + }); + + it('installs stored cookies and nothing else', async () => { + const fake = fakeClient(); + const withCookies = { ...real, cookies: [{ name: 'sid', value: '1', domain: 'example.com' }] }; + await startRealSession({ + connect: async () => fake.client, profile: withCookies, detached: true, onDisconnect: () => undefined, + }); + expect(fake.calls).toEqual(['Storage.setCookies']); + }); + + it('does not connect at all when detached with no cookies', async () => { + const connect = async (): Promise => { + throw new Error('must not connect'); + }; + const release = await startRealSession({ connect, profile: real, detached: true, onDisconnect: () => undefined }); + expect(release).toBeUndefined(); + }); +}); + +describe('realEngineConflictMessage', () => { + it('is reported as a configuration mistake, not a launch failure', () => { + expect(launchErrorCode(realEngineConflictMessage('Google Main', 'kernel'))).toBe('INVALID_CONFIG'); + }); +}); diff --git a/src/launcher/real.ts b/src/launcher/real.ts new file mode 100644 index 0000000..b0ddbe5 --- /dev/null +++ b/src/launcher/real.ts @@ -0,0 +1,69 @@ +// ============================================================================ +// @aitofy/browser-profiles - Real mode: Chrome keeps its own identity +// ============================================================================ +// A spoofed fingerprint on an account first signed in by hand reads as a new +// device, so a real profile skips every engine, override and injected script. + +import type { FingerprintEngine, ResolvedEngine, StoredProfile } from '../types'; +import type { CdpClient } from './deps'; +import { resolveEngine } from './kernel'; +import { applyCookies, cookieParams, sessionOf } from './protections'; + +/** Matched by the command layer to report a configuration mistake, not a launch failure. */ +export const REAL_ENGINE_ADVICE = + 'Drop the engine option (or pass "auto") to open it real, or switch the profile with: ' + + 'browser-profiles profile update --fingerprint generated'; + +export function realEngineConflictMessage(profileName: string, engine: FingerprintEngine): string { + return `Profile "${profileName}" is real (Chrome's own identity), but engine "${engine}" would spoof ` + + `its fingerprint. ${REAL_ENGINE_ADVICE}`; +} + +/** + * A real profile resolves to `real` whatever the binary is; asking it for a spoofing + * engine is a contradiction and throws. Other profiles resolve as before. + */ +export function resolveProfileEngine( + profile: StoredProfile, + requested: FingerprintEngine | undefined, + binaryIsKernel: () => boolean +): ResolvedEngine { + if (profile.fingerprint?.mode !== 'real') return resolveEngine(requested ?? 'auto', binaryIsKernel()); + if (requested !== undefined && requested !== 'auto') { + throw new Error(realEngineConflictMessage(profile.name, requested)); + } + return 'real'; +} + +export interface RealSessionOptions { + connect: () => Promise; + profile: StoredProfile; + detached: boolean; + /** Runs once when the browser goes away on its own. */ + onDisconnect: () => void; +} + +/** + * No tab is touched. Attached, one browser-level connection only watches for the browser + * to exit; a detached launch connects only when the profile carries cookies to install. + */ +export async function startRealSession(options: RealSessionOptions): Promise<(() => Promise) | undefined> { + const cookies = (options.profile.cookies ?? []).map(cookieParams); + if (options.detached && cookies.length === 0) return undefined; + + const client = await options.connect(); + await applyCookies(sessionOf(client), { cookies }); + if (options.detached) { + await client.close().catch(() => undefined); + return undefined; + } + + let released = false; + client.on('disconnect', () => { + if (!released) options.onDisconnect(); + }); + return async () => { + released = true; + await client.close().catch(() => undefined); + }; +} diff --git a/src/launcher/reuse.ts b/src/launcher/reuse.ts index 55fe0bb..847b577 100644 --- a/src/launcher/reuse.ts +++ b/src/launcher/reuse.ts @@ -40,6 +40,10 @@ export async function reuseExisting( } log.info(`Reusing browser for profile "${profile.name}" (pid ${lock.pid}, port ${lock.port})`); + const engine = lock.engine ?? 'inject'; + if ((engine === 'real') !== (profile.fingerprint?.mode === 'real')) { + log.warn(`Profile "${profile.name}" changed mode since its browser opened (${engine}). Close and reopen it.`); + } const close = async (): Promise => { try { @@ -60,6 +64,6 @@ export async function reuseExisting( close, reused: true, detached: lock.detached, - engine: lock.engine === 'kernel' ? 'kernel' : 'inject', + engine, }; } diff --git a/src/launcher/standalone.ts b/src/launcher/standalone.ts index 160b0f0..2d1e330 100644 --- a/src/launcher/standalone.ts +++ b/src/launcher/standalone.ts @@ -5,7 +5,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import type { ProxyConfig, StoredProfile } from '../types'; +import type { ProxyConfig, ResolvedEngine, StoredProfile } from '../types'; import { createLogger } from '../log'; import { launchChrome } from './launch'; @@ -100,7 +100,7 @@ export interface StandaloneLaunchResult { close: () => Promise; /** kernel spoofs inside Chromium. inject patches from JavaScript. */ - engine: 'kernel' | 'inject'; + engine: ResolvedEngine; } /** diff --git a/src/profile-fingerprint.ts b/src/profile-fingerprint.ts new file mode 100644 index 0000000..32cff39 --- /dev/null +++ b/src/profile-fingerprint.ts @@ -0,0 +1,38 @@ +// ============================================================================ +// @aitofy/browser-profiles - Stored fingerprint rules for create and update +// ============================================================================ + +import type { FingerprintConfig, ProfileConfig, StoredProfile } from './types'; +import { pickWebGLForPlatform } from './fingerprint'; + +/** + * Persist a platform-consistent WebGL vendor/renderer so every launch of the + * profile reports the same GPU + */ +function withDefaultWebGL(fingerprint: FingerprintConfig): FingerprintConfig { + if (fingerprint.webgl?.renderer) return fingerprint; + return { ...fingerprint, webgl: { ...pickWebGLForPlatform(fingerprint.platform), ...fingerprint.webgl } }; +} + +type Identity = Pick; + +/** A real profile gets no default timezone and no stored GPU: Chrome keeps its own. */ +export function initialIdentity(config: ProfileConfig, defaultTimezone: string | undefined): Identity { + if (config.fingerprint?.mode === 'real') return { timezone: config.timezone, fingerprint: config.fingerprint }; + return { + timezone: config.timezone || defaultTimezone || 'America/New_York', + fingerprint: withDefaultWebGL(config.fingerprint || {}), + }; +} + +/** + * Entering real mode drops the stored timezone (often the create-time default) unless the update + * names one; leaving it stores the GPU that create() gives a new profile. + */ +export function modeSwitch(existing: StoredProfile, updates: Partial): Partial { + const next = updates.fingerprint; + const wasReal = existing.fingerprint?.mode === 'real'; + if (!next || wasReal === (next.mode === 'real')) return {}; + if (next.mode === 'real') return 'timezone' in updates ? {} : { timezone: undefined }; + return { fingerprint: withDefaultWebGL(next) }; +} diff --git a/src/profile-manager.ts b/src/profile-manager.ts index 96bf5c9..6bebf42 100644 --- a/src/profile-manager.ts +++ b/src/profile-manager.ts @@ -16,21 +16,10 @@ import type { import { launchChrome, closeBrowser } from './chrome-launcher'; import { resolveStoragePath, writeJsonAtomic } from './storage'; import { createLogger } from './log'; -import { pickWebGLForPlatform } from './fingerprint'; +import { initialIdentity, modeSwitch } from './profile-fingerprint'; const log = createLogger('profile-manager'); -type FingerprintConfig = NonNullable; - -/** - * Persist a platform-consistent WebGL vendor/renderer so every launch of the - * profile reports the same GPU - */ -function withDefaultWebGL(fingerprint: FingerprintConfig): FingerprintConfig { - if (fingerprint.webgl?.renderer) return fingerprint; - return { ...fingerprint, webgl: { ...pickWebGLForPlatform(fingerprint.platform), ...fingerprint.webgl } }; -} - /** * Generate a unique profile ID */ @@ -175,10 +164,9 @@ export class BrowserProfiles { ...config, id, name: config.name || `Profile ${id.slice(0, 6)}`, - timezone: config.timezone || this.options.defaultTimezone || 'America/New_York', + ...initialIdentity(config, this.options.defaultTimezone), proxy: config.proxy || this.options.defaultProxy || null, cookies: config.cookies || [], - fingerprint: withDefaultWebGL(config.fingerprint || {}), startUrls: config.startUrls || [], tags: config.tags || [], createdAt: now, @@ -324,6 +312,7 @@ export class BrowserProfiles { const updated: StoredProfile = { ...existing, ...updates, + ...modeSwitch(existing, updates), id: existing.id, // Prevent ID change createdAt: existing.createdAt, // Preserve creation time updatedAt: Date.now(), diff --git a/src/storage.ts b/src/storage.ts index 9ac4d33..97c0814 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -6,6 +6,7 @@ import crypto from 'crypto'; import fs from 'fs'; import os from 'os'; import path from 'path'; +import type { ResolvedEngine } from './types'; /** Default profile store. Never hardcode this anywhere else. */ export const DEFAULT_STORAGE_PATH = path.join(os.homedir(), '.aitofy', 'browser-profiles'); @@ -74,13 +75,17 @@ export interface BrowserLockInfo { /** Placeholder written before Chrome starts: nothing is running behind it yet. */ claiming?: boolean; /** Engine the browser was launched with. Absent on locks written before kernel mode. */ - engine?: 'kernel' | 'inject'; + engine?: ResolvedEngine; } export function lockFilePath(userDataDir: string): string { return path.join(userDataDir, LOCK_FILE_NAME); } +function parseEngine(value: unknown): ResolvedEngine | undefined { + return value === 'kernel' || value === 'inject' || value === 'real' ? value : undefined; +} + export function readLockFile(userDataDir: string): BrowserLockInfo | null { try { const content = fs.readFileSync(lockFilePath(userDataDir), 'utf-8'); @@ -94,7 +99,7 @@ export function readLockFile(userDataDir: string): BrowserLockInfo | null { proxyUrl: parsed.proxyUrl, detached: parsed.detached, claiming: parsed.claiming, - engine: parsed.engine === 'kernel' || parsed.engine === 'inject' ? parsed.engine : undefined, + engine: parseEngine(parsed.engine), }; } catch { return null; diff --git a/src/types.ts b/src/types.ts index e43524c..25e5870 100644 --- a/src/types.ts +++ b/src/types.ts @@ -126,6 +126,8 @@ export interface WebGLConfig { * Fingerprint configuration for anti-detect */ export interface FingerprintConfig { + /** Absent or "generated": the profile gets a spoofed fingerprint built from these settings. */ + mode?: 'generated'; /** User agent string */ userAgent?: string; /** Accept-Language header */ @@ -148,6 +150,24 @@ export interface FingerprintConfig { audio?: 'noise' | 'real'; } +/** + * Chrome's own identity: nothing is generated, overridden or injected, so a hand-made + * login keeps looking like the same device. Only an explicit language becomes --lang. + */ +export interface RealFingerprint { + mode: 'real'; + /** BCP 47 tag passed to Chrome as --lang. Unset keeps Chrome's own language. */ + language?: string; +} + +/** What a stored profile says about its fingerprint. Stored JSON without `mode` is generated. */ +export type ProfileFingerprint = FingerprintConfig | RealFingerprint; + +/** The settings a spoofing engine reads. A real profile has none. */ +export function spoofSettings(fingerprint: ProfileFingerprint | undefined): FingerprintConfig | undefined { + return fingerprint?.mode === 'real' ? undefined : fingerprint; +} + /** * auto: kernel when the executable is fingerprint-chromium, otherwise JavaScript injection. * kernel: engine-level spoof. Refuses to launch on stock Chrome. @@ -155,8 +175,11 @@ export interface FingerprintConfig { */ export type FingerprintEngine = 'auto' | 'kernel' | 'inject'; -/** The engine a running browser actually launched with. */ -export type ResolvedEngine = 'kernel' | 'inject'; +/** The engines that spoof a fingerprint. */ +export type SpoofEngine = 'kernel' | 'inject'; + +/** What a running browser actually launched with. real: no engine, Chrome's own identity. */ +export type ResolvedEngine = SpoofEngine | 'real'; /** * Browser profile configuration @@ -172,8 +195,8 @@ export interface ProfileConfig { timezone?: string; /** Cookies to inject */ cookies?: ProfileCookie[]; - /** Fingerprint configuration */ - fingerprint?: FingerprintConfig; + /** Spoofed fingerprint settings (default), or `{ mode: 'real' }` for Chrome's own identity */ + fingerprint?: ProfileFingerprint; /** URLs to open on launch */ startUrls?: string[]; /** Additional notes/metadata */ @@ -221,6 +244,7 @@ export interface LaunchOptions { /** * Where the fingerprint is applied. * Default `auto`: kernel when the executable has the fingerprint-chromium switch. + * A real profile only accepts `auto`; it then launches with no engine. */ engine?: FingerprintEngine; } @@ -257,7 +281,7 @@ export interface LaunchResult { reused?: boolean; /** True when Chrome outlives this process, so only the first tab is protected */ detached?: boolean; - /** kernel spoofs inside Chromium. inject patches from JavaScript. */ + /** kernel spoofs inside Chromium. inject patches from JavaScript. real changes nothing. */ engine: ResolvedEngine; } From a54b079646ccef37fc6461c903dc40fa14a988b0 Mon Sep 17 00:00:00 2001 From: namvippro Date: Sun, 4 Oct 2026 20:09:03 +0700 Subject: [PATCH 2/5] feat(commands): set real fingerprint mode from the CLI and MCP profile.create and profile.update take fingerprint: generated | real (--fingerprint on the CLI). A real profile rejects platform; switching to real keeps only the language. browser.status reports each browser's engine and profile.list shows a Fingerprint column. Co-Authored-By: Claude Opus 5.5 --- src/commands/browser-runtime.ts | 5 +++ src/commands/browser-status.ts | 3 +- src/commands/fields.ts | 13 +++++++ src/commands/fingerprint-input.ts | 38 +++++++++++++++++++ src/commands/profile-create.ts | 19 +++++----- src/commands/profile-list.ts | 12 +++--- src/commands/profile-update.ts | 14 +++---- src/commands/registry.test.ts | 61 +++++++++++++++++++++++++++++++ 8 files changed, 142 insertions(+), 23 deletions(-) create mode 100644 src/commands/fingerprint-input.ts diff --git a/src/commands/browser-runtime.ts b/src/commands/browser-runtime.ts index a7f5de9..d3d9552 100644 --- a/src/commands/browser-runtime.ts +++ b/src/commands/browser-runtime.ts @@ -6,6 +6,7 @@ import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; import type { Logger } from '../log'; +import type { ResolvedEngine } from '../types'; import { deleteLockFile, isProcessAlive, @@ -27,6 +28,8 @@ export interface RunningBrowser { startedAt: number; /** True for `browser.launch` sessions that have no saved profile. */ temporary: boolean; + /** What the browser launched with. real: Chrome's own identity, nothing spoofed. */ + engine: ResolvedEngine; } interface LockedDir { @@ -119,6 +122,8 @@ export async function scanRunning(storagePath: string, log: Logger): Promise { + const mode = input.mode ?? (current?.mode === 'real' ? 'real' : 'generated'); + if (mode === 'real' && input.platform !== undefined) { + return Err({ code: 'INVALID_CONFIG', message: REAL_PLATFORM_MESSAGE }); + } + if (input.mode === undefined && input.language === undefined && input.platform === undefined) { + return Ok(undefined); + } + + const language = input.language ?? current?.language; + if (mode === 'real') return Ok(language ? { mode: 'real', language } : { mode: 'real' }); + + const next: FingerprintConfig = current?.mode === 'real' ? {} : { ...current }; + if (language) next.language = language; + if (input.platform !== undefined) next.platform = input.platform; + return Ok(next); +} diff --git a/src/commands/profile-create.ts b/src/commands/profile-create.ts index 1f1b219..a8be926 100644 --- a/src/commands/profile-create.ts +++ b/src/commands/profile-create.ts @@ -1,10 +1,12 @@ import { z } from 'zod'; import { Err, Ok } from '../types'; -import type { BrowserError, FingerprintConfig, ProxyConfig, Result, StoredProfile } from '../types'; +import type { BrowserError, ProxyConfig, Result, StoredProfile } from '../types'; import { defineCommand } from './define'; +import { nextFingerprint } from './fingerprint-input'; import { renderProfile } from './profile-get'; import { parseProxyUrl } from './proxy-url'; import { + fingerprintModeField, languageField, notesField, platformField, @@ -13,14 +15,6 @@ import { timezoneField, } from './fields'; -/** Only set fingerprint keys the caller actually provided. */ -export function buildFingerprint(language?: string, platform?: string): FingerprintConfig | undefined { - const fingerprint: FingerprintConfig = {}; - if (language) fingerprint.language = language; - if (platform) fingerprint.platform = platform; - return Object.keys(fingerprint).length > 0 ? fingerprint : undefined; -} - export const profileCreate = defineCommand({ name: 'profile.create', description: 'Create a new browser profile with its own storage, proxy and fingerprint.', @@ -45,12 +39,17 @@ export const profileCreate = defineCommand({ ), proxy: optionalProxyUrlField, timezone: timezoneField, + fingerprint: fingerprintModeField, language: languageField, platform: platformField, tags: tagsField, notes: notesField, }), async run(ctx, input): Promise> { + const { fingerprint: mode, language, platform } = input; + const fingerprint = nextFingerprint(undefined, { mode, language, platform }); + if (!fingerprint.ok) return Err(fingerprint.error); + let proxy: ProxyConfig | undefined; if (input.proxy) { const parsed = parseProxyUrl(input.proxy); @@ -64,7 +63,7 @@ export const profileCreate = defineCommand({ name: input.name, proxy, timezone: input.timezone, - fingerprint: buildFingerprint(input.language, input.platform), + fingerprint: fingerprint.data, tags: input.tags, notes: input.notes, }); diff --git a/src/commands/profile-list.ts b/src/commands/profile-list.ts index 93e241f..2f50385 100644 --- a/src/commands/profile-list.ts +++ b/src/commands/profile-list.ts @@ -3,10 +3,11 @@ import { Ok } from '../types'; import type { Result, StoredProfile } from '../types'; import { defineCommand } from './define'; -const COLUMNS = { id: 36, name: 17, proxy: 18 }; +const COLUMNS = { id: 36, name: 17, proxy: 18, mode: 11 }; -function row(id: string, name: string, proxy: string, created: string): string { - return `${id.padEnd(COLUMNS.id)} | ${name.padEnd(COLUMNS.name)} | ${proxy.padEnd(COLUMNS.proxy)} | ${created}`; +function row(id: string, name: string, proxy: string, mode: string, created: string): string { + return `${id.padEnd(COLUMNS.id)} | ${name.padEnd(COLUMNS.name)} | ${proxy.padEnd(COLUMNS.proxy)} | ` + + `${mode.padEnd(COLUMNS.mode)} | ${created}`; } export const profileList = defineCommand({ @@ -41,8 +42,8 @@ export const profileList = defineCommand({ '', 'Browser Profiles:', '', - row('ID', 'Name', 'Proxy', 'Created'), - `${'-'.repeat(COLUMNS.id)}-|-${'-'.repeat(COLUMNS.name)}-|-${'-'.repeat(COLUMNS.proxy)}-|----------------`, + row('ID', 'Name', 'Proxy', 'Fingerprint', 'Created'), + [COLUMNS.id, COLUMNS.name, COLUMNS.proxy, COLUMNS.mode, 15].map((width) => '-'.repeat(width)).join('-|-'), ]; for (const profile of profiles) { @@ -50,6 +51,7 @@ export const profileList = defineCommand({ profile.id.substring(0, COLUMNS.id), (profile.name || 'Unnamed').substring(0, COLUMNS.name), profile.proxy ? `${profile.proxy.host}:${profile.proxy.port}`.substring(0, COLUMNS.proxy) : 'No proxy', + profile.fingerprint?.mode === 'real' ? 'real' : 'generated', new Date(profile.createdAt).toLocaleDateString() )); } diff --git a/src/commands/profile-update.ts b/src/commands/profile-update.ts index 78b31b1..f4526cb 100644 --- a/src/commands/profile-update.ts +++ b/src/commands/profile-update.ts @@ -2,10 +2,12 @@ import { z } from 'zod'; import { Err, Ok } from '../types'; import type { BrowserError, ProfileConfig, Result, StoredProfile } from '../types'; import { defineCommand, resolveProfile } from './define'; +import { nextFingerprint } from './fingerprint-input'; import { renderProfile } from './profile-get'; import { parseProxyUrl } from './proxy-url'; import { PROXY_URL_DESCRIPTION, + fingerprintModeField, idOrNameField, languageField, notesField, @@ -27,6 +29,7 @@ export const profileUpdate = defineCommand({ .optional() .describe(`${PROXY_URL_DESCRIPTION} Pass null to remove the proxy; omit to keep the current one.`), timezone: timezoneField, + fingerprint: fingerprintModeField, language: languageField, platform: platformField, tags: tagsField, @@ -51,13 +54,10 @@ export const profileUpdate = defineCommand({ updates.proxy = parsed.data; } - if (input.language !== undefined || input.platform !== undefined) { - updates.fingerprint = { - ...current.fingerprint, - ...(input.language === undefined ? {} : { language: input.language }), - ...(input.platform === undefined ? {} : { platform: input.platform }), - }; - } + const { fingerprint: mode, language, platform } = input; + const fingerprint = nextFingerprint(current.fingerprint, { mode, language, platform }); + if (!fingerprint.ok) return Err(fingerprint.error); + if (fingerprint.data) updates.fingerprint = fingerprint.data; const updated = await ctx.profiles.update(current.id, updates); if (!updated) { diff --git a/src/commands/registry.test.ts b/src/commands/registry.test.ts index c65f669..3d96147 100644 --- a/src/commands/registry.test.ts +++ b/src/commands/registry.test.ts @@ -232,3 +232,64 @@ describe('atomic config writes', () => { expect(JSON.parse(fs.readFileSync(path.join(dir, 'config.json'), 'utf-8')).notes).toBe('changed'); }); }); + +describe('real fingerprint mode', () => { + it('round-trips create, get, list and update without spoof settings', async () => { + const created = await createProfile({ name: 'Google Main', fingerprint: 'real' }); + expect(created.fingerprint).toEqual({ mode: 'real' }); + expect(created.timezone).toBeUndefined(); + + const got = await runCommand(ctx, command('profile.get'), { idOrName: 'Google Main' }); + expect((got.data as StoredProfile).fingerprint).toEqual({ mode: 'real' }); + const listed = await runCommand(ctx, command('profile.list'), {}); + expect((listed.data as StoredProfile[])[0].fingerprint?.mode).toBe('real'); + expect(command('profile.list').render(listed.data)).toContain('| real '); + + const updated = await runCommand(ctx, command('profile.update'), { idOrName: created.id, language: 'vi-VN' }); + expect((updated.data as StoredProfile).fingerprint).toEqual({ mode: 'real', language: 'vi-VN' }); + expect(command('profile.get').render(updated.data)).toContain('Fingerprint: real'); + }); + + it('rejects platform on a real profile at create and update', async () => { + const create = await runCommand(ctx, command('profile.create'), { + name: 'Bad', fingerprint: 'real', platform: 'Win32', + }); + expect(create.ok).toBe(false); + expect(create.error?.code).toBe('INVALID_CONFIG'); + expect(create.error?.message).toContain('platform'); + + const real = await createProfile({ name: 'Real', fingerprint: 'real' }); + const update = await runCommand(ctx, command('profile.update'), { idOrName: real.id, platform: 'Win32' }); + expect(update.error?.code).toBe('INVALID_CONFIG'); + }); + + it('switches an existing profile to real and back', async () => { + const spoofed = await createProfile({ name: 'Switch', language: 'de-DE', platform: 'MacIntel' }); + const real = await runCommand(ctx, command('profile.update'), { idOrName: spoofed.id, fingerprint: 'real' }); + expect((real.data as StoredProfile).fingerprint).toEqual({ mode: 'real', language: 'de-DE' }); + // The create-time New York default must not follow the profile into real mode. + expect((real.data as StoredProfile).timezone).toBeUndefined(); + + const back = await runCommand(ctx, command('profile.update'), { idOrName: spoofed.id, fingerprint: 'generated' }); + const fingerprint = (back.data as StoredProfile).fingerprint; + expect(fingerprint?.mode).toBeUndefined(); + expect(fingerprint?.language).toBe('de-DE'); + expect(fingerprint && 'webgl' in fingerprint && fingerprint.webgl?.renderer).toBeTruthy(); + + const pinned = await runCommand(ctx, command('profile.update'), { + idOrName: spoofed.id, fingerprint: 'real', timezone: 'Asia/Tokyo', + }); + expect((pinned.data as StoredProfile).timezone).toBe('Asia/Tokyo'); + }); + + it('leaves a default profile as it was: generated, New York, a stored GPU', async () => { + const created = await createProfile({ name: 'Default' }); + expect(created.timezone).toBe('America/New_York'); + expect(created.fingerprint?.mode).toBeUndefined(); + const stored = JSON.parse( + fs.readFileSync(path.join(storagePath, 'profiles', created.id, 'config.json'), 'utf-8') + ); + expect(stored.fingerprint).not.toHaveProperty('mode'); + expect(stored.fingerprint.webgl.renderer).toBeTruthy(); + }); +}); From 40a861d9b6d735c8b7ed1a254fceb8261846c70c Mon Sep 17 00:00:00 2001 From: namvippro Date: Sun, 4 Oct 2026 20:09:13 +0700 Subject: [PATCH 3/5] docs: document real fingerprint mode State what a real profile does and does not protect, the new fingerprint field on create/update, engine "real" in open/status output, and the Unreleased changelog entry. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 21 +++++++++++++++++++++ docs/anti-detect.md | 32 +++++++++++++++++++++++++++++++- docs/cli.md | 15 +++++++++++---- docs/configuration.md | 4 ++-- docs/library.md | 17 ++++++++++++++--- docs/mcp.md | 20 ++++++++++++-------- llms.txt | 27 ++++++++++++++++++--------- 7 files changed, 109 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abfe4d2..45f5a8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Real fingerprint mode for persistent profiles.** `fingerprint: { mode: 'real' }` in the library, + `--fingerprint real` in the CLI, `fingerprint: "real"` in MCP, on create and update. A real + profile launches with Chrome's own identity: no generated fingerprint, no user agent or Client + Hints override, no injected script, no auto-attach, no kernel or anti-detect flags, and no + timezone or locale unless the profile sets one. Meant for accounts signed in by hand, where a + spoofed fingerprint triggers new-device checks. It refuses `engine: "kernel"` and `"inject"` with + `INVALID_CONFIG`, and rejects `platform`. Existing profiles are unchanged. +- `browser status` reports each browser's `engine`, and `engine` can now be `real` in launch results + and lock files. `profile list` shows a Fingerprint column. + +### Changed + +- `ResolvedEngine` gains `'real'`. Code that switches on `LaunchResult.engine` should handle it. +- A real profile gets no default timezone at create time, and switching a profile to real drops its + stored timezone unless the same update sets one. Generated profiles still default to + `America/New_York` (or `defaultTimezone`). + ## [0.4.0] - 2026-10-04 ### Added diff --git a/docs/anti-detect.md b/docs/anti-detect.md index fef76fa..f284b2c 100644 --- a/docs/anti-detect.md +++ b/docs/anti-detect.md @@ -1,18 +1,48 @@ # Anti-detect What a profile actually hides, how it is applied, and where the limits are. No extension is -installed. Two engines: +installed. Two engines, plus a real mode that uses neither: | Engine | When | How | |--------|------|-----| | `kernel` | The executable is a [fingerprint-chromium](https://github.com/adryfish/fingerprint-chromium) build, or `engine: "kernel"` | Chromium spoofs the fingerprint from a per-profile seed (`--fingerprint`). No JavaScript hooks are installed. | | `inject` | Stock Google Chrome, or `engine: "inject"` | Scripts and CDP overrides, applied when the browser launches. | +| `real` | The profile has `fingerprint: { mode: "real" }` | Nothing. Chrome runs with its own identity. See [Real mode](#real-mode). | `engine` defaults to `auto`: kernel when the binary's switch table (the framework on macOS, `chrome.dll` on Windows) contains `fingerprint-platform`, inject otherwise. With no `chromePath` and no `CHROMIUM_PATH`/`CHROME_PATH`, a kernel build at `~/.aitofy/browser-profiles/kernel/` or a `Chromium.app` that has the switch is preferred over Google Chrome. +## Real mode + +For your own accounts, signed in by hand once (Google Cloud, Firebase, Play Console, Cloudflare). +A spoofed fingerprint on such an account reads as a new device and triggers verification, so a +real profile spoofs nothing. Create it with `--fingerprint real` (CLI), `fingerprint: "real"` +(MCP) or `fingerprint: { mode: 'real' }` (library). `browser open` then reports `engine: "real"`, +and so does `browser status`. + +What a real launch does not do: + +- no generated fingerprint, user agent or Client Hints override, and no stored GPU +- no injected script, no per-tab CDP session, no auto-attach: the launcher's only CDP connection + installs stored cookies, if any, and watches for the browser to exit +- no kernel flags and no anti-detect flags except `--disable-blink-features=AutomationControlled`, + which keeps `navigator.webdriver` false as in a Chrome opened by hand +- no timezone and no locale: Chrome keeps the host's clock and language unless the profile sets + `timezone` (sent as `TZ`) or `language` (sent as `--lang`, which Chrome on macOS ignores; change + the language in Chrome's settings instead, it is stored in the profile) + +What it keeps: the profile's own user-data directory, the CDP endpoint, lock and reuse, `close`, +the profile's proxy (with the WebRTC policy that stops UDP from bypassing it) and `chromePath`. + +What it does not protect: anything. The browser is exactly as identifiable as your own Chrome on +this machine, and two real profiles on one machine look like the same device. Do not use it for +accounts that must not be linked. A real profile refuses `engine: "kernel"` or `"inject"` with +`INVALID_CONFIG`; open it with `auto`. Switching a profile to real drops its stored spoof settings +except the language, and its stored timezone (often the `America/New_York` default) unless the same +update sets one. A browser already running keeps its old mode until it is closed. + ## Kernel engine The seed is a hash of the profile id, so the same profile is the same machine and a duplicate is diff --git a/docs/cli.md b/docs/cli.md index 0e43547..a9d8abe 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -87,6 +87,7 @@ idOrName. | `--id ` | Optional stable id: 1-64 chars of letters, digits, hyphen or underscore, e.g. `"acme-fb-01"`. Must not start with `"tmp-"` (reserved for temporary `browser.launch` sessions). Omit to get a random 16-hex-character id. Must not already exist. | | `--proxy ` | Proxy as a URL with an explicit port: `"://[user:pass@]host:port"`. Schemes: http, https, socks5 (socks and socks5h are accepted and normalised to socks5). Percent-encode reserved characters in credentials, e.g. `"http://bob:p%40ss@10.0.0.1:8080"`. Example: `"socks5://gate.provider.net:1080"`. | | `--timezone ` | IANA timezone id used for the browser clock and Intl output, e.g. `"America/New_York"` or `"Asia/Ho_Chi_Minh"`. Match it to the proxy exit country or the profile becomes trivially detectable. | +| `--fingerprint ` | `generated` (default) spoofs a consistent fingerprint built from language and platform. `real` keeps Chrome's own identity: no user agent or client hints override, no injected scripts, no timezone or locale change unless `--timezone` or `--language` is set. Use `real` for your own accounts signed in by hand (Google, Firebase, Cloudflare), where a spoofed fingerprint triggers new-device checks. A real profile rejects `--platform` and opens only with `--engine auto`. See [anti-detect.md](./anti-detect.md#real-mode). | | `--language ` | BCP 47 language tag for navigator.language and the Accept-Language header, e.g. `"en-US"`, `"vi-VN"`, `"de-DE"`. Default `"en-US"`. | | `--platform ` | navigator.platform value to report. Use exactly one of `"Win32"`, `"MacIntel"` or `"Linux x86_64"`; it must agree with the user agent. | | `--tags ` | Free-form labels for filtering, e.g. `["facebook", "client-acme"]`. Pass an array of strings. Repeat the flag or pass a comma-separated list. | @@ -95,8 +96,11 @@ idOrName. ```bash browser-profiles create "Acme Main" --id acme-main \ --proxy socks5://gate.provider.net:1080 --tags facebook,acme +browser-profiles create "Google Main" --id google-main --fingerprint real ``` +A real profile is stored with `"fingerprint": { "mode": "real" }` and no default timezone. + ## profile update Change fields of an existing profile. Omitted fields are left untouched. @@ -107,6 +111,7 @@ Change fields of an existing profile. Omitted fields are left untouched. | `--proxy ` | Same format as `profile create`. Pass null to remove the proxy; omit to keep the current one. | | `--no-proxy` | Clear proxy (sends null). | | `--timezone ` | As in `profile create`. | +| `--fingerprint ` | As in `profile create`. Switching to `real` drops the stored spoof settings except the language, and the stored timezone unless `--timezone` is passed in the same update; switching back to `generated` gets a new stored GPU. A browser already running keeps its mode until it is closed. | | `--language ` | As in `profile create`. | | `--platform ` | As in `profile create`. | | `--tags ` | As in `profile create`. | @@ -115,6 +120,7 @@ Change fields of an existing profile. Omitted fields are left untouched. ```bash browser-profiles update acme-main --timezone America/New_York browser-profiles update acme-main --no-proxy +browser-profiles update google-main --fingerprint real ``` ## profile delete @@ -143,10 +149,10 @@ profile returns the running browser with `reused=true`. Close it with `browser c | `--headless` | Run Chrome without a visible window. Default false (a real window is far less detectable). Set true only on machines with no display. | | `--start-url ` | Absolute URL to open in the first tab, e.g. `"https://example.com"`. Omit for a blank tab. | | `--detached` | Let Chrome outlive the process that opened it. Default false: the opener keeps the browser protected (per-tab fingerprint injection, authenticated proxy relay) and closes it on exit. Set true only for scripts that must exit immediately; then only flag-level protections remain and an authenticated proxy will not work. Kernel mode is the exception: its spoof is in the Chrome flags, so later tabs stay spoofed after the opener exits. | -| `--engine ` | Where the fingerprint is applied. `kernel` needs a fingerprint-chromium binary and spoofs inside Chromium with no JavaScript hooks. `inject` patches stock Chrome over CDP. `auto` (default) uses kernel when the executable contains `--fingerprint-platform`, otherwise inject. | +| `--engine ` | Where the fingerprint is applied. `kernel` needs a fingerprint-chromium binary and spoofs inside Chromium with no JavaScript hooks. `inject` patches stock Chrome over CDP. `auto` (default) uses kernel when the executable contains `--fingerprint-platform`, otherwise inject. A real profile only accepts `auto`; `kernel` or `inject` fails with `INVALID_CONFIG`. | -Output: `{ profileId, wsEndpoint, port, pid, reused, detached, engine }`. `engine` is `kernel` or -`inject`. +Output: `{ profileId, wsEndpoint, port, pid, reused, detached, engine }`. `engine` is `kernel`, +`inject`, or `real` for a real profile (Chrome's own fingerprint, nothing applied). ## browser launch @@ -185,7 +191,8 @@ Output: `{ closed: string[] }`. List browsers that are currently running for this storage path, with their CDP endpoints. Lock files left behind by crashed browsers are removed while scanning. No flags beyond the global ones. -Output: `{ running: [{ profileId, pid, port, wsEndpoint, startedAt, temporary }] }`. +Output: `{ running: [{ profileId, pid, port, wsEndpoint, startedAt, temporary, engine }] }`. `engine` +is what the browser launched with: `kernel`, `inject` or `real`. ## storage path diff --git a/docs/configuration.md b/docs/configuration.md index 8195db2..53c55ab 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -73,8 +73,8 @@ credentials are never logged. `config.json` is written atomically (temp file plus rename), so a reader never sees half a file. `.browser-lock.json` is the cross-process record of a running Chrome: `pid`, `port`, `wsEndpoint`, -`startedAt`, and optionally the local relay `proxyUrl`, `detached`, and `claiming` while a launch is -in flight. It is what lets `browser close` and `browser status` work from a different process, and +`startedAt`, and optionally the `engine` it launched with (`kernel`, `inject` or `real`), the local +relay `proxyUrl`, `detached`, and `claiming` while a launch is in flight. It is what lets `browser close` and `browser status` work from a different process, and what gets cleaned up when a browser crashed. See [browser-lifecycle.md](./browser-lifecycle.md). ## See also diff --git a/docs/library.md b/docs/library.md index 43b66f0..a4e9d4e 100644 --- a/docs/library.md +++ b/docs/library.md @@ -57,6 +57,15 @@ const { wsEndpoint, close } = await profiles.launch(profile.id); await close(); ``` +For your own accounts signed in by hand, create the profile real: it launches with Chrome's own +identity, no generated fingerprint, no override, no injected script, and no default timezone +([anti-detect.md](./anti-detect.md#real-mode)). + +```typescript +const google = await profiles.create({ id: 'google-main', name: 'Google Main', fingerprint: { mode: 'real' } }); +const { engine } = await profiles.launch(google.id); // 'real'; engine: 'kernel' | 'inject' throws +``` + | Method | Description | |--------|-------------| | `create(config)` | Create a profile from a `ProfileConfig`. | @@ -80,8 +89,9 @@ await close(); | `deleteGroup(groupId)` | Delete a group. | | `moveToGroup(profileId, groupId)` | Move a profile into a group, or out with `null`. | -`LaunchResult` carries `wsEndpoint`, `pid`, `port`, `profileId`, `close()` and, when an already -running browser was returned, `reused: true`. +`LaunchResult` carries `wsEndpoint`, `pid`, `port`, `profileId`, `close()`, `engine` (`kernel`, +`inject`, or `real` for a real profile) and, when an already running browser was returned, +`reused: true`. ## Option types @@ -92,7 +102,7 @@ interface ProfileConfig { proxy?: ProxyConfig; timezone?: string; // e.g. "America/New_York" cookies?: ProfileCookie[]; - fingerprint?: FingerprintConfig; + fingerprint?: FingerprintConfig | RealFingerprint; // { mode: 'real', language? }: Chrome's own identity startUrls?: string[]; tags?: string[]; groupId?: string; @@ -115,6 +125,7 @@ interface LaunchOptions { slowMo?: number; timeout?: number; detached?: boolean; // let Chrome outlive this process (reduced protection) + engine?: 'auto' | 'kernel' | 'inject'; // default auto; a real profile accepts only auto } ``` diff --git a/docs/mcp.md b/docs/mcp.md index 3a8d344..513bcfb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -96,27 +96,31 @@ Tool names are the command names with `_` instead of the dot: `profile.list` bec |------|---------|-----------| | `profile_list` | List stored browser profiles, optionally filtered by group or tag. | `groupId?`, `tag?` | | `profile_get` | Show one profile by id or name, including its proxy and fingerprint. | `idOrName` | -| `profile_create` | Create a new browser profile with its own storage, proxy and fingerprint. | `name`, `id?`, `proxy?`, `timezone?`, `language?`, `platform?`, `tags?`, `notes?` | -| `profile_update` | Change fields of an existing profile. Omitted fields are left untouched. | `idOrName`, `name?`, `proxy?` (null removes it), `timezone?`, `language?`, `platform?`, `tags?`, `notes?` | +| `profile_create` | Create a new browser profile with its own storage, proxy and fingerprint. | `name`, `id?`, `proxy?`, `timezone?`, `fingerprint?` (`generated` or `real`), `language?`, `platform?`, `tags?`, `notes?` | +| `profile_update` | Change fields of an existing profile. Omitted fields are left untouched. | `idOrName`, `name?`, `proxy?` (null removes it), `timezone?`, `fingerprint?`, `language?`, `platform?`, `tags?`, `notes?` | | `profile_delete` | Delete a profile and all of its browser data. Irreversible. | `idOrName`, `force?` | | `profile_duplicate` | Copy a profile settings (proxy, timezone, fingerprint) into a new profile with a new id. | `idOrName`, `name?` | -| `browser_open` | Open Chrome with a stored profile and return its CDP wsEndpoint. | `idOrName`, `headless?`, `startUrl?`, `detached?` | +| `browser_open` | Open Chrome with a stored profile and return its CDP wsEndpoint. | `idOrName`, `headless?`, `startUrl?`, `detached?`, `engine?` | | `browser_launch` | Launch a throwaway Chrome with a random fingerprint and no saved profile. | `proxy?`, `headless?`, `randomFingerprint?` (default true), `detached?` | | `browser_close` | Close the browser running for a profile, from any process. | `idOrName` | | `browser_close_all` | Close every browser started from this storage path, including temporary sessions. | none | | `browser_status` | List browsers currently running for this storage path, with their CDP endpoints. | none | | `storage_path` | Print the directory where profiles and browser data are stored. | none | -Argument formats (id/name rules, proxy URLs, timezone, language, platform) are the flag -descriptions in [cli.md](./cli.md); the MCP input schemas carry the same text. +Argument formats (id/name rules, proxy URLs, timezone, fingerprint, language, platform) are the +flag descriptions in [cli.md](./cli.md); the MCP input schemas carry the same text. + +`fingerprint: "real"` is for your own accounts signed in by hand: the browser keeps Chrome's own +identity and `browser_open` reports `engine: "real"`, so an agent can check that nothing was +spoofed before it touches the account. See [anti-detect.md](./anti-detect.md#real-mode). Outputs: -- `browser_open` → `{ profileId, wsEndpoint, port, pid, reused, detached }` -- `browser_launch` → `{ profileId, wsEndpoint, port, pid, temporary: true, detached }` +- `browser_open` → `{ profileId, wsEndpoint, port, pid, reused, detached, engine }` (`kernel`, `inject` or `real`) +- `browser_launch` → `{ profileId, wsEndpoint, port, pid, temporary: true, detached, engine }` - `browser_close` → `{ profileId, closed }` - `browser_close_all` → `{ closed: string[] }` -- `browser_status` → `{ running: [{ profileId, pid, port, wsEndpoint, startedAt, temporary }] }` +- `browser_status` → `{ running: [{ profileId, pid, port, wsEndpoint, startedAt, temporary, engine }] }` - `storage_path` → `{ path }` - `profile_delete` → `{ id, deleted: true }` - the `profile_*` tools otherwise return the stored profile object diff --git a/llms.txt b/llms.txt index f93e320..bed1215 100644 --- a/llms.txt +++ b/llms.txt @@ -34,7 +34,10 @@ mode ignores a pinned user agent so the UA stays the binary's own version. The W vendor/renderer is picked once per profile, consistent with its platform, and stored in fingerprint.webgl (inject mode; the kernel derives the GPU from the seed). Every worker target (dedicated, module, shared, service) runs the same spoof as the main thread. Authenticated proxies are handled by a local relay. -Profiles persist between runs, so logins survive. +Profiles persist between runs, so logins survive. A profile created with fingerprint=real +(stored as fingerprint: {mode: "real"}) launches with Chrome's own identity: no engine, no user +agent or Client Hints override, no injected script, no timezone or locale change unless the +profile sets one. Use it for accounts signed in by hand; a real profile refuses engine kernel/inject. Benchmark (docs/benchmark.md, scripts/measure.mjs): kernel mode scores 100% on browserscan and passes a Cloudflare challenge, like AdsPower; inject mode scores 85% and creepjs flags its hooks. @@ -83,11 +86,12 @@ profile_get Show one profile with proxy and fingerprint. args: idOrName (string, required) out: StoredProfile profile_create Create a profile with its own storage, proxy and fingerprint. - args: name (required), id?, proxy?, timezone?, language?, platform?, tags?, notes? + args: name (required), id?, proxy?, timezone?, fingerprint? ("generated" | "real"), + language?, platform?, tags?, notes? out: StoredProfile profile_update Change fields of a profile; omitted fields stay as they are. args: idOrName (required), name?, proxy? (null removes the proxy), timezone?, - language?, platform?, tags?, notes? + fingerprint?, language?, platform?, tags?, notes? out: StoredProfile profile_delete Delete a profile and all of its browser data. Irreversible. args: idOrName (required), force? (boolean, closes a running browser first) @@ -97,8 +101,10 @@ profile_duplicate Copy a profile's settings into a new profile with a new id. out: StoredProfile browser_open Open Chrome with a stored profile and return its CDP endpoint. args: idOrName (required), headless? (default false), startUrl? (absolute URL), - detached? (default false; true = Chrome outlives the caller, reduced protection) - out: {profileId, wsEndpoint, port, pid, reused} + detached? (default false; true = Chrome outlives the caller, reduced protection), + engine? ("auto" | "kernel" | "inject"; a real profile accepts only "auto") + out: {profileId, wsEndpoint, port, pid, reused, detached, engine} + engine is "kernel", "inject", or "real" (Chrome's own fingerprint) browser_launch Throwaway Chrome with a random fingerprint and no saved profile. Nothing persists. args: proxy?, headless? (default false), randomFingerprint? (default true), detached? (default false) out: {profileId, wsEndpoint, port, pid, temporary: true} @@ -110,7 +116,7 @@ browser_close_all Close every browser started from this storage path, temporar out: {closed: string[]} browser_status Running browsers with their CDP endpoints. Stale lock files are cleaned up. args: none - out: {running: [{profileId, pid, port, wsEndpoint, startedAt, temporary}]} + out: {running: [{profileId, pid, port, wsEndpoint, startedAt, temporary, engine}]} storage_path Directory where profiles and browser data are stored. args: none out: {path} @@ -124,6 +130,9 @@ proxy A URL with an explicit port: ://[user:pass@]host:port Schemes: http, https, socks5 (socks and socks5h are normalised to socks5). Percent-encode reserved characters in credentials: "http://bob:p%40ss@10.0.0.1:8080". timezone IANA id, e.g. "America/New_York". Match it to the proxy exit country. +fingerprint "generated" (default, spoofed) or "real" (Chrome's own identity, for hand-made logins). + A real profile rejects platform. Switching to real keeps only the language and drops + the stored timezone unless timezone is passed in the same update. language BCP 47 tag, e.g. "en-US", "vi-VN". Default "en-US". platform Exactly one of "Win32", "MacIntel", "Linux x86_64". Must agree with the user agent. tags Array of strings, e.g. ["facebook", "client-acme"]. @@ -143,11 +152,11 @@ Command tree, with the short top-level alias in brackets. Every command accepts browser-profiles profile list [list, ls] --group-id --tag browser-profiles profile get [info] -browser-profiles profile create [create] --id --proxy --timezone --language --platform --tags --notes -browser-profiles profile update [update] --name --proxy --timezone --language --platform --tags --notes +browser-profiles profile create [create] --id --proxy --timezone --fingerprint --language --platform --tags --notes +browser-profiles profile update [update] --name --proxy --timezone --fingerprint --language --platform --tags --notes browser-profiles profile delete [delete, rm] --force browser-profiles profile duplicate [duplicate] --name -browser-profiles browser open [open] --headless --start-url --detached +browser-profiles browser open [open] --headless --start-url --detached --engine browser-profiles browser launch [launch] --proxy --headless --no-random-fingerprint --detached browser-profiles browser close [close] browser-profiles browser close-all [close-all] From 63831c9a1c3b5035acb8ae6b7c70ef49019ed58b Mon Sep 17 00:00:00 2001 From: namvippro Date: Sun, 4 Oct 2026 20:11:08 +0700 Subject: [PATCH 4/5] fix(launcher): keep Chrome's security defaults in real mode Real profiles hold logged-in accounts and browse untrusted pages, so they no longer inherit BASE_FLAGS (--no-sandbox, --disable-background-networking, --disable-client-side-phishing-detection, --disable-popup-blocking, --disable-sync and the rest). REAL_BASE_FLAGS adds only --no-first-run and --no-default-browser-check. Spoofing modes are unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/anti-detect.md | 2 ++ src/launcher/chrome-flags.ts | 11 +++++++---- src/launcher/launch.test.ts | 6 ++++++ 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f5a8d..18f3ddc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 timezone or locale unless the profile sets one. Meant for accounts signed in by hand, where a spoofed fingerprint triggers new-device checks. It refuses `engine: "kernel"` and `"inject"` with `INVALID_CONFIG`, and rejects `platform`. Existing profiles are unchanged. + Real launches keep Chrome's sandbox, Safe Browsing, popup blocking and updates on (no `--no-sandbox`). - `browser status` reports each browser's `engine`, and `engine` can now be `real` in launch results and lock files. `profile list` shows a Fingerprint column. diff --git a/docs/anti-detect.md b/docs/anti-detect.md index f284b2c..5b8766a 100644 --- a/docs/anti-detect.md +++ b/docs/anti-detect.md @@ -29,6 +29,8 @@ What a real launch does not do: installs stored cookies, if any, and watches for the browser to exit - no kernel flags and no anti-detect flags except `--disable-blink-features=AutomationControlled`, which keeps `navigator.webdriver` false as in a Chrome opened by hand +- no flag that weakens Chrome's security: the sandbox, Safe Browsing, popup blocking, sync and + component updates stay on; only `--no-first-run` and `--no-default-browser-check` are added - no timezone and no locale: Chrome keeps the host's clock and language unless the profile sets `timezone` (sent as `TZ`) or `language` (sent as `--lang`, which Chrome on macOS ignores; change the language in Chrome's settings instead, it is stored in the profile) diff --git a/src/launcher/chrome-flags.ts b/src/launcher/chrome-flags.ts index 26ca4d0..5523c29 100644 --- a/src/launcher/chrome-flags.ts +++ b/src/launcher/chrome-flags.ts @@ -17,7 +17,7 @@ export interface ChromeFlagOptions { proxyServer?: string; /** fingerprint-chromium switches. Empty on the inject engine. */ kernelFlags?: string[]; - /** Real profile: no anti-detect flags, and --lang only when the profile pins a language. */ + /** Real profile: Chrome's own protections stay on, no anti-detect flags, --lang only if pinned. */ real?: boolean; } @@ -38,6 +38,10 @@ const BASE_FLAGS = [ '--disable-dev-shm-usage', ]; +// A real profile holds logged-in accounts and browses untrusted pages, so sandbox, +// Safe Browsing, popup blocking and updates keep Chrome's defaults. +const REAL_BASE_FLAGS = ['--no-first-run', '--no-default-browser-check']; + // Drops the automation-controlled bit navigator.webdriver reads. const NOT_WEBDRIVER_FLAG = '--disable-blink-features=AutomationControlled'; @@ -81,10 +85,9 @@ export function buildChromeFlags(options: ChromeFlagOptions): string[] { const language = profile.fingerprint?.language; return [ - ...BASE_FLAGS, ...(options.real - ? realIdentityFlags(language, proxyServer) - : [`--lang=${language || 'en-US'}`, ...ANTI_DETECT_FLAGS, ...kernelFlags]), + ? [...REAL_BASE_FLAGS, ...realIdentityFlags(language, proxyServer)] + : [...BASE_FLAGS, `--lang=${language || 'en-US'}`, ...ANTI_DETECT_FLAGS, ...kernelFlags]), ...(userDataDir ? [`--user-data-dir=${userDataDir}`] : []), ...(headless ? HEADLESS_FLAGS : []), ...(proxyServer ? [`--proxy-server=${proxyServer}`] : []), diff --git a/src/launcher/launch.test.ts b/src/launcher/launch.test.ts index 63c8e03..5445c1c 100644 --- a/src/launcher/launch.test.ts +++ b/src/launcher/launch.test.ts @@ -76,6 +76,10 @@ describe('launchChrome with a real profile', () => { expect(launched.engine).toBe('real'); const [{ chromeFlags, envVars }] = fake.launches; expect(chromeFlags.filter((flag) => SPOOF_FLAG.test(flag))).toEqual([]); + expect(chromeFlags).not.toContain('--no-sandbox'); + expect(chromeFlags).not.toContain('--disable-background-networking'); + expect(chromeFlags).not.toContain('--disable-client-side-phishing-detection'); + expect(chromeFlags).toContain(`--user-data-dir=${path.join(dir, 'data')}`); expect(envVars).toBeUndefined(); expect(fake.cdpCalls).toEqual([]); expect(readLockFile(path.join(dir, 'data'))?.engine).toBe('real'); @@ -108,6 +112,8 @@ describe('launchChrome with a generated profile', () => { const [{ chromeFlags, envVars }] = fake.launches; expect(envVars).toEqual({ TZ: 'Europe/Paris' }); expect(chromeFlags).toEqual(expect.arrayContaining([ + '--no-sandbox', + '--disable-background-networking', '--lang=en-US', '--disable-features=IsolateOrigins,site-per-process', '--webrtc-ip-handling-policy=disable_non_proxied_udp', From 700082c0a78452b09a285fd61f43986a850e484b Mon Sep 17 00:00:00 2001 From: namvippro Date: Sun, 4 Oct 2026 20:17:31 +0700 Subject: [PATCH 5/5] chore: release 0.5.0 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src/index.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18f3ddc..bb8bd7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.5.0] - 2026-10-04 ### Added diff --git a/package-lock.json b/package-lock.json index 28663c4..c728253 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aitofy/browser-profiles", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aitofy/browser-profiles", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/package.json b/package.json index 8210e60..0ef61e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aitofy/browser-profiles", - "version": "0.4.0", + "version": "0.5.0", "description": "Self-hosted anti-detect browser profiles. Open-source AdsPower alternative for Puppeteer & Playwright.", "keywords": [ "antidetect", diff --git a/src/index.ts b/src/index.ts index 518fd42..18fc846 100644 --- a/src/index.ts +++ b/src/index.ts @@ -208,4 +208,4 @@ export type { } from './integrations/playwright'; // Version -export const VERSION = '0.4.0'; +export const VERSION = '0.5.0';