!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/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)
|
- 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
|
### 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.
|
- 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"
|
"storage"
|
||||||
],
|
],
|
||||||
"label": "Browser Profile Driver",
|
"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.",
|
"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
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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
|
"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.*.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.*.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.*.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.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.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.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.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.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}
|
{"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:
|
read_when:
|
||||||
- You use `openclaw browser` and want examples for common tasks
|
- 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 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"
|
title: "browser"
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -14,7 +14,6 @@ Manage OpenClaw’s browser control server and run browser actions (tabs, snapsh
|
|||||||
Related:
|
Related:
|
||||||
|
|
||||||
- Browser tool + API: [Browser tool](/tools/browser)
|
- Browser tool + API: [Browser tool](/tools/browser)
|
||||||
- Chrome extension relay: [Chrome extension](/tools/chrome-extension)
|
|
||||||
|
|
||||||
## Common flags
|
## Common flags
|
||||||
|
|
||||||
@ -37,13 +36,14 @@ openclaw browser --browser-profile openclaw snapshot
|
|||||||
|
|
||||||
Profiles are named browser routing configs. In practice:
|
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.
|
- `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
|
```bash
|
||||||
openclaw browser profiles
|
openclaw browser profiles
|
||||||
openclaw browser create-profile --name work --color "#FF5A36"
|
openclaw browser create-profile --name work --color "#FF5A36"
|
||||||
|
openclaw browser create-profile --name chrome-live --driver existing-session
|
||||||
openclaw browser delete-profile --name work
|
openclaw browser delete-profile --name work
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -84,20 +84,17 @@ openclaw browser click <ref>
|
|||||||
openclaw browser type <ref> "hello"
|
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).
|
Use the built-in `user` profile, or create your own `existing-session` profile:
|
||||||
|
|
||||||
Install the unpacked extension to a stable path:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
openclaw browser extension install
|
openclaw browser --browser-profile user tabs
|
||||||
openclaw browser extension path
|
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.
|
This path is host-only. For Docker, headless servers, Browserless, or other remote setups, use a CDP profile instead.
|
||||||
|
|
||||||
Full guide: [Chrome extension](/tools/chrome-extension)
|
|
||||||
|
|
||||||
## Remote browser control (node host proxy)
|
## Remote browser control (node host proxy)
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,6 @@ title: "docs"
|
|||||||
Search the live docs index.
|
Search the live docs index.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
openclaw docs browser extension
|
openclaw docs browser existing-session
|
||||||
openclaw docs sandbox allowHostControl
|
openclaw docs sandbox allowHostControl
|
||||||
```
|
```
|
||||||
|
|||||||
@ -1018,7 +1018,6 @@
|
|||||||
"pages": [
|
"pages": [
|
||||||
"tools/browser",
|
"tools/browser",
|
||||||
"tools/browser-login",
|
"tools/browser-login",
|
||||||
"tools/chrome-extension",
|
|
||||||
"tools/browser-linux-troubleshooting"
|
"tools/browser-linux-troubleshooting"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -1613,7 +1612,6 @@
|
|||||||
"pages": [
|
"pages": [
|
||||||
"zh-CN/tools/browser",
|
"zh-CN/tools/browser",
|
||||||
"zh-CN/tools/browser-login",
|
"zh-CN/tools/browser-login",
|
||||||
"zh-CN/tools/chrome-extension",
|
|
||||||
"zh-CN/tools/browser-linux-troubleshooting"
|
"zh-CN/tools/browser-linux-troubleshooting"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2442,13 +2442,13 @@ See [Plugins](/tools/plugin).
|
|||||||
profiles: {
|
profiles: {
|
||||||
openclaw: { cdpPort: 18800, color: "#FF4500" },
|
openclaw: { cdpPort: 18800, color: "#FF4500" },
|
||||||
work: { cdpPort: 18801, color: "#0066CC" },
|
work: { cdpPort: 18801, color: "#0066CC" },
|
||||||
|
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
|
||||||
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" },
|
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" },
|
||||||
},
|
},
|
||||||
color: "#FF4500",
|
color: "#FF4500",
|
||||||
// headless: false,
|
// headless: false,
|
||||||
// noSandbox: false,
|
// noSandbox: false,
|
||||||
// extraArgs: [],
|
// 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",
|
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||||
// attachOnly: false,
|
// attachOnly: false,
|
||||||
},
|
},
|
||||||
@ -2462,11 +2462,11 @@ See [Plugins](/tools/plugin).
|
|||||||
- `ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias.
|
- `ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias.
|
||||||
- In strict mode, use `ssrfPolicy.hostnameAllowlist` and `ssrfPolicy.allowedHostnames` for explicit exceptions.
|
- In strict mode, use `ssrfPolicy.hostnameAllowlist` and `ssrfPolicy.allowedHostnames` for explicit exceptions.
|
||||||
- Remote profiles are attach-only (start/stop/reset disabled).
|
- 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.
|
- 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`).
|
- Control service: loopback only (port derived from `gateway.port`, default `18791`).
|
||||||
- `extraArgs` appends extra launch flags to local Chromium startup (for example
|
- `extraArgs` appends extra launch flags to local Chromium startup (for example
|
||||||
`--disable-gpu`, window sizing, or debug flags).
|
`--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.
|
- Health check + restart prompt.
|
||||||
- Skills status summary (eligible/missing/blocked).
|
- Skills status summary (eligible/missing/blocked).
|
||||||
- Config normalization for legacy values.
|
- 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`).
|
- OpenCode provider override warnings (`models.providers.opencode` / `models.providers.opencode-go`).
|
||||||
- Legacy on-disk state migration (sessions/agent dir/WhatsApp auth).
|
- 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).
|
- 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`
|
- `agent.model`/`allowedModels`/`modelAliases`/`modelFallbacks`/`imageModelFallbacks`
|
||||||
→ `agents.defaults.models` + `agents.defaults.model.primary/fallbacks` + `agents.defaults.imageModel.primary/fallbacks`
|
→ `agents.defaults.models` + `agents.defaults.model.primary/fallbacks` + `agents.defaults.imageModel.primary/fallbacks`
|
||||||
- `browser.ssrfPolicy.allowPrivateNetwork` → `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork`
|
- `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:
|
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
|
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.
|
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)
|
### 3) Legacy state migrations (disk layout)
|
||||||
|
|
||||||
Doctor can migrate older on-disk layouts into the current structure:
|
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.
|
- 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).
|
- 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.
|
- 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.
|
- Keep the Gateway and node hosts tailnet-only; avoid exposing browser control ports to LAN or public Internet.
|
||||||
- The Chrome extension relay’s CDP endpoint is auth-gated; only OpenClaw clients can connect.
|
|
||||||
- Disable browser proxy routing when you don’t need it (`gateway.nodes.browser.mode="off"`).
|
- 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)
|
### Browser SSRF policy (trusted-network default)
|
||||||
|
|
||||||
|
|||||||
@ -289,19 +289,18 @@ Look for:
|
|||||||
|
|
||||||
- Valid browser executable path.
|
- Valid browser executable path.
|
||||||
- CDP profile reachability.
|
- CDP profile reachability.
|
||||||
- Extension relay tab attachment (if an extension relay profile is configured).
|
- Local Chrome availability for `existing-session` / `user` profiles.
|
||||||
|
|
||||||
Common signatures:
|
Common signatures:
|
||||||
|
|
||||||
- `Failed to start Chrome CDP on port` → browser process failed to launch.
|
- `Failed to start Chrome CDP on port` → browser process failed to launch.
|
||||||
- `browser.executablePath not found` → configured path is invalid.
|
- `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.
|
- `Browser attachOnly is enabled ... not reachable` → attach-only profile has no reachable target.
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
|
|
||||||
- [/tools/browser-linux-troubleshooting](/tools/browser-linux-troubleshooting)
|
- [/tools/browser-linux-troubleshooting](/tools/browser-linux-troubleshooting)
|
||||||
- [/tools/chrome-extension](/tools/chrome-extension)
|
|
||||||
- [/tools/browser](/tools/browser)
|
- [/tools/browser](/tools/browser)
|
||||||
|
|
||||||
## If you upgraded and something suddenly broke
|
## 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 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)
|
- [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)
|
- [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)
|
- [Sandboxing and memory](#sandboxing-and-memory)
|
||||||
- [Is there a dedicated sandboxing doc?](#is-there-a-dedicated-sandboxing-doc)
|
- [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)
|
- [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).
|
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
|
```bash
|
||||||
openclaw browser extension install
|
openclaw browser --browser-profile user tabs
|
||||||
openclaw browser extension path
|
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.
|
This path is host-local. If the Gateway runs elsewhere, either run a node host on the browser machine or use remote CDP instead.
|
||||||
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).
|
|
||||||
|
|
||||||
## Sandboxing and memory
|
## 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.
|
- **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.
|
- **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`.
|
- **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
|
- **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.
|
||||||
with the Chrome extension + a node host on the laptop.
|
|
||||||
|
|
||||||
SSH is fine for ad-hoc shell access, but nodes are simpler for ongoing agent workflows and
|
SSH is fine for ad-hoc shell access, but nodes are simpler for ongoing agent workflows and
|
||||||
device automation.
|
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
|
### 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.
|
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
|
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
|
still block automation. For the most reliable browser control, use local Chrome MCP on the host,
|
||||||
on the machine that runs the browser (and keep the Gateway anywhere).
|
or use CDP on the machine that actually runs the browser.
|
||||||
|
|
||||||
Best-practice setup:
|
Best-practice setup:
|
||||||
|
|
||||||
- Always-on Gateway host (VPS/Mac mini).
|
- Always-on Gateway host (VPS/Mac mini).
|
||||||
- One agent per role (bindings).
|
- One agent per role (bindings).
|
||||||
- Slack channel(s) bound to those agents.
|
- 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),
|
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
|
## Models: defaults, selection, aliases, switching
|
||||||
|
|
||||||
|
|||||||
@ -278,13 +278,13 @@ flowchart TD
|
|||||||
Good output looks like:
|
Good output looks like:
|
||||||
|
|
||||||
- Browser status shows `running: true` and a chosen browser/profile.
|
- 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:
|
Common log signatures:
|
||||||
|
|
||||||
- `Failed to start Chrome CDP on port` → local browser launch failed.
|
- `Failed to start Chrome CDP on port` → local browser launch failed.
|
||||||
- `browser.executablePath not found` → configured binary path is wrong.
|
- `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.
|
- `Browser attachOnly is enabled ... not reachable` → attach-only profile has no live CDP target.
|
||||||
|
|
||||||
Deep pages:
|
Deep pages:
|
||||||
@ -292,7 +292,6 @@ flowchart TD
|
|||||||
- [/gateway/troubleshooting#browser-tool-fails](/gateway/troubleshooting#browser-tool-fails)
|
- [/gateway/troubleshooting#browser-tool-fails](/gateway/troubleshooting#browser-tool-fails)
|
||||||
- [/tools/browser-linux-troubleshooting](/tools/browser-linux-troubleshooting)
|
- [/tools/browser-linux-troubleshooting](/tools/browser-linux-troubleshooting)
|
||||||
- [/tools/browser-wsl2-windows-remote-cdp-troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting)
|
- [/tools/browser-wsl2-windows-remote-cdp-troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting)
|
||||||
- [/tools/chrome-extension](/tools/chrome-extension)
|
|
||||||
|
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</AccordionGroup>
|
</AccordionGroup>
|
||||||
|
|||||||
@ -713,6 +713,7 @@ an optional noVNC observer (headful via Xvfb).
|
|||||||
|
|
||||||
Notes:
|
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.
|
- Headful (Xvfb) reduces bot blocking vs headless.
|
||||||
- Headless can still be used by setting `agents.defaults.sandbox.browser.headless=true`.
|
- Headless can still be used by setting `agents.defaults.sandbox.browser.headless=true`.
|
||||||
- No full desktop environment (GNOME) is needed; Xvfb provides the display.
|
- 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.attachOnly` | Don't launch browser, only attach to existing | `false` |
|
||||||
| `browser.cdpPort` | Chrome DevTools Protocol port | `18800` |
|
| `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
|
You're using an `existing-session` / Chrome MCP profile. OpenClaw can see local Chrome,
|
||||||
browser extension to be attached to a live tab.
|
but there are no open tabs available to attach to.
|
||||||
|
|
||||||
Fix options:
|
Fix options:
|
||||||
|
|
||||||
1. **Use the managed browser:** `openclaw browser start --browser-profile openclaw`
|
1. **Use the managed browser:** `openclaw browser start --browser-profile openclaw`
|
||||||
(or set `browser.defaultProfile: "openclaw"`).
|
(or set `browser.defaultProfile: "openclaw"`).
|
||||||
2. **Use the extension relay:** install the extension, open a tab, and click the
|
2. **Use Chrome MCP:** make sure local Chrome is running with at least one open tab, then retry with `--browser-profile user`.
|
||||||
OpenClaw extension icon to attach it.
|
|
||||||
|
|
||||||
Notes:
|
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.
|
- 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.
|
- 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="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.
|
- If you have multiple user-browser profiles, specify the profile explicitly instead of guessing.
|
||||||
|
|
||||||
Two easy ways to access it:
|
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:
|
read_when:
|
||||||
- Running OpenClaw Gateway in WSL2 while Chrome lives on Windows
|
- Running OpenClaw Gateway in WSL2 while Chrome lives on Windows
|
||||||
- Seeing overlapping browser/control-ui errors across WSL2 and 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"
|
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:
|
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.
|
Use a remote browser profile that points from WSL2 to a Windows Chrome CDP endpoint.
|
||||||
|
|
||||||
Choose this when:
|
Choose this when:
|
||||||
|
|
||||||
- you only need browser control
|
- the Gateway stays inside WSL2
|
||||||
- you are comfortable exposing Chrome remote debugging to WSL2
|
- Chrome runs on Windows
|
||||||
- you do not need the Chrome extension relay
|
- 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:
|
Choose this when:
|
||||||
|
|
||||||
- you want to attach to an existing Windows Chrome tab with the toolbar button
|
- OpenClaw and Chrome are on the same machine
|
||||||
- you want extension-based control instead of raw `--remote-debugging-port`
|
- you want the local signed-in browser state
|
||||||
- the relay itself must be reachable across the WSL2/Windows boundary
|
- 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
|
## Working architecture
|
||||||
|
|
||||||
@ -62,7 +62,6 @@ Several failures can overlap:
|
|||||||
- `gateway.controlUi.allowedOrigins` does not match the page origin
|
- `gateway.controlUi.allowedOrigins` does not match the page origin
|
||||||
- token or pairing is missing
|
- token or pairing is missing
|
||||||
- the browser profile points at the wrong address
|
- 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.
|
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
|
- keep `attachOnly: true` for externally managed browsers
|
||||||
- test the same URL with `curl` before expecting OpenClaw to succeed
|
- test the same URL with `curl` before expecting OpenClaw to succeed
|
||||||
|
|
||||||
### Layer 4: If you use the Chrome extension relay instead
|
### Layer 4: Verify the Control UI layer separately
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
Open the UI from Windows:
|
Open the UI from Windows:
|
||||||
|
|
||||||
@ -185,7 +160,7 @@ Helpful page:
|
|||||||
|
|
||||||
- [Control UI](/web/control-ui)
|
- [Control UI](/web/control-ui)
|
||||||
|
|
||||||
### Layer 6: Verify end-to-end browser control
|
### Layer 5: Verify end-to-end browser control
|
||||||
|
|
||||||
From WSL2:
|
From WSL2:
|
||||||
|
|
||||||
@ -194,12 +169,6 @@ openclaw browser open https://example.com --browser-profile remote
|
|||||||
openclaw browser tabs --browser-profile remote
|
openclaw browser tabs --browser-profile remote
|
||||||
```
|
```
|
||||||
|
|
||||||
For the extension relay:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openclaw browser tabs --browser-profile chrome-relay
|
|
||||||
```
|
|
||||||
|
|
||||||
Good result:
|
Good result:
|
||||||
|
|
||||||
- the tab opens in Windows Chrome
|
- the tab opens in Windows Chrome
|
||||||
@ -220,8 +189,8 @@ Treat each message as a layer-specific clue:
|
|||||||
- WSL2 cannot reach the configured `cdpUrl`
|
- WSL2 cannot reach the configured `cdpUrl`
|
||||||
- `gateway timeout after 1500ms`
|
- `gateway timeout after 1500ms`
|
||||||
- often still CDP reachability or a slow/unreachable remote endpoint
|
- often still CDP reachability or a slow/unreachable remote endpoint
|
||||||
- `Chrome extension relay is running, but no tab is connected`
|
- `No Chrome tabs found for profile="user"`
|
||||||
- extension relay profile selected, but no attached tab exists yet
|
- local Chrome MCP profile selected where no host-local tabs are available
|
||||||
|
|
||||||
## Fast triage checklist
|
## 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?
|
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?
|
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?
|
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
|
## 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:
|
When in doubt:
|
||||||
|
|
||||||
|
|||||||
@ -18,8 +18,7 @@ Beginner view:
|
|||||||
- Think of it as a **separate, agent-only browser**.
|
- Think of it as a **separate, agent-only browser**.
|
||||||
- The `openclaw` profile does **not** touch your personal browser profile.
|
- 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 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;
|
- The built-in `user` profile attaches to your real signed-in Chrome session via Chrome MCP.
|
||||||
`chrome-relay` is the explicit extension-relay profile.
|
|
||||||
|
|
||||||
## What you get
|
## 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
|
If you get “Browser disabled”, enable it in config (see below) and restart the
|
||||||
Gateway.
|
Gateway.
|
||||||
|
|
||||||
## Profiles: `openclaw` vs `user` vs `chrome-relay`
|
## Profiles: `openclaw` vs `user`
|
||||||
|
|
||||||
- `openclaw`: managed, isolated browser (no extension required).
|
- `openclaw`: managed, isolated browser (no extension required).
|
||||||
- `user`: built-in Chrome MCP attach profile for your **real signed-in Chrome**
|
- `user`: built-in Chrome MCP attach profile for your **real signed-in Chrome**
|
||||||
session.
|
session.
|
||||||
- `chrome-relay`: extension relay to your **system browser** (requires the
|
|
||||||
OpenClaw extension to be attached to a tab).
|
|
||||||
|
|
||||||
For agent browser tool calls:
|
For agent browser tool calls:
|
||||||
|
|
||||||
- Default: use the isolated `openclaw` browser.
|
- Default: use the isolated `openclaw` browser.
|
||||||
- Prefer `profile="user"` when existing logged-in sessions matter and the user
|
- Prefer `profile="user"` when existing logged-in sessions matter and the user
|
||||||
is at the computer to click/approve any attach prompt.
|
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.
|
- `profile` is the explicit override when you want a specific browser mode.
|
||||||
|
|
||||||
Set `browser.defaultProfile: "openclaw"` if you want managed mode by default.
|
Set `browser.defaultProfile: "openclaw"` if you want managed mode by default.
|
||||||
@ -93,11 +88,6 @@ Browser settings live in `~/.openclaw/openclaw.json`.
|
|||||||
attachOnly: true,
|
attachOnly: true,
|
||||||
color: "#00AA00",
|
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" },
|
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -107,10 +97,10 @@ Browser settings live in `~/.openclaw/openclaw.json`.
|
|||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- The browser control service binds to loopback on a port derived from `gateway.port`
|
- 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`),
|
- If you override the Gateway port (`gateway.port` or `OPENCLAW_GATEWAY_PORT`),
|
||||||
the derived browser ports shift to stay in the same “family”.
|
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.
|
- `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP reachability checks.
|
||||||
- `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket 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.
|
- 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.
|
- `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.”
|
- `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.
|
- `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.
|
- 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.
|
- 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
|
- `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
|
- **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)
|
- **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
|
- **existing session**: your existing Chrome profile via Chrome DevTools MCP auto-connect
|
||||||
|
|
||||||
Defaults:
|
Defaults:
|
||||||
|
|
||||||
- The `openclaw` profile is auto-created if missing.
|
- 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).
|
- The `user` profile is built-in for Chrome MCP existing-session attach.
|
||||||
- Existing-session profiles are opt-in; create them with `--driver existing-session`.
|
- Existing-session profiles are opt-in beyond `user`; create them with `--driver existing-session`.
|
||||||
- Local CDP ports allocate from **18800–18899** by default.
|
- Local CDP ports allocate from **18800–18899** by default.
|
||||||
- Deleting a profile moves its local data directory to Trash.
|
- Deleting a profile moves its local data directory to Trash.
|
||||||
|
|
||||||
All control endpoints accept `?profile=<name>`; the CLI uses `--browser-profile`.
|
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
|
## Chrome existing-session via MCP
|
||||||
|
|
||||||
OpenClaw can also attach to a running Chrome profile through the official
|
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+`
|
- Chrome is version `144+`
|
||||||
- remote debugging is enabled at `chrome://inspect/#remote-debugging`
|
- remote debugging is enabled at `chrome://inspect/#remote-debugging`
|
||||||
- Chrome showed and you accepted the attach consent prompt
|
- 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:
|
Agent use:
|
||||||
|
|
||||||
- Use `profile="user"` when you need the user’s logged-in browser state.
|
- 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.
|
- 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
|
- Only choose this mode when the user is at the computer to approve the attach
|
||||||
prompt.
|
prompt.
|
||||||
- the Gateway or node host can spawn `npx chrome-devtools-mcp@latest --autoConnect`
|
- 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.
|
captures from snapshots, but not CSS `--element` selectors.
|
||||||
- Existing-session `wait --url` supports exact, substring, and glob patterns
|
- Existing-session `wait --url` supports exact, substring, and glob patterns
|
||||||
like other browser drivers. `wait --load networkidle` is not supported yet.
|
like other browser drivers. `wait --load networkidle` is not supported yet.
|
||||||
- Some features still require the extension relay or managed browser path, such
|
- Some features still require the managed browser path, such as PDF export and
|
||||||
as PDF export and download interception.
|
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.
|
- 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.
|
||||||
WSL2 / cross-namespace example:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
browser: {
|
|
||||||
enabled: true,
|
|
||||||
relayBindHost: "0.0.0.0",
|
|
||||||
defaultProfile: "chrome-relay",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Isolation guarantees
|
## 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
|
Some features (navigate/act/AI snapshot/role snapshot, element screenshots, PDF) require
|
||||||
Playwright. If Playwright isn’t installed, those endpoints return a clear 501
|
Playwright. If Playwright isn’t installed, those endpoints return a clear 501
|
||||||
error. ARIA snapshots and basic screenshots still work for openclaw-managed Chrome.
|
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
|
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
|
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.
|
- All actions accept optional `profile` parameter for multi-instance support.
|
||||||
- Omit `profile` for the safe default: isolated OpenClaw-managed browser (`openclaw`).
|
- 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="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"` is host-only; do not combine it with sandbox/node targets.
|
||||||
- `profile="user"` and `profile="chrome-relay"` are host-only; do not combine them with sandbox/node targets.
|
|
||||||
- When `profile` is omitted, uses `browser.defaultProfile` (defaults to `openclaw`).
|
- When `profile` is omitted, uses `browser.defaultProfile` (defaults to `openclaw`).
|
||||||
- Profile names: lowercase alphanumeric + hyphens only (max 64 chars).
|
- Profile names: lowercase alphanumeric + hyphens only (max 64 chars).
|
||||||
- Port range: 18800-18899 (~100 profiles max).
|
- 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.
|
// Skills discovery/snapshot suites are filesystem-heavy and high-variance in vmForks lanes.
|
||||||
"src/agents/skills.test.ts",
|
"src/agents/skills.test.ts",
|
||||||
"src/agents/skills.buildworkspaceskillsnapshot.test.ts",
|
"src/agents/skills.buildworkspaceskillsnapshot.test.ts",
|
||||||
"src/browser/extension-relay.test.ts",
|
|
||||||
"extensions/acpx/src/runtime.test.ts",
|
"extensions/acpx/src/runtime.test.ts",
|
||||||
// Shell-heavy script harness can contend under vmForks startup bursts.
|
// Shell-heavy script harness can contend under vmForks startup bursts.
|
||||||
"test/scripts/ios-team-id.test.ts",
|
"test/scripts/ios-team-id.test.ts",
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
import { browserAct, browserConsoleMessages } from "../../browser/client-actions.js";
|
import { browserAct, browserConsoleMessages } from "../../browser/client-actions.js";
|
||||||
import { browserSnapshot, browserTabs } from "../../browser/client.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 { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../browser/constants.js";
|
||||||
|
import { getBrowserProfileCapabilities } from "../../browser/profile-capabilities.js";
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
import { wrapExternalContent } from "../../security/external-content.js";
|
import { wrapExternalContent } from "../../security/external-content.js";
|
||||||
import { imageResultFromFile, jsonResult } from "./common.js";
|
import { imageResultFromFile, jsonResult } from "./common.js";
|
||||||
@ -74,7 +76,17 @@ function formatConsoleToolResult(result: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isChromeStaleTargetError(profile: string | undefined, err: unknown): boolean {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
const msg = String(err);
|
const msg = String(err);
|
||||||
@ -334,12 +346,8 @@ export async function executeActAction(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!tabs.length) {
|
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(
|
throw new Error(
|
||||||
isRelayProfile
|
`No Chrome tabs found for profile="${profile}". Make sure Chrome (v144+) is running and has open tabs, then retry.`,
|
||||||
? "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.`,
|
|
||||||
{ cause: err },
|
{ cause: err },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -64,12 +64,7 @@ const browserConfigMocks = vi.hoisted(() => ({
|
|||||||
if (!profile) {
|
if (!profile) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const driver =
|
const driver = profile.driver === "existing-session" ? "existing-session" : "openclaw";
|
||||||
profile.driver === "extension"
|
|
||||||
? "extension"
|
|
||||||
: profile.driver === "existing-session"
|
|
||||||
? "existing-session"
|
|
||||||
: "openclaw";
|
|
||||||
if (driver === "existing-session") {
|
if (driver === "existing-session") {
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
@ -287,29 +282,6 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
expect(opts?.mode).toBeUndefined();
|
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 () => {
|
it("defaults to host when using profile=user (even in sandboxed sessions)", async () => {
|
||||||
setResolvedBrowserProfiles({
|
setResolvedBrowserProfiles({
|
||||||
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
|
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
|
||||||
|
|||||||
@ -290,7 +290,7 @@ function shouldPreferHostForProfile(profileName: string | undefined) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const capabilities = getBrowserProfileCapabilities(profile);
|
const capabilities = getBrowserProfileCapabilities(profile);
|
||||||
return capabilities.requiresRelay || capabilities.usesChromeMcp;
|
return capabilities.usesChromeMcp;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBrowserTool(opts?: {
|
export function createBrowserTool(opts?: {
|
||||||
@ -307,7 +307,7 @@ export function createBrowserTool(opts?: {
|
|||||||
description: [
|
description: [
|
||||||
"Control the browser via OpenClaw's browser control server (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions).",
|
"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`).",
|
"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 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).",
|
"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.',
|
'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") {
|
if (requestedNode && target && target !== "node") {
|
||||||
throw new Error('node is only supported with 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);
|
const isUserBrowserProfile = shouldPreferHostForProfile(profile);
|
||||||
if (isUserBrowserProfile) {
|
if (isUserBrowserProfile) {
|
||||||
if (requestedNode || target === "node") {
|
if (requestedNode || target === "node") {
|
||||||
|
|||||||
@ -7,15 +7,10 @@ import {
|
|||||||
import { __test } from "./client-fetch.js";
|
import { __test } from "./client-fetch.js";
|
||||||
import { resolveBrowserConfig, resolveProfile } from "./config.js";
|
import { resolveBrowserConfig, resolveProfile } from "./config.js";
|
||||||
import { shouldRejectBrowserMutation } from "./csrf.js";
|
import { shouldRejectBrowserMutation } from "./csrf.js";
|
||||||
import {
|
|
||||||
ensureChromeExtensionRelayServer,
|
|
||||||
stopChromeExtensionRelayServer,
|
|
||||||
} from "./extension-relay.js";
|
|
||||||
import { toBoolean } from "./routes/utils.js";
|
import { toBoolean } from "./routes/utils.js";
|
||||||
import type { BrowserServerState } from "./server-context.js";
|
import type { BrowserServerState } from "./server-context.js";
|
||||||
import { listKnownProfileNames } from "./server-context.js";
|
import { listKnownProfileNames } from "./server-context.js";
|
||||||
import { resolveTargetIdFromTabs } from "./target-id.js";
|
import { resolveTargetIdFromTabs } from "./target-id.js";
|
||||||
import { getFreePort } from "./test-port.js";
|
|
||||||
|
|
||||||
describe("toBoolean", () => {
|
describe("toBoolean", () => {
|
||||||
it("parses yes/no and 1/0", () => {
|
it("parses yes/no and 1/0", () => {
|
||||||
@ -195,29 +190,8 @@ describe("cdp.helpers", () => {
|
|||||||
expect(headers.Authorization).toBe("Bearer token");
|
expect(headers.Authorization).toBe("Bearer token");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not add relay header for unknown loopback ports", () => {
|
it("does not add custom headers when none are required", () => {
|
||||||
const headers = getHeadersWithAuth("http://127.0.0.1:19444/json/version");
|
expect(getHeadersWithAuth("http://127.0.0.1:19444/json/version")).toEqual({});
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import { redactSensitiveText } from "../logging/redact.js";
|
|||||||
import { getDirectAgentForCdp, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.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 { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js";
|
||||||
import { resolveBrowserRateLimitMessage } from "./client-fetch.js";
|
import { resolveBrowserRateLimitMessage } from "./client-fetch.js";
|
||||||
import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js";
|
|
||||||
|
|
||||||
export { isLoopbackHost };
|
export { isLoopbackHost };
|
||||||
|
|
||||||
@ -76,8 +75,7 @@ export type CdpSendFn = (
|
|||||||
) => Promise<unknown>;
|
) => Promise<unknown>;
|
||||||
|
|
||||||
export function getHeadersWithAuth(url: string, headers: Record<string, string> = {}) {
|
export function getHeadersWithAuth(url: string, headers: Record<string, string> = {}) {
|
||||||
const relayHeaders = getChromeExtensionRelayAuthHeaders(url);
|
const mergedHeaders = { ...headers };
|
||||||
const mergedHeaders = { ...relayHeaders, ...headers };
|
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
const hasAuthHeader = Object.keys(mergedHeaders).some(
|
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(() => {});
|
await client.close().catch(() => {});
|
||||||
throw new BrowserProfileUnavailableError(
|
throw new BrowserProfileUnavailableError(
|
||||||
`Chrome MCP existing-session attach failed for profile "${profileName}". ` +
|
`Chrome MCP existing-session attach failed for profile "${profileName}". ` +
|
||||||
`Make sure Chrome (v146+) is running. ` +
|
`Make sure Chrome (v144+) is running. ` +
|
||||||
`Details: ${String(err)}`,
|
`Details: ${String(err)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,8 @@ export type BrowserExecutable = {
|
|||||||
path: string;
|
path: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CHROME_VERSION_RE = /(\d+)(?:\.\d+){0,3}/;
|
||||||
|
|
||||||
const CHROMIUM_BUNDLE_IDS = new Set([
|
const CHROMIUM_BUNDLE_IDS = new Set([
|
||||||
"com.google.Chrome",
|
"com.google.Chrome",
|
||||||
"com.google.Chrome.beta",
|
"com.google.Chrome.beta",
|
||||||
@ -453,6 +455,22 @@ function findFirstExecutable(candidates: Array<BrowserExecutable>): BrowserExecu
|
|||||||
return null;
|
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 {
|
export function findChromeExecutableMac(): BrowserExecutable | null {
|
||||||
const candidates: Array<BrowserExecutable> = [
|
const candidates: Array<BrowserExecutable> = [
|
||||||
{
|
{
|
||||||
@ -506,6 +524,18 @@ export function findChromeExecutableMac(): BrowserExecutable | null {
|
|||||||
return findFirstExecutable(candidates);
|
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 {
|
export function findChromeExecutableLinux(): BrowserExecutable | null {
|
||||||
const candidates: Array<BrowserExecutable> = [
|
const candidates: Array<BrowserExecutable> = [
|
||||||
{ kind: "chrome", path: "/usr/bin/google-chrome" },
|
{ kind: "chrome", path: "/usr/bin/google-chrome" },
|
||||||
@ -525,6 +555,16 @@ export function findChromeExecutableLinux(): BrowserExecutable | null {
|
|||||||
return findFirstExecutable(candidates);
|
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 {
|
export function findChromeExecutableWindows(): BrowserExecutable | null {
|
||||||
const localAppData = process.env.LOCALAPPDATA ?? "";
|
const localAppData = process.env.LOCALAPPDATA ?? "";
|
||||||
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
|
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
|
||||||
@ -596,6 +636,56 @@ export function findChromeExecutableWindows(): BrowserExecutable | null {
|
|||||||
return findFirstExecutable(candidates);
|
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(
|
export function resolveBrowserExecutableForPlatform(
|
||||||
resolved: ResolvedBrowserConfig,
|
resolved: ResolvedBrowserConfig,
|
||||||
platform: NodeJS.Platform,
|
platform: NodeJS.Platform,
|
||||||
|
|||||||
@ -5,7 +5,7 @@ export type BrowserTransport = "cdp" | "chrome-mcp";
|
|||||||
export type BrowserStatus = {
|
export type BrowserStatus = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
profile?: string;
|
profile?: string;
|
||||||
driver?: "openclaw" | "extension" | "existing-session";
|
driver?: "openclaw" | "existing-session";
|
||||||
transport?: BrowserTransport;
|
transport?: BrowserTransport;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
cdpReady?: boolean;
|
cdpReady?: boolean;
|
||||||
@ -31,7 +31,7 @@ export type ProfileStatus = {
|
|||||||
cdpPort: number | null;
|
cdpPort: number | null;
|
||||||
cdpUrl: string | null;
|
cdpUrl: string | null;
|
||||||
color: string;
|
color: string;
|
||||||
driver: "openclaw" | "extension" | "existing-session";
|
driver: "openclaw" | "existing-session";
|
||||||
running: boolean;
|
running: boolean;
|
||||||
tabCount: number;
|
tabCount: number;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
@ -172,7 +172,7 @@ export async function browserCreateProfile(
|
|||||||
name: string;
|
name: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
cdpUrl?: string;
|
cdpUrl?: string;
|
||||||
driver?: "openclaw" | "extension" | "existing-session";
|
driver?: "openclaw" | "existing-session";
|
||||||
},
|
},
|
||||||
): Promise<BrowserCreateProfileResult> {
|
): Promise<BrowserCreateProfileResult> {
|
||||||
return await fetchBrowserJson<BrowserCreateProfileResult>(
|
return await fetchBrowserJson<BrowserCreateProfileResult>(
|
||||||
|
|||||||
@ -188,13 +188,6 @@ describe("browser config", () => {
|
|||||||
expect(profile?.cdpIsLoopback).toBe(true);
|
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", () => {
|
it("rejects unsupported protocols", () => {
|
||||||
expect(() => resolveBrowserConfig({ cdpUrl: "ftp://127.0.0.1:18791" })).toThrow(
|
expect(() => resolveBrowserConfig({ cdpUrl: "ftp://127.0.0.1:18791" })).toThrow(
|
||||||
"must be http(s) or ws(s)",
|
"must be http(s) or ws(s)",
|
||||||
@ -289,7 +282,6 @@ describe("browser config", () => {
|
|||||||
const resolved = resolveBrowserConfig({
|
const resolved = resolveBrowserConfig({
|
||||||
profiles: {
|
profiles: {
|
||||||
"chrome-live": { driver: "existing-session", attachOnly: true, color: "#00AA00" },
|
"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" },
|
work: { cdpPort: 18801, color: "#0066CC" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -300,9 +292,6 @@ describe("browser config", () => {
|
|||||||
const managed = resolveProfile(resolved, "openclaw")!;
|
const managed = resolveProfile(resolved, "openclaw")!;
|
||||||
expect(getBrowserProfileCapabilities(managed).usesChromeMcp).toBe(false);
|
expect(getBrowserProfileCapabilities(managed).usesChromeMcp).toBe(false);
|
||||||
|
|
||||||
const extension = resolveProfile(resolved, "relay")!;
|
|
||||||
expect(getBrowserProfileCapabilities(extension).usesChromeMcp).toBe(false);
|
|
||||||
|
|
||||||
const work = resolveProfile(resolved, "work")!;
|
const work = resolveProfile(resolved, "work")!;
|
||||||
expect(getBrowserProfileCapabilities(work).usesChromeMcp).toBe(false);
|
expect(getBrowserProfileCapabilities(work).usesChromeMcp).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -36,7 +36,6 @@ export type ResolvedBrowserConfig = {
|
|||||||
profiles: Record<string, BrowserProfileConfig>;
|
profiles: Record<string, BrowserProfileConfig>;
|
||||||
ssrfPolicy?: SsrFPolicy;
|
ssrfPolicy?: SsrFPolicy;
|
||||||
extraArgs: string[];
|
extraArgs: string[];
|
||||||
relayBindHost?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ResolvedBrowserProfile = {
|
export type ResolvedBrowserProfile = {
|
||||||
@ -46,7 +45,7 @@ export type ResolvedBrowserProfile = {
|
|||||||
cdpHost: string;
|
cdpHost: string;
|
||||||
cdpIsLoopback: boolean;
|
cdpIsLoopback: boolean;
|
||||||
color: string;
|
color: string;
|
||||||
driver: "openclaw" | "extension" | "existing-session";
|
driver: "openclaw" | "existing-session";
|
||||||
attachOnly: boolean;
|
attachOnly: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -279,8 +278,6 @@ export function resolveBrowserConfig(
|
|||||||
? cfg.extraArgs.filter((a): a is string => typeof a === "string" && a.trim().length > 0)
|
? cfg.extraArgs.filter((a): a is string => typeof a === "string" && a.trim().length > 0)
|
||||||
: [];
|
: [];
|
||||||
const ssrfPolicy = resolveBrowserSsrFPolicy(cfg);
|
const ssrfPolicy = resolveBrowserSsrFPolicy(cfg);
|
||||||
const relayBindHost = cfg?.relayBindHost?.trim() || undefined;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
evaluateEnabled,
|
evaluateEnabled,
|
||||||
@ -301,7 +298,6 @@ export function resolveBrowserConfig(
|
|||||||
profiles,
|
profiles,
|
||||||
ssrfPolicy,
|
ssrfPolicy,
|
||||||
extraArgs,
|
extraArgs,
|
||||||
relayBindHost,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -322,12 +318,7 @@ export function resolveProfile(
|
|||||||
let cdpHost = resolved.cdpHost;
|
let cdpHost = resolved.cdpHost;
|
||||||
let cdpPort = profile.cdpPort ?? 0;
|
let cdpPort = profile.cdpPort ?? 0;
|
||||||
let cdpUrl = "";
|
let cdpUrl = "";
|
||||||
const driver =
|
const driver = profile.driver === "existing-session" ? "existing-session" : "openclaw";
|
||||||
profile.driver === "extension"
|
|
||||||
? "extension"
|
|
||||||
: profile.driver === "existing-session"
|
|
||||||
? "existing-session"
|
|
||||||
: "openclaw";
|
|
||||||
|
|
||||||
if (driver === "existing-session") {
|
if (driver === "existing-session") {
|
||||||
// existing-session uses Chrome MCP auto-connect; no CDP port/URL needed
|
// 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";
|
import type { ResolvedBrowserProfile } from "./config.js";
|
||||||
|
|
||||||
export type BrowserProfileMode =
|
export type BrowserProfileMode = "local-managed" | "local-existing-session" | "remote-cdp";
|
||||||
| "local-managed"
|
|
||||||
| "local-extension-relay"
|
|
||||||
| "local-existing-session"
|
|
||||||
| "remote-cdp";
|
|
||||||
|
|
||||||
export type BrowserProfileCapabilities = {
|
export type BrowserProfileCapabilities = {
|
||||||
mode: BrowserProfileMode;
|
mode: BrowserProfileMode;
|
||||||
isRemote: boolean;
|
isRemote: boolean;
|
||||||
/** Profile uses the Chrome DevTools MCP server (existing-session driver). */
|
/** Profile uses the Chrome DevTools MCP server (existing-session driver). */
|
||||||
usesChromeMcp: boolean;
|
usesChromeMcp: boolean;
|
||||||
requiresRelay: boolean;
|
|
||||||
requiresAttachedTab: boolean;
|
|
||||||
usesPersistentPlaywright: boolean;
|
usesPersistentPlaywright: boolean;
|
||||||
supportsPerTabWs: boolean;
|
supportsPerTabWs: boolean;
|
||||||
supportsJsonTabEndpoints: boolean;
|
supportsJsonTabEndpoints: boolean;
|
||||||
@ -23,28 +17,11 @@ export type BrowserProfileCapabilities = {
|
|||||||
export function getBrowserProfileCapabilities(
|
export function getBrowserProfileCapabilities(
|
||||||
profile: ResolvedBrowserProfile,
|
profile: ResolvedBrowserProfile,
|
||||||
): BrowserProfileCapabilities {
|
): 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") {
|
if (profile.driver === "existing-session") {
|
||||||
return {
|
return {
|
||||||
mode: "local-existing-session",
|
mode: "local-existing-session",
|
||||||
isRemote: false,
|
isRemote: false,
|
||||||
usesChromeMcp: true,
|
usesChromeMcp: true,
|
||||||
requiresRelay: false,
|
|
||||||
requiresAttachedTab: false,
|
|
||||||
usesPersistentPlaywright: false,
|
usesPersistentPlaywright: false,
|
||||||
supportsPerTabWs: false,
|
supportsPerTabWs: false,
|
||||||
supportsJsonTabEndpoints: false,
|
supportsJsonTabEndpoints: false,
|
||||||
@ -58,8 +35,6 @@ export function getBrowserProfileCapabilities(
|
|||||||
mode: "remote-cdp",
|
mode: "remote-cdp",
|
||||||
isRemote: true,
|
isRemote: true,
|
||||||
usesChromeMcp: false,
|
usesChromeMcp: false,
|
||||||
requiresRelay: false,
|
|
||||||
requiresAttachedTab: false,
|
|
||||||
usesPersistentPlaywright: true,
|
usesPersistentPlaywright: true,
|
||||||
supportsPerTabWs: false,
|
supportsPerTabWs: false,
|
||||||
supportsJsonTabEndpoints: false,
|
supportsJsonTabEndpoints: false,
|
||||||
@ -72,8 +47,6 @@ export function getBrowserProfileCapabilities(
|
|||||||
mode: "local-managed",
|
mode: "local-managed",
|
||||||
isRemote: false,
|
isRemote: false,
|
||||||
usesChromeMcp: false,
|
usesChromeMcp: false,
|
||||||
requiresRelay: false,
|
|
||||||
requiresAttachedTab: false,
|
|
||||||
usesPersistentPlaywright: false,
|
usesPersistentPlaywright: false,
|
||||||
supportsPerTabWs: true,
|
supportsPerTabWs: true,
|
||||||
supportsJsonTabEndpoints: true,
|
supportsJsonTabEndpoints: true,
|
||||||
@ -96,9 +69,6 @@ export function resolveDefaultSnapshotFormat(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const capabilities = getBrowserProfileCapabilities(params.profile);
|
const capabilities = getBrowserProfileCapabilities(params.profile);
|
||||||
if (capabilities.mode === "local-extension-relay") {
|
|
||||||
return "aria";
|
|
||||||
}
|
|
||||||
if (capabilities.mode === "local-existing-session") {
|
if (capabilities.mode === "local-existing-session") {
|
||||||
return "ai";
|
return "ai";
|
||||||
}
|
}
|
||||||
@ -112,16 +82,12 @@ export function shouldUsePlaywrightForScreenshot(params: {
|
|||||||
ref?: string;
|
ref?: string;
|
||||||
element?: string;
|
element?: string;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const capabilities = getBrowserProfileCapabilities(params.profile);
|
return !params.wsUrl || Boolean(params.ref) || Boolean(params.element);
|
||||||
return (
|
|
||||||
capabilities.requiresRelay || !params.wsUrl || Boolean(params.ref) || Boolean(params.element)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldUsePlaywrightForAriaSnapshot(params: {
|
export function shouldUsePlaywrightForAriaSnapshot(params: {
|
||||||
profile: ResolvedBrowserProfile;
|
profile: ResolvedBrowserProfile;
|
||||||
wsUrl?: string;
|
wsUrl?: string;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const capabilities = getBrowserProfileCapabilities(params.profile);
|
return !params.wsUrl;
|
||||||
return capabilities.requiresRelay || !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 () => {
|
it("creates existing-session profiles as attach-only local entries", async () => {
|
||||||
const resolved = resolveBrowserConfig({});
|
const resolved = resolveBrowserConfig({});
|
||||||
const { ctx, state } = createCtx(resolved);
|
const { ctx, state } = createCtx(resolved);
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import path from "node:path";
|
|||||||
import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js";
|
import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js";
|
||||||
import { loadConfig, writeConfigFile } from "../config/config.js";
|
import { loadConfig, writeConfigFile } from "../config/config.js";
|
||||||
import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js";
|
import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js";
|
||||||
import { isLoopbackHost } from "../gateway/net.js";
|
|
||||||
import { resolveOpenClawUserDataDir } from "./chrome.js";
|
import { resolveOpenClawUserDataDir } from "./chrome.js";
|
||||||
import { parseHttpUrl, resolveProfile } from "./config.js";
|
import { parseHttpUrl, resolveProfile } from "./config.js";
|
||||||
import {
|
import {
|
||||||
@ -27,7 +26,7 @@ export type CreateProfileParams = {
|
|||||||
name: string;
|
name: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
cdpUrl?: string;
|
cdpUrl?: string;
|
||||||
driver?: "openclaw" | "extension" | "existing-session";
|
driver?: "openclaw" | "existing-session";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateProfileResult = {
|
export type CreateProfileResult = {
|
||||||
@ -80,12 +79,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
|
|||||||
const createProfile = async (params: CreateProfileParams): Promise<CreateProfileResult> => {
|
const createProfile = async (params: CreateProfileParams): Promise<CreateProfileResult> => {
|
||||||
const name = params.name.trim();
|
const name = params.name.trim();
|
||||||
const rawCdpUrl = params.cdpUrl?.trim() || undefined;
|
const rawCdpUrl = params.cdpUrl?.trim() || undefined;
|
||||||
const driver =
|
const driver = params.driver === "existing-session" ? "existing-session" : undefined;
|
||||||
params.driver === "extension"
|
|
||||||
? "extension"
|
|
||||||
: params.driver === "existing-session"
|
|
||||||
? "existing-session"
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (!isValidProfileName(name)) {
|
if (!isValidProfileName(name)) {
|
||||||
throw new BrowserValidationError(
|
throw new BrowserValidationError(
|
||||||
@ -117,18 +111,6 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new BrowserValidationError(String(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") {
|
if (driver === "existing-session") {
|
||||||
throw new BrowserValidationError(
|
throw new BrowserValidationError(
|
||||||
"driver=existing-session does not accept cdpUrl; it attaches via the Chrome MCP auto-connect flow",
|
"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,
|
color: profileColor,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
if (driver === "extension") {
|
|
||||||
throw new BrowserValidationError("driver=extension requires an explicit loopback cdpUrl");
|
|
||||||
}
|
|
||||||
if (driver === "existing-session") {
|
if (driver === "existing-session") {
|
||||||
// existing-session uses Chrome MCP auto-connect; no CDP port needed
|
// existing-session uses Chrome MCP auto-connect; no CDP port needed
|
||||||
profileConfig = {
|
profileConfig = {
|
||||||
|
|||||||
@ -52,7 +52,7 @@ function createExtensionFallbackBrowserHarness(options?: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("pw-session getPageForTargetId", () => {
|
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 { browserClose, pages } = createExtensionFallbackBrowserHarness();
|
||||||
const [page] = pages;
|
const [page] = pages;
|
||||||
|
|
||||||
@ -94,20 +94,14 @@ 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({
|
const { newCDPSession, pages } = createExtensionFallbackBrowserHarness({
|
||||||
urls: ["https://alpha.example", "https://beta.example"],
|
urls: ["https://alpha.example", "https://beta.example"],
|
||||||
newCDPSessionError: "Target.attachToBrowserTarget: Not allowed",
|
newCDPSessionError: "Target.attachToBrowserTarget: Not allowed",
|
||||||
});
|
});
|
||||||
const [, pageB] = pages;
|
const [, pageB] = pages;
|
||||||
|
|
||||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
fetchSpy
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ Browser: "OpenClaw/extension-relay" }),
|
|
||||||
} as Response)
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => [
|
json: async () => [
|
||||||
{ id: "TARGET_A", url: "https://alpha.example" },
|
{ id: "TARGET_A", url: "https://alpha.example" },
|
||||||
@ -121,7 +115,7 @@ describe("pw-session getPageForTargetId", () => {
|
|||||||
targetId: "TARGET_B",
|
targetId: "TARGET_B",
|
||||||
});
|
});
|
||||||
expect(resolved).toBe(pageB);
|
expect(resolved).toBe(pageB);
|
||||||
expect(newCDPSession).not.toHaveBeenCalled();
|
expect(newCDPSession).toHaveBeenCalled();
|
||||||
} finally {
|
} finally {
|
||||||
fetchSpy.mockRestore();
|
fetchSpy.mockRestore();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,61 +1,12 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { withPageScopedCdpClient } from "./pw-session.page-cdp.js";
|
||||||
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";
|
|
||||||
|
|
||||||
describe("pw-session page-scoped CDP client", () => {
|
describe("pw-session page-scoped CDP client", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses raw relay /cdp commands for extension endpoints when targetId is known", async () => {
|
it("uses Playwright page sessions", 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" });
|
|
||||||
const sessionSend = vi.fn(async () => ({ ok: true }));
|
const sessionSend = vi.fn(async () => ({ ok: true }));
|
||||||
const sessionDetach = vi.fn(async () => {});
|
const sessionDetach = vi.fn(async () => {});
|
||||||
const newCDPSession = vi.fn(async () => ({
|
const newCDPSession = vi.fn(async () => ({
|
||||||
@ -80,15 +31,5 @@ describe("pw-session page-scoped CDP client", () => {
|
|||||||
expect(newCDPSession).toHaveBeenCalledWith(page);
|
expect(newCDPSession).toHaveBeenCalledWith(page);
|
||||||
expect(sessionSend).toHaveBeenCalledWith("Emulation.setLocaleOverride", { locale: "en-US" });
|
expect(sessionSend).toHaveBeenCalledWith("Emulation.setLocaleOverride", { locale: "en-US" });
|
||||||
expect(sessionDetach).toHaveBeenCalledTimes(1);
|
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 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>;
|
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>(
|
async function withPlaywrightPageCdpSession<T>(
|
||||||
page: Page,
|
page: Page,
|
||||||
fn: (session: CDPSession) => Promise<T>,
|
fn: (session: CDPSession) => Promise<T>,
|
||||||
@ -57,17 +20,6 @@ export async function withPageScopedCdpClient<T>(opts: {
|
|||||||
targetId?: string;
|
targetId?: string;
|
||||||
fn: (send: PageCdpSend) => Promise<T>;
|
fn: (send: PageCdpSend) => Promise<T>;
|
||||||
}): 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 withPlaywrightPageCdpSession(opts.page, async (session) => {
|
||||||
return await opts.fn((method, params) =>
|
return await opts.fn((method, params) =>
|
||||||
(
|
(
|
||||||
|
|||||||
@ -26,7 +26,7 @@ import {
|
|||||||
assertBrowserNavigationResultAllowed,
|
assertBrowserNavigationResultAllowed,
|
||||||
withBrowserNavigationPolicy,
|
withBrowserNavigationPolicy,
|
||||||
} from "./navigation-guard.js";
|
} from "./navigation-guard.js";
|
||||||
import { isExtensionRelayCdpEndpoint, withPageScopedCdpClient } from "./pw-session.page-cdp.js";
|
import { withPageScopedCdpClient } from "./pw-session.page-cdp.js";
|
||||||
|
|
||||||
export type BrowserConsoleMessage = {
|
export type BrowserConsoleMessage = {
|
||||||
type: string;
|
type: string;
|
||||||
@ -454,21 +454,6 @@ async function findPageByTargetId(
|
|||||||
cdpUrl?: string,
|
cdpUrl?: string,
|
||||||
): Promise<Page | null> {
|
): Promise<Page | null> {
|
||||||
const pages = await getAllPages(browser);
|
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;
|
let resolvedViaCdp = false;
|
||||||
for (const page of pages) {
|
for (const page of pages) {
|
||||||
let tid: string | null = null;
|
let tid: string | null = null;
|
||||||
@ -522,9 +507,7 @@ export async function getPageForTargetId(opts: {
|
|||||||
}
|
}
|
||||||
const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
|
const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
|
||||||
if (!found) {
|
if (!found) {
|
||||||
// Extension relays can block CDP attachment APIs (e.g. Target.attachToBrowserTarget),
|
// If Playwright only exposes a single Page, use it as a best-effort fallback.
|
||||||
// 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 (pages.length === 1) {
|
if (pages.length === 1) {
|
||||||
return first;
|
return first;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,15 +3,15 @@ import { resolveBrowserConfig, resolveProfile } from "../config.js";
|
|||||||
import { resolveSnapshotPlan } from "./agent.snapshot.plan.js";
|
import { resolveSnapshotPlan } from "./agent.snapshot.plan.js";
|
||||||
|
|
||||||
describe("resolveSnapshotPlan", () => {
|
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({
|
const resolved = resolveBrowserConfig({
|
||||||
profiles: {
|
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).toBeTruthy();
|
||||||
expect(profile?.driver).toBe("extension");
|
expect(profile?.driver).toBe("existing-session");
|
||||||
|
|
||||||
const plan = resolveSnapshotPlan({
|
const plan = resolveSnapshotPlan({
|
||||||
profile: profile as NonNullable<typeof profile>,
|
profile: profile as NonNullable<typeof profile>,
|
||||||
@ -19,7 +19,7 @@ describe("resolveSnapshotPlan", () => {
|
|||||||
hasPlaywright: true,
|
hasPlaywright: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(plan.format).toBe("aria");
|
expect(plan.format).toBe("ai");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps ai snapshots for managed browsers when Playwright is available", () => {
|
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 name = toStringOrEmpty((req.body as { name?: unknown })?.name);
|
||||||
const color = toStringOrEmpty((req.body as { color?: unknown })?.color);
|
const color = toStringOrEmpty((req.body as { color?: unknown })?.color);
|
||||||
const cdpUrl = toStringOrEmpty((req.body as { cdpUrl?: unknown })?.cdpUrl);
|
const cdpUrl = toStringOrEmpty((req.body as { cdpUrl?: unknown })?.cdpUrl);
|
||||||
const driver = toStringOrEmpty((req.body as { driver?: unknown })?.driver) as
|
const driver = toStringOrEmpty((req.body as { driver?: unknown })?.driver);
|
||||||
| "openclaw"
|
|
||||||
| "extension"
|
|
||||||
| "existing-session"
|
|
||||||
| "";
|
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return jsonError(res, 400, "name is required");
|
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({
|
await withProfilesServiceMutation({
|
||||||
res,
|
res,
|
||||||
@ -195,10 +198,10 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
|
|||||||
color: color || undefined,
|
color: color || undefined,
|
||||||
cdpUrl: cdpUrl || undefined,
|
cdpUrl: cdpUrl || undefined,
|
||||||
driver:
|
driver:
|
||||||
driver === "extension"
|
driver === "existing-session"
|
||||||
? "extension"
|
|
||||||
: driver === "existing-session"
|
|
||||||
? "existing-session"
|
? "existing-session"
|
||||||
|
: driver === "openclaw" || driver === "clawd"
|
||||||
|
? "openclaw"
|
||||||
: undefined,
|
: undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -15,11 +15,7 @@ import {
|
|||||||
stopOpenClawChrome,
|
stopOpenClawChrome,
|
||||||
} from "./chrome.js";
|
} from "./chrome.js";
|
||||||
import type { ResolvedBrowserProfile } from "./config.js";
|
import type { ResolvedBrowserProfile } from "./config.js";
|
||||||
import { BrowserConfigurationError, BrowserProfileUnavailableError } from "./errors.js";
|
import { BrowserProfileUnavailableError } from "./errors.js";
|
||||||
import {
|
|
||||||
ensureChromeExtensionRelayServer,
|
|
||||||
stopChromeExtensionRelayServer,
|
|
||||||
} from "./extension-relay.js";
|
|
||||||
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
||||||
import {
|
import {
|
||||||
CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS,
|
CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS,
|
||||||
@ -124,9 +120,6 @@ export function createProfileAvailability({
|
|||||||
await stopOpenClawChrome(profileState.running).catch(() => {});
|
await stopOpenClawChrome(profileState.running).catch(() => {});
|
||||||
setProfileRunning(null);
|
setProfileRunning(null);
|
||||||
}
|
}
|
||||||
if (previousProfile.driver === "extension") {
|
|
||||||
await stopChromeExtensionRelayServer({ cdpUrl: previousProfile.cdpUrl }).catch(() => false);
|
|
||||||
}
|
|
||||||
if (getBrowserProfileCapabilities(previousProfile).usesChromeMcp) {
|
if (getBrowserProfileCapabilities(previousProfile).usesChromeMcp) {
|
||||||
await closeChromeMcpSession(previousProfile.name).catch(() => false);
|
await closeChromeMcpSession(previousProfile.name).catch(() => false);
|
||||||
}
|
}
|
||||||
@ -166,33 +159,9 @@ export function createProfileAvailability({
|
|||||||
const current = state();
|
const current = state();
|
||||||
const remoteCdp = capabilities.isRemote;
|
const remoteCdp = capabilities.isRemote;
|
||||||
const attachOnly = profile.attachOnly;
|
const attachOnly = profile.attachOnly;
|
||||||
const isExtension = capabilities.requiresRelay;
|
|
||||||
const profileState = getProfileState();
|
const profileState = getProfileState();
|
||||||
const httpReachable = await isHttpReachable();
|
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 (!httpReachable) {
|
||||||
if ((attachOnly || remoteCdp) && opts.onEnsureAttachTarget) {
|
if ((attachOnly || remoteCdp) && opts.onEnsureAttachTarget) {
|
||||||
await opts.onEnsureAttachTarget(profile);
|
await opts.onEnsureAttachTarget(profile);
|
||||||
@ -267,12 +236,6 @@ export function createProfileAvailability({
|
|||||||
const stopped = await closeChromeMcpSession(profile.name);
|
const stopped = await closeChromeMcpSession(profile.name);
|
||||||
return { stopped };
|
return { stopped };
|
||||||
}
|
}
|
||||||
if (capabilities.requiresRelay) {
|
|
||||||
const stopped = await stopChromeExtensionRelayServer({
|
|
||||||
cdpUrl: profile.cdpUrl,
|
|
||||||
});
|
|
||||||
return { stopped };
|
|
||||||
}
|
|
||||||
const profileState = getProfileState();
|
const profileState = getProfileState();
|
||||||
if (!profileState.running) {
|
if (!profileState.running) {
|
||||||
return { stopped: false };
|
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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createProfileResetOps } from "./server-context.reset.js";
|
import { createProfileResetOps } from "./server-context.reset.js";
|
||||||
|
|
||||||
const relayMocks = vi.hoisted(() => ({
|
|
||||||
stopChromeExtensionRelayServer: vi.fn(async () => true),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const trashMocks = vi.hoisted(() => ({
|
const trashMocks = vi.hoisted(() => ({
|
||||||
movePathToTrash: vi.fn(async (from: string) => `${from}.trashed`),
|
movePathToTrash: vi.fn(async (from: string) => `${from}.trashed`),
|
||||||
}));
|
}));
|
||||||
@ -16,7 +12,6 @@ const pwAiMocks = vi.hoisted(() => ({
|
|||||||
closePlaywrightBrowserConnection: vi.fn(async () => {}),
|
closePlaywrightBrowserConnection: vi.fn(async () => {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./extension-relay.js", () => relayMocks);
|
|
||||||
vi.mock("./trash.js", () => trashMocks);
|
vi.mock("./trash.js", () => trashMocks);
|
||||||
vi.mock("./pw-ai.js", () => pwAiMocks);
|
vi.mock("./pw-ai.js", () => pwAiMocks);
|
||||||
|
|
||||||
@ -54,23 +49,6 @@ function createStatelessResetOps(profile: Parameters<typeof createProfileResetOp
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("createProfileResetOps", () => {
|
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 () => {
|
it("rejects remote non-extension profiles", async () => {
|
||||||
const ops = createStatelessResetOps({
|
const ops = createStatelessResetOps({
|
||||||
...localOpenClawProfile(),
|
...localOpenClawProfile(),
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import type { ResolvedBrowserProfile } from "./config.js";
|
import type { ResolvedBrowserProfile } from "./config.js";
|
||||||
import { BrowserResetUnsupportedError } from "./errors.js";
|
import { BrowserResetUnsupportedError } from "./errors.js";
|
||||||
import { stopChromeExtensionRelayServer } from "./extension-relay.js";
|
|
||||||
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
||||||
import type { ProfileRuntimeState } from "./server-context.types.js";
|
import type { ProfileRuntimeState } from "./server-context.types.js";
|
||||||
import { movePathToTrash } from "./trash.js";
|
import { movePathToTrash } from "./trash.js";
|
||||||
@ -36,10 +35,6 @@ export function createProfileResetOps({
|
|||||||
}: ResetDeps): ResetOps {
|
}: ResetDeps): ResetOps {
|
||||||
const capabilities = getBrowserProfileCapabilities(profile);
|
const capabilities = getBrowserProfileCapabilities(profile);
|
||||||
const resetProfile = async () => {
|
const resetProfile = async () => {
|
||||||
if (capabilities.requiresRelay) {
|
|
||||||
await stopChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch(() => {});
|
|
||||||
return { moved: false, from: profile.cdpUrl };
|
|
||||||
}
|
|
||||||
if (!capabilities.supportsReset) {
|
if (!capabilities.supportsReset) {
|
||||||
throw new BrowserResetUnsupportedError(
|
throw new BrowserResetUnsupportedError(
|
||||||
`reset-profile is only supported for local profiles (profile "${profile.name}" is remote).`,
|
`reset-profile is only supported for local profiles (profile "${profile.name}" is remote).`,
|
||||||
|
|||||||
@ -36,29 +36,10 @@ export function createProfileSelectionOps({
|
|||||||
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
|
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
|
||||||
await ensureBrowserAvailable();
|
await ensureBrowserAvailable();
|
||||||
const profileState = getProfileState();
|
const profileState = getProfileState();
|
||||||
let tabs1 = await listTabs();
|
const tabs1 = await listTabs();
|
||||||
if (tabs1.length === 0) {
|
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();
|
const tabs = await listTabs();
|
||||||
const candidates = capabilities.supportsPerTabWs ? tabs.filter((t) => Boolean(t.wsUrl)) : tabs;
|
const candidates = capabilities.supportsPerTabWs ? tabs.filter((t) => Boolean(t.wsUrl)) : tabs;
|
||||||
|
|||||||
@ -1,13 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const { resolveProfileMock, ensureChromeExtensionRelayServerMock } = vi.hoisted(() => ({
|
const { stopOpenClawChromeMock } = vi.hoisted(() => ({
|
||||||
resolveProfileMock: vi.fn(),
|
|
||||||
ensureChromeExtensionRelayServerMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { stopOpenClawChromeMock, stopChromeExtensionRelayServerMock } = vi.hoisted(() => ({
|
|
||||||
stopOpenClawChromeMock: vi.fn(async () => {}),
|
stopOpenClawChromeMock: vi.fn(async () => {}),
|
||||||
stopChromeExtensionRelayServerMock: vi.fn(async () => true),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { createBrowserRouteContextMock, listKnownProfileNamesMock } = vi.hoisted(() => ({
|
const { createBrowserRouteContextMock, listKnownProfileNamesMock } = vi.hoisted(() => ({
|
||||||
@ -19,15 +13,6 @@ vi.mock("./chrome.js", () => ({
|
|||||||
stopOpenClawChrome: stopOpenClawChromeMock,
|
stopOpenClawChrome: stopOpenClawChromeMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./config.js", () => ({
|
|
||||||
resolveProfile: resolveProfileMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./extension-relay.js", () => ({
|
|
||||||
ensureChromeExtensionRelayServer: ensureChromeExtensionRelayServerMock,
|
|
||||||
stopChromeExtensionRelayServer: stopChromeExtensionRelayServerMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./server-context.js", () => ({
|
vi.mock("./server-context.js", () => ({
|
||||||
createBrowserRouteContext: createBrowserRouteContextMock,
|
createBrowserRouteContext: createBrowserRouteContextMock,
|
||||||
listKnownProfileNames: listKnownProfileNamesMock,
|
listKnownProfileNames: listKnownProfileNamesMock,
|
||||||
@ -36,49 +21,13 @@ vi.mock("./server-context.js", () => ({
|
|||||||
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
|
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
|
||||||
|
|
||||||
describe("ensureExtensionRelayForProfiles", () => {
|
describe("ensureExtensionRelayForProfiles", () => {
|
||||||
beforeEach(() => {
|
it("is a no-op after removing the Chrome extension relay path", async () => {
|
||||||
resolveProfileMock.mockClear();
|
await expect(
|
||||||
ensureChromeExtensionRelayServerMock.mockClear();
|
ensureExtensionRelayForProfiles({
|
||||||
});
|
resolved: { profiles: {} } as never,
|
||||||
|
|
||||||
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(),
|
onWarn: vi.fn(),
|
||||||
});
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
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',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -87,14 +36,13 @@ describe("stopKnownBrowserProfiles", () => {
|
|||||||
createBrowserRouteContextMock.mockClear();
|
createBrowserRouteContextMock.mockClear();
|
||||||
listKnownProfileNamesMock.mockClear();
|
listKnownProfileNamesMock.mockClear();
|
||||||
stopOpenClawChromeMock.mockClear();
|
stopOpenClawChromeMock.mockClear();
|
||||||
stopChromeExtensionRelayServerMock.mockClear();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stops all known profiles and ignores per-profile failures", async () => {
|
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>> = {
|
const stopMap: Record<string, ReturnType<typeof vi.fn>> = {
|
||||||
openclaw: vi.fn(async () => {}),
|
openclaw: vi.fn(async () => {}),
|
||||||
"chrome-relay": vi.fn(async () => {
|
user: vi.fn(async () => {
|
||||||
throw new Error("profile stop failed");
|
throw new Error("profile stop failed");
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@ -112,12 +60,12 @@ describe("stopKnownBrowserProfiles", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(stopMap.openclaw).toHaveBeenCalledTimes(1);
|
expect(stopMap.openclaw).toHaveBeenCalledTimes(1);
|
||||||
expect(stopMap["chrome-relay"]).toHaveBeenCalledTimes(1);
|
expect(stopMap.user).toHaveBeenCalledTimes(1);
|
||||||
expect(onWarn).not.toHaveBeenCalled();
|
expect(onWarn).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stops tracked runtime browsers even when the profile no longer resolves", async () => {
|
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({
|
createBrowserRouteContextMock.mockReturnValue({
|
||||||
forProfile: vi.fn(() => {
|
forProfile: vi.fn(() => {
|
||||||
throw new Error("profile not found");
|
throw new Error("profile not found");
|
||||||
@ -134,18 +82,7 @@ describe("stopKnownBrowserProfiles", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const launchedBrowser = localRuntime.running;
|
const launchedBrowser = localRuntime.running;
|
||||||
const extensionRuntime = {
|
const profiles = new Map<string, unknown>([["deleted-local", localRuntime]]);
|
||||||
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 state = {
|
const state = {
|
||||||
resolved: { profiles: {} },
|
resolved: { profiles: {} },
|
||||||
profiles,
|
profiles,
|
||||||
@ -158,9 +95,6 @@ describe("stopKnownBrowserProfiles", () => {
|
|||||||
|
|
||||||
expect(stopOpenClawChromeMock).toHaveBeenCalledWith(launchedBrowser);
|
expect(stopOpenClawChromeMock).toHaveBeenCalledWith(launchedBrowser);
|
||||||
expect(localRuntime.running).toBeNull();
|
expect(localRuntime.running).toBeNull();
|
||||||
expect(stopChromeExtensionRelayServerMock).toHaveBeenCalledWith({
|
|
||||||
cdpUrl: "http://127.0.0.1:19999",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("warns when profile enumeration fails", async () => {
|
it("warns when profile enumeration fails", async () => {
|
||||||
|
|||||||
@ -1,32 +1,18 @@
|
|||||||
import { stopOpenClawChrome } from "./chrome.js";
|
import { stopOpenClawChrome } from "./chrome.js";
|
||||||
import type { ResolvedBrowserConfig } from "./config.js";
|
import type { ResolvedBrowserConfig } from "./config.js";
|
||||||
import { resolveProfile } from "./config.js";
|
|
||||||
import {
|
|
||||||
ensureChromeExtensionRelayServer,
|
|
||||||
stopChromeExtensionRelayServer,
|
|
||||||
} from "./extension-relay.js";
|
|
||||||
import {
|
import {
|
||||||
type BrowserServerState,
|
type BrowserServerState,
|
||||||
createBrowserRouteContext,
|
createBrowserRouteContext,
|
||||||
listKnownProfileNames,
|
listKnownProfileNames,
|
||||||
} from "./server-context.js";
|
} from "./server-context.js";
|
||||||
|
|
||||||
export async function ensureExtensionRelayForProfiles(params: {
|
export async function ensureExtensionRelayForProfiles(_params: {
|
||||||
resolved: ResolvedBrowserConfig;
|
resolved: ResolvedBrowserConfig;
|
||||||
onWarn: (message: string) => void;
|
onWarn: (message: string) => void;
|
||||||
}) {
|
}) {
|
||||||
for (const name of Object.keys(params.resolved.profiles)) {
|
// Intentional no-op: the Chrome extension relay path has been removed.
|
||||||
const profile = resolveProfile(params.resolved, name);
|
// runtime-lifecycle still calls this helper, so keep the stub until the next
|
||||||
if (!profile || profile.driver !== "extension") {
|
// breaking cleanup rather than changing the call graph in a patch release.
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await ensureChromeExtensionRelayServer({
|
|
||||||
cdpUrl: profile.cdpUrl,
|
|
||||||
bindHost: params.resolved.relayBindHost,
|
|
||||||
}).catch((err) => {
|
|
||||||
params.onWarn(`Chrome extension relay init failed for profile "${name}": ${String(err)}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stopKnownBrowserProfiles(params: {
|
export async function stopKnownBrowserProfiles(params: {
|
||||||
@ -50,12 +36,6 @@ export async function stopKnownBrowserProfiles(params: {
|
|||||||
runtime.running = null;
|
runtime.running = null;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (runtime?.profile.driver === "extension") {
|
|
||||||
await stopChromeExtensionRelayServer({ cdpUrl: runtime.profile.cdpUrl }).catch(
|
|
||||||
() => false,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await ctx.forProfile(name).stopRunningBrowser();
|
await ctx.forProfile(name).stopRunningBrowser();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
|
|||||||
@ -18,7 +18,7 @@ type HarnessState = {
|
|||||||
cdpPort?: number;
|
cdpPort?: number;
|
||||||
cdpUrl?: string;
|
cdpUrl?: string;
|
||||||
color: string;
|
color: string;
|
||||||
driver?: "openclaw" | "extension" | "existing-session";
|
driver?: "openclaw" | "existing-session";
|
||||||
attachOnly?: boolean;
|
attachOnly?: boolean;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|||||||
@ -116,18 +116,29 @@ describe("profile CRUD endpoints", () => {
|
|||||||
const createBadRemoteBody = (await createBadRemote.json()) as { error: string };
|
const createBadRemoteBody = (await createBadRemote.json()) as { error: string };
|
||||||
expect(createBadRemoteBody.error).toContain("cdpUrl");
|
expect(createBadRemoteBody.error).toContain("cdpUrl");
|
||||||
|
|
||||||
const createBadExtension = await realFetch(`${base}/profiles/create`, {
|
const createClawd = await realFetch(`${base}/profiles/create`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ name: "legacyclawd", driver: "clawd" }),
|
||||||
name: "badextension",
|
|
||||||
driver: "extension",
|
|
||||||
cdpUrl: "http://10.0.0.42:9222",
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
expect(createBadExtension.status).toBe(400);
|
expect(createClawd.status).toBe(200);
|
||||||
const createBadExtensionBody = (await createBadExtension.json()) as { error: string };
|
const createClawdBody = (await createClawd.json()) as {
|
||||||
expect(createBadExtensionBody.error).toContain("loopback cdpUrl host");
|
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`, {
|
const deleteMissing = await realFetch(`${base}/profiles/nonexistent`, {
|
||||||
method: "DELETE",
|
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: {
|
function usesChromeMcpTransport(params: {
|
||||||
transport?: BrowserTransport;
|
transport?: BrowserTransport;
|
||||||
driver?: "openclaw" | "extension" | "existing-session";
|
driver?: "openclaw" | "existing-session";
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return params.transport === "chrome-mcp" || params.driver === "existing-session";
|
return params.transport === "chrome-mcp" || params.driver === "existing-session";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBrowserConnectionSummary(params: {
|
function formatBrowserConnectionSummary(params: {
|
||||||
transport?: BrowserTransport;
|
transport?: BrowserTransport;
|
||||||
driver?: "openclaw" | "extension" | "existing-session";
|
driver?: "openclaw" | "existing-session";
|
||||||
isRemote?: boolean;
|
isRemote?: boolean;
|
||||||
cdpPort?: number | null;
|
cdpPort?: number | null;
|
||||||
cdpUrl?: string | null;
|
cdpUrl?: string | null;
|
||||||
@ -455,10 +455,7 @@ export function registerBrowserManageCommands(
|
|||||||
.requiredOption("--name <name>", "Profile name (lowercase, numbers, hyphens)")
|
.requiredOption("--name <name>", "Profile name (lowercase, numbers, hyphens)")
|
||||||
.option("--color <hex>", "Profile color (hex format, e.g. #0066CC)")
|
.option("--color <hex>", "Profile color (hex format, e.g. #0066CC)")
|
||||||
.option("--cdp-url <url>", "CDP URL for remote Chrome (http/https)")
|
.option("--cdp-url <url>", "CDP URL for remote Chrome (http/https)")
|
||||||
.option(
|
.option("--driver <driver>", "Profile driver (openclaw|existing-session). Default: openclaw")
|
||||||
"--driver <driver>",
|
|
||||||
"Profile driver (openclaw|extension|existing-session). Default: openclaw",
|
|
||||||
)
|
|
||||||
.action(
|
.action(
|
||||||
async (opts: { name: string; color?: string; cdpUrl?: string; driver?: string }, cmd) => {
|
async (opts: { name: string; color?: string; cdpUrl?: string; driver?: string }, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
@ -472,12 +469,7 @@ export function registerBrowserManageCommands(
|
|||||||
name: opts.name,
|
name: opts.name,
|
||||||
color: opts.color,
|
color: opts.color,
|
||||||
cdpUrl: opts.cdpUrl,
|
cdpUrl: opts.cdpUrl,
|
||||||
driver:
|
driver: opts.driver === "existing-session" ? "existing-session" : undefined,
|
||||||
opts.driver === "extension"
|
|
||||||
? "extension"
|
|
||||||
: opts.driver === "existing-session"
|
|
||||||
? "existing-session"
|
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ timeoutMs: 10_000 },
|
{ timeoutMs: 10_000 },
|
||||||
@ -489,11 +481,7 @@ export function registerBrowserManageCommands(
|
|||||||
defaultRuntime.log(
|
defaultRuntime.log(
|
||||||
info(
|
info(
|
||||||
`🦞 Created profile "${result.profile}"\n${loc}\n color: ${result.color}${
|
`🦞 Created profile "${result.profile}"\n${loc}\n color: ${result.color}${
|
||||||
opts.driver === "extension"
|
opts.driver === "existing-session" ? "\n driver: existing-session" : ""
|
||||||
? "\n driver: extension"
|
|
||||||
: 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 { registerBrowserActionObserveCommands } from "./browser-cli-actions-observe.js";
|
||||||
import { registerBrowserDebugCommands } from "./browser-cli-debug.js";
|
import { registerBrowserDebugCommands } from "./browser-cli-debug.js";
|
||||||
import { browserActionExamples, browserCoreExamples } from "./browser-cli-examples.js";
|
import { browserActionExamples, browserCoreExamples } from "./browser-cli-examples.js";
|
||||||
import { registerBrowserExtensionCommands } from "./browser-cli-extension.js";
|
|
||||||
import { registerBrowserInspectCommands } from "./browser-cli-inspect.js";
|
import { registerBrowserInspectCommands } from "./browser-cli-inspect.js";
|
||||||
import { registerBrowserManageCommands } from "./browser-cli-manage.js";
|
import { registerBrowserManageCommands } from "./browser-cli-manage.js";
|
||||||
import type { BrowserParentOpts } from "./browser-cli-shared.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;
|
const parentOpts = (cmd: Command) => cmd.parent?.opts?.() as BrowserParentOpts;
|
||||||
|
|
||||||
registerBrowserManageCommands(browser, parentOpts);
|
registerBrowserManageCommands(browser, parentOpts);
|
||||||
registerBrowserExtensionCommands(browser, parentOpts);
|
|
||||||
registerBrowserInspectCommands(browser, parentOpts);
|
registerBrowserInspectCommands(browser, parentOpts);
|
||||||
registerBrowserActionInputCommands(browser, parentOpts);
|
registerBrowserActionInputCommands(browser, parentOpts);
|
||||||
registerBrowserActionObserveCommands(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 () => {
|
it("preserves discord streaming intent while stripping unsupported keys on repair", async () => {
|
||||||
const result = await runDoctorConfigWithInput({
|
const result = await runDoctorConfigWithInput({
|
||||||
repair: true,
|
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 seedMissingDefaultAccountsFromSingleAccountBase = () => {
|
||||||
const channels = next.channels as Record<string, unknown> | undefined;
|
const channels = next.channels as Record<string, unknown> | undefined;
|
||||||
if (!channels) {
|
if (!channels) {
|
||||||
@ -365,6 +426,7 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
|
|||||||
normalizeProvider("slack");
|
normalizeProvider("slack");
|
||||||
normalizeProvider("discord");
|
normalizeProvider("discord");
|
||||||
seedMissingDefaultAccountsFromSingleAccountBase();
|
seedMissingDefaultAccountsFromSingleAccountBase();
|
||||||
|
normalizeLegacyBrowserProfiles();
|
||||||
|
|
||||||
const normalizeBrowserSsrFPolicyAlias = () => {
|
const normalizeBrowserSsrFPolicyAlias = () => {
|
||||||
const rawBrowser = next.browser;
|
const rawBrowser = next.browser;
|
||||||
|
|||||||
@ -8,6 +8,10 @@ vi.mock("./doctor-bootstrap-size.js", () => ({
|
|||||||
noteBootstrapFileSize: vi.fn().mockResolvedValue(undefined),
|
noteBootstrapFileSize: vi.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("./doctor-browser.js", () => ({
|
||||||
|
noteChromeMcpBrowserReadiness: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("./doctor-gateway-daemon-flow.js", () => ({
|
vi.mock("./doctor-gateway-daemon-flow.js", () => ({
|
||||||
maybeRepairGatewayDaemon: vi.fn().mockResolvedValue(undefined),
|
maybeRepairGatewayDaemon: vi.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import {
|
|||||||
noteAuthProfileHealth,
|
noteAuthProfileHealth,
|
||||||
} from "./doctor-auth.js";
|
} from "./doctor-auth.js";
|
||||||
import { noteBootstrapFileSize } from "./doctor-bootstrap-size.js";
|
import { noteBootstrapFileSize } from "./doctor-bootstrap-size.js";
|
||||||
|
import { noteChromeMcpBrowserReadiness } from "./doctor-browser.js";
|
||||||
import { doctorShellCompletion } from "./doctor-completion.js";
|
import { doctorShellCompletion } from "./doctor-completion.js";
|
||||||
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
|
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
|
||||||
import { maybeRepairLegacyCronStore } from "./doctor-cron.js";
|
import { maybeRepairLegacyCronStore } from "./doctor-cron.js";
|
||||||
@ -236,6 +237,7 @@ export async function doctorCommand(
|
|||||||
await noteMacLaunchctlGatewayEnvOverrides(cfg);
|
await noteMacLaunchctlGatewayEnvOverrides(cfg);
|
||||||
|
|
||||||
await noteSecurityWarnings(cfg);
|
await noteSecurityWarnings(cfg);
|
||||||
|
await noteChromeMcpBrowserReadiness(cfg);
|
||||||
await noteOpenAIOAuthTlsPrerequisites({
|
await noteOpenAIOAuthTlsPrerequisites({
|
||||||
cfg,
|
cfg,
|
||||||
deep: options.deep === true,
|
deep: options.deep === true,
|
||||||
|
|||||||
@ -422,7 +422,7 @@ const ENUM_EXPECTATIONS: Record<string, string[]> = {
|
|||||||
"gateway.bind": ['"auto"', '"lan"', '"loopback"', '"custom"', '"tailnet"'],
|
"gateway.bind": ['"auto"', '"lan"', '"loopback"', '"custom"', '"tailnet"'],
|
||||||
"gateway.auth.mode": ['"none"', '"token"', '"password"', '"trusted-proxy"'],
|
"gateway.auth.mode": ['"none"', '"token"', '"password"', '"trusted-proxy"'],
|
||||||
"gateway.tailscale.mode": ['"off"', '"serve"', '"funnel"'],
|
"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"'],
|
"discovery.mdns.mode": ['"off"', '"minimal"', '"full"'],
|
||||||
"wizard.lastRunMode": ['"local"', '"remote"'],
|
"wizard.lastRunMode": ['"local"', '"remote"'],
|
||||||
"diagnostics.otel.protocol": ['"http/protobuf"', '"grpc"'],
|
"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.",
|
"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":
|
"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.",
|
"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":
|
"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.",
|
"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":
|
"browser.profiles.*.cdpPort":
|
||||||
@ -263,7 +261,7 @@ export const FIELD_HELP: Record<string, string> = {
|
|||||||
"browser.profiles.*.cdpUrl":
|
"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.",
|
"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":
|
"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":
|
"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.",
|
"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":
|
"browser.profiles.*.color":
|
||||||
|
|||||||
@ -120,7 +120,6 @@ export const FIELD_LABELS: Record<string, string> = {
|
|||||||
"browser.attachOnly": "Browser Attach-only Mode",
|
"browser.attachOnly": "Browser Attach-only Mode",
|
||||||
"browser.cdpPortRangeStart": "Browser CDP Port Range Start",
|
"browser.cdpPortRangeStart": "Browser CDP Port Range Start",
|
||||||
"browser.defaultProfile": "Browser Default Profile",
|
"browser.defaultProfile": "Browser Default Profile",
|
||||||
"browser.relayBindHost": "Browser Relay Bind Address",
|
|
||||||
"browser.profiles": "Browser Profiles",
|
"browser.profiles": "Browser Profiles",
|
||||||
"browser.profiles.*.cdpPort": "Browser Profile CDP Port",
|
"browser.profiles.*.cdpPort": "Browser Profile CDP Port",
|
||||||
"browser.profiles.*.cdpUrl": "Browser Profile CDP URL",
|
"browser.profiles.*.cdpUrl": "Browser Profile CDP URL",
|
||||||
|
|||||||
@ -4,7 +4,7 @@ export type BrowserProfileConfig = {
|
|||||||
/** CDP URL for this profile (use for remote Chrome). */
|
/** CDP URL for this profile (use for remote Chrome). */
|
||||||
cdpUrl?: string;
|
cdpUrl?: string;
|
||||||
/** Profile driver (default: openclaw). */
|
/** 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. */
|
/** If true, never launch a browser for this profile; only attach. Falls back to browser.attachOnly. */
|
||||||
attachOnly?: boolean;
|
attachOnly?: boolean;
|
||||||
/** Profile color (hex). Auto-assigned at creation. */
|
/** Profile color (hex). Auto-assigned at creation. */
|
||||||
@ -66,10 +66,4 @@ export type BrowserConfig = {
|
|||||||
* Example: ["--window-size=1920,1080", "--disable-infobars"]
|
* Example: ["--window-size=1920,1080", "--disable-infobars"]
|
||||||
*/
|
*/
|
||||||
extraArgs?: string[];
|
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(),
|
cdpPort: z.number().int().min(1).max(65535).optional(),
|
||||||
cdpUrl: z.string().optional(),
|
cdpUrl: z.string().optional(),
|
||||||
driver: z
|
driver: z
|
||||||
.union([
|
.union([z.literal("openclaw"), z.literal("clawd"), z.literal("existing-session")])
|
||||||
z.literal("openclaw"),
|
|
||||||
z.literal("clawd"),
|
|
||||||
z.literal("extension"),
|
|
||||||
z.literal("existing-session"),
|
|
||||||
])
|
|
||||||
.optional(),
|
.optional(),
|
||||||
attachOnly: z.boolean().optional(),
|
attachOnly: z.boolean().optional(),
|
||||||
color: HexColorSchema,
|
color: HexColorSchema,
|
||||||
@ -380,7 +375,6 @@ export const OpenClawSchema = z
|
|||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
extraArgs: z.array(z.string()).optional(),
|
extraArgs: z.array(z.string()).optional(),
|
||||||
relayBindHost: z.union([z.string().ipv4(), z.string().ipv6()]).optional(),
|
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@ -70,12 +70,12 @@ describe("runBrowserProxyCommand", () => {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/snapshot",
|
path: "/snapshot",
|
||||||
profile: "chrome-relay",
|
profile: "openclaw",
|
||||||
timeoutMs: 5,
|
timeoutMs: 5,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).rejects.toThrow(
|
).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({
|
JSON.stringify({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/act",
|
path: "/act",
|
||||||
profile: "chrome-relay",
|
profile: "openclaw",
|
||||||
timeoutMs: 50,
|
timeoutMs: 50,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user