diff --git a/README.md b/README.md index 81c0bd80..c47e2a9f 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ needs to be useful: | ⌨️ **Slash commands** | Type `/` for every Claude Code command — `/compact`, `/model`, `/effort`, `/clear`… — with argument hints and tab completion. | | 🌐 **Real browser** | browser-use drives a managed Chromium over CDP. Watch it live right next to the chat and *take control* for CAPTCHAs. | | 🗂️ **Parallel chats, own tabs** | Every chat gets its own tab (in its own background window) in the shared browser profile, so several chats and agents browse at the same time — signed in with the same logins, without ever touching each other's pages. | +| 🥷 **Bot-detection hardening** | Sites that block automated browsers see a normal Chrome: the automation flag stays off, and even a headless browser has the user agent and screen of a regular Chrome window. *Run check* (Settings → Browser) shows what bot detection sees, signal by signal. | | 🖥️ **Computer use** | Share a single window, a display, the entire desktop (every monitor) or a browser tab with an agent — like sharing your screen with ChatGPT. A shared window is controlled **in the background** with [Cua Driver](https://github.com/trycua/cua): your mouse and keyboard stay yours. Watch live and take over anytime. | | 💻 **macOS VMs** | Give an agent its own Mac: spin up isolated macOS virtual machines (Apple's Virtualization framework, via [Tart](https://tart.run)) with one click and assign them to an agent, a chat or a workspace. The agent works *entirely inside the VM* — commands, files, apps (computer use with Cua Driver) and the web (Google Chrome with browser-use) — and no browser opens on your Mac. Watch the VM's screen next to the chat and take control anytime. Allow it once and agents sign in inside the VM too: Godmode fills your saved logins and 2FA codes for them (best effort — the agent controls the VM, so it's closer to reveal than to fill-only). VMs live on your Mac, keep everything between tasks, suspend when you quit, and can be reset to a clean macOS or duplicated in seconds. | | 🍪 **Chrome session import** | Continue where Chrome left off — import cookies from your Chrome/Edge/Brave profile (profile-use technique), or sync via browser-use `profile-use`. | @@ -130,6 +131,10 @@ needs to be useful: An agent waiting for a signed contract, set to continue tomorrow at 09:00
Follow-ups — the agent sets itself a time to continue; move it, cancel it or continue now The agent picked the chat up again at the time it set and finished the task
Back on it — at that time the agent picks the chat up again, with all the context + + Bot check of a hardened headless browser: 9 of 10 checks pass
Bot check — what bot detection sees in the agents' browser + Bot check without hardening: the headless user agent and screen give the browser away
Without hardening — headless Chrome gives itself away + ## 📦 Install diff --git a/apps/desktop/src/components/settings/bot-check.tsx b/apps/desktop/src/components/settings/bot-check.tsx new file mode 100644 index 00000000..75afc80e --- /dev/null +++ b/apps/desktop/src/components/settings/bot-check.tsx @@ -0,0 +1,198 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { format } from "date-fns"; +import { AnimatePresence, motion } from "motion/react"; +import { CircleCheck, CircleX, Radar, RotateCw, ShieldAlert, ShieldCheck, TriangleAlert } from "lucide-react"; +import type { BotCheckReport, BotCheckStatus, BrowserSettings } from "@godmode/shared"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Spinner } from "@/components/ui/spinner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { toastApiError } from "@/components/vault/vault-utils"; +import { api } from "@/lib/api"; +import { qk } from "@/lib/queryKeys"; +import { cn } from "@/lib/utils"; +import { SettingRow } from "./settings-kit"; + +const STATUS: Record = { + pass: { icon: CircleCheck, text: "text-success", bar: "bg-success", label: "Passed" }, + warn: { icon: TriangleAlert, text: "text-warning", bar: "bg-warning", label: "Minor difference" }, + fail: { icon: CircleX, text: "text-destructive", bar: "bg-destructive", label: "Gives it away" }, +}; + +function verdict(report: BotCheckReport) { + const fails = report.checks.filter((c) => c.status === "fail").length; + const warns = report.checks.filter((c) => c.status === "warn").length; + const passed = report.checks.length - fails - warns; + if (fails) return { tone: "fail" as const, title: fails === 1 ? "1 signal gives the browser away" : `${fails} signals give the browser away`, passed }; + if (warns) return { tone: "pass" as const, title: "Passes common bot checks", passed }; + return { tone: "pass" as const, title: "Looks like a regular Chrome", passed }; +} + +/** Bot check of the global default profile's browser, shown inline in Settings → Browser. */ +export function BotCheck({ browser }: { browser: BrowserSettings }) { + const qc = useQueryClient(); + const { data: profiles } = useQuery({ queryKey: qk.browserProfiles, queryFn: api.browser.profiles }); + const profile = profiles?.find((p) => p.isDefault && !p.workspaceId) ?? null; + const key = [...qk.botCheck, profile?.id ?? ""]; + const report = useQuery({ queryKey: key, queryFn: () => null, enabled: false, initialData: null, staleTime: Infinity }); + + const run = useMutation({ + mutationFn: async (restart: boolean) => { + if (!profile) throw new Error("The default browser profile isn't ready yet."); + if (restart) { + await api.browser.stop(profile.id); + await api.browser.launch(profile.id, profile.headless ?? undefined); + } + return api.browser.botCheck(profile.id); + }, + onSuccess: (r) => { + qc.setQueryData([...qk.botCheck, r.profileId], r); + void qc.invalidateQueries({ queryKey: qk.browserProfiles }); + }, + onError: (e) => toastApiError(e, "Bot check failed", qc), + }); + + const r = report.data; + const restartNeeded = !!profile?.running && profile.stealth !== null && profile.stealth !== browser.stealth; + const stale = !!r && !restartNeeded && r.stealth !== browser.stealth; + const disabled = !browser.enabled || !profile; + + if (!r && !run.isPending) { + return ( + + + + ); + } + + const v = r ? verdict(r) : null; + return ( +
+
+
+
+ {!v ? : v.tone === "pass" ? : } +
+
+
{v ? v.title : "Checking how sites see the browser…"}
+
+ {r ? ( + <> + {r.browser.replace("/", " ")} · {r.headless ? "Headless" : "Visible window"} · Stealth {r.stealth ? "on" : "off"} · {format(new Date(r.checkedAt), "HH:mm")} + + ) : ( + "Opening a test page in the default profile's browser" + )} +
+
+
+ {r && ( +
+ + +
+ )} +
+ + + {(restartNeeded || stale) && !run.isPending && ( + +
+ + {restartNeeded + ? `The browser is still running with stealth ${profile?.stealth ? "on" : "off"}. Restart it to apply the new setting — agents using it lose their open tabs.` + : `Stealth is ${browser.stealth ? "on" : "off"} now — check again to see what changes.`} + + {restartNeeded && ( + + )} +
+
+ )} +
+ +
    + {r && !run.isPending + ? r.checks.map((c, i) => { + const s = STATUS[c.status]; + return ( + + + {c.label} + {c.detail} + + ); + }) + : Array.from({ length: 10 }).map((_, i) => ( +
  • + + + +
  • + ))} +
+
+ ); +} + +function Meter({ report }: { report: BotCheckReport }) { + const v = verdict(report); + return ( + + +
+
+ {report.checks.map((c) => ( + + ))} +
+ + {v.passed}/{report.checks.length} + +
+
+ + {v.passed} of {report.checks.length} checks passed + +
+ ); +} diff --git a/apps/desktop/src/components/settings/browser-section.tsx b/apps/desktop/src/components/settings/browser-section.tsx index a74c304f..7fc9c3be 100644 --- a/apps/desktop/src/components/settings/browser-section.tsx +++ b/apps/desktop/src/components/settings/browser-section.tsx @@ -1,8 +1,9 @@ import { Link } from "react-router"; -import { AppWindow, ArrowRight, Eye, Globe, Wrench } from "lucide-react"; +import { AppWindow, ArrowRight, Eye, Fingerprint, Globe, Wrench } from "lucide-react"; import type { Settings } from "@godmode/shared"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; +import { BotCheck } from "./bot-check"; import { CommitInput, NumberField, SectionHeading, SettingRow, SettingsGroup, useSettingsPatch } from "./settings-kit"; export function BrowserSection({ settings }: { settings: Settings }) { @@ -56,6 +57,22 @@ export function BrowserSection({ settings }: { settings: Settings }) { + } + description="Many sites block browsers that look automated or bury them in CAPTCHAs. Godmode hides the signals they look for." + > + + patch({ browser: { stealth } })} /> + + + + }> post<{ ok: true }>(`/api/browser/profiles/${id}/navigate`, { url, conversationId: conversationId ?? null }), + botCheck: (id: string) => post(`/api/browser/profiles/${id}/bot-check`), /** Human takeover in live view: forward a click / key / text to the page (the chat's tab, with `conversationId`) */ input: ( id: string, diff --git a/apps/desktop/src/lib/queryKeys.ts b/apps/desktop/src/lib/queryKeys.ts index c41eafa2..e18938a8 100644 --- a/apps/desktop/src/lib/queryKeys.ts +++ b/apps/desktop/src/lib/queryKeys.ts @@ -66,6 +66,7 @@ export const qk = { messagingUsers: (id: string) => ["messaging", "users", id], messagingChats: (id: string) => ["messaging", "chats", id], browserProfiles: ["browser-profiles"] as unknown[], + botCheck: ["browser-bot-check"] as unknown[], computer: ["computer"] as unknown[], computerStatus: ["computer", "status"] as unknown[], computerSources: ["computer", "sources"] as unknown[], diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fde44984..f2004428 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -268,6 +268,20 @@ Server → UI events are defined in `packages/shared/src/events.ts`. The UI keep `profile-use` — copy the Chrome profile’s cookie store to a temp dir, start the real Chrome binary headless on it with CDP, read decrypted cookies via `Storage.getCookies`, inject them into the Godmode profile with `Storage.setCookies`. `profile-use` itself is supported for syncing to browser-use Cloud profiles. +* **Bot detection** (`browser/stealth.ts`, `settings.browser.stealth`, on by default): Chromium starts with + `--disable-blink-features=AutomationControlled`, so `navigator.webdriver` stays false on every Chromium build (current + Chrome already leaves it false with a debugging port; the VM's Chrome gets the flag too). Headless it also gets the + user agent the same executable sends with a window (`--user-agent`, learned once per executable from a + throwaway headless launch, so requests, frames and workers agree) and a desktop screen (`--screen-info`), and + browser-use doesn't emulate a viewport over it (a page larger than its window gives headless away). The one difference + left: Chrome withholds detailed client hints (full version) while `--user-agent` is set. Takes effect when a browser + starts; the launch marker records it for adopted browsers. +* **Bot check** (`browser/botCheck.ts`, `POST /api/browser/profiles/:id/bot-check`, Settings → Browser): serves a page + from a throwaway loopback server into a background window of the profile's browser and judges its navigator, a web + worker, window and screen metrics, WebGL, plugins, languages, permissions and request headers the way common bot + detection does (pass / warn / fail per signal). A browser started just for the check (or for a session import) is + *transient*: it's stopped again afterwards unless someone else got it meanwhile (`ensureBrowser` / `touchBrowser` + claim it). * **Live view**: CDP `Page.startScreencast` frames streamed to subscribed UIs; the human can take over (click/type) e.g. to solve a CAPTCHA. A view shows the profile's active tab, or with `conversationId` the tab one chat works in (`browser.subscribe { profileId, conversationId }`, frames carry `conversationId`; navigate and input take diff --git a/docs/screenshots/bot-check-off.png b/docs/screenshots/bot-check-off.png new file mode 100644 index 00000000..3aac8507 Binary files /dev/null and b/docs/screenshots/bot-check-off.png differ diff --git a/docs/screenshots/bot-check.png b/docs/screenshots/bot-check.png new file mode 100644 index 00000000..d814b3ad Binary files /dev/null and b/docs/screenshots/bot-check.png differ diff --git a/packages/core/src/browser/botCheck.ts b/packages/core/src/browser/botCheck.ts new file mode 100644 index 00000000..d4796f02 --- /dev/null +++ b/packages/core/src/browser/botCheck.ts @@ -0,0 +1,183 @@ +/** + * Bot check: what a website's bot detection sees in a profile's browser. A throwaway loopback server serves a page + * (and records its request headers) that is opened in a background tab; its navigator, a worker, the window + * metrics, WebGL and permissions are compared with what a regular Chrome shows. + */ +import type { BotCheckItem, BotCheckReport } from "@godmode/shared"; +import { now } from "../util"; +import { attachToPage, type CdpClient } from "./cdp"; + +export interface BotSignals { + webdriver: boolean | null; + userAgent: string; + brands: { brand: string; version: string }[] | null; + fullVersionList: number; + languages: string[]; + language: string; + plugins: number; + pdfViewer: boolean; + chrome: boolean; + notification: string | null; + notificationQuery: string | null; + webgl: string | null; + window: { outerWidth: number; outerHeight: number; innerWidth: number; innerHeight: number }; + screen: { width: number; height: number }; + worker: { userAgent: string; webdriver: boolean | null } | null; +} + +export interface BotHeaders { + userAgent: string | null; + secChUa: string | null; + acceptLanguage: string | null; +} + +const PAGE = `Godmode bot check

Checking how sites see this browser…

`; +const WORKER = `postMessage({ userAgent: navigator.userAgent, webdriver: navigator.webdriver ?? null });`; + +const COLLECT = `(async () => { + const worker = await new Promise((resolve) => { + try { + const w = new Worker("/worker.js"); + const timer = setTimeout(() => resolve(null), 3000); + w.onmessage = (e) => { clearTimeout(timer); w.terminate(); resolve(e.data); }; + w.onerror = () => { clearTimeout(timer); resolve(null); }; + } catch { resolve(null); } + }); + const uaData = navigator.userAgentData; + let high = null; + try { high = uaData ? await uaData.getHighEntropyValues(["fullVersionList"]) : null; } catch {} + let webgl = null; + try { + const gl = document.createElement("canvas").getContext("webgl"); + const ext = gl && gl.getExtension("WEBGL_debug_renderer_info"); + webgl = gl ? String(gl.getParameter(ext ? ext.UNMASKED_RENDERER_WEBGL : gl.RENDERER)) : null; + } catch {} + let notificationQuery = null; + try { notificationQuery = (await navigator.permissions.query({ name: "notifications" })).state; } catch {} + return { + webdriver: navigator.webdriver ?? null, + userAgent: navigator.userAgent, + brands: uaData ? uaData.brands.map((b) => ({ brand: b.brand, version: b.version })) : null, + fullVersionList: high && high.fullVersionList ? high.fullVersionList.length : 0, + languages: [...navigator.languages], + language: navigator.language, + plugins: navigator.plugins.length, + pdfViewer: !!navigator.pdfViewerEnabled, + chrome: typeof window.chrome === "object" && window.chrome !== null, + notification: typeof Notification === "undefined" ? null : Notification.permission, + notificationQuery, + webgl, + window: { outerWidth, outerHeight, innerWidth, innerHeight }, + screen: { width: screen.width, height: screen.height }, + worker, + }; +})()`; + +/** Load the check page in a background tab of the browser behind `client` and collect what it sees. */ +export async function collectBotSignals( + client: CdpClient, + close = async (targetId: string) => { + await client.send("Target.closeTarget", { targetId }, undefined, 5000); + }, +): Promise<{ signals: BotSignals; headers: BotHeaders }> { + let headers: BotHeaders | null = null; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(req) { + const path = new URL(req.url).pathname; + if (path === "/worker.js") return new Response(WORKER, { headers: { "content-type": "text/javascript", "cache-control": "no-store" } }); + if (path !== "/") return new Response("Not found", { status: 404 }); + headers = { userAgent: req.headers.get("user-agent"), secChUa: req.headers.get("sec-ch-ua"), acceptLanguage: req.headers.get("accept-language") }; + return new Response(PAGE, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } }); + }, + }); + let targetId: string | null = null; + try { + ({ targetId } = await client.send<{ targetId: string }>("Target.createTarget", { url: "about:blank", newWindow: true, background: true })); + const page = await attachToPage(client, targetId); + try { + await page.navigate(`http://127.0.0.1:${server.port}/`); + await page.waitForReady(10_000); + const signals = await page.evaluate(COLLECT, { timeoutMs: 15_000 }); + if (!headers) throw new Error("The check page didn't load"); + return { signals, headers }; + } finally { + await page.detach(); + } + } finally { + if (targetId) await close(targetId).catch(() => {}); + server.stop(true); + } +} + +const SOFTWARE_GL = /swiftshader|llvmpipe|softpipe|software|basic render/i; + +function major(userAgent: string): string | null { + return /(?:Headless)?Chrome\/(\d+)/.exec(userAgent)?.[1] ?? null; +} + +function size(w: number, h: number): string { + return `${w}×${h}`; +} + +/** Judge the collected signals the way common bot detection does. */ +export function judgeBotSignals(s: BotSignals, h: BotHeaders): BotCheckItem[] { + const checks: BotCheckItem[] = []; + const add = (id: BotCheckItem["id"], label: string, status: BotCheckItem["status"], detail: string) => checks.push({ id, label, status, detail }); + + const webdriver = s.webdriver === true || s.worker?.webdriver === true; + add("webdriver", "Automation flag", webdriver ? "fail" : "pass", webdriver ? "navigator.webdriver is true — sites see an automated browser." : "navigator.webdriver is false, like in a browser a person uses."); + + const headless = /headless/i.test(s.userAgent) || /headless/i.test(h.userAgent ?? ""); + const product = /(?:Headless)?Chrome\/[\d.]+/.exec(s.userAgent)?.[0] ?? s.userAgent; + add("userAgent", "User agent", headless ? "fail" : "pass", headless ? `The browser introduces itself as ${product} — blocked on sight by many sites.` : `Introduces itself as ${product}.`); + + const version = major(s.userAgent); + const chromium = s.brands?.find((b) => b.brand === "Chromium")?.version ?? null; + if (!s.brands?.length) add("clientHints", "Client hints", "fail", "navigator.userAgentData is missing — real Chrome always has it."); + else if (!h.secChUa) add("clientHints", "Client hints", "fail", "Requests don't carry the Sec-CH-UA header Chrome always sends."); + else if (version && chromium && version !== chromium) add("clientHints", "Client hints", "fail", `The user agent says ${version}, the client hints say ${chromium}.`); + else if (!s.fullVersionList) add("clientHints", "Client hints", "warn", "Brands match, but detailed hints (full version) are withheld — only sites that ask for them notice."); + else add("clientHints", "Client hints", "pass", "Brands and the Sec-CH-UA header match the user agent."); + + if (!s.worker) add("worker", "Workers", "warn", "A web worker couldn't be started to compare."); + else if (s.worker.userAgent !== s.userAgent) add("worker", "Workers", "fail", "Web workers report a different user agent than the page."); + else add("worker", "Workers", "pass", "Web workers report the same browser as the page."); + + const { outerWidth, outerHeight, innerWidth, innerHeight } = s.window; + if (innerWidth > outerWidth || innerHeight > outerHeight) { + add("window", "Window size", "fail", `The page (${size(innerWidth, innerHeight)}) is larger than its window (${size(outerWidth, outerHeight)}).`); + } else if (outerWidth > s.screen.width || outerHeight > s.screen.height) { + add("window", "Window size", "fail", `The window (${size(outerWidth, outerHeight)}) is larger than the screen (${size(s.screen.width, s.screen.height)}).`); + } else { + add("window", "Window size", "pass", `Window ${size(outerWidth, outerHeight)} on a ${size(s.screen.width, s.screen.height)} screen.`); + } + + if (!s.webgl) add("webgl", "Graphics", "warn", "WebGL is unavailable, which is unusual for a desktop browser."); + else if (SOFTWARE_GL.test(s.webgl)) add("webgl", "Graphics", "warn", `Software rendering (${s.webgl.slice(0, 60)}) is typical for servers and headless browsers.`); + else add("webgl", "Graphics", "pass", s.webgl.replace(/^ANGLE \((.+)\)$/, "$1").slice(0, 90)); + + const plugins = s.plugins > 0 && s.pdfViewer; + add("plugins", "Plugins", plugins ? "pass" : "warn", plugins ? `Built-in PDF viewer and ${s.plugins} plugins.` : "No plugins — typical for headless browsers."); + + const first = h.acceptLanguage?.split(",")[0]?.split(";")[0]?.trim().toLowerCase(); + if (!s.languages.length) add("languages", "Languages", "fail", "navigator.languages is empty."); + else if (first && first !== s.language.toLowerCase()) add("languages", "Languages", "warn", `Requests ask for ${first}, the page reports ${s.language}.`); + else add("languages", "Languages", "pass", s.languages.join(", ")); + + const contradicts = s.notification === "denied" && s.notificationQuery === "prompt"; + add("permissions", "Permissions", contradicts ? "fail" : "pass", contradicts ? "Notification.permission says denied while the Permissions API says prompt." : "Notification and Permissions API agree."); + + add("chrome", "Chrome runtime", s.chrome ? "pass" : "fail", s.chrome ? "window.chrome is present." : "window.chrome is missing — real Chrome always has it."); + return checks; +} + +export async function botCheckReport( + client: CdpClient, + meta: { profileId: string; browser: string; headless: boolean; stealth: boolean }, + close?: (targetId: string) => Promise, +): Promise { + const { signals, headers } = await collectBotSignals(client, close); + return { ...meta, checks: judgeBotSignals(signals, headers), checkedAt: now() }; +} diff --git a/packages/core/src/browser/browserUse.ts b/packages/core/src/browser/browserUse.ts index 26c62448..7e32c569 100644 --- a/packages/core/src/browser/browserUse.ts +++ b/packages/core/src/browser/browserUse.ts @@ -71,6 +71,8 @@ export interface BrowserUseConfigInput { configPath?: string; cdpUrl: string; headless: boolean; + /** In headless mode browser-use emulates the screen size as viewport, making the page larger than its window — a bot signal. */ + stealth?: boolean; /** Only used if browser-use ever has to launch a browser itself (e.g. after a crash). */ userDataDir: string; downloadsPath: string; @@ -93,7 +95,7 @@ export function browserUseConfig(input: BrowserUseConfigInput, profileId: string default: true, created_at: createdAt, cdp_url: input.cdpUrl, - headless: input.headless, + headless: input.headless && !input.stealth, // browser-use copies any user_data_dir whose path contains "chrome" into a temp dir on every start; // never hand it such a path. user_data_dir: /chrome/i.test(input.userDataDir) ? join(input.configDir, "user-data") : input.userDataDir, diff --git a/packages/core/src/browser/chrome.ts b/packages/core/src/browser/chrome.ts index 3aff2a68..3edc1155 100644 --- a/packages/core/src/browser/chrome.ts +++ b/packages/core/src/browser/chrome.ts @@ -204,6 +204,7 @@ export interface LaunchMarker { pid: number; port: number; headless: boolean; + stealth?: boolean; } export function writeLaunchMarker(userDataDir: string, marker: LaunchMarker) { @@ -218,7 +219,7 @@ export function readLaunchMarker(userDataDir: string): LaunchMarker | null { try { const m = JSON.parse(readFileSync(join(userDataDir, LAUNCH_MARKER), "utf8")) as Partial; const valid = Number.isInteger(m.pid) && m.pid! > 0 && Number.isInteger(m.port) && m.port! > 0 && m.port! <= 65535; - return valid ? { pid: m.pid!, port: m.port!, headless: !!m.headless } : null; + return valid ? { pid: m.pid!, port: m.port!, headless: !!m.headless, stealth: !!m.stealth } : null; } catch { return null; } @@ -250,6 +251,8 @@ export interface LaunchOptions { /** URL to open instead of the browser's default start page. */ startUrl?: string; timeoutMs?: number; + /** Kills the browser (or the launch in progress) when aborted. */ + signal?: AbortSignal; } export interface ChromeProcess { @@ -257,6 +260,7 @@ export interface ChromeProcess { port: number; wsUrl: string; browserVersion: string; + userAgent: string; /** The browser, or `open` when LaunchServices started it (macOS, visible). */ proc: Subprocess; /** Resolves with the exit code once the process is gone. */ @@ -311,7 +315,7 @@ export async function launchChrome(opts: LaunchOptions): Promise } else { cmd = [opts.executable, ...args, ...(opts.startUrl ? [opts.startUrl] : [])]; } - const proc = Bun.spawn(cmd, { stdin: "ignore", stdout: "ignore", stderr: "pipe" }); + const proc = Bun.spawn(cmd, { stdin: "ignore", stdout: "ignore", stderr: "pipe", signal: opts.signal, killSignal: "SIGKILL" }); let tail = ""; let alive = true; @@ -352,6 +356,7 @@ export async function launchChrome(opts: LaunchOptions): Promise port, wsUrl: version.webSocketDebuggerUrl, browserVersion: version.Browser, + userAgent: version["User-Agent"], proc, exited, isAlive: () => alive, @@ -385,6 +390,7 @@ export async function launchChrome(opts: LaunchOptions): Promise port, wsUrl: version.webSocketDebuggerUrl, browserVersion: version.Browser, + userAgent: version["User-Agent"], proc, exited: exited.then(async () => { while (isAlive()) await sleep(250); diff --git a/packages/core/src/browser/manager.ts b/packages/core/src/browser/manager.ts index 78890afd..f38fb9de 100644 --- a/packages/core/src/browser/manager.ts +++ b/packages/core/src/browser/manager.ts @@ -5,7 +5,7 @@ */ import { existsSync, rmSync } from "node:fs"; import { join, relative, resolve, isAbsolute } from "node:path"; -import type { Agent, BrowserChat, BrowserProfile, ChromeImportInput, ChromeImportResult, LocalChromeProfile } from "@godmode/shared"; +import type { Agent, BotCheckReport, BrowserChat, BrowserProfile, ChromeImportInput, ChromeImportResult, LocalChromeProfile } from "@godmode/shared"; import type { McpServerJson } from "../types"; import { config, ensureDir } from "../config"; import { bool, get, all, insert, run, update, tx } from "../db"; @@ -21,6 +21,8 @@ import { CdpClient, attachToPage, pickActivePage, probeCdp, isUserPage, type Pag import { clearLaunchMarker, findChrome, isProcessAlive, launchChrome, readLaunchMarker, writeLaunchMarker, type ChromeProcess } from "./chrome"; import { fillIntoActivePage, fillPrecheck, type FillKind } from "./fill"; import { browserUseCommand, browserUseEnv, writeBrowserUseConfig } from "./browserUse"; +import { stealthArgs, stopProbes, windowedUserAgent } from "./stealth"; +import { botCheckReport } from "./botCheck"; import { allRunning, getRegistered, getRunning, registerBrowser, touchBrowser, unregisterBrowser, type RunningBrowser } from "./state"; import { initLiveView, pauseLiveViews, resumeLiveViews } from "./screencast"; import { TabRegistry } from "./tabs"; @@ -61,6 +63,8 @@ function toProfile(r: ProfileRow): BrowserProfile { cookieCount: r.cookie_count, running: !!rb, cdpUrl: rb ? rb.httpUrl : null, + headless: rb ? rb.headless : null, + stealth: rb ? rb.stealth : null, chats: rb ? chatsOf(rb) : [], createdAt: r.created_at, updatedAt: r.updated_at, @@ -307,22 +311,35 @@ export async function launchBrowser(profileId: string, opts: { headless?: boolea return { cdpUrl: rb.httpUrl, port: rb.port }; } -async function ensureBrowser(profileId: string, opts: { headless?: boolean } = {}): Promise { +/** A transient user (bot check, import) borrows the browser; anyone else keeps it running afterwards. */ +function take(rb: RunningBrowser, transient?: boolean): RunningBrowser { + if (transient) rb.borrowers++; + else rb.transient = false; + return rb; +} + +/** Hand a borrowed browser back: the last borrower stops a browser only borrowers used. */ +async function giveBack(rb: RunningBrowser) { + rb.borrowers--; + if (rb.transient && rb.borrowers <= 0 && getRegistered(rb.profileId) === rb) await stopBrowser(rb.profileId); +} + +/** `transient`: only borrow the browser — pair with `giveBack` (see `RunningBrowser.transient`). */ +async function ensureBrowser(profileId: string, opts: { headless?: boolean; transient?: boolean } = {}): Promise { const pendingStop = stopping.get(profileId); if (pendingStop) await pendingStop; const current = getRunning(profileId); if (current) { current.lastUsedAt = Date.now(); - return current; + return take(current, opts.transient); } const inflight = launching.get(profileId); - if (inflight) return inflight; - const p = startBrowser(profileId, opts).finally(() => launching.delete(profileId)); - launching.set(profileId, p); - return p; + const p = inflight ?? startBrowser(profileId, opts).finally(() => launching.delete(profileId)); + if (!inflight) launching.set(profileId, p); + return p.then((rb) => take(rb, opts.transient)); } -async function startBrowser(profileId: string, opts: { headless?: boolean }): Promise { +async function startBrowser(profileId: string, opts: { headless?: boolean; transient?: boolean }): Promise { const profile = requireRow(profileId); ensureDir(profile.user_data_dir); const settings = getSettings(); @@ -332,6 +349,7 @@ async function startBrowser(profileId: string, opts: { headless?: boolean }): Pr let port: number; let wsUrl: string; let headless: boolean; + let stealth: boolean; // A Chromium left running by a previous core process still owns this profile dir — adopt it. const marker = readLaunchMarker(profile.user_data_dir); const orphan = marker && isProcessAlive(marker.pid) ? await probeCdp(marker.port) : null; @@ -340,20 +358,23 @@ async function startBrowser(profileId: string, opts: { headless?: boolean }): Pr port = marker.port; wsUrl = orphan.webSocketDebuggerUrl; headless = marker.headless; + stealth = !!marker.stealth; log.info(`adopting running browser for profile ${profileId} (pid ${pid}, port ${port})`); } else { if (marker) clearLaunchMarker(profile.user_data_dir); const chrome = requireChrome(settings.browser.chromePath); headless = opts.headless ?? settings.browser.headless; + stealth = settings.browser.stealth; + const extraArgs = stealth ? stealthArgs({ headless, userAgent: headless ? await windowedUserAgent(chrome.path) : null }) : []; try { - proc = await launchChrome({ executable: chrome.path, userDataDir: profile.user_data_dir, headless }); + proc = await launchChrome({ executable: chrome.path, userDataDir: profile.user_data_dir, headless, extraArgs }); } catch (err) { throw new HttpError(500, `Could not start ${chrome.browser}: ${err instanceof Error ? err.message : String(err)}`, "browser_launch_failed"); } pid = proc.pid; port = proc.port; wsUrl = proc.wsUrl; - writeLaunchMarker(profile.user_data_dir, { pid, port, headless }); + writeLaunchMarker(profile.user_data_dir, { pid, port, headless, stealth }); log.info(`started ${chrome.browser} for profile ${profileId} (pid ${pid}, port ${port}, ${headless ? "headless" : "headed"})`); } @@ -375,6 +396,7 @@ async function startBrowser(profileId: string, opts: { headless?: boolean }): Pr httpUrl: `http://127.0.0.1:${port}`, wsUrl, headless, + stealth, client, tabs, process: proc, @@ -383,6 +405,8 @@ async function startBrowser(profileId: string, opts: { headless?: boolean }): Pr startedAt: Date.now(), lastUsedAt: Date.now(), stopping: false, + transient: !!opts.transient && !!proc, + borrowers: 0, }; // Navigation / new tabs count as activity (this is how browser-use usage keeps the browser alive). @@ -510,6 +534,7 @@ async function shutdownOne(rb: RunningBrowser) { export async function shutdownBrowsers(): Promise { stopIdleWatcher(); + stopProbes(); stopChatProxy(); for (const timer of emitTimers.values()) clearTimeout(timer); emitTimers.clear(); @@ -777,6 +802,33 @@ export async function navigate(profileId: string, url: string, conversationId?: if (!done) await rb.client.send("Target.createTarget", { url: parsed.href }); } +/** What bot detection sees in the profile's browser; a browser started just for the check is stopped again. */ +export async function botCheck(profileId: string): Promise { + requireRow(profileId); + const rb = await ensureBrowser(profileId, { transient: true }); + rb.lastUsedAt = Date.now(); + // The check's window may be the browser's last one: leave it blank for the next chat instead of closing the browser. + const close = async (targetId: string) => { + if (rb.tabs.userPages().some((p) => p.targetId !== targetId)) { + await rb.client.send("Target.closeTarget", { targetId }); + return; + } + const page = await attachToPage(rb.client, targetId); + try { + await page.navigate("about:blank"); + } finally { + await page.detach(); + } + rb.tabs.addSpares([targetId]); + }; + try { + const { product } = await rb.client.send<{ product: string }>("Browser.getVersion"); + return await botCheckReport(rb.client, { profileId, browser: product.replace(/^HeadlessChrome/, "Chrome"), headless: rb.headless, stealth: rb.stealth }, close); + } finally { + await giveBack(rb); + } +} + /** Running browser for the profile or a 409. */ export function requireRunning(profileId: string): RunningBrowser { requireRow(profileId); @@ -860,7 +912,8 @@ export async function browserMcpServer( const profile = profileId ? getProfile(profileId) : resolveProfileForAgent(agent, run.conversationId); const headless = agent.browser.headless ?? settings.browser.headless; - if (!getRunning(profile.id)) requireChrome(settings.browser.chromePath); + const running = getRunning(profile.id); + if (!running) requireChrome(settings.browser.chromePath); // No browser starts here: browser-use asks the run's endpoint for it on its first browser tool call. const cdpUrl = openChatLease({ runId: run.runId, @@ -882,7 +935,8 @@ export async function browserMcpServer( configDir, configPath, cdpUrl, - headless, + headless: running?.headless ?? headless, + stealth: running?.stealth ?? settings.browser.stealth, userDataDir: profile.userDataDir, downloadsPath: join(workspace, "downloads"), fileSystemPath: join(runDir, "files"), @@ -949,13 +1003,12 @@ export async function importChromeSession(profileId: string, input: ChromeImport // Import into the running browser, or start it headless just for the import and stop it afterwards // (Browser.close flushes the cookie store to disk). - const wasRunning = !!getRunning(profileId); - const rb = await ensureBrowser(profileId, wasRunning ? {} : { headless: true }); + const rb = await ensureBrowser(profileId, { headless: true, transient: true }); let result: { set: number; failed: number; total: number }; try { result = await importer.injectCookies(rb.client, cookies); } finally { - if (!wasRunning) await stopBrowser(profileId); + await giveBack(rb); } update("browser_profiles", profileId, { diff --git a/packages/core/src/browser/state.ts b/packages/core/src/browser/state.ts index ea577fb6..ed332334 100644 --- a/packages/core/src/browser/state.ts +++ b/packages/core/src/browser/state.ts @@ -12,6 +12,8 @@ export interface RunningBrowser { httpUrl: string; wsUrl: string; headless: boolean; + /** Launched with bot-detection hardening (see stealth.ts). */ + stealth: boolean; /** Persistent browser-level CDP connection owned by Godmode. */ client: CdpClient; /** Which chat owns which tab. */ @@ -24,6 +26,9 @@ export interface RunningBrowser { startedAt: number; lastUsedAt: number; stopping: boolean; + /** Started just for bot checks or imports, stopped when the last of them (`borrowers`) is done — unless anyone else got it meanwhile. */ + transient: boolean; + borrowers: number; } const running = new Map(); @@ -60,7 +65,10 @@ export function unregisterBrowser(rb: RunningBrowser): boolean { /** Mark the profile browser as in use (defers idle shutdown). */ export function touchBrowser(profileId: string) { const rb = running.get(profileId); - if (rb) rb.lastUsedAt = Date.now(); + if (rb) { + rb.lastUsedAt = Date.now(); + rb.transient = false; + } } /** Called with the browser when one starts and with null when it stops. */ diff --git a/packages/core/src/browser/stealth.ts b/packages/core/src/browser/stealth.ts new file mode 100644 index 00000000..c66d6753 --- /dev/null +++ b/packages/core/src/browser/stealth.ts @@ -0,0 +1,96 @@ +/** + * Bot-detection hardening for the Chromium Godmode launches, so sites see a regular Chrome: + * - `--disable-blink-features=AutomationControlled` keeps `navigator.webdriver` false on every Chromium build. + * - Headless: every request, frame and worker carries the user agent the same Chrome sends with a window (learned once + * per executable from a throwaway headless launch), and the screen is a desktop display larger than the window. + * browser-use doesn't emulate a viewport over it either (see browserUse.ts), so a page never outgrows its window. + */ +import { mkdtempSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { logger } from "../log"; +import { sleep } from "../util"; +import { launchChrome, type ChromeProcess, type LaunchOptions } from "./chrome"; + +const log = logger("browser"); + +/** A common desktop display minus the menu bar (macOS) or the taskbar (Windows), in `--screen-info` syntax. */ +export function headlessScreen(platform: NodeJS.Platform = process.platform): string { + if (platform === "darwin") return "{1920x1080 workAreaTop=25}"; + if (platform === "win32") return "{1920x1080 workAreaBottom=48}"; + return "{1920x1080}"; +} + +export function stealthArgs(opts: { headless: boolean; userAgent: string | null; platform?: NodeJS.Platform }): string[] { + const args = ["--disable-blink-features=AutomationControlled"]; + if (opts.headless) { + if (opts.userAgent) args.push(`--user-agent=${opts.userAgent}`); + args.push(`--screen-info=${headlessScreen(opts.platform)}`); + } + return args; +} + +/** "… HeadlessChrome/154.0.0.0 Safari/537.36" → "… Chrome/154.0.0.0 Safari/537.36" (Edge keeps its "Edg/" part). */ +export function withoutHeadless(userAgent: string): string { + return userAgent.replace(/HeadlessChrome\//g, "Chrome/"); +} + +type Launch = (opts: LaunchOptions) => Promise>; + +const userAgents = new Map>(); +const probes = new Set(); +/** A browser that can't be probed isn't asked again for a while: every headless start would wait for it. */ +const RETRY_AFTER_MS = 10 * 60_000; + +/** The user agent `executable` sends with a window; null when it can't be learned (then headless keeps its own). */ +export function windowedUserAgent(executable: string, launch: Launch = launchChrome): Promise { + let key = executable; + try { + key += `@${statSync(executable).mtimeMs}`; + } catch { + /* the path alone */ + } + let ua = userAgents.get(key); + if (!ua) { + ua = probeUserAgent(executable, launch); + userAgents.set(key, ua); + void ua.then((v) => { + if (!v) setTimeout(() => userAgents.delete(key), RETRY_AFTER_MS).unref?.(); + }); + } + return ua; +} + +async function probeUserAgent(executable: string, launch: Launch): Promise { + const abort = new AbortController(); + probes.add(abort); + let dir: string | null = null; + let proc: Awaited> | null = null; + try { + dir = mkdtempSync(join(tmpdir(), "godmode-ua-")); + proc = await launch({ executable, userDataDir: dir, headless: true, timeoutMs: 5_000, signal: abort.signal }); + return proc.userAgent ? withoutHeadless(proc.userAgent) : null; + } catch (err) { + log.warn("could not learn the browser's user agent; headless keeps its own", err); + return null; + } finally { + probes.delete(abort); + if (proc) { + proc.kill("SIGKILL"); + await Promise.race([proc.exited, sleep(3000)]); + } + if (dir) { + try { + rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + } catch { + /* a leftover temp profile is harmless */ + } + } + } +} + +/** Kill user-agent probes still starting (core shutdown). */ +export function stopProbes() { + for (const abort of probes) abort.abort(); + probes.clear(); +} diff --git a/packages/core/src/server/routes/browser.ts b/packages/core/src/server/routes/browser.ts index cb390e6b..18b0e924 100644 --- a/packages/core/src/server/routes/browser.ts +++ b/packages/core/src/server/routes/browser.ts @@ -1,5 +1,6 @@ import type { Hono } from "hono"; import { + botCheck, createProfile, deleteProfile, getProfile, @@ -86,6 +87,8 @@ export function registerBrowserRoutes(app: Hono): void { return c.json({ ok: true }); }); + app.post("/api/browser/profiles/:id/bot-check", async (c) => c.json(await botCheck(c.req.param("id")))); + app.post("/api/browser/profiles/:id/input", async (c) => { const { conversationId, ...event } = await body(c, inputEvent.and(z.object({ conversationId: chatId }))); getProfile(c.req.param("id")); diff --git a/packages/core/src/services/settings.ts b/packages/core/src/services/settings.ts index 32a814a5..6a6ae699 100644 --- a/packages/core/src/services/settings.ts +++ b/packages/core/src/services/settings.ts @@ -33,6 +33,7 @@ export const DEFAULT_SETTINGS: Settings = { browserUseCommand: "", keepAliveMinutes: 5, liveView: true, + stealth: true, }, computer: { enabled: true, diff --git a/packages/core/src/vm/guest.ts b/packages/core/src/vm/guest.ts index 7120eb82..ed0e0686 100644 --- a/packages/core/src/vm/guest.ts +++ b/packages/core/src/vm/guest.ts @@ -23,9 +23,11 @@ import { BROWSER_USE_SPEC, BROWSER_USE_VERSION, browserUseConfig } from "../brow import { CdpClient, pickActivePage, probeCdp } from "../browser/cdp"; import { findFreePort } from "../browser/chrome"; import { fillIntoActivePage, fillPrecheck, type FillOptions, type FillResult } from "../browser/fill"; +import { stealthArgs } from "../browser/stealth"; import { CUA_DRIVER_SPEC, CUA_DRIVER_VERSION } from "../computer/cua"; import { logger } from "../log"; import { resolveUvx } from "../services/doctor"; +import { getSettings } from "../services/settings"; import type { McpServerJson } from "../types"; import { GUEST_USER, execInVm, onVmStopped, shq, sshKeyPath, vmAddress, vmName } from "./service"; import { TartError, resolveTart, tartEnv } from "./tart"; @@ -57,7 +59,7 @@ const BROWSER_STAMP = `"$HOME/.godmode/stamps/browser-use-${BROWSER_USE_VERSION} const CUA_STAMP = `"$HOME/.godmode/stamps/cua-driver-${CUA_DRIVER_VERSION}"`; const CDP_UP = `curl -fsS -m 2 http://127.0.0.1:${GUEST_CDP_PORT}/json/version >/dev/null 2>&1`; -const CHROME_FLAGS = [ +const chromeFlags = (stealth: boolean) => [ `--remote-debugging-port=${GUEST_CDP_PORT}`, "--remote-debugging-address=127.0.0.1", '"--user-data-dir=$HOME/.godmode/browser-profile"', @@ -69,6 +71,7 @@ const CHROME_FLAGS = [ "--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows", "--disable-renderer-backgrounding", + ...(stealth ? stealthArgs({ headless: false, userAgent: null }) : []), ].join(" "); /** Lines "uv", "chrome", "browser-use=", "cua=" for what is installed. */ @@ -81,9 +84,9 @@ const PROBE = [ ].join("\n"); /** Start Chrome with DevTools unless it already answers. Output goes to stderr (the MCP wrapper's stdout is JSON-RPC). */ -const START_CHROME = `if ! ${CDP_UP}; then +const startChrome = () => `if ! ${CDP_UP}; then mkdir -p ${KIT}/browser-profile "$HOME/Downloads" - open -n -a ${CHROME_APP} --args ${CHROME_FLAGS} >&2 + open -n -a ${CHROME_APP} --args ${chromeFlags(getSettings().browser.stealth)} >&2 i=0 until ${CDP_UP}; do i=$((i + 1)) @@ -307,7 +310,7 @@ export async function prepareGuest( BROWSER_PROFILE_ID, "2026-01-01T00:00:00.000Z", ); - const script = `mkdir -p ${KIT}/browser-use/files && cat > ${KIT}/browser-use/config.json\n${START_CHROME}`; + const script = `mkdir -p ${KIT}/browser-use/files && cat > ${KIT}/browser-use/config.json\n${startChrome()}`; const res = await execInVm(vmId, script, { stdin: JSON.stringify(config, null, 2), timeoutMs: 90_000, signal: opts.signal }); if (res.exitCode !== 0) { if (opts.signal?.aborted) throw new Error("cancelled"); @@ -331,7 +334,7 @@ function inGuest(vmId: string, script: string): McpServerJson { /** browser-use's MCP server in the VM, connected to the VM's Chrome (started again if it was closed). */ export function guestBrowserServer(vmId: string, browserUse: string): McpServerJson { - return inGuest(vmId, `{\n${START_CHROME}\n} >&2 || exit 1\nexport BROWSER_USE_CONFIG_DIR=${KIT}/browser-use ${GUEST_ENV}\nexec ${shq(browserUse)} --mcp`); + return inGuest(vmId, `{\n${startChrome()}\n} >&2 || exit 1\nexport BROWSER_USE_CONFIG_DIR=${KIT}/browser-use ${GUEST_ENV}\nexec ${shq(browserUse)} --mcp`); } /** diff --git a/packages/core/test/browser-chrome.test.ts b/packages/core/test/browser-chrome.test.ts index 9acabb31..7d62d905 100644 --- a/packages/core/test/browser-chrome.test.ts +++ b/packages/core/test/browser-chrome.test.ts @@ -115,8 +115,10 @@ describe("Chromium executable detection", () => { const dir = join(tmp, "marker"); mkdirSync(dir, { recursive: true }); expect(readLaunchMarker(dir)).toBeNull(); - writeLaunchMarker(dir, { pid: process.pid, port: 51234, headless: true }); - expect(readLaunchMarker(dir)).toEqual({ pid: process.pid, port: 51234, headless: true }); + writeLaunchMarker(dir, { pid: process.pid, port: 51234, headless: true, stealth: true }); + expect(readLaunchMarker(dir)).toEqual({ pid: process.pid, port: 51234, headless: true, stealth: true }); + writeFileSync(join(dir, LAUNCH_MARKER), JSON.stringify({ pid: process.pid, port: 51234, headless: false })); + expect(readLaunchMarker(dir)).toEqual({ pid: process.pid, port: 51234, headless: false, stealth: false }); writeFileSync(join(dir, LAUNCH_MARKER), '{"pid":"x","port":1}'); expect(readLaunchMarker(dir)).toBeNull(); clearLaunchMarker(dir); diff --git a/packages/core/test/browser-stealth.test.ts b/packages/core/test/browser-stealth.test.ts new file mode 100644 index 00000000..08be2e6e --- /dev/null +++ b/packages/core/test/browser-stealth.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from "bun:test"; +import { browserUseConfig } from "../src/browser/browserUse"; +import { judgeBotSignals, type BotHeaders, type BotSignals } from "../src/browser/botCheck"; +import { headlessScreen, stealthArgs, windowedUserAgent, withoutHeadless } from "../src/browser/stealth"; +import { DEFAULT_SETTINGS } from "../src/services/settings"; + +const UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36"; + +function signals(patch: Partial = {}): BotSignals { + return { + webdriver: false, + userAgent: UA, + brands: [ + { brand: "Chromium", version: "154" }, + { brand: "Google Chrome", version: "154" }, + { brand: "Not A(Brand", version: "99" }, + ], + fullVersionList: 3, + languages: ["en-US", "en"], + language: "en-US", + plugins: 5, + pdfViewer: true, + chrome: true, + notification: "default", + notificationQuery: "prompt", + webgl: "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + window: { outerWidth: 1280, outerHeight: 900, innerWidth: 1280, innerHeight: 813 }, + screen: { width: 1920, height: 1080 }, + worker: { userAgent: UA, webdriver: false }, + ...patch, + }; +} + +const HEADERS: BotHeaders = { userAgent: UA, secChUa: '"Chromium";v="154", "Google Chrome";v="154", "Not A(Brand";v="99"', acceptLanguage: "en-US,en;q=0.9" }; + +function statuses(s: BotSignals, h: BotHeaders = HEADERS) { + return Object.fromEntries(judgeBotSignals(s, h).map((c) => [c.id, c.status])); +} + +describe("stealth launch", () => { + test("a visible browser only hides the automation flag", () => { + expect(stealthArgs({ headless: false, userAgent: UA })).toEqual(["--disable-blink-features=AutomationControlled"]); + }); + + test("headless gets the windowed user agent and a desktop screen", () => { + const args = stealthArgs({ headless: true, userAgent: UA, platform: "darwin" }); + expect(args).toContain("--disable-blink-features=AutomationControlled"); + expect(args).toContain(`--user-agent=${UA}`); + expect(args).toContain("--screen-info={1920x1080 workAreaTop=25}"); + expect(stealthArgs({ headless: true, userAgent: null, platform: "linux" })).toEqual([ + "--disable-blink-features=AutomationControlled", + "--screen-info={1920x1080}", + ]); + expect(headlessScreen("win32")).toBe("{1920x1080 workAreaBottom=48}"); + }); + + test("drops only the Headless product (Edge keeps Edg/)", () => { + expect(withoutHeadless(UA.replace("Chrome/", "HeadlessChrome/"))).toBe(UA); + const edge = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36 Edg/154.0.0.0"; + expect(withoutHeadless(edge)).toBe(edge.replace("HeadlessChrome/", "Chrome/")); + }); + + test("learns the windowed user agent once per executable; a failed probe never throws and isn't retried right away", async () => { + let launches = 0; + const ok = async () => { + launches++; + return { userAgent: UA.replace("Chrome/", "HeadlessChrome/"), kill: () => {}, exited: Promise.resolve(0) }; + }; + expect(await windowedUserAgent("/nonexistent/ok-chrome", ok)).toBe(UA); + expect(await windowedUserAgent("/nonexistent/ok-chrome", ok)).toBe(UA); + expect(launches).toBe(1); + + let failures = 0; + const broken = async (): Promise => { + failures++; + throw new Error("Chromium exited during startup"); + }; + expect(await windowedUserAgent("/nonexistent/broken-chrome", broken)).toBeNull(); + expect(await windowedUserAgent("/nonexistent/broken-chrome", broken)).toBeNull(); + expect(failures).toBe(1); + }); + + test("is on by default", () => { + expect(DEFAULT_SETTINGS.browser.stealth).toBe(true); + }); + + test("browser-use doesn't emulate a viewport over a hardened headless browser", () => { + const input = { configDir: "/cfg", cdpUrl: "http://127.0.0.1:9222", headless: true, userDataDir: "/data/p", downloadsPath: "/d", fileSystemPath: "/f" }; + const headless = (cfg: ReturnType) => Object.values(cfg.browser_profile)[0]!.headless; + expect(headless(browserUseConfig({ ...input, stealth: true }, "id", "now"))).toBe(false); + expect(headless(browserUseConfig(input, "id", "now"))).toBe(true); + }); +}); + +describe("bot check verdicts", () => { + test("a regular Chrome passes everything", () => { + const checks = judgeBotSignals(signals(), HEADERS); + expect(checks).toHaveLength(10); + expect(checks.filter((c) => c.status !== "pass")).toEqual([]); + }); + + test("headless tells fail", () => { + const headless = UA.replace("Chrome/", "HeadlessChrome/"); + expect(statuses(signals({ userAgent: headless, worker: { userAgent: headless, webdriver: false } })).userAgent).toBe("fail"); + expect(statuses(signals(), { ...HEADERS, userAgent: headless }).userAgent).toBe("fail"); + expect(statuses(signals({ webdriver: true })).webdriver).toBe("fail"); + expect(statuses(signals({ worker: { userAgent: UA, webdriver: true } })).webdriver).toBe("fail"); + expect(statuses(signals({ screen: { width: 800, height: 600 } })).window).toBe("fail"); + expect(statuses(signals({ window: { outerWidth: 1280, outerHeight: 900, innerWidth: 1920, innerHeight: 1080 } })).window).toBe("fail"); + expect(statuses(signals({ worker: { userAgent: UA.replace("Chrome/", "HeadlessChrome/"), webdriver: false } })).worker).toBe("fail"); + expect(statuses(signals({ notification: "denied", notificationQuery: "prompt" })).permissions).toBe("fail"); + expect(statuses(signals({ chrome: false })).chrome).toBe("fail"); + }); + + test("client hints: missing or mismatched fail, withheld details warn", () => { + expect(statuses(signals({ brands: null })).clientHints).toBe("fail"); + expect(statuses(signals(), { ...HEADERS, secChUa: null }).clientHints).toBe("fail"); + expect(statuses(signals({ brands: [{ brand: "Chromium", version: "150" }] })).clientHints).toBe("fail"); + expect(statuses(signals({ fullVersionList: 0 })).clientHints).toBe("warn"); + }); + + test("softer signals only warn", () => { + const s = statuses(signals({ webgl: "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)", plugins: 0, worker: null }), { + ...HEADERS, + acceptLanguage: "de-DE,de;q=0.9", + }); + expect(s).toMatchObject({ webgl: "warn", plugins: "warn", worker: "warn", languages: "warn" }); + }); +}); diff --git a/packages/core/test/cdp-integration.test.ts b/packages/core/test/cdp-integration.test.ts index 051b8c6a..ad258bc8 100644 --- a/packages/core/test/cdp-integration.test.ts +++ b/packages/core/test/cdp-integration.test.ts @@ -195,6 +195,51 @@ suite("managed Chromium (CDP integration)", () => { expect(res.ok).toBe(true); }); + test("stealth: the bot check sees a regular Chrome in the headless browser and leaves no tab behind", async () => { + const report = await manager.botCheck(profileId); + expect(report).toMatchObject({ profileId, headless: true, stealth: true }); + expect(report.browser).toMatch(/^Chrome\//); + const status = Object.fromEntries(report.checks.map((c) => [c.id, c.status])); + expect(status).toMatchObject({ webdriver: "pass", userAgent: "pass", worker: "pass", window: "pass" }); + expect(status.clientHints).not.toBe("fail"); + expect(manager.getProfile(profileId).running).toBe(true); + expect((await listPages(getRunning(profileId)!.client)).some((p) => p.title.includes("bot check"))).toBe(false); + }, 60_000); + + test("without stealth the bot check shows what gives a headless browser away", async () => { + updateSettings({ browser: { stealth: false } }); + const plain = manager.createProfile({ name: "Plain", workspaceId: null }); + try { + await manager.launchBrowser(plain.id, { headless: true }); + const report = await manager.botCheck(plain.id); + expect(report.stealth).toBe(false); + expect(report.checks.find((c) => c.id === "userAgent")).toMatchObject({ status: "fail" }); + expect(report.checks.find((c) => c.id === "userAgent")!.detail).toContain("HeadlessChrome"); + } finally { + updateSettings({ browser: { stealth: true } }); + await manager.stopBrowser(plain.id); + } + }, 60_000); + + test("a browser started for the bot check is stopped again, unless someone else got it meanwhile", async () => { + updateSettings({ browser: { headless: true } }); + const borrowed = manager.createProfile({ name: "Borrowed", workspaceId: null }); + try { + await manager.botCheck(borrowed.id); + expect(manager.getProfile(borrowed.id).running).toBe(false); + await Promise.all([manager.botCheck(borrowed.id), manager.botCheck(borrowed.id)]); + expect(manager.getProfile(borrowed.id).running).toBe(false); + + const check = manager.botCheck(borrowed.id); + const joined = await manager.launchBrowser(borrowed.id); + await check; + expect(manager.getProfile(borrowed.id)).toMatchObject({ running: true, cdpUrl: joined.cdpUrl, headless: true, stealth: true }); + } finally { + updateSettings({ browser: { headless: false } }); + await manager.stopBrowser(borrowed.id); + } + }, 60_000); + test("fills username and password by kind without a selector (and never into the focused wrong field)", async () => { await openPage(profileId, "/login"); const user = await manager.fillIntoPage(profileId, { text: "alice@example.com", kind: "username", ...LOCAL }); diff --git a/packages/core/test/fixtures/runner-harness.ts b/packages/core/test/fixtures/runner-harness.ts index 0b6f2212..dbfe5866 100644 --- a/packages/core/test/fixtures/runner-harness.ts +++ b/packages/core/test/fixtures/runner-harness.ts @@ -53,6 +53,8 @@ const fakeProfile: BrowserProfile = { cookieCount: 0, running: false, cdpUrl: null, + headless: null, + stealth: null, chats: [], createdAt: new Date(0).toISOString(), updatedAt: new Date(0).toISOString(), diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 06222a7d..5ed4b164 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -360,6 +360,26 @@ export type BrowserInputEvent = | { type: "key"; key: string } | { type: "text"; text: string }; +export type BotCheckStatus = "pass" | "warn" | "fail"; + +export interface BotCheckItem { + id: "webdriver" | "userAgent" | "clientHints" | "worker" | "window" | "webgl" | "plugins" | "languages" | "permissions" | "chrome"; + label: string; + status: BotCheckStatus; + detail: string; +} + +/** POST /api/browser/profiles/:id/bot-check — what a website's bot detection sees in the profile's browser. */ +export interface BotCheckReport { + profileId: string; + /** e.g. "Chrome/154.0.8037.59" */ + browser: string; + headless: boolean; + stealth: boolean; + checks: BotCheckItem[]; + checkedAt: string; +} + /** GET /api/browser/profile-use — browser-use's `profile-use` CLI (sync local Chrome cookies to browser-use Cloud). */ export interface ProfileUseStatus { installed: boolean; diff --git a/packages/shared/src/models.ts b/packages/shared/src/models.ts index 2aaf0c0a..9ab975c2 100644 --- a/packages/shared/src/models.ts +++ b/packages/shared/src/models.ts @@ -684,6 +684,9 @@ export interface BrowserProfile { cookieCount: number; running: boolean; cdpUrl: string | null; + /** How the running browser was started; null while it isn't running. */ + headless: boolean | null; + stealth: boolean | null; /** Chats with tabs open in the running browser, in the order they opened their first tab. */ chats: BrowserChat[]; createdAt: ISODate; @@ -814,6 +817,8 @@ export interface BrowserSettings { browserUseCommand: string; keepAliveMinutes: number; liveView: boolean; + /** Hide automation signals so sites don't treat agents as bots (applies when a browser launches). */ + stealth: boolean; } export interface ComputerSettings {