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:
Follow-ups — the agent sets itself a time to continue; move it, cancel it or continue now
Back on it — at that time the agent picks the chat up again, with all the context
+
+
Bot check — what bot detection sees in the agents' browser
+
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…"}
+
+ {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 && (
+
+ )}
+
+
+
+ {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 {