!refactor(browser): remove Chrome extension path and add MCP doctor migration (#47893)

* Browser: replace extension path with Chrome MCP

* Browser: clarify relay stub and doctor checks

* Docs: mark browser MCP migration as breaking

* Browser: reject unsupported profile drivers

* Browser: accept clawd alias on profile create

* Doctor: narrow legacy browser driver migration
This commit is contained in:
Vincent Koc 2026-03-15 23:56:08 -07:00 committed by GitHub
parent 10cd276641
commit 476d948732
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
80 changed files with 644 additions and 5955 deletions

View File

@ -27,6 +27,10 @@ Docs: https://docs.openclaw.ai
- Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873)
- Feishu/ACP: add current-conversation ACP and subagent session binding for supported DMs and topic conversations, including completion delivery back to the originating Feishu conversation. (#46819)
### Breaking
- Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. Thanks @vincentkoc.
### Fixes
- Group mention gating: reject invalid and unsafe nested-repetition `mentionPatterns`, reuse the shared safe config-regex compiler across mention stripping and detection, and cache strip-time regex compilation so noisy groups avoid repeated recompiles.

View File

@ -1,23 +0,0 @@
# OpenClaw Chrome Extension (Browser Relay)
Purpose: attach OpenClaw to an existing Chrome tab so the Gateway can automate it (via the local CDP relay server).
## Dev / load unpacked
1. Build/run OpenClaw Gateway with browser control enabled.
2. Ensure the relay server is reachable at `http://127.0.0.1:18792/` (default).
3. Install the extension to a stable path:
```bash
openclaw browser extension install
openclaw browser extension path
```
4. Chrome → `chrome://extensions` → enable “Developer mode”.
5. “Load unpacked” → select the path printed above.
6. Pin the extension. Click the icon on a tab to attach/detach.
## Options
- `Relay port`: defaults to `18792`.
- `Gateway token`: required. Set this to `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`).

View File

@ -1,64 +0,0 @@
export function reconnectDelayMs(
attempt,
opts = { baseMs: 1000, maxMs: 30000, jitterMs: 1000, random: Math.random },
) {
const baseMs = Number.isFinite(opts.baseMs) ? opts.baseMs : 1000;
const maxMs = Number.isFinite(opts.maxMs) ? opts.maxMs : 30000;
const jitterMs = Number.isFinite(opts.jitterMs) ? opts.jitterMs : 1000;
const random = typeof opts.random === "function" ? opts.random : Math.random;
const safeAttempt = Math.max(0, Number.isFinite(attempt) ? attempt : 0);
const backoff = Math.min(baseMs * 2 ** safeAttempt, maxMs);
return backoff + Math.max(0, jitterMs) * random();
}
export async function deriveRelayToken(gatewayToken, port) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(gatewayToken),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign(
"HMAC",
key,
enc.encode(`openclaw-extension-relay-v1:${port}`),
);
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
export async function buildRelayWsUrl(port, gatewayToken) {
const token = String(gatewayToken || "").trim();
if (!token) {
throw new Error(
"Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)",
);
}
const relayToken = await deriveRelayToken(token, port);
return `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(relayToken)}`;
}
export function isRetryableReconnectError(err) {
const message = err instanceof Error ? err.message : String(err || "");
if (message.includes("Missing gatewayToken")) {
return false;
}
return true;
}
export function isMissingTabError(err) {
const message = (err instanceof Error ? err.message : String(err || "")).toLowerCase();
return (
message.includes("no tab with id") ||
message.includes("no tab with given id") ||
message.includes("tab not found")
);
}
export function isLastRemainingTab(allTabs, tabIdToClose) {
if (!Array.isArray(allTabs)) {
return true;
}
return allTabs.filter((tab) => tab && tab.id !== tabIdToClose).length === 0;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,25 +0,0 @@
{
"manifest_version": 3,
"name": "OpenClaw Browser Relay",
"version": "0.1.0",
"description": "Attach OpenClaw to your existing Chrome tab via a local CDP relay server.",
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"permissions": ["debugger", "tabs", "activeTab", "storage", "alarms", "webNavigation"],
"host_permissions": ["http://127.0.0.1/*", "http://localhost/*"],
"background": { "service_worker": "background.js", "type": "module" },
"action": {
"default_title": "OpenClaw Browser Relay (click to attach/detach)",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"options_ui": { "page": "options.html", "open_in_tab": true }
}

View File

@ -1,57 +0,0 @@
const PORT_GUIDANCE = 'Use gateway port + 3 (for gateway 18789, relay is 18792).'
function hasCdpVersionShape(data) {
return !!data && typeof data === 'object' && 'Browser' in data && 'Protocol-Version' in data
}
export function classifyRelayCheckResponse(res, port) {
if (!res) {
return { action: 'throw', error: 'No response from service worker' }
}
if (res.status === 401) {
return { action: 'status', kind: 'error', message: 'Gateway token rejected. Check token and save again.' }
}
if (res.error) {
return { action: 'throw', error: res.error }
}
if (!res.ok) {
return { action: 'throw', error: `HTTP ${res.status}` }
}
const contentType = String(res.contentType || '')
if (!contentType.includes('application/json')) {
return {
action: 'status',
kind: 'error',
message: `Wrong port: this is likely the gateway, not the relay. ${PORT_GUIDANCE}`,
}
}
if (!hasCdpVersionShape(res.json)) {
return {
action: 'status',
kind: 'error',
message: `Wrong port: expected relay /json/version response. ${PORT_GUIDANCE}`,
}
}
return { action: 'status', kind: 'ok', message: `Relay reachable and authenticated at http://127.0.0.1:${port}/` }
}
export function classifyRelayCheckException(err, port) {
const message = String(err || '').toLowerCase()
if (message.includes('json') || message.includes('syntax')) {
return {
kind: 'error',
message: `Wrong port: this is not a relay endpoint. ${PORT_GUIDANCE}`,
}
}
return {
kind: 'error',
message: `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`,
}
}

View File

@ -1,200 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenClaw Browser Relay</title>
<style>
:root {
color-scheme: light dark;
--accent: #ff5a36;
--panel: color-mix(in oklab, canvas 92%, canvasText 8%);
--border: color-mix(in oklab, canvasText 18%, transparent);
--muted: color-mix(in oklab, canvasText 70%, transparent);
--shadow: 0 10px 30px color-mix(in oklab, canvasText 18%, transparent);
font-family: ui-rounded, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Rounded",
"SF Pro Display", "Segoe UI", sans-serif;
line-height: 1.4;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(1000px 500px at 10% 0%, color-mix(in oklab, var(--accent) 30%, transparent), transparent 70%),
radial-gradient(900px 450px at 90% 0%, color-mix(in oklab, var(--accent) 18%, transparent), transparent 75%),
canvas;
color: canvasText;
}
.wrap {
max-width: 820px;
margin: 36px auto;
padding: 0 24px 48px 24px;
}
header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.logo {
width: 44px;
height: 44px;
border-radius: 14px;
background: color-mix(in oklab, var(--accent) 18%, transparent);
border: 1px solid color-mix(in oklab, var(--accent) 35%, transparent);
box-shadow: var(--shadow);
display: grid;
place-items: center;
}
.logo img {
width: 28px;
height: 28px;
image-rendering: pixelated;
}
h1 {
font-size: 20px;
margin: 0;
letter-spacing: -0.01em;
}
.subtitle {
margin: 2px 0 0 0;
color: var(--muted);
font-size: 13px;
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
padding: 16px;
box-shadow: var(--shadow);
}
.card h2 {
margin: 0 0 10px 0;
font-size: 14px;
letter-spacing: 0.01em;
}
.card p {
margin: 8px 0 0 0;
color: var(--muted);
font-size: 13px;
}
.row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
label {
display: block;
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
input {
width: 160px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: color-mix(in oklab, canvas 92%, canvasText 8%);
color: canvasText;
outline: none;
}
input:focus {
border-color: color-mix(in oklab, var(--accent) 70%, transparent);
box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent) 20%, transparent);
}
button {
padding: 10px 14px;
border-radius: 12px;
border: 1px solid color-mix(in oklab, var(--accent) 55%, transparent);
background: linear-gradient(
180deg,
color-mix(in oklab, var(--accent) 80%, white 20%),
var(--accent)
);
color: white;
font-weight: 650;
letter-spacing: 0.01em;
cursor: pointer;
}
button:active {
transform: translateY(1px);
}
.hint {
margin-top: 10px;
font-size: 12px;
color: var(--muted);
}
code {
font-family: ui-monospace, Menlo, Monaco, Consolas, "SF Mono", monospace;
font-size: 12px;
}
a {
color: color-mix(in oklab, var(--accent) 85%, canvasText 15%);
}
.status {
margin-top: 10px;
font-size: 12px;
color: color-mix(in oklab, var(--accent) 70%, canvasText 30%);
min-height: 16px;
}
.status[data-kind='ok'] {
color: color-mix(in oklab, #16a34a 75%, canvasText 25%);
}
.status[data-kind='error'] {
color: color-mix(in oklab, #ef4444 75%, canvasText 25%);
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="logo" aria-hidden="true">
<img src="icons/icon128.png" alt="" />
</div>
<div>
<h1>OpenClaw Browser Relay</h1>
<p class="subtitle">Click the toolbar button on a tab to attach / detach.</p>
</div>
</header>
<div class="grid">
<div class="card">
<h2>Getting started</h2>
<p>
If you see a red <code>!</code> badge on the extension icon, the relay server is not reachable.
Start OpenClaws browser relay on this machine (Gateway or node host), then click the toolbar button again.
</p>
<p>
Full guide (install, remote Gateway, security): <a href="https://docs.openclaw.ai/tools/chrome-extension" target="_blank" rel="noreferrer">docs.openclaw.ai/tools/chrome-extension</a>
</p>
</div>
<div class="card">
<h2>Relay connection</h2>
<label for="port">Port</label>
<div class="row">
<input id="port" inputmode="numeric" pattern="[0-9]*" />
</div>
<label for="token" style="margin-top: 10px">Gateway token</label>
<div class="row">
<input id="token" type="password" autocomplete="off" style="width: min(520px, 100%)" />
<button id="save" type="button">Save</button>
</div>
<div class="hint">
Default port: <code>18792</code>. Extension connects to: <code id="relay-url">http://127.0.0.1:&lt;port&gt;/</code>.
Gateway token must match <code>gateway.auth.token</code> (or <code>OPENCLAW_GATEWAY_TOKEN</code>).
</div>
<div class="status" id="status"></div>
</div>
</div>
<script type="module" src="options.js"></script>
</div>
</body>
</html>

View File

@ -1,74 +0,0 @@
import { deriveRelayToken } from './background-utils.js'
import { classifyRelayCheckException, classifyRelayCheckResponse } from './options-validation.js'
const DEFAULT_PORT = 18792
function clampPort(value) {
const n = Number.parseInt(String(value || ''), 10)
if (!Number.isFinite(n)) return DEFAULT_PORT
if (n <= 0 || n > 65535) return DEFAULT_PORT
return n
}
function updateRelayUrl(port) {
const el = document.getElementById('relay-url')
if (!el) return
el.textContent = `http://127.0.0.1:${port}/`
}
function setStatus(kind, message) {
const status = document.getElementById('status')
if (!status) return
status.dataset.kind = kind || ''
status.textContent = message || ''
}
async function checkRelayReachable(port, token) {
const url = `http://127.0.0.1:${port}/json/version`
const trimmedToken = String(token || '').trim()
if (!trimmedToken) {
setStatus('error', 'Gateway token required. Save your gateway token to connect.')
return
}
try {
const relayToken = await deriveRelayToken(trimmedToken, port)
// Delegate the fetch to the background service worker to bypass
// CORS preflight on the custom x-openclaw-relay-token header.
const res = await chrome.runtime.sendMessage({
type: 'relayCheck',
url,
token: relayToken,
})
const result = classifyRelayCheckResponse(res, port)
if (result.action === 'throw') throw new Error(result.error)
setStatus(result.kind, result.message)
} catch (err) {
const result = classifyRelayCheckException(err, port)
setStatus(result.kind, result.message)
}
}
async function load() {
const stored = await chrome.storage.local.get(['relayPort', 'gatewayToken'])
const port = clampPort(stored.relayPort)
const token = String(stored.gatewayToken || '').trim()
document.getElementById('port').value = String(port)
document.getElementById('token').value = token
updateRelayUrl(port)
await checkRelayReachable(port, token)
}
async function save() {
const portInput = document.getElementById('port')
const tokenInput = document.getElementById('token')
const port = clampPort(portInput.value)
const token = String(tokenInput.value || '').trim()
await chrome.storage.local.set({ relayPort: port, gatewayToken: token })
portInput.value = String(port)
tokenInput.value = token
updateRelayUrl(port)
await checkRelayReachable(port, token)
}
document.getElementById('save').addEventListener('click', () => void save())
void load()

View File

@ -8035,21 +8035,7 @@
"storage"
],
"label": "Browser Profile Driver",
"help": "Per-profile browser driver mode: \"openclaw\" (or legacy \"clawd\") or \"extension\" depending on connection/runtime strategy. Use the driver that matches your browser control stack to avoid protocol mismatches.",
"hasChildren": false
},
{
"path": "browser.relayBindHost",
"kind": "core",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Browser Relay Bind Address",
"help": "Bind IP address for the Chrome extension relay listener. Leave unset for loopback-only access, or set an explicit non-loopback IP such as 0.0.0.0 only when the relay must be reachable across network namespaces (for example WSL2) and the surrounding network is already trusted.",
"help": "Per-profile browser driver mode. Use \"openclaw\" (or legacy \"clawd\") for CDP-based profiles, or use \"existing-session\" for host-local Chrome MCP attachment.",
"hasChildren": false
},
{

View File

@ -707,8 +707,7 @@
{"recordType":"path","path":"browser.profiles.*.cdpPort","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile CDP Port","help":"Per-profile local CDP port used when connecting to browser instances by port instead of URL. Use unique ports per profile to avoid connection collisions.","hasChildren":false}
{"recordType":"path","path":"browser.profiles.*.cdpUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile CDP URL","help":"Per-profile CDP websocket URL used for explicit remote browser routing by profile name. Use this when profile connections terminate on remote hosts or tunnels.","hasChildren":false}
{"recordType":"path","path":"browser.profiles.*.color","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile Accent Color","help":"Per-profile accent color for visual differentiation in dashboards and browser-related UI hints. Use distinct colors for high-signal operator recognition of active profiles.","hasChildren":false}
{"recordType":"path","path":"browser.profiles.*.driver","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile Driver","help":"Per-profile browser driver mode: \"openclaw\" (or legacy \"clawd\") or \"extension\" depending on connection/runtime strategy. Use the driver that matches your browser control stack to avoid protocol mismatches.","hasChildren":false}
{"recordType":"path","path":"browser.relayBindHost","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Browser Relay Bind Address","help":"Bind IP address for the Chrome extension relay listener. Leave unset for loopback-only access, or set an explicit non-loopback IP such as 0.0.0.0 only when the relay must be reachable across network namespaces (for example WSL2) and the surrounding network is already trusted.","hasChildren":false}
{"recordType":"path","path":"browser.profiles.*.driver","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile Driver","help":"Per-profile browser driver mode. Use \"openclaw\" (or legacy \"clawd\") for CDP-based profiles, or use \"existing-session\" for host-local Chrome MCP attachment.","hasChildren":false}
{"recordType":"path","path":"browser.remoteCdpHandshakeTimeoutMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Remote CDP Handshake Timeout (ms)","help":"Timeout in milliseconds for post-connect CDP handshake readiness checks against remote browser targets. Raise this for slow-start remote browsers and lower to fail fast in automation loops.","hasChildren":false}
{"recordType":"path","path":"browser.remoteCdpTimeoutMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Remote CDP Timeout (ms)","help":"Timeout in milliseconds for connecting to a remote CDP endpoint before failing the browser attach attempt. Increase for high-latency tunnels, or lower for faster failure detection.","hasChildren":false}
{"recordType":"path","path":"browser.snapshotDefaults","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Browser Snapshot Defaults","help":"Default snapshot capture configuration used when callers do not provide explicit snapshot options. Tune this for consistent capture behavior across channels and automation paths.","hasChildren":true}

View File

@ -1,9 +1,9 @@
---
summary: "CLI reference for `openclaw browser` (profiles, tabs, actions, extension relay)"
summary: "CLI reference for `openclaw browser` (profiles, tabs, actions, Chrome MCP, and CDP)"
read_when:
- You use `openclaw browser` and want examples for common tasks
- You want to control a browser running on another machine via a node host
- You want to use the Chrome extension relay (attach/detach via toolbar button)
- You want to attach to your local signed-in Chrome via Chrome MCP
title: "browser"
---
@ -14,7 +14,6 @@ Manage OpenClaws browser control server and run browser actions (tabs, snapsh
Related:
- Browser tool + API: [Browser tool](/tools/browser)
- Chrome extension relay: [Chrome extension](/tools/chrome-extension)
## Common flags
@ -37,13 +36,14 @@ openclaw browser --browser-profile openclaw snapshot
Profiles are named browser routing configs. In practice:
- `openclaw`: launches/attaches to a dedicated OpenClaw-managed Chrome instance (isolated user data dir).
- `openclaw`: launches or attaches to a dedicated OpenClaw-managed Chrome instance (isolated user data dir).
- `user`: controls your existing signed-in Chrome session via Chrome DevTools MCP.
- `chrome-relay`: controls your existing Chrome tab(s) via the Chrome extension relay.
- custom CDP profiles: point at a local or remote CDP endpoint.
```bash
openclaw browser profiles
openclaw browser create-profile --name work --color "#FF5A36"
openclaw browser create-profile --name chrome-live --driver existing-session
openclaw browser delete-profile --name work
```
@ -84,20 +84,17 @@ openclaw browser click <ref>
openclaw browser type <ref> "hello"
```
## Chrome extension relay (attach via toolbar button)
## Existing Chrome via MCP
This mode lets the agent control an existing Chrome tab that you attach manually (it does not auto-attach).
Install the unpacked extension to a stable path:
Use the built-in `user` profile, or create your own `existing-session` profile:
```bash
openclaw browser extension install
openclaw browser extension path
openclaw browser --browser-profile user tabs
openclaw browser create-profile --name chrome-live --driver existing-session
openclaw browser --browser-profile chrome-live tabs
```
Then Chrome → `chrome://extensions` → enable “Developer mode” → “Load unpacked” → select the printed folder.
Full guide: [Chrome extension](/tools/chrome-extension)
This path is host-only. For Docker, headless servers, Browserless, or other remote setups, use a CDP profile instead.
## Remote browser control (node host proxy)

View File

@ -10,6 +10,6 @@ title: "docs"
Search the live docs index.
```bash
openclaw docs browser extension
openclaw docs browser existing-session
openclaw docs sandbox allowHostControl
```

View File

@ -1018,7 +1018,6 @@
"pages": [
"tools/browser",
"tools/browser-login",
"tools/chrome-extension",
"tools/browser-linux-troubleshooting"
]
},
@ -1613,7 +1612,6 @@
"pages": [
"zh-CN/tools/browser",
"zh-CN/tools/browser-login",
"zh-CN/tools/chrome-extension",
"zh-CN/tools/browser-linux-troubleshooting"
]
},

View File

@ -2442,13 +2442,13 @@ See [Plugins](/tools/plugin).
profiles: {
openclaw: { cdpPort: 18800, color: "#FF4500" },
work: { cdpPort: 18801, color: "#0066CC" },
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" },
},
color: "#FF4500",
// headless: false,
// noSandbox: false,
// extraArgs: [],
// relayBindHost: "0.0.0.0", // only when the extension relay must be reachable across namespaces (for example WSL2)
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
// attachOnly: false,
},
@ -2462,11 +2462,11 @@ See [Plugins](/tools/plugin).
- `ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias.
- In strict mode, use `ssrfPolicy.hostnameAllowlist` and `ssrfPolicy.allowedHostnames` for explicit exceptions.
- Remote profiles are attach-only (start/stop/reset disabled).
- `existing-session` profiles are host-only and use Chrome MCP instead of CDP.
- Auto-detect order: default browser if Chromium-based → Chrome → Brave → Edge → Chromium → Chrome Canary.
- Control service: loopback only (port derived from `gateway.port`, default `18791`).
- `extraArgs` appends extra launch flags to local Chromium startup (for example
`--disable-gpu`, window sizing, or debug flags).
- `relayBindHost` changes where the Chrome extension relay listens. Leave unset for loopback-only access; set an explicit non-loopback bind address such as `0.0.0.0` only when the relay must cross a namespace boundary (for example WSL2) and the host network is already trusted.
---

View File

@ -63,6 +63,7 @@ cat ~/.openclaw/openclaw.json
- Health check + restart prompt.
- Skills status summary (eligible/missing/blocked).
- Config normalization for legacy values.
- Browser migration checks for legacy Chrome extension configs and Chrome MCP readiness.
- OpenCode provider override warnings (`models.providers.opencode` / `models.providers.opencode-go`).
- Legacy on-disk state migration (sessions/agent dir/WhatsApp auth).
- Legacy cron store migration (`jobId`, `schedule.cron`, top-level delivery/payload fields, payload `provider`, simple `notify: true` webhook fallback jobs).
@ -128,6 +129,8 @@ Current migrations:
- `agent.model`/`allowedModels`/`modelAliases`/`modelFallbacks`/`imageModelFallbacks`
`agents.defaults.models` + `agents.defaults.model.primary/fallbacks` + `agents.defaults.imageModel.primary/fallbacks`
- `browser.ssrfPolicy.allowPrivateNetwork``browser.ssrfPolicy.dangerouslyAllowPrivateNetwork`
- `browser.profiles.*.driver: "extension"``"existing-session"`
- remove `browser.relayBindHost` (legacy extension relay setting)
Doctor warnings also include account-default guidance for multi-account channels:
@ -141,6 +144,33 @@ manually, it overrides the built-in OpenCode catalog from `@mariozechner/pi-ai`.
That can force models onto the wrong API or zero out costs. Doctor warns so you
can remove the override and restore per-model API routing + costs.
### 2c) Browser migration and Chrome MCP readiness
If your browser config still points at the removed Chrome extension path, doctor
normalizes it to the current host-local Chrome MCP attach model:
- `browser.profiles.*.driver: "extension"` becomes `"existing-session"`
- `browser.relayBindHost` is removed
Doctor also audits the host-local Chrome MCP path when you use `defaultProfile:
"user"` or a configured `existing-session` profile:
- checks whether Google Chrome is installed on the same host
- checks the detected Chrome version and warns when it is below Chrome 144
- reminds you to enable remote debugging in Chrome at
`chrome://inspect/#remote-debugging`
Doctor cannot enable the Chrome-side setting for you. Host-local Chrome MCP
still requires:
- Google Chrome 144+ on the gateway/node host
- Chrome running locally
- remote debugging enabled in Chrome
- approving the first attach consent prompt in Chrome
This check does **not** apply to Docker, sandbox, remote-browser, or other
headless flows. Those continue to use raw CDP.
### 3) Legacy state migrations (disk layout)
Doctor can migrate older on-disk layouts into the current structure:

View File

@ -990,10 +990,9 @@ access those accounts and data. Treat browser profiles as **sensitive state**:
- Treat browser downloads as untrusted input; prefer an isolated downloads directory.
- Disable browser sync/password managers in the agent profile if possible (reduces blast radius).
- For remote gateways, assume “browser control” is equivalent to “operator access” to whatever that profile can reach.
- Keep the Gateway and node hosts tailnet-only; avoid exposing relay/control ports to LAN or public Internet.
- The Chrome extension relays CDP endpoint is auth-gated; only OpenClaw clients can connect.
- Keep the Gateway and node hosts tailnet-only; avoid exposing browser control ports to LAN or public Internet.
- Disable browser proxy routing when you dont need it (`gateway.nodes.browser.mode="off"`).
- Chrome extension relay mode is **not** “safer”; it can take over your existing Chrome tabs. Assume it can act as you in whatever that tab/profile can reach.
- Chrome MCP existing-session mode is **not** “safer”; it can act as you in whatever that host Chrome profile can reach.
### Browser SSRF policy (trusted-network default)

View File

@ -289,19 +289,18 @@ Look for:
- Valid browser executable path.
- CDP profile reachability.
- Extension relay tab attachment (if an extension relay profile is configured).
- Local Chrome availability for `existing-session` / `user` profiles.
Common signatures:
- `Failed to start Chrome CDP on port` → browser process failed to launch.
- `browser.executablePath not found` → configured path is invalid.
- `Chrome extension relay is running, but no tab is connected` → extension relay not attached.
- `No Chrome tabs found for profile="user"` → the Chrome MCP attach profile has no open local Chrome tabs.
- `Browser attachOnly is enabled ... not reachable` → attach-only profile has no reachable target.
Related:
- [/tools/browser-linux-troubleshooting](/tools/browser-linux-troubleshooting)
- [/tools/chrome-extension](/tools/chrome-extension)
- [/tools/browser](/tools/browser)
## If you upgraded and something suddenly broke

View File

@ -80,7 +80,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Can OpenClaw run tasks on a schedule or continuously in the background?](#can-openclaw-run-tasks-on-a-schedule-or-continuously-in-the-background)
- [Can I run Apple macOS-only skills from Linux?](#can-i-run-apple-macos-only-skills-from-linux)
- [Do you have a Notion or HeyGen integration?](#do-you-have-a-notion-or-heygen-integration)
- [How do I install the Chrome extension for browser takeover?](#how-do-i-install-the-chrome-extension-for-browser-takeover)
- [How do I use my existing signed-in Chrome with OpenClaw?](#how-do-i-use-my-existing-signed-in-chrome-with-openclaw)
- [Sandboxing and memory](#sandboxing-and-memory)
- [Is there a dedicated sandboxing doc?](#is-there-a-dedicated-sandboxing-doc)
- [How do I bind a host folder into the sandbox?](#how-do-i-bind-a-host-folder-into-the-sandbox)
@ -1214,22 +1214,23 @@ clawhub update --all
ClawHub installs into `./skills` under your current directory (or falls back to your configured OpenClaw workspace); OpenClaw treats that as `<workspace>/skills` on the next session. For shared skills across agents, place them in `~/.openclaw/skills/<name>/SKILL.md`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills) and [ClawHub](/tools/clawhub).
### How do I install the Chrome extension for browser takeover
### How do I use my existing signed-in Chrome with OpenClaw
Use the built-in installer, then load the unpacked extension in Chrome:
Use the built-in `user` browser profile, which attaches through Chrome DevTools MCP:
```bash
openclaw browser extension install
openclaw browser extension path
openclaw browser --browser-profile user tabs
openclaw browser --browser-profile user snapshot
```
Then Chrome → `chrome://extensions` → enable "Developer mode" → "Load unpacked" → pick that folder.
If you want a custom name, create an explicit MCP profile:
Full guide (including remote Gateway + security notes): [Chrome extension](/tools/chrome-extension)
```bash
openclaw browser create-profile --name chrome-live --driver existing-session
openclaw browser --browser-profile chrome-live tabs
```
If the Gateway runs on the same machine as Chrome (default setup), you usually **do not** need anything extra.
If the Gateway runs elsewhere, run a node host on the browser machine so the Gateway can proxy browser actions.
You still need to click the extension button on the tab you want to control (it doesn't auto-attach).
This path is host-local. If the Gateway runs elsewhere, either run a node host on the browser machine or use remote CDP instead.
## Sandboxing and memory
@ -1665,13 +1666,12 @@ setup is an always-on host plus your laptop as a node.
- **No inbound SSH required.** Nodes connect out to the Gateway WebSocket and use device pairing.
- **Safer execution controls.** `system.run` is gated by node allowlists/approvals on that laptop.
- **More device tools.** Nodes expose `canvas`, `camera`, and `screen` in addition to `system.run`.
- **Local browser automation.** Keep the Gateway on a VPS, but run Chrome locally and relay control
with the Chrome extension + a node host on the laptop.
- **Local browser automation.** Keep the Gateway on a VPS, but run Chrome locally through a node host on the laptop, or attach to local Chrome on the host via Chrome MCP.
SSH is fine for ad-hoc shell access, but nodes are simpler for ongoing agent workflows and
device automation.
Docs: [Nodes](/nodes), [Nodes CLI](/cli/nodes), [Chrome extension](/tools/chrome-extension).
Docs: [Nodes](/nodes), [Nodes CLI](/cli/nodes), [Browser](/tools/browser).
### Should I install on a second laptop or just add a node
@ -2039,18 +2039,18 @@ Yes. Use **Multi-Agent Routing** to run multiple isolated agents and route inbou
channel/account/peer. Slack is supported as a channel and can be bound to specific agents.
Browser access is powerful but not "do anything a human can" - anti-bot, CAPTCHAs, and MFA can
still block automation. For the most reliable browser control, use the Chrome extension relay
on the machine that runs the browser (and keep the Gateway anywhere).
still block automation. For the most reliable browser control, use local Chrome MCP on the host,
or use CDP on the machine that actually runs the browser.
Best-practice setup:
- Always-on Gateway host (VPS/Mac mini).
- One agent per role (bindings).
- Slack channel(s) bound to those agents.
- Local browser via extension relay (or a node) when needed.
- Local browser via Chrome MCP or a node when needed.
Docs: [Multi-Agent Routing](/concepts/multi-agent), [Slack](/channels/slack),
[Browser](/tools/browser), [Chrome extension](/tools/chrome-extension), [Nodes](/nodes).
[Browser](/tools/browser), [Nodes](/nodes).
## Models: defaults, selection, aliases, switching

View File

@ -278,13 +278,13 @@ flowchart TD
Good output looks like:
- Browser status shows `running: true` and a chosen browser/profile.
- `openclaw` profile starts or `chrome` relay has an attached tab.
- `openclaw` starts, or `user` can see local Chrome tabs.
Common log signatures:
- `Failed to start Chrome CDP on port` → local browser launch failed.
- `browser.executablePath not found` → configured binary path is wrong.
- `Chrome extension relay is running, but no tab is connected` → extension not attached.
- `No Chrome tabs found for profile="user"` → the Chrome MCP attach profile has no open local Chrome tabs.
- `Browser attachOnly is enabled ... not reachable` → attach-only profile has no live CDP target.
Deep pages:
@ -292,7 +292,6 @@ flowchart TD
- [/gateway/troubleshooting#browser-tool-fails](/gateway/troubleshooting#browser-tool-fails)
- [/tools/browser-linux-troubleshooting](/tools/browser-linux-troubleshooting)
- [/tools/browser-wsl2-windows-remote-cdp-troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting)
- [/tools/chrome-extension](/tools/chrome-extension)
</Accordion>
</AccordionGroup>

View File

@ -713,6 +713,7 @@ an optional noVNC observer (headful via Xvfb).
Notes:
- Docker and other headless/container browser flows stay on raw CDP. Chrome MCP `existing-session` is for host-local Chrome, not container takeover.
- Headful (Xvfb) reduces bot blocking vs headless.
- Headless can still be used by setting `agents.defaults.sandbox.browser.headless=true`.
- No full desktop environment (GNOME) is needed; Xvfb provides the display.

View File

@ -121,19 +121,18 @@ curl -s http://127.0.0.1:18791/tabs
| `browser.attachOnly` | Don't launch browser, only attach to existing | `false` |
| `browser.cdpPort` | Chrome DevTools Protocol port | `18800` |
### Problem: "Chrome extension relay is running, but no tab is connected"
### Problem: "No Chrome tabs found for profile=\"user\""
You're using an extension relay profile. It expects the OpenClaw
browser extension to be attached to a live tab.
You're using an `existing-session` / Chrome MCP profile. OpenClaw can see local Chrome,
but there are no open tabs available to attach to.
Fix options:
1. **Use the managed browser:** `openclaw browser start --browser-profile openclaw`
(or set `browser.defaultProfile: "openclaw"`).
2. **Use the extension relay:** install the extension, open a tab, and click the
OpenClaw extension icon to attach it.
2. **Use Chrome MCP:** make sure local Chrome is running with at least one open tab, then retry with `--browser-profile user`.
Notes:
- The `chrome-relay` profile uses your **system default Chromium browser** when possible.
- `user` is host-only. For Linux servers, containers, or remote hosts, prefer CDP profiles.
- Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl`; only set those for remote CDP.

View File

@ -24,7 +24,6 @@ For agent browser tool calls:
- Default choice: the agent should use its isolated `openclaw` browser.
- Use `profile="user"` only when existing logged-in sessions matter and the user is at the computer to click/approve any attach prompt.
- Use `profile="chrome-relay"` only for the Chrome extension / toolbar-button attach flow.
- If you have multiple user-browser profiles, specify the profile explicitly instead of guessing.
Two easy ways to access it:

View File

@ -1,9 +1,9 @@
---
summary: "Troubleshoot WSL2 Gateway + Windows Chrome remote CDP and extension-relay setups in layers"
summary: "Troubleshoot WSL2 Gateway + Windows Chrome remote CDP in layers"
read_when:
- Running OpenClaw Gateway in WSL2 while Chrome lives on Windows
- Seeing overlapping browser/control-ui errors across WSL2 and Windows
- Deciding between raw remote CDP and the Chrome extension relay in split-host setups
- Deciding between host-local Chrome MCP and raw remote CDP in split-host setups
title: "WSL2 + Windows + remote Chrome CDP troubleshooting"
---
@ -21,27 +21,27 @@ It also covers the layered failure pattern from [issue #39369](https://github.co
You have two valid patterns:
### Option 1: Raw remote CDP
### Option 1: Raw remote CDP from WSL2 to Windows
Use a remote browser profile that points from WSL2 to a Windows Chrome CDP endpoint.
Choose this when:
- you only need browser control
- you are comfortable exposing Chrome remote debugging to WSL2
- you do not need the Chrome extension relay
- the Gateway stays inside WSL2
- Chrome runs on Windows
- you need browser control to cross the WSL2/Windows boundary
### Option 2: Chrome extension relay
### Option 2: Host-local Chrome MCP
Use the built-in `chrome-relay` profile plus the OpenClaw Chrome extension.
Use `existing-session` / `user` only when the Gateway itself runs on the same host as Chrome.
Choose this when:
- you want to attach to an existing Windows Chrome tab with the toolbar button
- you want extension-based control instead of raw `--remote-debugging-port`
- the relay itself must be reachable across the WSL2/Windows boundary
- OpenClaw and Chrome are on the same machine
- you want the local signed-in browser state
- you do not need cross-host browser transport
If you use the extension relay across namespaces, `browser.relayBindHost` is the important setting introduced in [Browser](/tools/browser) and [Chrome extension](/tools/chrome-extension).
For WSL2 Gateway + Windows Chrome, prefer raw remote CDP. Chrome MCP is host-local, not a WSL2-to-Windows bridge.
## Working architecture
@ -62,7 +62,6 @@ Several failures can overlap:
- `gateway.controlUi.allowedOrigins` does not match the page origin
- token or pairing is missing
- the browser profile points at the wrong address
- the extension relay is still loopback-only when you actually need cross-namespace access
Because of that, fixing one layer can still leave a different error visible.
@ -145,31 +144,7 @@ Notes:
- keep `attachOnly: true` for externally managed browsers
- test the same URL with `curl` before expecting OpenClaw to succeed
### Layer 4: If you use the Chrome extension relay instead
If the browser machine and the Gateway are separated by a namespace boundary, the relay may need a non-loopback bind address.
Example:
```json5
{
browser: {
enabled: true,
defaultProfile: "chrome-relay",
relayBindHost: "0.0.0.0",
},
}
```
Use this only when needed:
- default behavior is safer because the relay stays loopback-only
- `0.0.0.0` expands exposure surface
- keep Gateway auth, node pairing, and the surrounding network private
If you do not need the extension relay, prefer the raw remote CDP profile above.
### Layer 5: Verify the Control UI layer separately
### Layer 4: Verify the Control UI layer separately
Open the UI from Windows:
@ -185,7 +160,7 @@ Helpful page:
- [Control UI](/web/control-ui)
### Layer 6: Verify end-to-end browser control
### Layer 5: Verify end-to-end browser control
From WSL2:
@ -194,12 +169,6 @@ openclaw browser open https://example.com --browser-profile remote
openclaw browser tabs --browser-profile remote
```
For the extension relay:
```bash
openclaw browser tabs --browser-profile chrome-relay
```
Good result:
- the tab opens in Windows Chrome
@ -220,8 +189,8 @@ Treat each message as a layer-specific clue:
- WSL2 cannot reach the configured `cdpUrl`
- `gateway timeout after 1500ms`
- often still CDP reachability or a slow/unreachable remote endpoint
- `Chrome extension relay is running, but no tab is connected`
- extension relay profile selected, but no attached tab exists yet
- `No Chrome tabs found for profile="user"`
- local Chrome MCP profile selected where no host-local tabs are available
## Fast triage checklist
@ -229,11 +198,11 @@ Treat each message as a layer-specific clue:
2. WSL2: does `curl http://WINDOWS_HOST_OR_IP:9222/json/version` work?
3. OpenClaw config: does `browser.profiles.<name>.cdpUrl` use that exact WSL2-reachable address?
4. Control UI: are you opening `http://127.0.0.1:18789/` instead of a LAN IP?
5. Extension relay only: do you actually need `browser.relayBindHost`, and if so is it set explicitly?
5. Are you trying to use `existing-session` across WSL2 and Windows instead of raw remote CDP?
## Practical takeaway
The setup is usually viable. The hard part is that browser transport, Control UI origin security, token/pairing, and extension-relay topology can each fail independently while looking similar from the user side.
The setup is usually viable. The hard part is that browser transport, Control UI origin security, and token/pairing can each fail independently while looking similar from the user side.
When in doubt:

View File

@ -18,8 +18,7 @@ Beginner view:
- Think of it as a **separate, agent-only browser**.
- The `openclaw` profile does **not** touch your personal browser profile.
- The agent can **open tabs, read pages, click, and type** in a safe lane.
- The built-in `user` profile attaches to your real signed-in Chrome session;
`chrome-relay` is the explicit extension-relay profile.
- The built-in `user` profile attaches to your real signed-in Chrome session via Chrome MCP.
## What you get
@ -43,21 +42,17 @@ openclaw browser --browser-profile openclaw snapshot
If you get “Browser disabled”, enable it in config (see below) and restart the
Gateway.
## Profiles: `openclaw` vs `user` vs `chrome-relay`
## Profiles: `openclaw` vs `user`
- `openclaw`: managed, isolated browser (no extension required).
- `user`: built-in Chrome MCP attach profile for your **real signed-in Chrome**
session.
- `chrome-relay`: extension relay to your **system browser** (requires the
OpenClaw extension to be attached to a tab).
For agent browser tool calls:
- Default: use the isolated `openclaw` browser.
- Prefer `profile="user"` when existing logged-in sessions matter and the user
is at the computer to click/approve any attach prompt.
- Use `profile="chrome-relay"` only when the user explicitly wants the Chrome
extension / toolbar-button attach flow.
- `profile` is the explicit override when you want a specific browser mode.
Set `browser.defaultProfile: "openclaw"` if you want managed mode by default.
@ -93,11 +88,6 @@ Browser settings live in `~/.openclaw/openclaw.json`.
attachOnly: true,
color: "#00AA00",
},
"chrome-relay": {
driver: "extension",
cdpUrl: "http://127.0.0.1:18792",
color: "#00AA00",
},
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" },
},
},
@ -107,10 +97,10 @@ Browser settings live in `~/.openclaw/openclaw.json`.
Notes:
- The browser control service binds to loopback on a port derived from `gateway.port`
(default: `18791`, which is gateway + 2). The relay uses the next port (`18792`).
(default: `18791`, which is gateway + 2).
- If you override the Gateway port (`gateway.port` or `OPENCLAW_GATEWAY_PORT`),
the derived browser ports shift to stay in the same “family”.
- `cdpUrl` defaults to the relay port when unset.
- `cdpUrl` defaults to the managed local CDP port when unset.
- `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP reachability checks.
- `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket reachability checks.
- Browser navigation/open-tab is SSRF-guarded before navigation and best-effort re-checked on final `http(s)` URL after navigation.
@ -119,7 +109,7 @@ Notes:
- `browser.ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias for compatibility.
- `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.
- Default profile is `openclaw` (OpenClaw-managed standalone browser). Use `defaultProfile: "user"` to opt into the signed-in user browser, or `defaultProfile: "chrome-relay"` for the extension relay.
- Default profile is `openclaw` (OpenClaw-managed standalone browser). Use `defaultProfile: "user"` to opt into the signed-in user browser.
- Auto-detect order: system default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
- Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl` — set those only for remote CDP.
- `driver: "existing-session"` uses Chrome DevTools MCP instead of raw CDP. Do
@ -287,77 +277,18 @@ OpenClaw supports multiple named profiles (routing configs). Profiles can be:
- **openclaw-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
- **existing session**: your existing Chrome profile via Chrome DevTools MCP auto-connect
Defaults:
- The `openclaw` profile is auto-created if missing.
- The `chrome-relay` profile is built-in for the Chrome extension relay (points at `http://127.0.0.1:18792` by default).
- Existing-session profiles are opt-in; create them with `--driver existing-session`.
- The `user` profile is built-in for Chrome MCP existing-session attach.
- Existing-session profiles are opt-in beyond `user`; create them with `--driver existing-session`.
- Local CDP ports allocate from **1880018899** by default.
- Deleting a profile moves its local data directory to Trash.
All control endpoints accept `?profile=<name>`; the CLI uses `--browser-profile`.
## Chrome extension relay (use your existing Chrome)
OpenClaw can also drive **your existing Chrome tabs** (no separate “openclaw” Chrome instance) via a local CDP relay + a Chrome extension.
Full guide: [Chrome extension](/tools/chrome-extension)
Flow:
- The Gateway runs locally (same machine) or a node host runs on the browser machine.
- A local **relay server** listens at a loopback `cdpUrl` (default: `http://127.0.0.1:18792`).
- You click the **OpenClaw Browser Relay** extension icon on a tab to attach (it does not auto-attach).
- The agent controls that tab via the normal `browser` tool, by selecting the right profile.
If the Gateway runs elsewhere, run a node host on the browser machine so the Gateway can proxy browser actions.
### Sandboxed sessions
If the agent session is sandboxed, the `browser` tool may default to `target="sandbox"` (sandbox browser).
Chrome extension relay takeover requires host browser control, so either:
- run the session unsandboxed, or
- set `agents.defaults.sandbox.browser.allowHostControl: true` and use `target="host"` when calling the tool.
### Setup
1. Load the extension (dev/unpacked):
```bash
openclaw browser extension install
```
- Chrome → `chrome://extensions` → enable “Developer mode”
- “Load unpacked” → select the directory printed by `openclaw browser extension path`
- Pin the extension, then click it on the tab you want to control (badge shows `ON`).
2. Use it:
- CLI: `openclaw browser --browser-profile chrome-relay tabs`
- Agent tool: `browser` with `profile="chrome-relay"`
Optional: if you want a different name or relay port, create your own profile:
```bash
openclaw browser create-profile \
--name my-chrome \
--driver extension \
--cdp-url http://127.0.0.1:18792 \
--color "#00AA00"
```
Notes:
- This mode relies on Playwright-on-CDP for most operations (screenshots/snapshots/actions).
- Detach by clicking the extension icon again.
- Agent use: prefer `profile="user"` for logged-in sites. Use `profile="chrome-relay"`
only when you specifically want the extension flow. The user must be present
to click the extension and attach the tab.
## Chrome existing-session via MCP
OpenClaw can also attach to a running Chrome profile through the official
@ -404,13 +335,14 @@ What to check if attach does not work:
- Chrome is version `144+`
- remote debugging is enabled at `chrome://inspect/#remote-debugging`
- Chrome showed and you accepted the attach consent prompt
- `openclaw doctor` migrates old extension-based browser config and checks that
Chrome is installed locally with a compatible version, but it cannot enable
Chrome-side remote debugging for you
Agent use:
- Use `profile="user"` when you need the users logged-in browser state.
- If you use a custom existing-session profile, pass that explicit profile name.
- Prefer `profile="user"` over `profile="chrome-relay"` unless the user
explicitly wants the extension / attach-tab flow.
- Only choose this mode when the user is at the computer to approve the attach
prompt.
- the Gateway or node host can spawn `npx chrome-devtools-mcp@latest --autoConnect`
@ -427,21 +359,10 @@ Notes:
captures from snapshots, but not CSS `--element` selectors.
- Existing-session `wait --url` supports exact, substring, and glob patterns
like other browser drivers. `wait --load networkidle` is not supported yet.
- Some features still require the extension relay or managed browser path, such
as PDF export and download interception.
- Leave the relay loopback-only by default. If the relay must be reachable from a different network namespace (for example Gateway in WSL2, Chrome on Windows), set `browser.relayBindHost` to an explicit bind address such as `0.0.0.0` while keeping the surrounding network private and authenticated.
WSL2 / cross-namespace example:
```json5
{
browser: {
enabled: true,
relayBindHost: "0.0.0.0",
defaultProfile: "chrome-relay",
},
}
```
- Some features still require the managed browser path, such as PDF export and
download interception.
- Existing-session is host-local. If Chrome lives on a different machine or a
different network namespace, use remote CDP or a node host instead.
## Isolation guarantees
@ -496,7 +417,6 @@ If gateway auth is configured, browser HTTP routes require auth too:
Some features (navigate/act/AI snapshot/role snapshot, element screenshots, PDF) require
Playwright. If Playwright isnt installed, those endpoints return a clear 501
error. ARIA snapshots and basic screenshots still work for openclaw-managed Chrome.
For the Chrome extension relay driver, ARIA snapshots and screenshots require Playwright.
If you see `Playwright is not available in this gateway build`, install the full
Playwright package (not `playwright-core`) and restart the gateway, or reinstall

View File

@ -1,203 +0,0 @@
---
summary: "Chrome extension: let OpenClaw drive your existing Chrome tab"
read_when:
- You want the agent to drive an existing Chrome tab (toolbar button)
- You need remote Gateway + local browser automation via Tailscale
- You want to understand the security implications of browser takeover
title: "Chrome Extension"
---
# Chrome extension (browser relay)
The OpenClaw Chrome extension lets the agent control your **existing Chrome tabs** (your normal Chrome window) instead of launching a separate openclaw-managed Chrome profile.
Attach/detach happens via a **single Chrome toolbar button**.
If you want Chromes official DevTools MCP attach flow instead of the OpenClaw
extension relay, use an `existing-session` browser profile instead. See
[Browser](/tools/browser#chrome-existing-session-via-mcp). For Chromes own
setup docs, see [Chrome for Developers: Use Chrome DevTools MCP with your
browser session](https://developer.chrome.com/blog/chrome-devtools-mcp-debug-your-browser-session)
and the [Chrome DevTools MCP README](https://github.com/ChromeDevTools/chrome-devtools-mcp).
## What it is (concept)
There are three parts:
- **Browser control service** (Gateway or node): the API the agent/tool calls (via the Gateway)
- **Local relay server** (loopback CDP): bridges between the control server and the extension (`http://127.0.0.1:18792` by default)
- **Chrome MV3 extension**: attaches to the active tab using `chrome.debugger` and pipes CDP messages to the relay
OpenClaw then controls the attached tab through the normal `browser` tool surface (selecting the right profile).
## Install / load (unpacked)
1. Install the extension to a stable local path:
```bash
openclaw browser extension install
```
2. Print the installed extension directory path:
```bash
openclaw browser extension path
```
3. Chrome → `chrome://extensions`
- Enable “Developer mode”
- “Load unpacked” → select the directory printed above
4. Pin the extension.
## Updates (no build step)
The extension ships inside the OpenClaw release (npm package) as static files. There is no separate “build” step.
After upgrading OpenClaw:
- Re-run `openclaw browser extension install` to refresh the installed files under your OpenClaw state directory.
- Chrome → `chrome://extensions` → click “Reload” on the extension.
## Use it (set gateway token once)
To use the extension relay, create a browser profile for it:
Before first attach, open extension Options and set:
- `Port` (default `18792`)
- `Gateway token` (must match `gateway.auth.token` / `OPENCLAW_GATEWAY_TOKEN`)
Then create a profile:
```bash
openclaw browser create-profile \
--name my-chrome \
--driver extension \
--cdp-url http://127.0.0.1:18792 \
--color "#00AA00"
```
Use it:
- CLI: `openclaw browser --browser-profile my-chrome tabs`
- Agent tool: `browser` with `profile="my-chrome"`
### Custom Gateway ports
If you're using a custom gateway port, the extension relay port is automatically derived:
**Extension Relay Port = Gateway Port + 3**
Example: if `gateway.port: 19001`, then:
- Extension relay port: `19004` (gateway + 3)
Configure the extension to use the derived relay port in the extension Options page.
## Attach / detach (toolbar button)
- Open the tab you want OpenClaw to control.
- Click the extension icon.
- Badge shows `ON` when attached.
- Click again to detach.
## Which tab does it control?
- It does **not** automatically control “whatever tab youre looking at”.
- It controls **only the tab(s) you explicitly attached** by clicking the toolbar button.
- To switch: open the other tab and click the extension icon there.
## Badge + common errors
- `ON`: attached; OpenClaw can drive that tab.
- `…`: connecting to the local relay.
- `!`: relay not reachable/authenticated (most common: relay server not running, or gateway token missing/wrong).
If you see `!`:
- Make sure the Gateway is running locally (default setup), or run a node host on this machine if the Gateway runs elsewhere.
- Open the extension Options page; it validates relay reachability + gateway-token auth.
## Remote Gateway (use a node host)
### Local Gateway (same machine as Chrome) — usually **no extra steps**
If the Gateway runs on the same machine as Chrome, it starts the browser control service on loopback
and auto-starts the relay server. The extension talks to the local relay; the CLI/tool calls go to the Gateway.
### Remote Gateway (Gateway runs elsewhere) — **run a node host**
If your Gateway runs on another machine, start a node host on the machine that runs Chrome.
The Gateway will proxy browser actions to that node; the extension + relay stay local to the browser machine.
If multiple nodes are connected, pin one with `gateway.nodes.browser.node` or set `gateway.nodes.browser.mode`.
## Sandboxing (tool containers)
If your agent session is sandboxed (`agents.defaults.sandbox.mode != "off"`), the `browser` tool can be restricted:
- By default, sandboxed sessions often target the **sandbox browser** (`target="sandbox"`), not your host Chrome.
- Chrome extension relay takeover requires controlling the **host** browser control server.
Options:
- Easiest: use the extension from a **non-sandboxed** session/agent.
- Or allow host browser control for sandboxed sessions:
```json5
{
agents: {
defaults: {
sandbox: {
browser: {
allowHostControl: true,
},
},
},
},
}
```
Then ensure the tool isnt denied by tool policy, and (if needed) call `browser` with `target="host"`.
Debugging: `openclaw sandbox explain`
## Remote access tips
- Keep the Gateway and node host on the same tailnet; avoid exposing relay ports to LAN or public Internet.
- Pair nodes intentionally; disable browser proxy routing if you dont want remote control (`gateway.nodes.browser.mode="off"`).
- Leave the relay on loopback unless you have a real cross-namespace need. For WSL2 or similar split-host setups, set `browser.relayBindHost` to an explicit bind address such as `0.0.0.0`, then keep access constrained with Gateway auth, node pairing, and a private network.
## How “extension path” works
`openclaw browser extension path` prints the **installed** on-disk directory containing the extension files.
The CLI intentionally does **not** print a `node_modules` path. Always run `openclaw browser extension install` first to copy the extension to a stable location under your OpenClaw state directory.
If you move or delete that install directory, Chrome will mark the extension as broken until you reload it from a valid path.
## Security implications (read this)
This is powerful and risky. Treat it like giving the model “hands on your browser”.
- The extension uses Chromes debugger API (`chrome.debugger`). When attached, the model can:
- click/type/navigate in that tab
- read page content
- access whatever the tabs logged-in session can access
- **This is not isolated** like the dedicated openclaw-managed profile.
- If you attach to your daily-driver profile/tab, youre granting access to that account state.
Recommendations:
- Prefer a dedicated Chrome profile (separate from your personal browsing) for extension relay usage.
- Keep the Gateway and any node hosts tailnet-only; rely on Gateway auth + node pairing.
- Avoid exposing relay ports over LAN (`0.0.0.0`) and avoid Funnel (public).
- The relay blocks non-extension origins and requires gateway-token auth for both `/cdp` and `/extension`.
Related:
- Browser tool overview: [Browser](/tools/browser)
- Security audit: [Security](/gateway/security)
- Tailscale setup: [Tailscale](/gateway/tailscale)

View File

@ -318,8 +318,7 @@ Common parameters:
- All actions accept optional `profile` parameter for multi-instance support.
- Omit `profile` for the safe default: isolated OpenClaw-managed browser (`openclaw`).
- Use `profile="user"` for the real local host browser when existing logins/cookies matter and the user is present to click/approve any attach prompt.
- Use `profile="chrome-relay"` only for the Chrome extension / toolbar-button attach flow.
- `profile="user"` and `profile="chrome-relay"` are host-only; do not combine them with sandbox/node targets.
- `profile="user"` is host-only; do not combine it with sandbox/node targets.
- When `profile` is omitted, uses `browser.defaultProfile` (defaults to `openclaw`).
- Profile names: lowercase alphanumeric + hyphens only (max 64 chars).
- Port range: 18800-18899 (~100 profiles max).

View File

@ -60,7 +60,6 @@ const unitIsolatedFilesRaw = [
// Skills discovery/snapshot suites are filesystem-heavy and high-variance in vmForks lanes.
"src/agents/skills.test.ts",
"src/agents/skills.buildworkspaceskillsnapshot.test.ts",
"src/browser/extension-relay.test.ts",
"extensions/acpx/src/runtime.test.ts",
// Shell-heavy script harness can contend under vmForks startup bursts.
"test/scripts/ios-team-id.test.ts",

View File

@ -1,7 +1,9 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { browserAct, browserConsoleMessages } from "../../browser/client-actions.js";
import { browserSnapshot, browserTabs } from "../../browser/client.js";
import { resolveBrowserConfig, resolveProfile } from "../../browser/config.js";
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../browser/constants.js";
import { getBrowserProfileCapabilities } from "../../browser/profile-capabilities.js";
import { loadConfig } from "../../config/config.js";
import { wrapExternalContent } from "../../security/external-content.js";
import { imageResultFromFile, jsonResult } from "./common.js";
@ -74,7 +76,17 @@ function formatConsoleToolResult(result: {
}
function isChromeStaleTargetError(profile: string | undefined, err: unknown): boolean {
if (profile !== "chrome-relay" && profile !== "chrome" && profile !== "user") {
if (!profile) {
return false;
}
if (profile === "user") {
const msg = String(err);
return msg.includes("404:") && msg.includes("tab not found");
}
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const browserProfile = resolveProfile(resolved, profile);
if (!browserProfile || !getBrowserProfileCapabilities(browserProfile).usesChromeMcp) {
return false;
}
const msg = String(err);
@ -334,12 +346,8 @@ export async function executeActAction(params: {
}
}
if (!tabs.length) {
// Extension relay profiles need the toolbar icon click; Chrome MCP just needs Chrome running.
const isRelayProfile = profile === "chrome-relay" || profile === "chrome";
throw new Error(
isRelayProfile
? "No Chrome tabs are attached via the OpenClaw Browser Relay extension. Click the toolbar icon on the tab you want to control (badge ON), then retry."
: `No Chrome tabs found for profile="${profile}". Make sure Chrome (v146+) is running and has open tabs, then retry.`,
`No Chrome tabs found for profile="${profile}". Make sure Chrome (v144+) is running and has open tabs, then retry.`,
{ cause: err },
);
}

View File

@ -64,12 +64,7 @@ const browserConfigMocks = vi.hoisted(() => ({
if (!profile) {
return null;
}
const driver =
profile.driver === "extension"
? "extension"
: profile.driver === "existing-session"
? "existing-session"
: "openclaw";
const driver = profile.driver === "existing-session" ? "existing-session" : "openclaw";
if (driver === "existing-session") {
return {
name,
@ -287,29 +282,6 @@ describe("browser tool snapshot maxChars", () => {
expect(opts?.mode).toBeUndefined();
});
it("defaults to host when using an explicit extension relay profile (even in sandboxed sessions)", async () => {
setResolvedBrowserProfiles({
relay: {
driver: "extension",
cdpUrl: "http://127.0.0.1:18792",
color: "#0066CC",
},
});
const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
await tool.execute?.("call-1", {
action: "snapshot",
profile: "relay",
snapshotFormat: "ai",
});
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
undefined,
expect.objectContaining({
profile: "relay",
}),
);
});
it("defaults to host when using profile=user (even in sandboxed sessions)", async () => {
setResolvedBrowserProfiles({
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },

View File

@ -290,7 +290,7 @@ function shouldPreferHostForProfile(profileName: string | undefined) {
return false;
}
const capabilities = getBrowserProfileCapabilities(profile);
return capabilities.requiresRelay || capabilities.usesChromeMcp;
return capabilities.usesChromeMcp;
}
export function createBrowserTool(opts?: {
@ -307,7 +307,7 @@ export function createBrowserTool(opts?: {
description: [
"Control the browser via OpenClaw's browser control server (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions).",
"Browser choice: omit profile by default for the isolated OpenClaw-managed browser (`openclaw`).",
'For the logged-in user browser on the local host, use profile="user". Chrome (v146+) must be running. Use only when existing logins/cookies matter and the user is present.',
'For the logged-in user browser on the local host, use profile="user". Chrome (v144+) must be running. Use only when existing logins/cookies matter and the user is present.',
'When a node-hosted browser proxy is available, the tool may auto-route to it. Pin a node with node=<id|name> or target="node".',
"When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc).",
'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.',
@ -326,7 +326,7 @@ export function createBrowserTool(opts?: {
if (requestedNode && target && target !== "node") {
throw new Error('node is only supported with target="node".');
}
// User-browser profiles (existing-session, extension relay) are host-only.
// User-browser profiles (existing-session) are host-only.
const isUserBrowserProfile = shouldPreferHostForProfile(profile);
if (isUserBrowserProfile) {
if (requestedNode || target === "node") {

View File

@ -7,15 +7,10 @@ import {
import { __test } from "./client-fetch.js";
import { resolveBrowserConfig, resolveProfile } from "./config.js";
import { shouldRejectBrowserMutation } from "./csrf.js";
import {
ensureChromeExtensionRelayServer,
stopChromeExtensionRelayServer,
} from "./extension-relay.js";
import { toBoolean } from "./routes/utils.js";
import type { BrowserServerState } from "./server-context.js";
import { listKnownProfileNames } from "./server-context.js";
import { resolveTargetIdFromTabs } from "./target-id.js";
import { getFreePort } from "./test-port.js";
describe("toBoolean", () => {
it("parses yes/no and 1/0", () => {
@ -195,29 +190,8 @@ describe("cdp.helpers", () => {
expect(headers.Authorization).toBe("Bearer token");
});
it("does not add relay header for unknown loopback ports", () => {
const headers = getHeadersWithAuth("http://127.0.0.1:19444/json/version");
expect(headers["x-openclaw-relay-token"]).toBeUndefined();
});
it("adds relay header for known relay ports", async () => {
const port = await getFreePort();
const cdpUrl = `http://127.0.0.1:${port}`;
const prev = process.env.OPENCLAW_GATEWAY_TOKEN;
process.env.OPENCLAW_GATEWAY_TOKEN = "test-gateway-token";
try {
await ensureChromeExtensionRelayServer({ cdpUrl });
const headers = getHeadersWithAuth(`${cdpUrl}/json/version`);
expect(headers["x-openclaw-relay-token"]).toBeTruthy();
expect(headers["x-openclaw-relay-token"]).not.toBe("test-gateway-token");
} finally {
await stopChromeExtensionRelayServer({ cdpUrl }).catch(() => {});
if (prev === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = prev;
}
}
it("does not add custom headers when none are required", () => {
expect(getHeadersWithAuth("http://127.0.0.1:19444/json/version")).toEqual({});
});
});

View File

@ -6,7 +6,6 @@ import { redactSensitiveText } from "../logging/redact.js";
import { getDirectAgentForCdp, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js";
import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js";
import { resolveBrowserRateLimitMessage } from "./client-fetch.js";
import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js";
export { isLoopbackHost };
@ -76,8 +75,7 @@ export type CdpSendFn = (
) => Promise<unknown>;
export function getHeadersWithAuth(url: string, headers: Record<string, string> = {}) {
const relayHeaders = getChromeExtensionRelayAuthHeaders(url);
const mergedHeaders = { ...relayHeaders, ...headers };
const mergedHeaders = { ...headers };
try {
const parsed = new URL(url);
const hasAuthHeader = Object.keys(mergedHeaders).some(

View File

@ -1,133 +0,0 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
type BackgroundUtilsModule = {
buildRelayWsUrl: (port: number, gatewayToken: string) => Promise<string>;
deriveRelayToken: (gatewayToken: string, port: number) => Promise<string>;
isLastRemainingTab: (
allTabs: Array<{ id?: number | undefined } | null | undefined>,
tabIdToClose: number,
) => boolean;
isMissingTabError: (err: unknown) => boolean;
isRetryableReconnectError: (err: unknown) => boolean;
reconnectDelayMs: (
attempt: number,
opts?: { baseMs?: number; maxMs?: number; jitterMs?: number; random?: () => number },
) => number;
};
const require = createRequire(import.meta.url);
const BACKGROUND_UTILS_MODULE = "../../assets/chrome-extension/background-utils.js";
async function loadBackgroundUtils(): Promise<BackgroundUtilsModule> {
try {
return require(BACKGROUND_UTILS_MODULE) as BackgroundUtilsModule;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes("Unexpected token 'export'")) {
throw error;
}
return (await import(BACKGROUND_UTILS_MODULE)) as BackgroundUtilsModule;
}
}
const {
buildRelayWsUrl,
deriveRelayToken,
isLastRemainingTab,
isMissingTabError,
isRetryableReconnectError,
reconnectDelayMs,
} = await loadBackgroundUtils();
describe("chrome extension background utils", () => {
it("derives relay token as HMAC-SHA256 of gateway token and port", async () => {
const relayToken = await deriveRelayToken("test-gateway-token", 18792);
expect(relayToken).toMatch(/^[0-9a-f]{64}$/);
const relayToken2 = await deriveRelayToken("test-gateway-token", 18792);
expect(relayToken).toBe(relayToken2);
const differentPort = await deriveRelayToken("test-gateway-token", 9999);
expect(relayToken).not.toBe(differentPort);
});
it("builds websocket url with derived relay token", async () => {
const url = await buildRelayWsUrl(18792, "test-token");
expect(url).toMatch(/^ws:\/\/127\.0\.0\.1:18792\/extension\?token=[0-9a-f]{64}$/);
});
it("throws when gateway token is missing", async () => {
await expect(buildRelayWsUrl(18792, "")).rejects.toThrow(/Missing gatewayToken/);
await expect(buildRelayWsUrl(18792, " ")).rejects.toThrow(/Missing gatewayToken/);
});
it("uses exponential backoff from attempt index", () => {
expect(reconnectDelayMs(0, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe(
1000,
);
expect(reconnectDelayMs(1, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe(
2000,
);
expect(reconnectDelayMs(4, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe(
16000,
);
});
it("caps reconnect delay at max", () => {
const delay = reconnectDelayMs(20, {
baseMs: 1000,
maxMs: 30000,
jitterMs: 0,
random: () => 0,
});
expect(delay).toBe(30000);
});
it("adds jitter using injected random source", () => {
const delay = reconnectDelayMs(3, {
baseMs: 1000,
maxMs: 30000,
jitterMs: 1000,
random: () => 0.25,
});
expect(delay).toBe(8250);
});
it("sanitizes invalid attempts and options", () => {
expect(reconnectDelayMs(-2, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe(
1000,
);
expect(
reconnectDelayMs(Number.NaN, {
baseMs: Number.NaN,
maxMs: Number.NaN,
jitterMs: Number.NaN,
random: () => 0,
}),
).toBe(1000);
});
it("marks missing token errors as non-retryable", () => {
expect(
isRetryableReconnectError(
new Error("Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)"),
),
).toBe(false);
});
it("keeps transient network errors retryable", () => {
expect(isRetryableReconnectError(new Error("WebSocket connect timeout"))).toBe(true);
expect(isRetryableReconnectError(new Error("Relay server not reachable"))).toBe(true);
});
it("recognizes missing-tab debugger errors", () => {
expect(isMissingTabError(new Error("No tab with given id"))).toBe(true);
expect(isMissingTabError(new Error("tab not found"))).toBe(true);
expect(isMissingTabError(new Error("Cannot access a chrome:// URL"))).toBe(false);
});
it("blocks closing the final remaining tab only", () => {
expect(isLastRemainingTab([{ id: 7 }], 7)).toBe(true);
expect(isLastRemainingTab([{ id: 7 }, { id: 8 }], 7)).toBe(false);
expect(isLastRemainingTab([{ id: 7 }, { id: 8 }], 8)).toBe(false);
});
});

View File

@ -1,29 +0,0 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
type ExtensionManifest = {
background?: { service_worker?: string; type?: string };
permissions?: string[];
};
function readManifest(): ExtensionManifest {
const path = resolve(process.cwd(), "assets/chrome-extension/manifest.json");
return JSON.parse(readFileSync(path, "utf8")) as ExtensionManifest;
}
describe("chrome extension manifest", () => {
it("keeps background worker configured as module", () => {
const manifest = readManifest();
expect(manifest.background?.service_worker).toBe("background.js");
expect(manifest.background?.type).toBe("module");
});
it("includes resilience permissions", () => {
const permissions = readManifest().permissions ?? [];
expect(permissions).toContain("alarms");
expect(permissions).toContain("webNavigation");
expect(permissions).toContain("storage");
expect(permissions).toContain("debugger");
});
});

View File

@ -1,113 +0,0 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
type RelayCheckResponse = {
status?: number;
ok?: boolean;
error?: string;
contentType?: string;
json?: unknown;
};
type RelayCheckStatus =
| { action: "throw"; error: string }
| { action: "status"; kind: "ok" | "error"; message: string };
type RelayCheckExceptionStatus = { kind: "error"; message: string };
type OptionsValidationModule = {
classifyRelayCheckResponse: (
res: RelayCheckResponse | null | undefined,
port: number,
) => RelayCheckStatus;
classifyRelayCheckException: (err: unknown, port: number) => RelayCheckExceptionStatus;
};
const require = createRequire(import.meta.url);
const OPTIONS_VALIDATION_MODULE = "../../assets/chrome-extension/options-validation.js";
async function loadOptionsValidation(): Promise<OptionsValidationModule> {
try {
return require(OPTIONS_VALIDATION_MODULE) as OptionsValidationModule;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes("Unexpected token 'export'")) {
throw error;
}
return (await import(OPTIONS_VALIDATION_MODULE)) as OptionsValidationModule;
}
}
const { classifyRelayCheckException, classifyRelayCheckResponse } = await loadOptionsValidation();
describe("chrome extension options validation", () => {
it("maps 401 response to token rejected error", () => {
const result = classifyRelayCheckResponse({ status: 401, ok: false }, 18792);
expect(result).toEqual({
action: "status",
kind: "error",
message: "Gateway token rejected. Check token and save again.",
});
});
it("maps non-json 200 response to wrong-port error", () => {
const result = classifyRelayCheckResponse(
{ status: 200, ok: true, contentType: "text/html; charset=utf-8", json: null },
18792,
);
expect(result).toEqual({
action: "status",
kind: "error",
message:
"Wrong port: this is likely the gateway, not the relay. Use gateway port + 3 (for gateway 18789, relay is 18792).",
});
});
it("maps json response without CDP keys to wrong-port error", () => {
const result = classifyRelayCheckResponse(
{ status: 200, ok: true, contentType: "application/json", json: { ok: true } },
18792,
);
expect(result).toEqual({
action: "status",
kind: "error",
message:
"Wrong port: expected relay /json/version response. Use gateway port + 3 (for gateway 18789, relay is 18792).",
});
});
it("maps valid relay json response to success", () => {
const result = classifyRelayCheckResponse(
{
status: 200,
ok: true,
contentType: "application/json",
json: { Browser: "Chrome/136", "Protocol-Version": "1.3" },
},
19004,
);
expect(result).toEqual({
action: "status",
kind: "ok",
message: "Relay reachable and authenticated at http://127.0.0.1:19004/",
});
});
it("maps syntax/json exceptions to wrong-endpoint error", () => {
const result = classifyRelayCheckException(new Error("SyntaxError: Unexpected token <"), 18792);
expect(result).toEqual({
kind: "error",
message:
"Wrong port: this is not a relay endpoint. Use gateway port + 3 (for gateway 18789, relay is 18792).",
});
});
it("maps generic exceptions to relay unreachable error", () => {
const result = classifyRelayCheckException(new Error("TypeError: Failed to fetch"), 18792);
expect(result).toEqual({
kind: "error",
message:
"Relay not reachable/authenticated at http://127.0.0.1:18792/. Start OpenClaw browser relay and verify token.",
});
});
});

View File

@ -193,7 +193,7 @@ async function createRealSession(profileName: string): Promise<ChromeMcpSession>
await client.close().catch(() => {});
throw new BrowserProfileUnavailableError(
`Chrome MCP existing-session attach failed for profile "${profileName}". ` +
`Make sure Chrome (v146+) is running. ` +
`Make sure Chrome (v144+) is running. ` +
`Details: ${String(err)}`,
);
}

View File

@ -9,6 +9,8 @@ export type BrowserExecutable = {
path: string;
};
const CHROME_VERSION_RE = /(\d+)(?:\.\d+){0,3}/;
const CHROMIUM_BUNDLE_IDS = new Set([
"com.google.Chrome",
"com.google.Chrome.beta",
@ -453,6 +455,22 @@ function findFirstExecutable(candidates: Array<BrowserExecutable>): BrowserExecu
return null;
}
function findFirstChromeExecutable(candidates: string[]): BrowserExecutable | null {
for (const candidate of candidates) {
if (exists(candidate)) {
return {
kind:
candidate.toLowerCase().includes("sxs") || candidate.toLowerCase().includes("canary")
? "canary"
: "chrome",
path: candidate,
};
}
}
return null;
}
export function findChromeExecutableMac(): BrowserExecutable | null {
const candidates: Array<BrowserExecutable> = [
{
@ -506,6 +524,18 @@ export function findChromeExecutableMac(): BrowserExecutable | null {
return findFirstExecutable(candidates);
}
export function findGoogleChromeExecutableMac(): BrowserExecutable | null {
return findFirstChromeExecutable([
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
path.join(os.homedir(), "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
path.join(
os.homedir(),
"Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
),
]);
}
export function findChromeExecutableLinux(): BrowserExecutable | null {
const candidates: Array<BrowserExecutable> = [
{ kind: "chrome", path: "/usr/bin/google-chrome" },
@ -525,6 +555,16 @@ export function findChromeExecutableLinux(): BrowserExecutable | null {
return findFirstExecutable(candidates);
}
export function findGoogleChromeExecutableLinux(): BrowserExecutable | null {
return findFirstChromeExecutable([
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome-beta",
"/usr/bin/google-chrome-unstable",
"/snap/bin/google-chrome",
]);
}
export function findChromeExecutableWindows(): BrowserExecutable | null {
const localAppData = process.env.LOCALAPPDATA ?? "";
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
@ -596,6 +636,56 @@ export function findChromeExecutableWindows(): BrowserExecutable | null {
return findFirstExecutable(candidates);
}
export function findGoogleChromeExecutableWindows(): BrowserExecutable | null {
const localAppData = process.env.LOCALAPPDATA ?? "";
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
const joinWin = path.win32.join;
const candidates: string[] = [];
if (localAppData) {
candidates.push(joinWin(localAppData, "Google", "Chrome", "Application", "chrome.exe"));
candidates.push(joinWin(localAppData, "Google", "Chrome SxS", "Application", "chrome.exe"));
}
candidates.push(joinWin(programFiles, "Google", "Chrome", "Application", "chrome.exe"));
candidates.push(joinWin(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"));
return findFirstChromeExecutable(candidates);
}
export function resolveGoogleChromeExecutableForPlatform(
platform: NodeJS.Platform,
): BrowserExecutable | null {
if (platform === "darwin") {
return findGoogleChromeExecutableMac();
}
if (platform === "linux") {
return findGoogleChromeExecutableLinux();
}
if (platform === "win32") {
return findGoogleChromeExecutableWindows();
}
return null;
}
export function readBrowserVersion(executablePath: string): string | null {
const output = execText(executablePath, ["--version"], 2000);
if (!output) {
return null;
}
return output.replace(/\s+/g, " ").trim();
}
export function parseBrowserMajorVersion(rawVersion: string | null | undefined): number | null {
const match = String(rawVersion ?? "").match(CHROME_VERSION_RE);
if (!match?.[1]) {
return null;
}
const major = Number.parseInt(match[1], 10);
return Number.isFinite(major) ? major : null;
}
export function resolveBrowserExecutableForPlatform(
resolved: ResolvedBrowserConfig,
platform: NodeJS.Platform,

View File

@ -5,7 +5,7 @@ export type BrowserTransport = "cdp" | "chrome-mcp";
export type BrowserStatus = {
enabled: boolean;
profile?: string;
driver?: "openclaw" | "extension" | "existing-session";
driver?: "openclaw" | "existing-session";
transport?: BrowserTransport;
running: boolean;
cdpReady?: boolean;
@ -31,7 +31,7 @@ export type ProfileStatus = {
cdpPort: number | null;
cdpUrl: string | null;
color: string;
driver: "openclaw" | "extension" | "existing-session";
driver: "openclaw" | "existing-session";
running: boolean;
tabCount: number;
isDefault: boolean;
@ -172,7 +172,7 @@ export async function browserCreateProfile(
name: string;
color?: string;
cdpUrl?: string;
driver?: "openclaw" | "extension" | "existing-session";
driver?: "openclaw" | "existing-session";
},
): Promise<BrowserCreateProfileResult> {
return await fetchBrowserJson<BrowserCreateProfileResult>(

View File

@ -188,13 +188,6 @@ describe("browser config", () => {
expect(profile?.cdpIsLoopback).toBe(true);
});
it("trims relayBindHost when configured", () => {
const resolved = resolveBrowserConfig({
relayBindHost: " 0.0.0.0 ",
});
expect(resolved.relayBindHost).toBe("0.0.0.0");
});
it("rejects unsupported protocols", () => {
expect(() => resolveBrowserConfig({ cdpUrl: "ftp://127.0.0.1:18791" })).toThrow(
"must be http(s) or ws(s)",
@ -289,7 +282,6 @@ describe("browser config", () => {
const resolved = resolveBrowserConfig({
profiles: {
"chrome-live": { driver: "existing-session", attachOnly: true, color: "#00AA00" },
relay: { driver: "extension", cdpUrl: "http://127.0.0.1:18792", color: "#0066CC" },
work: { cdpPort: 18801, color: "#0066CC" },
},
});
@ -300,9 +292,6 @@ describe("browser config", () => {
const managed = resolveProfile(resolved, "openclaw")!;
expect(getBrowserProfileCapabilities(managed).usesChromeMcp).toBe(false);
const extension = resolveProfile(resolved, "relay")!;
expect(getBrowserProfileCapabilities(extension).usesChromeMcp).toBe(false);
const work = resolveProfile(resolved, "work")!;
expect(getBrowserProfileCapabilities(work).usesChromeMcp).toBe(false);
});

View File

@ -36,7 +36,6 @@ export type ResolvedBrowserConfig = {
profiles: Record<string, BrowserProfileConfig>;
ssrfPolicy?: SsrFPolicy;
extraArgs: string[];
relayBindHost?: string;
};
export type ResolvedBrowserProfile = {
@ -46,7 +45,7 @@ export type ResolvedBrowserProfile = {
cdpHost: string;
cdpIsLoopback: boolean;
color: string;
driver: "openclaw" | "extension" | "existing-session";
driver: "openclaw" | "existing-session";
attachOnly: boolean;
};
@ -279,8 +278,6 @@ export function resolveBrowserConfig(
? cfg.extraArgs.filter((a): a is string => typeof a === "string" && a.trim().length > 0)
: [];
const ssrfPolicy = resolveBrowserSsrFPolicy(cfg);
const relayBindHost = cfg?.relayBindHost?.trim() || undefined;
return {
enabled,
evaluateEnabled,
@ -301,7 +298,6 @@ export function resolveBrowserConfig(
profiles,
ssrfPolicy,
extraArgs,
relayBindHost,
};
}
@ -322,12 +318,7 @@ export function resolveProfile(
let cdpHost = resolved.cdpHost;
let cdpPort = profile.cdpPort ?? 0;
let cdpUrl = "";
const driver =
profile.driver === "extension"
? "extension"
: profile.driver === "existing-session"
? "existing-session"
: "openclaw";
const driver = profile.driver === "existing-session" ? "existing-session" : "openclaw";
if (driver === "existing-session") {
// existing-session uses Chrome MCP auto-connect; no CDP port/URL needed

View File

@ -1,117 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const loadConfigMock = vi.hoisted(() => vi.fn());
vi.mock("../config/config.js", () => ({
loadConfig: loadConfigMock,
}));
const { resolveRelayAcceptedTokensForPort } = await import("./extension-relay-auth.js");
describe("extension-relay-auth SecretRef handling", () => {
const ENV_KEYS = ["OPENCLAW_GATEWAY_TOKEN", "CLAWDBOT_GATEWAY_TOKEN", "CUSTOM_GATEWAY_TOKEN"];
const envSnapshot = new Map<string, string | undefined>();
beforeEach(() => {
for (const key of ENV_KEYS) {
envSnapshot.set(key, process.env[key]);
delete process.env[key];
}
loadConfigMock.mockReset();
});
afterEach(() => {
for (const key of ENV_KEYS) {
const previous = envSnapshot.get(key);
if (previous === undefined) {
delete process.env[key];
} else {
process.env[key] = previous;
}
}
});
it("resolves env-template gateway.auth.token from its referenced env var", async () => {
loadConfigMock.mockReturnValue({
gateway: { auth: { token: "${CUSTOM_GATEWAY_TOKEN}" } },
secrets: { providers: { default: { source: "env" } } },
});
process.env.CUSTOM_GATEWAY_TOKEN = "resolved-gateway-token";
const tokens = await resolveRelayAcceptedTokensForPort(18790);
expect(tokens).toContain("resolved-gateway-token");
expect(tokens[0]).not.toBe("resolved-gateway-token");
});
it("fails closed when env-template gateway.auth.token is unresolved", async () => {
loadConfigMock.mockReturnValue({
gateway: { auth: { token: "${CUSTOM_GATEWAY_TOKEN}" } },
secrets: { providers: { default: { source: "env" } } },
});
await expect(resolveRelayAcceptedTokensForPort(18790)).rejects.toThrow(
"gateway.auth.token SecretRef is unavailable",
);
});
it("resolves file-backed gateway.auth.token SecretRef", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-relay-file-secret-"));
const secretFile = path.join(tempDir, "relay-secrets.json");
await fs.writeFile(secretFile, JSON.stringify({ relayToken: "resolved-file-relay-token" }));
await fs.chmod(secretFile, 0o600);
loadConfigMock.mockReturnValue({
secrets: {
providers: {
fileProvider: { source: "file", path: secretFile, mode: "json" },
},
},
gateway: {
auth: {
token: { source: "file", provider: "fileProvider", id: "/relayToken" },
},
},
});
try {
const tokens = await resolveRelayAcceptedTokensForPort(18790);
expect(tokens.length).toBeGreaterThan(0);
expect(tokens).toContain("resolved-file-relay-token");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("resolves exec-backed gateway.auth.token SecretRef", async () => {
const execProgram = [
"process.stdout.write(",
"JSON.stringify({ protocolVersion: 1, values: { RELAY_TOKEN: 'resolved-exec-relay-token' } })",
");",
].join("");
loadConfigMock.mockReturnValue({
secrets: {
providers: {
execProvider: {
source: "exec",
command: process.execPath,
args: ["-e", execProgram],
allowInsecurePath: true,
},
},
},
gateway: {
auth: {
token: { source: "exec", provider: "execProvider", id: "RELAY_TOKEN" },
},
},
});
const tokens = await resolveRelayAcceptedTokensForPort(18790);
expect(tokens.length).toBeGreaterThan(0);
expect(tokens).toContain("resolved-exec-relay-token");
});
});

View File

@ -1,131 +0,0 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
probeAuthenticatedOpenClawRelay,
resolveRelayAcceptedTokensForPort,
resolveRelayAuthTokenForPort,
} from "./extension-relay-auth.js";
import { getFreePort } from "./test-port.js";
async function withRelayServer(
handler: (req: IncomingMessage, res: ServerResponse) => void,
run: (params: { port: number }) => Promise<void>,
) {
const port = await getFreePort();
const server = createServer(handler);
await new Promise<void>((resolve, reject) => {
server.listen(port, "127.0.0.1", () => resolve());
server.once("error", reject);
});
try {
const actualPort = (server.address() as AddressInfo).port;
await run({ port: actualPort });
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
function handleNonVersionRequest(req: IncomingMessage, res: ServerResponse): boolean {
if (req.url?.startsWith("/json/version")) {
return false;
}
res.writeHead(404);
res.end("not found");
return true;
}
async function probeRelay(baseUrl: string, relayAuthToken: string): Promise<boolean> {
return await probeAuthenticatedOpenClawRelay({
baseUrl,
relayAuthHeader: "x-openclaw-relay-token",
relayAuthToken,
});
}
describe("extension-relay-auth", () => {
const TEST_GATEWAY_TOKEN = "test-gateway-token";
let prevGatewayToken: string | undefined;
beforeEach(() => {
prevGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN;
process.env.OPENCLAW_GATEWAY_TOKEN = TEST_GATEWAY_TOKEN;
});
afterEach(() => {
if (prevGatewayToken === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = prevGatewayToken;
}
});
it("derives deterministic relay tokens per port", async () => {
const tokenA1 = await resolveRelayAuthTokenForPort(18790);
const tokenA2 = await resolveRelayAuthTokenForPort(18790);
const tokenB = await resolveRelayAuthTokenForPort(18791);
expect(tokenA1).toBe(tokenA2);
expect(tokenA1).not.toBe(tokenB);
expect(tokenA1).not.toBe(TEST_GATEWAY_TOKEN);
});
it("accepts both relay-scoped and raw gateway tokens for compatibility", async () => {
const tokens = await resolveRelayAcceptedTokensForPort(18790);
expect(tokens).toContain(TEST_GATEWAY_TOKEN);
expect(tokens[0]).not.toBe(TEST_GATEWAY_TOKEN);
expect(tokens[0]).toBe(await resolveRelayAuthTokenForPort(18790));
});
it("accepts authenticated openclaw relay probe responses", async () => {
let seenToken: string | undefined;
await withRelayServer(
(req, res) => {
if (handleNonVersionRequest(req, res)) {
return;
}
const header = req.headers["x-openclaw-relay-token"];
seenToken = Array.isArray(header) ? header[0] : header;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ Browser: "OpenClaw/extension-relay" }));
},
async ({ port }) => {
const token = await resolveRelayAuthTokenForPort(port);
const ok = await probeRelay(`http://127.0.0.1:${port}`, token);
expect(ok).toBe(true);
expect(seenToken).toBe(token);
},
);
});
it("rejects unauthenticated probe responses", async () => {
await withRelayServer(
(req, res) => {
if (handleNonVersionRequest(req, res)) {
return;
}
res.writeHead(401);
res.end("Unauthorized");
},
async ({ port }) => {
const ok = await probeRelay(`http://127.0.0.1:${port}`, "irrelevant");
expect(ok).toBe(false);
},
);
});
it("rejects probe responses with wrong browser identity", async () => {
await withRelayServer(
(req, res) => {
if (handleNonVersionRequest(req, res)) {
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ Browser: "FakeRelay" }));
},
async ({ port }) => {
const ok = await probeRelay(`http://127.0.0.1:${port}`, "irrelevant");
expect(ok).toBe(false);
},
);
});
});

View File

@ -1,113 +0,0 @@
import { createHmac } from "node:crypto";
import { loadConfig } from "../config/config.js";
import { normalizeSecretInputString, resolveSecretInputRef } from "../config/types.secrets.js";
import { secretRefKey } from "../secrets/ref-contract.js";
import { resolveSecretRefValues } from "../secrets/resolve.js";
const RELAY_TOKEN_CONTEXT = "openclaw-extension-relay-v1";
const DEFAULT_RELAY_PROBE_TIMEOUT_MS = 500;
const OPENCLAW_RELAY_BROWSER = "OpenClaw/extension-relay";
class SecretRefUnavailableError extends Error {
readonly isSecretRefUnavailable = true;
}
function trimToUndefined(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
async function resolveGatewayAuthToken(): Promise<string | null> {
const envToken =
process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || process.env.CLAWDBOT_GATEWAY_TOKEN?.trim();
if (envToken) {
return envToken;
}
try {
const cfg = loadConfig();
const tokenRef = resolveSecretInputRef({
value: cfg.gateway?.auth?.token,
defaults: cfg.secrets?.defaults,
}).ref;
if (tokenRef) {
const refLabel = `${tokenRef.source}:${tokenRef.provider}:${tokenRef.id}`;
try {
const resolved = await resolveSecretRefValues([tokenRef], {
config: cfg,
env: process.env,
});
const resolvedToken = trimToUndefined(resolved.get(secretRefKey(tokenRef)));
if (resolvedToken) {
return resolvedToken;
}
} catch {
// handled below
}
throw new SecretRefUnavailableError(
`extension relay requires a resolved gateway token, but gateway.auth.token SecretRef is unavailable (${refLabel}). Set OPENCLAW_GATEWAY_TOKEN or resolve your secret provider.`,
);
}
const configToken = normalizeSecretInputString(cfg.gateway?.auth?.token);
if (configToken) {
return configToken;
}
} catch (err) {
if (err instanceof SecretRefUnavailableError) {
throw err;
}
// ignore config read failures; caller can fallback to per-process random token
}
return null;
}
function deriveRelayAuthToken(gatewayToken: string, port: number): string {
return createHmac("sha256", gatewayToken).update(`${RELAY_TOKEN_CONTEXT}:${port}`).digest("hex");
}
export async function resolveRelayAcceptedTokensForPort(port: number): Promise<string[]> {
const gatewayToken = await resolveGatewayAuthToken();
if (!gatewayToken) {
throw new Error(
"extension relay requires gateway auth token (set gateway.auth.token or OPENCLAW_GATEWAY_TOKEN)",
);
}
const relayToken = deriveRelayAuthToken(gatewayToken, port);
if (relayToken === gatewayToken) {
return [relayToken];
}
return [relayToken, gatewayToken];
}
export async function resolveRelayAuthTokenForPort(port: number): Promise<string> {
return (await resolveRelayAcceptedTokensForPort(port))[0];
}
export async function probeAuthenticatedOpenClawRelay(params: {
baseUrl: string;
relayAuthHeader: string;
relayAuthToken: string;
timeoutMs?: number;
}): Promise<boolean> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), params.timeoutMs ?? DEFAULT_RELAY_PROBE_TIMEOUT_MS);
try {
const versionUrl = new URL("/json/version", `${params.baseUrl}/`).toString();
const res = await fetch(versionUrl, {
signal: ctrl.signal,
headers: { [params.relayAuthHeader]: params.relayAuthToken },
});
if (!res.ok) {
return false;
}
const body = (await res.json()) as { Browser?: unknown };
const browserName = typeof body?.Browser === "string" ? body.Browser.trim() : "";
return browserName === OPENCLAW_RELAY_BROWSER;
} catch {
return false;
} finally {
clearTimeout(timer);
}
}

View File

@ -1,49 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { captureEnv } from "../test-utils/env.js";
import {
ensureChromeExtensionRelayServer,
stopChromeExtensionRelayServer,
} from "./extension-relay.js";
import { getFreePort } from "./test-port.js";
describe("chrome extension relay bindHost coordination", () => {
let cdpUrl = "";
let envSnapshot: ReturnType<typeof captureEnv>;
beforeEach(() => {
envSnapshot = captureEnv(["OPENCLAW_GATEWAY_TOKEN"]);
process.env.OPENCLAW_GATEWAY_TOKEN = "test-gateway-token";
});
afterEach(async () => {
if (cdpUrl) {
await stopChromeExtensionRelayServer({ cdpUrl }).catch(() => {});
cdpUrl = "";
}
envSnapshot.restore();
});
it("rebinds the relay when concurrent callers request different bind hosts", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
const [first, second] = await Promise.all([
ensureChromeExtensionRelayServer({ cdpUrl }),
ensureChromeExtensionRelayServer({ cdpUrl, bindHost: "0.0.0.0" }),
]);
const settled = await ensureChromeExtensionRelayServer({
cdpUrl,
bindHost: "0.0.0.0",
});
expect(first.port).toBe(port);
expect(second.port).toBe(port);
expect(second).not.toBe(first);
expect(second.bindHost).toBe("0.0.0.0");
expect(settled).toBe(second);
const res = await fetch(`http://127.0.0.1:${port}/`);
expect(res.status).toBe(200);
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,12 @@
import type { ResolvedBrowserProfile } from "./config.js";
export type BrowserProfileMode =
| "local-managed"
| "local-extension-relay"
| "local-existing-session"
| "remote-cdp";
export type BrowserProfileMode = "local-managed" | "local-existing-session" | "remote-cdp";
export type BrowserProfileCapabilities = {
mode: BrowserProfileMode;
isRemote: boolean;
/** Profile uses the Chrome DevTools MCP server (existing-session driver). */
usesChromeMcp: boolean;
requiresRelay: boolean;
requiresAttachedTab: boolean;
usesPersistentPlaywright: boolean;
supportsPerTabWs: boolean;
supportsJsonTabEndpoints: boolean;
@ -23,28 +17,11 @@ export type BrowserProfileCapabilities = {
export function getBrowserProfileCapabilities(
profile: ResolvedBrowserProfile,
): BrowserProfileCapabilities {
if (profile.driver === "extension") {
return {
mode: "local-extension-relay",
isRemote: false,
usesChromeMcp: false,
requiresRelay: true,
requiresAttachedTab: true,
usesPersistentPlaywright: false,
supportsPerTabWs: false,
supportsJsonTabEndpoints: true,
supportsReset: true,
supportsManagedTabLimit: false,
};
}
if (profile.driver === "existing-session") {
return {
mode: "local-existing-session",
isRemote: false,
usesChromeMcp: true,
requiresRelay: false,
requiresAttachedTab: false,
usesPersistentPlaywright: false,
supportsPerTabWs: false,
supportsJsonTabEndpoints: false,
@ -58,8 +35,6 @@ export function getBrowserProfileCapabilities(
mode: "remote-cdp",
isRemote: true,
usesChromeMcp: false,
requiresRelay: false,
requiresAttachedTab: false,
usesPersistentPlaywright: true,
supportsPerTabWs: false,
supportsJsonTabEndpoints: false,
@ -72,8 +47,6 @@ export function getBrowserProfileCapabilities(
mode: "local-managed",
isRemote: false,
usesChromeMcp: false,
requiresRelay: false,
requiresAttachedTab: false,
usesPersistentPlaywright: false,
supportsPerTabWs: true,
supportsJsonTabEndpoints: true,
@ -96,9 +69,6 @@ export function resolveDefaultSnapshotFormat(params: {
}
const capabilities = getBrowserProfileCapabilities(params.profile);
if (capabilities.mode === "local-extension-relay") {
return "aria";
}
if (capabilities.mode === "local-existing-session") {
return "ai";
}
@ -112,16 +82,12 @@ export function shouldUsePlaywrightForScreenshot(params: {
ref?: string;
element?: string;
}): boolean {
const capabilities = getBrowserProfileCapabilities(params.profile);
return (
capabilities.requiresRelay || !params.wsUrl || Boolean(params.ref) || Boolean(params.element)
);
return !params.wsUrl || Boolean(params.ref) || Boolean(params.element);
}
export function shouldUsePlaywrightForAriaSnapshot(params: {
profile: ResolvedBrowserProfile;
wsUrl?: string;
}): boolean {
const capabilities = getBrowserProfileCapabilities(params.profile);
return capabilities.requiresRelay || !params.wsUrl;
return !params.wsUrl;
}

View File

@ -136,37 +136,6 @@ describe("BrowserProfilesService", () => {
);
});
it("rejects driver=extension with non-loopback cdpUrl", async () => {
const resolved = resolveBrowserConfig({});
const { ctx } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
const service = createBrowserProfilesService(ctx);
await expect(
service.createProfile({
name: "chrome-remote",
driver: "extension",
cdpUrl: "http://10.0.0.42:9222",
}),
).rejects.toThrow(/loopback cdpUrl host/i);
});
it("rejects driver=extension without an explicit cdpUrl", async () => {
const resolved = resolveBrowserConfig({});
const { ctx } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
const service = createBrowserProfilesService(ctx);
await expect(
service.createProfile({
name: "chrome-extension",
driver: "extension",
}),
).rejects.toThrow(/requires an explicit loopback cdpUrl/i);
});
it("creates existing-session profiles as attach-only local entries", async () => {
const resolved = resolveBrowserConfig({});
const { ctx, state } = createCtx(resolved);

View File

@ -3,7 +3,6 @@ import path from "node:path";
import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js";
import { loadConfig, writeConfigFile } from "../config/config.js";
import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js";
import { isLoopbackHost } from "../gateway/net.js";
import { resolveOpenClawUserDataDir } from "./chrome.js";
import { parseHttpUrl, resolveProfile } from "./config.js";
import {
@ -27,7 +26,7 @@ export type CreateProfileParams = {
name: string;
color?: string;
cdpUrl?: string;
driver?: "openclaw" | "extension" | "existing-session";
driver?: "openclaw" | "existing-session";
};
export type CreateProfileResult = {
@ -80,12 +79,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
const createProfile = async (params: CreateProfileParams): Promise<CreateProfileResult> => {
const name = params.name.trim();
const rawCdpUrl = params.cdpUrl?.trim() || undefined;
const driver =
params.driver === "extension"
? "extension"
: params.driver === "existing-session"
? "existing-session"
: undefined;
const driver = params.driver === "existing-session" ? "existing-session" : undefined;
if (!isValidProfileName(name)) {
throw new BrowserValidationError(
@ -117,18 +111,6 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
} catch (err) {
throw new BrowserValidationError(String(err));
}
if (driver === "extension") {
if (!isLoopbackHost(parsed.parsed.hostname)) {
throw new BrowserValidationError(
`driver=extension requires a loopback cdpUrl host, got: ${parsed.parsed.hostname}`,
);
}
if (parsed.parsed.protocol !== "http:" && parsed.parsed.protocol !== "https:") {
throw new BrowserValidationError(
`driver=extension requires an http(s) cdpUrl, got: ${parsed.parsed.protocol.replace(":", "")}`,
);
}
}
if (driver === "existing-session") {
throw new BrowserValidationError(
"driver=existing-session does not accept cdpUrl; it attaches via the Chrome MCP auto-connect flow",
@ -140,9 +122,6 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
color: profileColor,
};
} else {
if (driver === "extension") {
throw new BrowserValidationError("driver=extension requires an explicit loopback cdpUrl");
}
if (driver === "existing-session") {
// existing-session uses Chrome MCP auto-connect; no CDP port needed
profileConfig = {

View File

@ -52,7 +52,7 @@ function createExtensionFallbackBrowserHarness(options?: {
}
describe("pw-session getPageForTargetId", () => {
it("falls back to the only page when CDP session attachment is blocked (extension relays)", async () => {
it("falls back to the only page when Playwright cannot resolve target ids", async () => {
const { browserClose, pages } = createExtensionFallbackBrowserHarness();
const [page] = pages;
@ -94,26 +94,20 @@ describe("pw-session getPageForTargetId", () => {
}
});
it("resolves extension-relay pages from /json/list without probing page CDP sessions first", async () => {
it("resolves pages from /json/list when page CDP probing fails", async () => {
const { newCDPSession, pages } = createExtensionFallbackBrowserHarness({
urls: ["https://alpha.example", "https://beta.example"],
newCDPSessionError: "Target.attachToBrowserTarget: Not allowed",
});
const [, pageB] = pages;
const fetchSpy = vi.spyOn(globalThis, "fetch");
fetchSpy
.mockResolvedValueOnce({
ok: true,
json: async () => ({ Browser: "OpenClaw/extension-relay" }),
} as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => [
{ id: "TARGET_A", url: "https://alpha.example" },
{ id: "TARGET_B", url: "https://beta.example" },
],
} as Response);
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => [
{ id: "TARGET_A", url: "https://alpha.example" },
{ id: "TARGET_B", url: "https://beta.example" },
],
} as Response);
try {
const resolved = await getPageForTargetId({
@ -121,7 +115,7 @@ describe("pw-session getPageForTargetId", () => {
targetId: "TARGET_B",
});
expect(resolved).toBe(pageB);
expect(newCDPSession).not.toHaveBeenCalled();
expect(newCDPSession).toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}

View File

@ -1,61 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const cdpHelperMocks = vi.hoisted(() => ({
fetchJson: vi.fn(),
withCdpSocket: vi.fn(),
}));
const chromeMocks = vi.hoisted(() => ({
getChromeWebSocketUrl: vi.fn(async () => "ws://127.0.0.1:18792/cdp"),
}));
vi.mock("./cdp.helpers.js", async () => {
const actual = await vi.importActual<typeof import("./cdp.helpers.js")>("./cdp.helpers.js");
return {
...actual,
fetchJson: cdpHelperMocks.fetchJson,
withCdpSocket: cdpHelperMocks.withCdpSocket,
};
});
vi.mock("./chrome.js", () => chromeMocks);
import { isExtensionRelayCdpEndpoint, withPageScopedCdpClient } from "./pw-session.page-cdp.js";
import { withPageScopedCdpClient } from "./pw-session.page-cdp.js";
describe("pw-session page-scoped CDP client", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses raw relay /cdp commands for extension endpoints when targetId is known", async () => {
cdpHelperMocks.fetchJson.mockResolvedValue({ Browser: "OpenClaw/extension-relay" });
const send = vi.fn(async () => ({ ok: true }));
cdpHelperMocks.withCdpSocket.mockImplementation(async (_wsUrl, fn) => await fn(send));
const newCDPSession = vi.fn();
const page = {
context: () => ({
newCDPSession,
}),
};
await withPageScopedCdpClient({
cdpUrl: "http://127.0.0.1:18792",
page: page as never,
targetId: "tab-1",
fn: async (pageSend) => {
await pageSend("Page.bringToFront", { foo: "bar" });
},
});
expect(send).toHaveBeenCalledWith("Page.bringToFront", {
foo: "bar",
targetId: "tab-1",
});
expect(newCDPSession).not.toHaveBeenCalled();
});
it("falls back to Playwright page sessions for non-relay endpoints", async () => {
cdpHelperMocks.fetchJson.mockResolvedValue({ Browser: "Chrome/145.0" });
it("uses Playwright page sessions", async () => {
const sessionSend = vi.fn(async () => ({ ok: true }));
const sessionDetach = vi.fn(async () => {});
const newCDPSession = vi.fn(async () => ({
@ -80,15 +31,5 @@ describe("pw-session page-scoped CDP client", () => {
expect(newCDPSession).toHaveBeenCalledWith(page);
expect(sessionSend).toHaveBeenCalledWith("Emulation.setLocaleOverride", { locale: "en-US" });
expect(sessionDetach).toHaveBeenCalledTimes(1);
expect(cdpHelperMocks.withCdpSocket).not.toHaveBeenCalled();
});
it("caches extension-relay endpoint detection by cdpUrl", async () => {
cdpHelperMocks.fetchJson.mockResolvedValue({ Browser: "OpenClaw/extension-relay" });
await expect(isExtensionRelayCdpEndpoint("http://127.0.0.1:19992")).resolves.toBe(true);
await expect(isExtensionRelayCdpEndpoint("http://127.0.0.1:19992/")).resolves.toBe(true);
expect(cdpHelperMocks.fetchJson).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,44 +1,7 @@
import type { CDPSession, Page } from "playwright-core";
import {
appendCdpPath,
fetchJson,
normalizeCdpHttpBaseForJsonEndpoints,
withCdpSocket,
} from "./cdp.helpers.js";
import { getChromeWebSocketUrl } from "./chrome.js";
const OPENCLAW_EXTENSION_RELAY_BROWSER = "OpenClaw/extension-relay";
type PageCdpSend = (method: string, params?: Record<string, unknown>) => Promise<unknown>;
const extensionRelayByCdpUrl = new Map<string, boolean>();
function normalizeCdpUrl(raw: string) {
return raw.replace(/\/$/, "");
}
export async function isExtensionRelayCdpEndpoint(cdpUrl: string): Promise<boolean> {
const normalized = normalizeCdpUrl(cdpUrl);
const cached = extensionRelayByCdpUrl.get(normalized);
if (cached !== undefined) {
return cached;
}
try {
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(normalized);
const version = await fetchJson<{ Browser?: string }>(
appendCdpPath(cdpHttpBase, "/json/version"),
2000,
);
const isRelay = String(version?.Browser ?? "").trim() === OPENCLAW_EXTENSION_RELAY_BROWSER;
extensionRelayByCdpUrl.set(normalized, isRelay);
return isRelay;
} catch {
extensionRelayByCdpUrl.set(normalized, false);
return false;
}
}
async function withPlaywrightPageCdpSession<T>(
page: Page,
fn: (session: CDPSession) => Promise<T>,
@ -57,17 +20,6 @@ export async function withPageScopedCdpClient<T>(opts: {
targetId?: string;
fn: (send: PageCdpSend) => Promise<T>;
}): Promise<T> {
const targetId = opts.targetId?.trim();
if (targetId && (await isExtensionRelayCdpEndpoint(opts.cdpUrl))) {
const wsUrl = await getChromeWebSocketUrl(opts.cdpUrl, 2000);
if (!wsUrl) {
throw new Error("CDP websocket unavailable");
}
return await withCdpSocket(wsUrl, async (send) => {
return await opts.fn((method, params) => send(method, { ...params, targetId }));
});
}
return await withPlaywrightPageCdpSession(opts.page, async (session) => {
return await opts.fn((method, params) =>
(

View File

@ -26,7 +26,7 @@ import {
assertBrowserNavigationResultAllowed,
withBrowserNavigationPolicy,
} from "./navigation-guard.js";
import { isExtensionRelayCdpEndpoint, withPageScopedCdpClient } from "./pw-session.page-cdp.js";
import { withPageScopedCdpClient } from "./pw-session.page-cdp.js";
export type BrowserConsoleMessage = {
type: string;
@ -454,21 +454,6 @@ async function findPageByTargetId(
cdpUrl?: string,
): Promise<Page | null> {
const pages = await getAllPages(browser);
const isExtensionRelay = cdpUrl
? await isExtensionRelayCdpEndpoint(cdpUrl).catch(() => false)
: false;
if (cdpUrl && isExtensionRelay) {
try {
const matched = await findPageByTargetIdViaTargetList(pages, targetId, cdpUrl);
if (matched) {
return matched;
}
} catch {
// Ignore fetch errors and fall through to best-effort single-page fallback.
}
return pages.length === 1 ? (pages[0] ?? null) : null;
}
let resolvedViaCdp = false;
for (const page of pages) {
let tid: string | null = null;
@ -522,9 +507,7 @@ export async function getPageForTargetId(opts: {
}
const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!found) {
// Extension relays can block CDP attachment APIs (e.g. Target.attachToBrowserTarget),
// which prevents us from resolving a page's targetId via newCDPSession(). If Playwright
// only exposes a single Page, use it as a best-effort fallback.
// If Playwright only exposes a single Page, use it as a best-effort fallback.
if (pages.length === 1) {
return first;
}

View File

@ -3,15 +3,15 @@ import { resolveBrowserConfig, resolveProfile } from "../config.js";
import { resolveSnapshotPlan } from "./agent.snapshot.plan.js";
describe("resolveSnapshotPlan", () => {
it("defaults extension relay snapshots to aria when format is omitted", () => {
it("defaults existing-session snapshots to ai when format is omitted", () => {
const resolved = resolveBrowserConfig({
profiles: {
relay: { driver: "extension", cdpUrl: "http://127.0.0.1:18792", color: "#0066CC" },
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
},
});
const profile = resolveProfile(resolved, "relay");
const profile = resolveProfile(resolved, "user");
expect(profile).toBeTruthy();
expect(profile?.driver).toBe("extension");
expect(profile?.driver).toBe("existing-session");
const plan = resolveSnapshotPlan({
profile: profile as NonNullable<typeof profile>,
@ -19,7 +19,7 @@ describe("resolveSnapshotPlan", () => {
hasPlaywright: true,
});
expect(plan.format).toBe("aria");
expect(plan.format).toBe("ai");
});
it("keeps ai snapshots for managed browsers when Playwright is available", () => {

View File

@ -176,15 +176,18 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
const name = toStringOrEmpty((req.body as { name?: unknown })?.name);
const color = toStringOrEmpty((req.body as { color?: unknown })?.color);
const cdpUrl = toStringOrEmpty((req.body as { cdpUrl?: unknown })?.cdpUrl);
const driver = toStringOrEmpty((req.body as { driver?: unknown })?.driver) as
| "openclaw"
| "extension"
| "existing-session"
| "";
const driver = toStringOrEmpty((req.body as { driver?: unknown })?.driver);
if (!name) {
return jsonError(res, 400, "name is required");
}
if (driver && driver !== "openclaw" && driver !== "clawd" && driver !== "existing-session") {
return jsonError(
res,
400,
`unsupported profile driver "${driver}"; use "openclaw", "clawd", or "existing-session"`,
);
}
await withProfilesServiceMutation({
res,
@ -195,10 +198,10 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
color: color || undefined,
cdpUrl: cdpUrl || undefined,
driver:
driver === "extension"
? "extension"
: driver === "existing-session"
? "existing-session"
driver === "existing-session"
? "existing-session"
: driver === "openclaw" || driver === "clawd"
? "openclaw"
: undefined,
}),
});

View File

@ -15,11 +15,7 @@ import {
stopOpenClawChrome,
} from "./chrome.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { BrowserConfigurationError, BrowserProfileUnavailableError } from "./errors.js";
import {
ensureChromeExtensionRelayServer,
stopChromeExtensionRelayServer,
} from "./extension-relay.js";
import { BrowserProfileUnavailableError } from "./errors.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import {
CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS,
@ -124,9 +120,6 @@ export function createProfileAvailability({
await stopOpenClawChrome(profileState.running).catch(() => {});
setProfileRunning(null);
}
if (previousProfile.driver === "extension") {
await stopChromeExtensionRelayServer({ cdpUrl: previousProfile.cdpUrl }).catch(() => false);
}
if (getBrowserProfileCapabilities(previousProfile).usesChromeMcp) {
await closeChromeMcpSession(previousProfile.name).catch(() => false);
}
@ -166,33 +159,9 @@ export function createProfileAvailability({
const current = state();
const remoteCdp = capabilities.isRemote;
const attachOnly = profile.attachOnly;
const isExtension = capabilities.requiresRelay;
const profileState = getProfileState();
const httpReachable = await isHttpReachable();
if (isExtension && remoteCdp) {
throw new BrowserConfigurationError(
`Profile "${profile.name}" uses driver=extension but cdpUrl is not loopback (${profile.cdpUrl}).`,
);
}
if (isExtension) {
if (!httpReachable) {
await ensureChromeExtensionRelayServer({
cdpUrl: profile.cdpUrl,
bindHost: current.resolved.relayBindHost,
});
if (!(await isHttpReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS))) {
throw new BrowserProfileUnavailableError(
`Chrome extension relay for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`,
);
}
}
// Browser startup should only ensure relay availability.
// Tab attachment is checked when a tab is actually required.
return;
}
if (!httpReachable) {
if ((attachOnly || remoteCdp) && opts.onEnsureAttachTarget) {
await opts.onEnsureAttachTarget(profile);
@ -267,12 +236,6 @@ export function createProfileAvailability({
const stopped = await closeChromeMcpSession(profile.name);
return { stopped };
}
if (capabilities.requiresRelay) {
const stopped = await stopChromeExtensionRelayServer({
cdpUrl: profile.cdpUrl,
});
return { stopped };
}
const profileState = getProfileState();
if (!profileState.running) {
return { stopped: false };

View File

@ -1,178 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import type { BrowserServerState } from "./server-context.js";
import "./server-context.chrome-test-harness.js";
import { createBrowserRouteContext } from "./server-context.js";
function makeBrowserState(): BrowserServerState {
return {
// oxlint-disable-next-line typescript/no-explicit-any
server: null as any,
port: 0,
resolved: {
enabled: true,
controlPort: 18791,
cdpPortRangeStart: 18800,
cdpPortRangeEnd: 18899,
cdpProtocol: "http",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
evaluateEnabled: false,
remoteCdpTimeoutMs: 1500,
remoteCdpHandshakeTimeoutMs: 3000,
extraArgs: [],
color: "#FF4500",
headless: true,
noSandbox: false,
attachOnly: false,
defaultProfile: "chrome-relay",
profiles: {
"chrome-relay": {
driver: "extension",
cdpUrl: "http://127.0.0.1:18792",
cdpPort: 18792,
color: "#00AA00",
},
openclaw: { cdpPort: 18800, color: "#FF4500" },
},
},
profiles: new Map(),
};
}
function stubChromeJsonList(responses: unknown[]) {
const fetchMock = vi.fn();
const queue = [...responses];
fetchMock.mockImplementation(async (url: unknown) => {
const u = String(url);
if (!u.includes("/json/list")) {
throw new Error(`unexpected fetch: ${u}`);
}
const next = queue.shift();
if (!next) {
throw new Error("no more responses");
}
return {
ok: true,
json: async () => next,
} as unknown as Response;
});
global.fetch = withFetchPreconnect(fetchMock);
return fetchMock;
}
describe("browser server-context ensureTabAvailable", () => {
it("sticks to the last selected target when targetId is omitted", async () => {
// 1st call (snapshot): stable ordering A then B (twice)
// 2nd call (act): reversed ordering B then A (twice)
const responses = [
[
{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" },
{ id: "B", type: "page", url: "https://b.example", webSocketDebuggerUrl: "ws://x/b" },
],
[
{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" },
{ id: "B", type: "page", url: "https://b.example", webSocketDebuggerUrl: "ws://x/b" },
],
[
{ id: "B", type: "page", url: "https://b.example", webSocketDebuggerUrl: "ws://x/b" },
{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" },
],
[
{ id: "B", type: "page", url: "https://b.example", webSocketDebuggerUrl: "ws://x/b" },
{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" },
],
];
stubChromeJsonList(responses);
const state = makeBrowserState();
const ctx = createBrowserRouteContext({
getState: () => state,
});
const chromeRelay = ctx.forProfile("chrome-relay");
const first = await chromeRelay.ensureTabAvailable();
expect(first.targetId).toBe("A");
const second = await chromeRelay.ensureTabAvailable();
expect(second.targetId).toBe("A");
});
it("rejects invalid targetId even when only one extension tab remains", async () => {
const responses = [
[{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }],
[{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }],
];
stubChromeJsonList(responses);
const state = makeBrowserState();
const ctx = createBrowserRouteContext({ getState: () => state });
const chromeRelay = ctx.forProfile("chrome-relay");
await expect(chromeRelay.ensureTabAvailable("NOT_A_TAB")).rejects.toThrow(/tab not found/i);
});
it("returns a descriptive message when no extension tabs are attached", async () => {
const responses = [[]];
stubChromeJsonList(responses);
const state = makeBrowserState();
const ctx = createBrowserRouteContext({ getState: () => state });
const chromeRelay = ctx.forProfile("chrome-relay");
await expect(chromeRelay.ensureTabAvailable()).rejects.toThrow(/no attached Chrome tabs/i);
});
it("waits briefly for extension tabs to reappear when a previous target exists", async () => {
vi.useFakeTimers();
try {
const responses = [
// First call: select tab A and store lastTargetId.
[{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }],
[{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }],
// Second call: transient drop, then the extension re-announces attached tab A.
[],
[{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }],
[{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }],
];
stubChromeJsonList(responses);
const state = makeBrowserState();
const ctx = createBrowserRouteContext({ getState: () => state });
const chromeRelay = ctx.forProfile("chrome-relay");
const first = await chromeRelay.ensureTabAvailable();
expect(first.targetId).toBe("A");
const secondPromise = chromeRelay.ensureTabAvailable();
await vi.advanceTimersByTimeAsync(250);
const second = await secondPromise;
expect(second.targetId).toBe("A");
} finally {
vi.useRealTimers();
}
});
it("still fails after the extension-tab grace window expires", async () => {
vi.useFakeTimers();
try {
const responses = [
[{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }],
[{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }],
...Array.from({ length: 20 }, () => []),
];
stubChromeJsonList(responses);
const state = makeBrowserState();
const ctx = createBrowserRouteContext({ getState: () => state });
const chromeRelay = ctx.forProfile("chrome-relay");
await chromeRelay.ensureTabAvailable();
const pending = expect(chromeRelay.ensureTabAvailable()).rejects.toThrow(
/no attached Chrome tabs/i,
);
await vi.advanceTimersByTimeAsync(3_500);
await pending;
} finally {
vi.useRealTimers();
}
});
});

View File

@ -4,10 +4,6 @@ import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createProfileResetOps } from "./server-context.reset.js";
const relayMocks = vi.hoisted(() => ({
stopChromeExtensionRelayServer: vi.fn(async () => true),
}));
const trashMocks = vi.hoisted(() => ({
movePathToTrash: vi.fn(async (from: string) => `${from}.trashed`),
}));
@ -16,7 +12,6 @@ const pwAiMocks = vi.hoisted(() => ({
closePlaywrightBrowserConnection: vi.fn(async () => {}),
}));
vi.mock("./extension-relay.js", () => relayMocks);
vi.mock("./trash.js", () => trashMocks);
vi.mock("./pw-ai.js", () => pwAiMocks);
@ -54,23 +49,6 @@ function createStatelessResetOps(profile: Parameters<typeof createProfileResetOp
}
describe("createProfileResetOps", () => {
it("stops extension relay for extension profiles", async () => {
const ops = createStatelessResetOps({
...localOpenClawProfile(),
name: "chrome",
driver: "extension",
});
await expect(ops.resetProfile()).resolves.toEqual({
moved: false,
from: "http://127.0.0.1:18800",
});
expect(relayMocks.stopChromeExtensionRelayServer).toHaveBeenCalledWith({
cdpUrl: "http://127.0.0.1:18800",
});
expect(trashMocks.movePathToTrash).not.toHaveBeenCalled();
});
it("rejects remote non-extension profiles", async () => {
const ops = createStatelessResetOps({
...localOpenClawProfile(),

View File

@ -1,7 +1,6 @@
import fs from "node:fs";
import type { ResolvedBrowserProfile } from "./config.js";
import { BrowserResetUnsupportedError } from "./errors.js";
import { stopChromeExtensionRelayServer } from "./extension-relay.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import type { ProfileRuntimeState } from "./server-context.types.js";
import { movePathToTrash } from "./trash.js";
@ -36,10 +35,6 @@ export function createProfileResetOps({
}: ResetDeps): ResetOps {
const capabilities = getBrowserProfileCapabilities(profile);
const resetProfile = async () => {
if (capabilities.requiresRelay) {
await stopChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch(() => {});
return { moved: false, from: profile.cdpUrl };
}
if (!capabilities.supportsReset) {
throw new BrowserResetUnsupportedError(
`reset-profile is only supported for local profiles (profile "${profile.name}" is remote).`,

View File

@ -36,28 +36,9 @@ export function createProfileSelectionOps({
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
await ensureBrowserAvailable();
const profileState = getProfileState();
let tabs1 = await listTabs();
const tabs1 = await listTabs();
if (tabs1.length === 0) {
if (capabilities.requiresAttachedTab) {
// Chrome extension relay can briefly drop its WebSocket connection (MV3 service worker
// lifecycle, relay restart). If we previously had a target selected, wait briefly for
// the extension to reconnect and re-announce its attached tabs before failing.
if (profileState.lastTargetId?.trim()) {
const deadlineAt = Date.now() + 3_000;
while (tabs1.length === 0 && Date.now() < deadlineAt) {
await new Promise((resolve) => setTimeout(resolve, 200));
tabs1 = await listTabs();
}
}
if (tabs1.length === 0) {
throw new BrowserTabNotFoundError(
`tab not found (no attached Chrome tabs for profile "${profile.name}"). ` +
"Click the OpenClaw Browser Relay toolbar icon on the tab you want to control (badge ON).",
);
}
} else {
await openTab("about:blank");
}
await openTab("about:blank");
}
const tabs = await listTabs();

View File

@ -1,13 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { resolveProfileMock, ensureChromeExtensionRelayServerMock } = vi.hoisted(() => ({
resolveProfileMock: vi.fn(),
ensureChromeExtensionRelayServerMock: vi.fn(),
}));
const { stopOpenClawChromeMock, stopChromeExtensionRelayServerMock } = vi.hoisted(() => ({
const { stopOpenClawChromeMock } = vi.hoisted(() => ({
stopOpenClawChromeMock: vi.fn(async () => {}),
stopChromeExtensionRelayServerMock: vi.fn(async () => true),
}));
const { createBrowserRouteContextMock, listKnownProfileNamesMock } = vi.hoisted(() => ({
@ -19,15 +13,6 @@ vi.mock("./chrome.js", () => ({
stopOpenClawChrome: stopOpenClawChromeMock,
}));
vi.mock("./config.js", () => ({
resolveProfile: resolveProfileMock,
}));
vi.mock("./extension-relay.js", () => ({
ensureChromeExtensionRelayServer: ensureChromeExtensionRelayServerMock,
stopChromeExtensionRelayServer: stopChromeExtensionRelayServerMock,
}));
vi.mock("./server-context.js", () => ({
createBrowserRouteContext: createBrowserRouteContextMock,
listKnownProfileNames: listKnownProfileNamesMock,
@ -36,49 +21,13 @@ vi.mock("./server-context.js", () => ({
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
describe("ensureExtensionRelayForProfiles", () => {
beforeEach(() => {
resolveProfileMock.mockClear();
ensureChromeExtensionRelayServerMock.mockClear();
});
it("starts relay only for extension profiles", async () => {
resolveProfileMock.mockImplementation((_resolved: unknown, name: string) => {
if (name === "chrome-relay") {
return { driver: "extension", cdpUrl: "http://127.0.0.1:18888" };
}
return { driver: "openclaw", cdpUrl: "http://127.0.0.1:18889" };
});
ensureChromeExtensionRelayServerMock.mockResolvedValue(undefined);
await ensureExtensionRelayForProfiles({
resolved: {
profiles: {
"chrome-relay": {},
openclaw: {},
},
} as never,
onWarn: vi.fn(),
});
expect(ensureChromeExtensionRelayServerMock).toHaveBeenCalledTimes(1);
expect(ensureChromeExtensionRelayServerMock).toHaveBeenCalledWith({
cdpUrl: "http://127.0.0.1:18888",
});
});
it("reports relay startup errors", async () => {
resolveProfileMock.mockReturnValue({ driver: "extension", cdpUrl: "http://127.0.0.1:18888" });
ensureChromeExtensionRelayServerMock.mockRejectedValue(new Error("boom"));
const onWarn = vi.fn();
await ensureExtensionRelayForProfiles({
resolved: { profiles: { "chrome-relay": {} } } as never,
onWarn,
});
expect(onWarn).toHaveBeenCalledWith(
'Chrome extension relay init failed for profile "chrome-relay": Error: boom',
);
it("is a no-op after removing the Chrome extension relay path", async () => {
await expect(
ensureExtensionRelayForProfiles({
resolved: { profiles: {} } as never,
onWarn: vi.fn(),
}),
).resolves.toBeUndefined();
});
});
@ -87,14 +36,13 @@ describe("stopKnownBrowserProfiles", () => {
createBrowserRouteContextMock.mockClear();
listKnownProfileNamesMock.mockClear();
stopOpenClawChromeMock.mockClear();
stopChromeExtensionRelayServerMock.mockClear();
});
it("stops all known profiles and ignores per-profile failures", async () => {
listKnownProfileNamesMock.mockReturnValue(["openclaw", "chrome-relay"]);
listKnownProfileNamesMock.mockReturnValue(["openclaw", "user"]);
const stopMap: Record<string, ReturnType<typeof vi.fn>> = {
openclaw: vi.fn(async () => {}),
"chrome-relay": vi.fn(async () => {
user: vi.fn(async () => {
throw new Error("profile stop failed");
}),
};
@ -112,12 +60,12 @@ describe("stopKnownBrowserProfiles", () => {
});
expect(stopMap.openclaw).toHaveBeenCalledTimes(1);
expect(stopMap["chrome-relay"]).toHaveBeenCalledTimes(1);
expect(stopMap.user).toHaveBeenCalledTimes(1);
expect(onWarn).not.toHaveBeenCalled();
});
it("stops tracked runtime browsers even when the profile no longer resolves", async () => {
listKnownProfileNamesMock.mockReturnValue(["deleted-local", "deleted-extension"]);
listKnownProfileNamesMock.mockReturnValue(["deleted-local"]);
createBrowserRouteContextMock.mockReturnValue({
forProfile: vi.fn(() => {
throw new Error("profile not found");
@ -134,18 +82,7 @@ describe("stopKnownBrowserProfiles", () => {
},
};
const launchedBrowser = localRuntime.running;
const extensionRuntime = {
profile: {
name: "deleted-extension",
driver: "extension",
cdpUrl: "http://127.0.0.1:19999",
},
running: null,
};
const profiles = new Map<string, unknown>([
["deleted-local", localRuntime],
["deleted-extension", extensionRuntime],
]);
const profiles = new Map<string, unknown>([["deleted-local", localRuntime]]);
const state = {
resolved: { profiles: {} },
profiles,
@ -158,9 +95,6 @@ describe("stopKnownBrowserProfiles", () => {
expect(stopOpenClawChromeMock).toHaveBeenCalledWith(launchedBrowser);
expect(localRuntime.running).toBeNull();
expect(stopChromeExtensionRelayServerMock).toHaveBeenCalledWith({
cdpUrl: "http://127.0.0.1:19999",
});
});
it("warns when profile enumeration fails", async () => {

View File

@ -1,32 +1,18 @@
import { stopOpenClawChrome } from "./chrome.js";
import type { ResolvedBrowserConfig } from "./config.js";
import { resolveProfile } from "./config.js";
import {
ensureChromeExtensionRelayServer,
stopChromeExtensionRelayServer,
} from "./extension-relay.js";
import {
type BrowserServerState,
createBrowserRouteContext,
listKnownProfileNames,
} from "./server-context.js";
export async function ensureExtensionRelayForProfiles(params: {
export async function ensureExtensionRelayForProfiles(_params: {
resolved: ResolvedBrowserConfig;
onWarn: (message: string) => void;
}) {
for (const name of Object.keys(params.resolved.profiles)) {
const profile = resolveProfile(params.resolved, name);
if (!profile || profile.driver !== "extension") {
continue;
}
await ensureChromeExtensionRelayServer({
cdpUrl: profile.cdpUrl,
bindHost: params.resolved.relayBindHost,
}).catch((err) => {
params.onWarn(`Chrome extension relay init failed for profile "${name}": ${String(err)}`);
});
}
// Intentional no-op: the Chrome extension relay path has been removed.
// runtime-lifecycle still calls this helper, so keep the stub until the next
// breaking cleanup rather than changing the call graph in a patch release.
}
export async function stopKnownBrowserProfiles(params: {
@ -50,12 +36,6 @@ export async function stopKnownBrowserProfiles(params: {
runtime.running = null;
continue;
}
if (runtime?.profile.driver === "extension") {
await stopChromeExtensionRelayServer({ cdpUrl: runtime.profile.cdpUrl }).catch(
() => false,
);
continue;
}
await ctx.forProfile(name).stopRunningBrowser();
} catch {
// ignore

View File

@ -18,7 +18,7 @@ type HarnessState = {
cdpPort?: number;
cdpUrl?: string;
color: string;
driver?: "openclaw" | "extension" | "existing-session";
driver?: "openclaw" | "existing-session";
attachOnly?: boolean;
}
>;

View File

@ -116,18 +116,29 @@ describe("profile CRUD endpoints", () => {
const createBadRemoteBody = (await createBadRemote.json()) as { error: string };
expect(createBadRemoteBody.error).toContain("cdpUrl");
const createBadExtension = await realFetch(`${base}/profiles/create`, {
const createClawd = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "badextension",
driver: "extension",
cdpUrl: "http://10.0.0.42:9222",
}),
body: JSON.stringify({ name: "legacyclawd", driver: "clawd" }),
});
expect(createBadExtension.status).toBe(400);
const createBadExtensionBody = (await createBadExtension.json()) as { error: string };
expect(createBadExtensionBody.error).toContain("loopback cdpUrl host");
expect(createClawd.status).toBe(200);
const createClawdBody = (await createClawd.json()) as {
profile?: string;
transport?: string;
cdpPort?: number | null;
};
expect(createClawdBody.profile).toBe("legacyclawd");
expect(createClawdBody.transport).toBe("cdp");
expect(createClawdBody.cdpPort).toBeTypeOf("number");
const createLegacyDriver = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "legacy", driver: "extension" }),
});
expect(createLegacyDriver.status).toBe(400);
const createLegacyDriverBody = (await createLegacyDriver.json()) as { error: string };
expect(createLegacyDriverBody.error).toContain('unsupported profile driver "extension"');
const deleteMissing = await realFetch(`${base}/profiles/nonexistent`, {
method: "DELETE",

View File

@ -1,190 +0,0 @@
import path from "node:path";
import { Command } from "commander";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
const copyToClipboard = vi.fn();
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
type FakeFsEntry = { kind: "file"; content: string } | { kind: "dir" };
const state = vi.hoisted(() => ({
entries: new Map<string, FakeFsEntry>(),
counter: 0,
}));
const abs = (p: string) => path.resolve(p);
function setFile(p: string, content = "") {
const resolved = abs(p);
state.entries.set(resolved, { kind: "file", content });
setDir(path.dirname(resolved));
}
function setDir(p: string) {
const resolved = abs(p);
if (!state.entries.has(resolved)) {
state.entries.set(resolved, { kind: "dir" });
}
}
function copyTree(src: string, dest: string) {
const srcAbs = abs(src);
const destAbs = abs(dest);
const srcPrefix = `${srcAbs}${path.sep}`;
for (const [key, entry] of state.entries.entries()) {
if (key === srcAbs || key.startsWith(srcPrefix)) {
const rel = key === srcAbs ? "" : key.slice(srcPrefix.length);
const next = rel ? path.join(destAbs, rel) : destAbs;
state.entries.set(next, entry);
}
}
}
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
const pathMod = await import("node:path");
const absInMock = (p: string) => pathMod.resolve(p);
const wrapped = {
...actual,
existsSync: (p: string) => state.entries.has(absInMock(p)),
mkdirSync: (p: string, _opts?: unknown) => {
setDir(p);
},
writeFileSync: (p: string, content: string) => {
setFile(p, content);
},
renameSync: (from: string, to: string) => {
const fromAbs = absInMock(from);
const toAbs = absInMock(to);
const entry = state.entries.get(fromAbs);
if (!entry) {
throw new Error(`ENOENT: no such file or directory, rename '${from}' -> '${to}'`);
}
state.entries.delete(fromAbs);
state.entries.set(toAbs, entry);
},
rmSync: (p: string) => {
const root = absInMock(p);
const prefix = `${root}${pathMod.sep}`;
const keys = Array.from(state.entries.keys());
for (const key of keys) {
if (key === root || key.startsWith(prefix)) {
state.entries.delete(key);
}
}
},
mkdtempSync: (prefix: string) => {
const dir = `${prefix}${state.counter++}`;
setDir(dir);
return dir;
},
promises: {
...actual.promises,
cp: async (src: string, dest: string, _opts?: unknown) => {
copyTree(src, dest);
},
},
};
return { ...wrapped, default: wrapped };
});
vi.mock("../infra/clipboard.js", () => ({
copyToClipboard,
}));
vi.mock("../runtime.js", () => ({
defaultRuntime: runtime,
}));
let resolveBundledExtensionRootDir: typeof import("./browser-cli-extension.js").resolveBundledExtensionRootDir;
let installChromeExtension: typeof import("./browser-cli-extension.js").installChromeExtension;
let registerBrowserExtensionCommands: typeof import("./browser-cli-extension.js").registerBrowserExtensionCommands;
beforeAll(async () => {
({ resolveBundledExtensionRootDir, installChromeExtension, registerBrowserExtensionCommands } =
await import("./browser-cli-extension.js"));
});
beforeEach(() => {
state.entries.clear();
state.counter = 0;
copyToClipboard.mockClear();
copyToClipboard.mockResolvedValue(false);
runtime.log.mockClear();
runtime.error.mockClear();
runtime.exit.mockClear();
});
function writeManifest(dir: string) {
setDir(dir);
setFile(path.join(dir, "manifest.json"), JSON.stringify({ manifest_version: 3 }));
}
describe("bundled extension resolver (fs-mocked)", () => {
it("walks up to find the assets directory", () => {
const root = abs("/tmp/openclaw-ext-root");
const here = path.join(root, "dist", "cli");
const assets = path.join(root, "assets", "chrome-extension");
writeManifest(assets);
setDir(here);
expect(resolveBundledExtensionRootDir(here)).toBe(assets);
});
it("prefers the nearest assets directory", () => {
const root = abs("/tmp/openclaw-ext-root-nearest");
const here = path.join(root, "dist", "cli");
const distAssets = path.join(root, "dist", "assets", "chrome-extension");
const rootAssets = path.join(root, "assets", "chrome-extension");
writeManifest(distAssets);
writeManifest(rootAssets);
setDir(here);
expect(resolveBundledExtensionRootDir(here)).toBe(distAssets);
});
});
describe("browser extension install (fs-mocked)", () => {
it("installs into the state dir (never node_modules)", async () => {
const tmp = abs("/tmp/openclaw-ext-install");
const sourceDir = path.join(tmp, "source-ext");
writeManifest(sourceDir);
setFile(path.join(sourceDir, "test.txt"), "ok");
const result = await installChromeExtension({ stateDir: tmp, sourceDir });
expect(result.path).toBe(path.join(tmp, "browser", "chrome-extension"));
expect(state.entries.has(abs(path.join(result.path, "manifest.json")))).toBe(true);
expect(state.entries.has(abs(path.join(result.path, "test.txt")))).toBe(true);
expect(result.path.includes("node_modules")).toBe(false);
});
it("copies extension path to clipboard", async () => {
const tmp = abs("/tmp/openclaw-ext-path");
await withEnvAsync({ OPENCLAW_STATE_DIR: tmp }, async () => {
copyToClipboard.mockResolvedValue(true);
const dir = path.join(tmp, "browser", "chrome-extension");
writeManifest(dir);
const program = new Command();
const browser = program.command("browser").option("--json", "JSON output", false);
registerBrowserExtensionCommands(
browser,
(cmd) => cmd.parent?.opts?.() as { json?: boolean },
);
await program.parseAsync(["browser", "extension", "path"], { from: "user" });
expect(copyToClipboard).toHaveBeenCalledWith(dir);
});
});
});

View File

@ -1,140 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { Command } from "commander";
import { movePathToTrash } from "../browser/trash.js";
import { resolveStateDir } from "../config/paths.js";
import { danger, info } from "../globals.js";
import { copyToClipboard } from "../infra/clipboard.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { shortenHomePath } from "../utils.js";
import { formatCliCommand } from "./command-format.js";
export function resolveBundledExtensionRootDir(
here = path.dirname(fileURLToPath(import.meta.url)),
) {
let current = here;
while (true) {
const candidate = path.join(current, "assets", "chrome-extension");
if (hasManifest(candidate)) {
return candidate;
}
const parent = path.dirname(current);
if (parent === current) {
break;
}
current = parent;
}
return path.resolve(here, "../../assets/chrome-extension");
}
function installedExtensionRootDir() {
return path.join(resolveStateDir(), "browser", "chrome-extension");
}
function hasManifest(dir: string) {
return fs.existsSync(path.join(dir, "manifest.json"));
}
export async function installChromeExtension(opts?: {
stateDir?: string;
sourceDir?: string;
}): Promise<{ path: string }> {
const src = opts?.sourceDir ?? resolveBundledExtensionRootDir();
if (!hasManifest(src)) {
throw new Error("Bundled Chrome extension is missing. Reinstall OpenClaw and try again.");
}
const stateDir = opts?.stateDir ?? resolveStateDir();
const dest = path.join(stateDir, "browser", "chrome-extension");
fs.mkdirSync(path.dirname(dest), { recursive: true });
if (fs.existsSync(dest)) {
await movePathToTrash(dest).catch(() => {
const backup = `${dest}.old-${Date.now()}`;
fs.renameSync(dest, backup);
});
}
await fs.promises.cp(src, dest, { recursive: true });
if (!hasManifest(dest)) {
throw new Error("Chrome extension install failed (manifest.json missing). Try again.");
}
return { path: dest };
}
export function registerBrowserExtensionCommands(
browser: Command,
parentOpts: (cmd: Command) => { json?: boolean },
) {
const ext = browser.command("extension").description("Chrome extension helpers");
ext
.command("install")
.description("Install the Chrome extension to a stable local path")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
let installed: { path: string };
try {
installed = await installChromeExtension();
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
return;
}
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ ok: true, path: installed.path }, null, 2));
return;
}
const displayPath = shortenHomePath(installed.path);
defaultRuntime.log(displayPath);
const copied = await copyToClipboard(installed.path).catch(() => false);
defaultRuntime.error(
info(
[
copied ? "Copied to clipboard." : "Copy to clipboard unavailable.",
"Next:",
`- Chrome → chrome://extensions → enable “Developer mode”`,
`- “Load unpacked” → select: ${displayPath}`,
`- Pin “OpenClaw Browser Relay”, then click it on the tab (badge shows ON)`,
"",
`${theme.muted("Docs:")} ${formatDocsLink("/tools/chrome-extension", "docs.openclaw.ai/tools/chrome-extension")}`,
].join("\n"),
),
);
});
ext
.command("path")
.description("Print the path to the installed Chrome extension (load unpacked)")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const dir = installedExtensionRootDir();
if (!hasManifest(dir)) {
defaultRuntime.error(
danger(
[
`Chrome extension is not installed. Run: "${formatCliCommand("openclaw browser extension install")}"`,
`Docs: ${formatDocsLink("/tools/chrome-extension", "docs.openclaw.ai/tools/chrome-extension")}`,
].join("\n"),
),
);
defaultRuntime.exit(1);
}
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ path: dir }, null, 2));
return;
}
const displayPath = shortenHomePath(dir);
defaultRuntime.log(displayPath);
const copied = await copyToClipboard(dir).catch(() => false);
if (copied) {
defaultRuntime.error(info("Copied to clipboard."));
}
});
}

View File

@ -105,14 +105,14 @@ function logBrowserTabs(tabs: BrowserTab[], json?: boolean) {
function usesChromeMcpTransport(params: {
transport?: BrowserTransport;
driver?: "openclaw" | "extension" | "existing-session";
driver?: "openclaw" | "existing-session";
}): boolean {
return params.transport === "chrome-mcp" || params.driver === "existing-session";
}
function formatBrowserConnectionSummary(params: {
transport?: BrowserTransport;
driver?: "openclaw" | "extension" | "existing-session";
driver?: "openclaw" | "existing-session";
isRemote?: boolean;
cdpPort?: number | null;
cdpUrl?: string | null;
@ -455,10 +455,7 @@ export function registerBrowserManageCommands(
.requiredOption("--name <name>", "Profile name (lowercase, numbers, hyphens)")
.option("--color <hex>", "Profile color (hex format, e.g. #0066CC)")
.option("--cdp-url <url>", "CDP URL for remote Chrome (http/https)")
.option(
"--driver <driver>",
"Profile driver (openclaw|extension|existing-session). Default: openclaw",
)
.option("--driver <driver>", "Profile driver (openclaw|existing-session). Default: openclaw")
.action(
async (opts: { name: string; color?: string; cdpUrl?: string; driver?: string }, cmd) => {
const parent = parentOpts(cmd);
@ -472,12 +469,7 @@ export function registerBrowserManageCommands(
name: opts.name,
color: opts.color,
cdpUrl: opts.cdpUrl,
driver:
opts.driver === "extension"
? "extension"
: opts.driver === "existing-session"
? "existing-session"
: undefined,
driver: opts.driver === "existing-session" ? "existing-session" : undefined,
},
},
{ timeoutMs: 10_000 },
@ -489,11 +481,7 @@ export function registerBrowserManageCommands(
defaultRuntime.log(
info(
`🦞 Created profile "${result.profile}"\n${loc}\n color: ${result.color}${
opts.driver === "extension"
? "\n driver: extension"
: opts.driver === "existing-session"
? "\n driver: existing-session"
: ""
opts.driver === "existing-session" ? "\n driver: existing-session" : ""
}`,
),
);

View File

@ -7,7 +7,6 @@ import { registerBrowserActionInputCommands } from "./browser-cli-actions-input.
import { registerBrowserActionObserveCommands } from "./browser-cli-actions-observe.js";
import { registerBrowserDebugCommands } from "./browser-cli-debug.js";
import { browserActionExamples, browserCoreExamples } from "./browser-cli-examples.js";
import { registerBrowserExtensionCommands } from "./browser-cli-extension.js";
import { registerBrowserInspectCommands } from "./browser-cli-inspect.js";
import { registerBrowserManageCommands } from "./browser-cli-manage.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
@ -46,7 +45,6 @@ export function registerBrowserCli(program: Command) {
const parentOpts = (cmd: Command) => cmd.parent?.opts?.() as BrowserParentOpts;
registerBrowserManageCommands(browser, parentOpts);
registerBrowserExtensionCommands(browser, parentOpts);
registerBrowserInspectCommands(browser, parentOpts);
registerBrowserActionInputCommands(browser, parentOpts);
registerBrowserActionObserveCommands(browser, parentOpts);

View File

@ -0,0 +1,96 @@
import { describe, expect, it, vi } from "vitest";
import { noteChromeMcpBrowserReadiness } from "./doctor-browser.js";
describe("doctor browser readiness", () => {
it("does nothing when Chrome MCP is not configured", async () => {
const noteFn = vi.fn();
await noteChromeMcpBrowserReadiness(
{
browser: {
profiles: {
openclaw: { color: "#FF4500" },
},
},
},
{
noteFn,
},
);
expect(noteFn).not.toHaveBeenCalled();
});
it("warns when Chrome MCP is configured but Chrome is missing", async () => {
const noteFn = vi.fn();
await noteChromeMcpBrowserReadiness(
{
browser: {
defaultProfile: "user",
},
},
{
noteFn,
platform: "darwin",
resolveChromeExecutable: () => null,
},
);
expect(noteFn).toHaveBeenCalledTimes(1);
expect(String(noteFn.mock.calls[0]?.[0])).toContain("Google Chrome was not found");
expect(String(noteFn.mock.calls[0]?.[0])).toContain("chrome://inspect/#remote-debugging");
});
it("warns when detected Chrome is too old for Chrome MCP", async () => {
const noteFn = vi.fn();
await noteChromeMcpBrowserReadiness(
{
browser: {
profiles: {
chromeLive: {
driver: "existing-session",
color: "#00AA00",
},
},
},
},
{
noteFn,
platform: "linux",
resolveChromeExecutable: () => ({ path: "/usr/bin/google-chrome" }),
readVersion: () => "Google Chrome 143.0.7499.4",
},
);
expect(noteFn).toHaveBeenCalledTimes(1);
expect(String(noteFn.mock.calls[0]?.[0])).toContain("too old");
expect(String(noteFn.mock.calls[0]?.[0])).toContain("Chrome 144+");
});
it("reports the detected Chrome version for existing-session profiles", async () => {
const noteFn = vi.fn();
await noteChromeMcpBrowserReadiness(
{
browser: {
profiles: {
chromeLive: {
driver: "existing-session",
color: "#00AA00",
},
},
},
},
{
noteFn,
platform: "win32",
resolveChromeExecutable: () => ({
path: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
}),
readVersion: () => "Google Chrome 144.0.7534.0",
},
);
expect(noteFn).toHaveBeenCalledTimes(1);
expect(String(noteFn.mock.calls[0]?.[0])).toContain(
"Detected Chrome Google Chrome 144.0.7534.0",
);
});
});

View File

@ -0,0 +1,108 @@
import {
parseBrowserMajorVersion,
readBrowserVersion,
resolveGoogleChromeExecutableForPlatform,
} from "../browser/chrome.executables.js";
import type { OpenClawConfig } from "../config/config.js";
import { note } from "../terminal/note.js";
const CHROME_MCP_MIN_MAJOR = 144;
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function collectChromeMcpProfileNames(cfg: OpenClawConfig): string[] {
const browser = asRecord(cfg.browser);
if (!browser) {
return [];
}
const names = new Set<string>();
const defaultProfile =
typeof browser.defaultProfile === "string" ? browser.defaultProfile.trim() : "";
if (defaultProfile === "user") {
names.add("user");
}
const profiles = asRecord(browser.profiles);
if (!profiles) {
return [...names];
}
for (const [profileName, rawProfile] of Object.entries(profiles)) {
const profile = asRecord(rawProfile);
const driver = typeof profile?.driver === "string" ? profile.driver.trim() : "";
if (driver === "existing-session") {
names.add(profileName);
}
}
return [...names].toSorted((a, b) => a.localeCompare(b));
}
export async function noteChromeMcpBrowserReadiness(
cfg: OpenClawConfig,
deps?: {
platform?: NodeJS.Platform;
noteFn?: typeof note;
resolveChromeExecutable?: (platform: NodeJS.Platform) => { path: string } | null;
readVersion?: (executablePath: string) => string | null;
},
) {
const profiles = collectChromeMcpProfileNames(cfg);
if (profiles.length === 0) {
return;
}
const noteFn = deps?.noteFn ?? note;
const platform = deps?.platform ?? process.platform;
const resolveChromeExecutable =
deps?.resolveChromeExecutable ?? resolveGoogleChromeExecutableForPlatform;
const readVersion = deps?.readVersion ?? readBrowserVersion;
const chrome = resolveChromeExecutable(platform);
const profileLabel = profiles.join(", ");
if (!chrome) {
noteFn(
[
`- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`,
"- Google Chrome was not found on this host. OpenClaw does not bundle Chrome.",
`- Install Google Chrome ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node.`,
"- In Chrome, enable remote debugging at chrome://inspect/#remote-debugging.",
"- Keep Chrome running and accept the attach consent prompt the first time OpenClaw connects.",
"- Docker, headless, and sandbox browser flows stay on raw CDP; this check only applies to host-local Chrome MCP attach.",
].join("\n"),
"Browser",
);
return;
}
const versionRaw = readVersion(chrome.path);
const major = parseBrowserMajorVersion(versionRaw);
const lines = [
`- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`,
`- Chrome path: ${chrome.path}`,
];
if (!versionRaw || major === null) {
lines.push(
`- Could not determine the installed Chrome version. Chrome MCP requires Google Chrome ${CHROME_MCP_MIN_MAJOR}+ on this host.`,
);
} else if (major < CHROME_MCP_MIN_MAJOR) {
lines.push(
`- Detected Chrome ${versionRaw}, which is too old for Chrome MCP existing-session attach. Upgrade to Chrome ${CHROME_MCP_MIN_MAJOR}+.`,
);
} else {
lines.push(`- Detected Chrome ${versionRaw}.`);
}
lines.push("- In Chrome, enable remote debugging at chrome://inspect/#remote-debugging.");
lines.push(
"- Keep Chrome running and accept the attach consent prompt the first time OpenClaw connects.",
);
noteFn(lines.join("\n"), "Browser");
}

View File

@ -179,6 +179,60 @@ describe("doctor config flow", () => {
});
});
it("migrates legacy browser extension profiles to existing-session on repair", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
browser: {
relayBindHost: "0.0.0.0",
profiles: {
chromeLive: {
driver: "extension",
color: "#00AA00",
},
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const browser = (result.cfg as { browser?: Record<string, unknown> }).browser ?? {};
expect(browser.relayBindHost).toBeUndefined();
expect(
((browser.profiles as Record<string, { driver?: string }>)?.chromeLive ?? {}).driver,
).toBe("existing-session");
});
it("notes legacy browser extension migration changes", async () => {
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
try {
await runDoctorConfigWithInput({
config: {
browser: {
relayBindHost: "127.0.0.1",
profiles: {
chromeLive: {
driver: "extension",
color: "#00AA00",
},
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const messages = noteSpy.mock.calls
.filter((call) => call[1] === "Doctor changes")
.map((call) => String(call[0]));
expect(
messages.some((line) => line.includes('browser.profiles.chromeLive.driver "extension"')),
).toBe(true);
expect(messages.some((line) => line.includes("browser.relayBindHost"))).toBe(true);
} finally {
noteSpy.mockRestore();
}
});
it("preserves discord streaming intent while stripping unsupported keys on repair", async () => {
const result = await runDoctorConfigWithInput({
repair: true,

View File

@ -291,6 +291,67 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
}
};
const normalizeLegacyBrowserProfiles = () => {
const rawBrowser = next.browser;
if (!isRecord(rawBrowser)) {
return;
}
const browser = structuredClone(rawBrowser);
let browserChanged = false;
if ("relayBindHost" in browser) {
delete browser.relayBindHost;
browserChanged = true;
changes.push(
"Removed browser.relayBindHost (legacy Chrome extension relay setting; host-local Chrome now uses Chrome MCP existing-session attach).",
);
}
const rawProfiles = browser.profiles;
if (!isRecord(rawProfiles)) {
if (!browserChanged) {
return;
}
next = { ...next, browser };
return;
}
const profiles = { ...rawProfiles };
let profilesChanged = false;
for (const [profileName, rawProfile] of Object.entries(rawProfiles)) {
if (!isRecord(rawProfile)) {
continue;
}
const rawDriver = typeof rawProfile.driver === "string" ? rawProfile.driver.trim() : "";
if (rawDriver !== "extension") {
continue;
}
profiles[profileName] = {
...rawProfile,
driver: "existing-session",
};
profilesChanged = true;
changes.push(
`Moved browser.profiles.${profileName}.driver "extension" → "existing-session" (Chrome MCP attach).`,
);
}
if (profilesChanged) {
browser.profiles = profiles;
browserChanged = true;
}
if (!browserChanged) {
return;
}
next = {
...next,
browser,
};
};
const seedMissingDefaultAccountsFromSingleAccountBase = () => {
const channels = next.channels as Record<string, unknown> | undefined;
if (!channels) {
@ -365,6 +426,7 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
normalizeProvider("slack");
normalizeProvider("discord");
seedMissingDefaultAccountsFromSingleAccountBase();
normalizeLegacyBrowserProfiles();
const normalizeBrowserSsrFPolicyAlias = () => {
const rawBrowser = next.browser;

View File

@ -8,6 +8,10 @@ vi.mock("./doctor-bootstrap-size.js", () => ({
noteBootstrapFileSize: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./doctor-browser.js", () => ({
noteChromeMcpBrowserReadiness: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./doctor-gateway-daemon-flow.js", () => ({
maybeRepairGatewayDaemon: vi.fn().mockResolvedValue(undefined),
}));

View File

@ -29,6 +29,7 @@ import {
noteAuthProfileHealth,
} from "./doctor-auth.js";
import { noteBootstrapFileSize } from "./doctor-bootstrap-size.js";
import { noteChromeMcpBrowserReadiness } from "./doctor-browser.js";
import { doctorShellCompletion } from "./doctor-completion.js";
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
import { maybeRepairLegacyCronStore } from "./doctor-cron.js";
@ -236,6 +237,7 @@ export async function doctorCommand(
await noteMacLaunchctlGatewayEnvOverrides(cfg);
await noteSecurityWarnings(cfg);
await noteChromeMcpBrowserReadiness(cfg);
await noteOpenAIOAuthTlsPrerequisites({
cfg,
deep: options.deep === true,

View File

@ -422,7 +422,7 @@ const ENUM_EXPECTATIONS: Record<string, string[]> = {
"gateway.bind": ['"auto"', '"lan"', '"loopback"', '"custom"', '"tailnet"'],
"gateway.auth.mode": ['"none"', '"token"', '"password"', '"trusted-proxy"'],
"gateway.tailscale.mode": ['"off"', '"serve"', '"funnel"'],
"browser.profiles.*.driver": ['"openclaw"', '"clawd"', '"extension"'],
"browser.profiles.*.driver": ['"openclaw"', '"clawd"', '"existing-session"'],
"discovery.mdns.mode": ['"off"', '"minimal"', '"full"'],
"wizard.lastRunMode": ['"local"', '"remote"'],
"diagnostics.otel.protocol": ['"http/protobuf"', '"grpc"'],

View File

@ -254,8 +254,6 @@ export const FIELD_HELP: Record<string, string> = {
"Starting local CDP port used for auto-allocated browser profile ports. Increase this when host-level port defaults conflict with other local services.",
"browser.defaultProfile":
"Default browser profile name selected when callers do not explicitly choose a profile. Use a stable low-privilege profile as the default to reduce accidental cross-context state use.",
"browser.relayBindHost":
"Bind IP address for the Chrome extension relay listener. Leave unset for loopback-only access, or set an explicit non-loopback IP such as 0.0.0.0 only when the relay must be reachable across network namespaces (for example WSL2) and the surrounding network is already trusted.",
"browser.profiles":
"Named browser profile connection map used for explicit routing to CDP ports or URLs with optional metadata. Keep profile names consistent and avoid overlapping endpoint definitions.",
"browser.profiles.*.cdpPort":
@ -263,7 +261,7 @@ export const FIELD_HELP: Record<string, string> = {
"browser.profiles.*.cdpUrl":
"Per-profile CDP websocket URL used for explicit remote browser routing by profile name. Use this when profile connections terminate on remote hosts or tunnels.",
"browser.profiles.*.driver":
'Per-profile browser driver mode: "openclaw" (or legacy "clawd") or "extension" depending on connection/runtime strategy. Use the driver that matches your browser control stack to avoid protocol mismatches.',
'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, or use "existing-session" for host-local Chrome MCP attachment.',
"browser.profiles.*.attachOnly":
"Per-profile attach-only override that skips local browser launch and only attaches to an existing CDP endpoint. Useful when one profile is externally managed but others are locally launched.",
"browser.profiles.*.color":

View File

@ -120,7 +120,6 @@ export const FIELD_LABELS: Record<string, string> = {
"browser.attachOnly": "Browser Attach-only Mode",
"browser.cdpPortRangeStart": "Browser CDP Port Range Start",
"browser.defaultProfile": "Browser Default Profile",
"browser.relayBindHost": "Browser Relay Bind Address",
"browser.profiles": "Browser Profiles",
"browser.profiles.*.cdpPort": "Browser Profile CDP Port",
"browser.profiles.*.cdpUrl": "Browser Profile CDP URL",

View File

@ -4,7 +4,7 @@ export type BrowserProfileConfig = {
/** CDP URL for this profile (use for remote Chrome). */
cdpUrl?: string;
/** Profile driver (default: openclaw). */
driver?: "openclaw" | "clawd" | "extension" | "existing-session";
driver?: "openclaw" | "clawd" | "existing-session";
/** If true, never launch a browser for this profile; only attach. Falls back to browser.attachOnly. */
attachOnly?: boolean;
/** Profile color (hex). Auto-assigned at creation. */
@ -66,10 +66,4 @@ export type BrowserConfig = {
* Example: ["--window-size=1920,1080", "--disable-infobars"]
*/
extraArgs?: string[];
/**
* Bind address for the Chrome extension relay server.
* Default: "127.0.0.1". Set to "0.0.0.0" for WSL2 or other environments where
* the relay must be reachable from a different network namespace.
*/
relayBindHost?: string;
};

View File

@ -360,12 +360,7 @@ export const OpenClawSchema = z
cdpPort: z.number().int().min(1).max(65535).optional(),
cdpUrl: z.string().optional(),
driver: z
.union([
z.literal("openclaw"),
z.literal("clawd"),
z.literal("extension"),
z.literal("existing-session"),
])
.union([z.literal("openclaw"), z.literal("clawd"), z.literal("existing-session")])
.optional(),
attachOnly: z.boolean().optional(),
color: HexColorSchema,
@ -380,7 +375,6 @@ export const OpenClawSchema = z
)
.optional(),
extraArgs: z.array(z.string()).optional(),
relayBindHost: z.union([z.string().ipv4(), z.string().ipv6()]).optional(),
})
.strict()
.optional(),

View File

@ -70,12 +70,12 @@ describe("runBrowserProxyCommand", () => {
JSON.stringify({
method: "GET",
path: "/snapshot",
profile: "chrome-relay",
profile: "openclaw",
timeoutMs: 5,
}),
),
).rejects.toThrow(
/browser proxy timed out for GET \/snapshot after 5ms; ws-backed browser action; profile=chrome-relay; status\(running=true, cdpHttp=true, cdpReady=false, cdpUrl=http:\/\/127\.0\.0\.1:18792\)/,
/browser proxy timed out for GET \/snapshot after 5ms; ws-backed browser action; profile=openclaw; status\(running=true, cdpHttp=true, cdpReady=false, cdpUrl=http:\/\/127\.0\.0\.1:18792\)/,
);
});
@ -150,7 +150,7 @@ describe("runBrowserProxyCommand", () => {
JSON.stringify({
method: "POST",
path: "/act",
profile: "chrome-relay",
profile: "openclaw",
timeoutMs: 50,
}),
),