feat(browser): prefer Chrome default + add Brave/Edge fallbacks

Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
This commit is contained in:
Peter Steinberger
2026-01-16 03:24:53 +00:00
parent a0d2a7232e
commit a5d8f89b53
16 changed files with 172 additions and 67 deletions

View File

@@ -29,6 +29,7 @@
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot``act`.
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
- Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.

View File

@@ -785,7 +785,7 @@ Location:
## Browser
Browser control CLI (dedicated Chrome/Chromium). See [`clawdbot browser`](/cli/browser) and the [Browser tool](/tools/browser).
Browser control CLI (dedicated Chrome/Brave/Edge/Chromium). See [`clawdbot browser`](/cli/browser) and the [Browser tool](/tools/browser).
Common options:
- `--url <controlUrl>`

View File

@@ -2376,10 +2376,10 @@ Example:
}
```
### `browser` (clawd-managed Chrome)
### `browser` (clawd-managed browser)
Clawdbot can start a **dedicated, isolated** Chrome/Chromium instance for clawd and expose a small loopback control server.
Profiles can point at a **remote** Chrome via `profiles.<name>.cdpUrl`. Remote
Clawdbot can start a **dedicated, isolated** Chrome/Brave/Edge/Chromium instance for clawd and expose a small loopback control server.
Profiles can point at a **remote** Chromium-based browser via `profiles.<name>.cdpUrl`. Remote
profiles are attach-only (start/stop/reset are disabled).
`browser.cdpUrl` remains for legacy single-profile configs and as the base
@@ -2391,6 +2391,7 @@ Defaults:
- CDP URL: `http://127.0.0.1:18792` (control URL + 1, legacy single-profile)
- profile color: `#FF4500` (lobster-orange)
- Note: the control server is started by the running gateway (Clawdbot.app menubar, or `clawdbot gateway`).
- Auto-detect order: Chrome → Brave → Edge → Chromium → Chrome Canary.
```json5
{
@@ -2408,7 +2409,7 @@ Defaults:
// Advanced:
// headless: false,
// noSandbox: false,
// executablePath: "/usr/bin/chromium",
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
// attachOnly: false, // set true when tunneling a remote CDP to localhost
}
}

View File

@@ -55,6 +55,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [How do I enable web search (and web fetch)?](#how-do-i-enable-web-search-and-web-fetch)
- [How do I run a central Gateway with specialized workers across devices?](#how-do-i-run-a-central-gateway-with-specialized-workers-across-devices)
- [Can the Clawdbot browser run headless?](#can-the-clawdbot-browser-run-headless)
- [How do I use Brave for browser control?](#how-do-i-use-brave-for-browser-control)
- [Remote gateways + nodes](#remote-gateways-nodes)
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
@@ -663,6 +664,11 @@ Headless uses the **same Chromium engine** and works for most automation (forms,
- Some sites are stricter about automation in headless mode (CAPTCHAs, antibot).
For example, X/Twitter often blocks headless sessions.
### How do I use Brave for browser control?
Set `browser.executablePath` to your Brave binary (or any Chromium-based browser) and restart the Gateway.
See the full config examples in [Browser](/tools/browser#use-brave-or-another-chromium-based-browser).
## Remote gateways + nodes
### How do commands propagate between Telegram, the gateway, and nodes?

View File

@@ -1,5 +1,5 @@
---
summary: "Fix Chrome/Chromium CDP startup issues for Clawdbot browser control on Linux"
summary: "Fix Chrome/Brave/Edge/Chromium CDP startup issues for Clawdbot browser control on Linux"
read_when: "Browser control fails on Linux, especially with snap Chromium"
---
@@ -7,7 +7,7 @@ read_when: "Browser control fails on Linux, especially with snap Chromium"
## Problem: "Failed to start Chrome CDP on port 18800"
Clawdbot's browser control server fails to launch Chrome/Chromium with the error:
Clawdbot's browser control server fails to launch Chrome/Brave/Edge/Chromium with the error:
```
{"error":"Error: Failed to start Chrome CDP on port 18800 for profile \"clawd\"."}
```
@@ -107,7 +107,7 @@ curl -s http://127.0.0.1:18791/tabs
| Option | Description | Default |
|--------|-------------|---------|
| `browser.enabled` | Enable browser control | `true` |
| `browser.executablePath` | Path to Chrome/Chromium binary | auto-detected |
| `browser.executablePath` | Path to a Chromium-based browser binary (Chrome/Brave/Edge/Chromium) | auto-detected |
| `browser.headless` | Run without GUI | `false` |
| `browser.noSandbox` | Add `--no-sandbox` flag (needed for some Linux setups) | `false` |
| `browser.attachOnly` | Don't launch browser, only attach to existing | `false` |

View File

@@ -8,13 +8,13 @@ read_when:
# Browser (clawd-managed)
Clawdbot can run a **dedicated Chrome/Chromium profile** that the agent controls.
Clawdbot can run a **dedicated Chrome/Brave/Edge/Chromium profile** that the agent controls.
It is isolated from your personal browser and is managed through a small local
control server.
Beginner view:
- Think of it as a **separate, agent-only browser**.
- It does **not** touch your personal Chrome profile.
- It does **not** touch your personal browser profile.
- The agent can **open tabs, read pages, click, and type** in a safe lane.
## What you get
@@ -54,7 +54,7 @@ Browser settings live in `~/.clawdbot/clawdbot.json`.
headless: false,
noSandbox: false,
attachOnly: false,
executablePath: "/Applications/Chromium.app/Contents/MacOS/Chromium",
executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
profiles: {
clawd: { cdpPort: 18800, color: "#FF4500" },
work: { cdpPort: 18801, color: "#0066CC" },
@@ -69,17 +69,45 @@ Notes:
- If you override the Gateway port (`gateway.port` or `CLAWDBOT_GATEWAY_PORT`),
the default browser ports shift to stay in the same “family” (control = gateway + 2).
- `cdpUrl` defaults to `controlUrl + 1` when unset.
- `attachOnly: true` means “never launch Chrome; only attach if it is already running.”
- `attachOnly: true` means “never launch a local browser; only attach if it is already running.”
- `color` + per-profile `color` tint the browser UI so you can see which profile is active.
- Auto-detect order: Chrome → Brave → Edge → Chromium → Chrome Canary.
## Use Brave (or another Chromium-based browser)
Set `browser.executablePath` to override auto-detection:
```json5
// macOS
{
browser: {
executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
}
}
// Windows
{
browser: {
executablePath: "C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe"
}
}
// Linux
{
browser: {
executablePath: "/usr/bin/brave-browser"
}
}
```
## Local vs remote control
- **Local control (default):** `controlUrl` is loopback (`127.0.0.1`/`localhost`).
The Gateway starts the control server and can launch Chrome.
The Gateway starts the control server and can launch a local browser.
- **Remote control:** `controlUrl` is non-loopback. The Gateway **does not** start
a local server; it assumes you are pointing at an existing server elsewhere.
- **Remote CDP:** set `browser.profiles.<name>.cdpUrl` (or `browser.cdpUrl`) to
attach to a remote Chrome. In this case, Clawdbot will not launch a local browser.
attach to a remote Chromium-based browser. In this case, Clawdbot will not launch a local browser.
## Remote browser (control server)
@@ -88,7 +116,7 @@ Gateway at it with a remote `controlUrl`. This lets the agent drive a browser
outside the host (lab box, VM, remote desktop, etc.).
Key points:
- The **control server** speaks to Chrome/Chromium via **CDP**.
- The **control server** speaks to Chromium-based browsers (Chrome/Brave/Edge/Chromium) via **CDP**.
- The **Gateway** only needs the HTTP control URL.
- Profiles are resolved on the **control server** side.
@@ -104,14 +132,14 @@ Example:
```
Use `profiles.<name>.cdpUrl` for **remote CDP** if you want the Gateway to talk
directly to a Chrome instance without a remote control server.
directly to a Chromium-based browser instance without a remote control server.
### Running the control server on the browser machine
Run a standalone browser control server (recommended when your Gateway is remote):
```bash
# on the machine that runs Chrome
# on the machine that runs Chrome/Brave/Edge
clawdbot browser serve --bind <browser-host> --port 18791 --token <token>
```
@@ -198,8 +226,8 @@ Notes:
## Profiles (multi-browser)
Clawdbot supports multiple named profiles (routing configs). Profiles can be:
- **clawd-managed**: a dedicated Chrome instance with its own user data directory + CDP port
- **remote**: an explicit CDP URL (Chrome running elsewhere)
- **clawd-managed**: a dedicated Chromium-based browser instance with its own user data directory + CDP port
- **remote**: an explicit CDP URL (Chromium-based browser running elsewhere)
- **extension relay**: your existing Chrome tab(s) via the local relay + Chrome extension
Defaults:
@@ -264,22 +292,24 @@ Notes:
## Isolation guarantees
- **Dedicated user data dir**: never touches your personal Chrome profile.
- **Dedicated user data dir**: never touches your personal browser profile.
- **Dedicated ports**: avoids `9222` to prevent collisions with dev workflows.
- **Deterministic tab control**: target tabs by `targetId`, not “last tab”.
## Browser selection
When launching locally, Clawdbot picks the first available:
1. Chrome Canary
2. Chromium
3. Chrome
1. Chrome
2. Brave
3. Edge
4. Chromium
5. Chrome Canary
You can override with `browser.executablePath`.
Platforms:
- macOS: checks `/Applications` and `~/Applications`.
- Linux: looks for `google-chrome`, `chromium`, etc.
- Linux: looks for `google-chrome`, `brave`, `microsoft-edge`, `chromium`, etc.
- Windows: checks common install locations.
## Control API (optional)
@@ -313,7 +343,7 @@ For the Chrome extension relay driver, ARIA snapshots and screenshots require Pl
High-level flow:
- A small **control server** accepts HTTP requests.
- It connects to Chrome/Chromium via **CDP**.
- It connects to Chromium-based browsers (Chrome/Brave/Edge/Chromium) via **CDP**.
- For advanced actions (click/type/snapshot/PDF), it uses **Playwright** on top
of CDP.
- When Playwright is missing, only non-Playwright operations are available.

View File

@@ -54,7 +54,9 @@ describe("subagent announce formatting", () => {
});
expect(agentSpy).toHaveBeenCalled();
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string; sessionKey?: string } };
const call = agentSpy.mock.calls[0]?.[0] as {
params?: { message?: string; sessionKey?: string };
};
const msg = call?.params?.message as string;
expect(call?.params?.sessionKey).toBe("agent:main:main");
expect(msg).toContain("background task");

View File

@@ -119,7 +119,8 @@ describe("subagent registry persistence", () => {
// announce should NOT be called since cleanupHandled was true
const calls = announceSpy.mock.calls.map((call) => call[0]);
const match = calls.find(
(params) => (params as { childSessionKey?: string }).childSessionKey === "agent:main:subagent:two",
(params) =>
(params as { childSessionKey?: string }).childSessionKey === "agent:main:subagent:two",
);
expect(match).toBeFalsy();
});

View File

@@ -38,9 +38,7 @@ export function loadSubagentRegistryFromDisk(): Map<string, SubagentRunRecord> {
const announceCompletedAt =
typeof typed.announceCompletedAt === "number" ? typed.announceCompletedAt : undefined;
const cleanupCompletedAt =
typeof typed.cleanupCompletedAt === "number"
? typed.cleanupCompletedAt
: announceCompletedAt;
typeof typed.cleanupCompletedAt === "number" ? typed.cleanupCompletedAt : announceCompletedAt;
const cleanupHandled =
typeof typed.cleanupHandled === "boolean"
? typed.cleanupHandled

View File

@@ -214,11 +214,7 @@ function ensureListener() {
});
}
function finalizeSubagentCleanup(
runId: string,
cleanup: "delete" | "keep",
didAnnounce: boolean,
) {
function finalizeSubagentCleanup(runId: string, cleanup: "delete" | "keep", didAnnounce: boolean) {
const entry = subagentRuns.get(runId);
if (!entry) return;
if (cleanup === "delete") {

View File

@@ -442,7 +442,8 @@ export function buildAgentSystemPrompt(params: {
if (extraSystemPrompt) {
// Use "Subagent Context" header for minimal mode (subagents), otherwise "Group Chat Context"
const contextHeader = promptMode === "minimal" ? "## Subagent Context" : "## Group Chat Context";
const contextHeader =
promptMode === "minimal" ? "## Subagent Context" : "## Group Chat Context";
lines.push(contextHeader, extraSystemPrompt, "");
}
if (params.reactionGuidance) {

View File

@@ -5,7 +5,7 @@ import path from "node:path";
import type { ResolvedBrowserConfig } from "./config.js";
export type BrowserExecutable = {
kind: "canary" | "chromium" | "chrome" | "custom";
kind: "brave" | "canary" | "chromium" | "chrome" | "custom" | "edge";
path: string;
};
@@ -28,15 +28,28 @@ function findFirstExecutable(candidates: Array<BrowserExecutable>): BrowserExecu
export function findChromeExecutableMac(): BrowserExecutable | null {
const candidates: Array<BrowserExecutable> = [
{
kind: "canary",
path: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
kind: "chrome",
path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
},
{
kind: "canary",
path: path.join(
os.homedir(),
"Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
),
kind: "chrome",
path: path.join(os.homedir(), "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
},
{
kind: "brave",
path: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
},
{
kind: "brave",
path: path.join(os.homedir(), "Applications/Brave Browser.app/Contents/MacOS/Brave Browser"),
},
{
kind: "edge",
path: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
},
{
kind: "edge",
path: path.join(os.homedir(), "Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
},
{
kind: "chromium",
@@ -47,12 +60,15 @@ export function findChromeExecutableMac(): BrowserExecutable | null {
path: path.join(os.homedir(), "Applications/Chromium.app/Contents/MacOS/Chromium"),
},
{
kind: "chrome",
path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
kind: "canary",
path: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
},
{
kind: "chrome",
path: path.join(os.homedir(), "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
kind: "canary",
path: path.join(
os.homedir(),
"Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
),
},
];
@@ -63,10 +79,16 @@ export function findChromeExecutableLinux(): BrowserExecutable | null {
const candidates: Array<BrowserExecutable> = [
{ kind: "chrome", path: "/usr/bin/google-chrome" },
{ kind: "chrome", path: "/usr/bin/google-chrome-stable" },
{ kind: "chrome", path: "/usr/bin/chrome" },
{ kind: "brave", path: "/usr/bin/brave-browser" },
{ kind: "brave", path: "/usr/bin/brave-browser-stable" },
{ kind: "brave", path: "/usr/bin/brave" },
{ kind: "brave", path: "/snap/bin/brave" },
{ kind: "edge", path: "/usr/bin/microsoft-edge" },
{ kind: "edge", path: "/usr/bin/microsoft-edge-stable" },
{ kind: "chromium", path: "/usr/bin/chromium" },
{ kind: "chromium", path: "/usr/bin/chromium-browser" },
{ kind: "chromium", path: "/snap/bin/chromium" },
{ kind: "chrome", path: "/usr/bin/chrome" },
];
return findFirstExecutable(candidates);
@@ -82,20 +104,30 @@ export function findChromeExecutableWindows(): BrowserExecutable | null {
const candidates: Array<BrowserExecutable> = [];
if (localAppData) {
// Chrome Canary (user install)
// Chrome (user install)
candidates.push({
kind: "canary",
path: joinWin(localAppData, "Google", "Chrome SxS", "Application", "chrome.exe"),
kind: "chrome",
path: joinWin(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
});
// Brave (user install)
candidates.push({
kind: "brave",
path: joinWin(localAppData, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
});
// Edge (user install)
candidates.push({
kind: "edge",
path: joinWin(localAppData, "Microsoft", "Edge", "Application", "msedge.exe"),
});
// Chromium (user install)
candidates.push({
kind: "chromium",
path: joinWin(localAppData, "Chromium", "Application", "chrome.exe"),
});
// Chrome (user install)
// Chrome Canary (user install)
candidates.push({
kind: "chrome",
path: joinWin(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
kind: "canary",
path: joinWin(localAppData, "Google", "Chrome SxS", "Application", "chrome.exe"),
});
}
@@ -109,6 +141,26 @@ export function findChromeExecutableWindows(): BrowserExecutable | null {
kind: "chrome",
path: joinWin(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
});
// Brave (system install, 64-bit)
candidates.push({
kind: "brave",
path: joinWin(programFiles, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
});
// Brave (system install, 32-bit on 64-bit Windows)
candidates.push({
kind: "brave",
path: joinWin(programFilesX86, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
});
// Edge (system install, 64-bit)
candidates.push({
kind: "edge",
path: joinWin(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
});
// Edge (system install, 32-bit on 64-bit Windows)
candidates.push({
kind: "edge",
path: joinWin(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"),
});
return findFirstExecutable(candidates);
}

View File

@@ -128,10 +128,12 @@ describe("browser chrome helpers", () => {
it("picks the first existing Chrome candidate on macOS", () => {
const exists = vi
.spyOn(fs, "existsSync")
.mockImplementation((p) => String(p).includes("Google Chrome Canary"));
.mockImplementation((p) =>
String(p).includes("Google Chrome.app/Contents/MacOS/Google Chrome"),
);
const exe = findChromeExecutableMac();
expect(exe?.kind).toBe("canary");
expect(exe?.path).toMatch(/Google Chrome Canary/);
expect(exe?.kind).toBe("chrome");
expect(exe?.path).toMatch(/Google Chrome\.app/);
exists.mockRestore();
});
@@ -143,12 +145,17 @@ describe("browser chrome helpers", () => {
it("picks the first existing Chrome candidate on Windows", () => {
vi.stubEnv("LOCALAPPDATA", "C:\\Users\\Test\\AppData\\Local");
const exists = vi
.spyOn(fs, "existsSync")
.mockImplementation((p) => String(p).includes("Chrome SxS"));
const exists = vi.spyOn(fs, "existsSync").mockImplementation((p) => {
const pathStr = String(p);
return (
pathStr.includes("Google\\Chrome\\Application\\chrome.exe") ||
pathStr.includes("BraveSoftware\\Brave-Browser\\Application\\brave.exe") ||
pathStr.includes("Microsoft\\Edge\\Application\\msedge.exe")
);
});
const exe = findChromeExecutableWindows();
expect(exe?.kind).toBe("canary");
expect(exe?.path).toMatch(/Chrome SxS/);
expect(exe?.kind).toBe("chrome");
expect(exe?.path).toMatch(/chrome\.exe$/);
exists.mockRestore();
});

View File

@@ -147,7 +147,9 @@ export async function launchClawdChrome(
const exe = resolveBrowserExecutable(resolved);
if (!exe) {
throw new Error("No supported browser found (Chrome/Chromium on macOS, Linux, or Windows).");
throw new Error(
"No supported browser found (Chrome/Brave/Edge/Chromium on macOS, Linux, or Windows).",
);
}
const userDataDir = resolveClawdUserDataDir(profile.name);

View File

@@ -23,7 +23,7 @@ export type BrowserConfig = {
cdpUrl?: string;
/** Accent color for the clawd browser profile (hex). Default: #FF4500 */
color?: string;
/** Override the browser executable path (macOS/Linux). */
/** Override the browser executable path (all platforms). */
executablePath?: string;
/** Start Chrome headless (best-effort). Default: false */
headless?: boolean;

View File

@@ -389,7 +389,11 @@ export const chatHandlers: GatewayRequestHandlers = {
: path.join(path.dirname(storePath), `${sessionId}.jsonl`);
if (!fs.existsSync(transcriptPath)) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "transcript file not found"));
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "transcript file not found"),
);
return;
}
@@ -416,7 +420,11 @@ export const chatHandlers: GatewayRequestHandlers = {
fs.appendFileSync(transcriptPath, `${JSON.stringify(transcriptEntry)}\n`, "utf-8");
} catch (err) {
const errMessage = err instanceof Error ? err.message : String(err);
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, `failed to write transcript: ${errMessage}`));
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, `failed to write transcript: ${errMessage}`),
);
return;
}