!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:
parent
10cd276641
commit
476d948732
@ -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.
|
||||
|
||||
@ -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`).
|
||||
@ -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
@ -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 }
|
||||
}
|
||||
@ -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.`,
|
||||
}
|
||||
}
|
||||
@ -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 OpenClaw’s 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:<port>/</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>
|
||||
@ -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()
|
||||
@ -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
|
||||
},
|
||||
{
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 OpenClaw’s 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)
|
||||
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
@ -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"
|
||||
]
|
||||
},
|
||||
|
||||
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 relay’s 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 don’t 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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
|
||||
@ -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 **18800–18899** 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 user’s 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 isn’t 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
|
||||
|
||||
@ -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 Chrome’s 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 Chrome’s 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 you’re 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 isn’t 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 don’t 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 Chrome’s debugger API (`chrome.debugger`). When attached, the model can:
|
||||
- click/type/navigate in that tab
|
||||
- read page content
|
||||
- access whatever the tab’s logged-in session can access
|
||||
- **This is not isolated** like the dedicated openclaw-managed profile.
|
||||
- If you attach to your daily-driver profile/tab, you’re 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)
|
||||
@ -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).
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@ -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" },
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>(
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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) =>
|
||||
(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -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(),
|
||||
|
||||
@ -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).`,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -18,7 +18,7 @@ type HarnessState = {
|
||||
cdpPort?: number;
|
||||
cdpUrl?: string;
|
||||
color: string;
|
||||
driver?: "openclaw" | "extension" | "existing-session";
|
||||
driver?: "openclaw" | "existing-session";
|
||||
attachOnly?: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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."));
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -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" : ""
|
||||
}`,
|
||||
),
|
||||
);
|
||||
|
||||
@ -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);
|
||||
|
||||
96
src/commands/doctor-browser.test.ts
Normal file
96
src/commands/doctor-browser.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
108
src/commands/doctor-browser.ts
Normal file
108
src/commands/doctor-browser.ts
Normal 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");
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
}));
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"'],
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user