Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down Expand Up @@ -130,6 +131,10 @@ needs to be useful:
<td><img src="docs/screenshots/followup-waiting.png" alt="An agent waiting for a signed contract, set to continue tomorrow at 09:00" /><br /><sub><b>Follow-ups</b> — the agent sets itself a time to continue; move it, cancel it or continue now</sub></td>
<td><img src="docs/screenshots/followup.png" alt="The agent picked the chat up again at the time it set and finished the task" /><br /><sub><b>Back on it</b> — at that time the agent picks the chat up again, with all the context</sub></td>
</tr>
<tr>
<td><img src="docs/screenshots/bot-check.png" alt="Bot check of a hardened headless browser: 9 of 10 checks pass" /><br /><sub><b>Bot check</b> — what bot detection sees in the agents' browser</sub></td>
<td><img src="docs/screenshots/bot-check-off.png" alt="Bot check without hardening: the headless user agent and screen give the browser away" /><br /><sub><b>Without hardening</b> — headless Chrome gives itself away</sub></td>
</tr>
</table>

## 📦 Install
Expand Down
198 changes: 198 additions & 0 deletions apps/desktop/src/components/settings/bot-check.tsx
Original file line number Diff line number Diff line change
@@ -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<BotCheckStatus, { icon: typeof CircleCheck; text: string; bar: string; label: string }> = {
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<BotCheckReport | null>({ 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 (
<SettingRow
label="Bot check"
disabled={!browser.enabled}
description="Opens a test page in the default profile's browser and shows what bot detection sees there — the same checks sites run before a CAPTCHA."
>
<Button variant="outline" size="sm" onClick={() => run.mutate(false)} disabled={disabled}>
<Radar /> Run check
</Button>
</SettingRow>
);
}

const v = r ? verdict(r) : null;
return (
<div className="py-4">
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<div
className={cn(
"grid size-10 shrink-0 place-items-center rounded-full border [&_svg]:size-[18px]",
!v && "bg-paper-2 text-muted-foreground",
v?.tone === "pass" && "border-success/25 bg-success/[0.08] text-success",
v?.tone === "fail" && "border-destructive/25 bg-destructive/[0.07] text-destructive",
)}
>
{!v ? <Spinner /> : v.tone === "pass" ? <ShieldCheck /> : <ShieldAlert />}
</div>
<div className="min-w-0">
<div className="text-sm font-medium tracking-[-0.01em]">{v ? v.title : "Checking how sites see the browser…"}</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">
{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"
)}
</div>
</div>
</div>
{r && (
<div className="flex items-center gap-3">
<Meter report={r} />
<Button variant="ghost" size="sm" onClick={() => run.mutate(false)} disabled={run.isPending || disabled}>
{run.isPending ? <Spinner /> : <RotateCw />} Check again
</Button>
</div>
)}
</div>

<AnimatePresence initial={false}>
{(restartNeeded || stale) && !run.isPending && (
<motion.div
key={restartNeeded ? "restart" : "stale"}
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div
className={cn(
"mt-4 flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border px-3.5 py-2.5 text-xs",
restartNeeded ? "border-warning/25 bg-warning/[0.06]" : "bg-paper-2/60 py-3",
)}
>
<span className="min-w-0 flex-1 text-foreground/85">
{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.`}
</span>
{restartNeeded && (
<Button size="xs" variant="outline" onClick={() => run.mutate(true)} disabled={disabled}>
<RotateCw /> Restart & check
</Button>
)}
</div>
</motion.div>
)}
</AnimatePresence>

<ul className="mt-4 overflow-hidden rounded-lg border">
{r && !run.isPending
? r.checks.map((c, i) => {
const s = STATUS[c.status];
return (
<motion.li
key={c.id}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.025 }}
className="grid grid-cols-[auto_1fr] items-start gap-x-3 border-b px-3.5 py-2.5 last:border-b-0 @lg:grid-cols-[auto_9rem_1fr]"
>
<s.icon role="img" className={cn("mt-px size-4", s.text)} aria-label={s.label} />
<span className="text-[13px] font-medium">{c.label}</span>
<span className="col-start-2 text-xs leading-relaxed break-words text-muted-foreground @lg:col-start-3 @lg:pt-px">{c.detail}</span>
</motion.li>
);
})
: Array.from({ length: 10 }).map((_, i) => (
<li key={i} className="flex items-center gap-3 border-b px-3.5 py-3 last:border-b-0">
<Skeleton className="size-4 rounded-full" />
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 flex-1" style={{ maxWidth: `${40 + ((i * 37) % 45)}%` }} />
</li>
))}
</ul>
</div>
);
}

function Meter({ report }: { report: BotCheckReport }) {
const v = verdict(report);
return (
<Tooltip>
<TooltipTrigger asChild>
<div
tabIndex={0}
role="img"
className="flex items-center gap-2 rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
aria-label={`${v.passed} of ${report.checks.length} checks passed`}
>
<div className="flex gap-[3px]">
{report.checks.map((c) => (
<span key={c.id} className={cn("h-3.5 w-1.5 rounded-full", STATUS[c.status].bar, c.status === "pass" && "opacity-80")} />
))}
</div>
<span className="text-xs text-muted-foreground tabular-nums">
{v.passed}/{report.checks.length}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
{v.passed} of {report.checks.length} checks passed
</TooltipContent>
</Tooltip>
);
}
19 changes: 18 additions & 1 deletion apps/desktop/src/components/settings/browser-section.tsx
Original file line number Diff line number Diff line change
@@ -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 }) {
Expand Down Expand Up @@ -56,6 +57,22 @@ export function BrowserSection({ settings }: { settings: Settings }) {
</SettingRow>
</SettingsGroup>

<SettingsGroup
title="Bot detection"
icon={<Fingerprint />}
description="Many sites block browsers that look automated or bury them in CAPTCHAs. Godmode hides the signals they look for."
>
<SettingRow
label="Look like a regular browser"
htmlFor="stealth"
disabled={!b.enabled}
description="Hides the automation flag, and gives a headless browser the user agent and screen of a normal Chrome window. Applies the next time a browser starts."
>
<Switch id="stealth" checked={b.stealth} disabled={!b.enabled} onCheckedChange={(stealth) => patch({ browser: { stealth } })} />
</SettingRow>
<BotCheck browser={b} />
</SettingsGroup>

<SettingsGroup title="Live view" icon={<Eye />}>
<SettingRow
label="Stream live view"
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
BackupExportInput,
BackupImportResult,
Bootstrap,
BotCheckReport,
BrowserProfile,
ChromeImportInput,
ClientLogInput,
Expand Down Expand Up @@ -478,6 +479,7 @@ export const api = {
/** With `conversationId`: that chat's tab (opened if it has none) instead of the active one. */
navigate: (id: string, url: string, conversationId?: string | null) =>
post<{ ok: true }>(`/api/browser/profiles/${id}/navigate`, { url, conversationId: conversationId ?? null }),
botCheck: (id: string) => post<BotCheckReport>(`/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,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/lib/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand Down
14 changes: 14 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file added docs/screenshots/bot-check-off.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/bot-check.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading