diff --git a/CHANGELOG.md b/CHANGELOG.md index a21ff47279b..384fcffc330 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ Docs: https://docs.openclaw.ai - Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873) - Feishu/ACP: add current-conversation ACP and subagent session binding for supported DMs and topic conversations, including completion delivery back to the originating Feishu conversation. (#46819) +### Breaking + +- Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. Thanks @vincentkoc. + ### Fixes - Group mention gating: reject invalid and unsafe nested-repetition `mentionPatterns`, reuse the shared safe config-regex compiler across mention stripping and detection, and cache strip-time regex compilation so noisy groups avoid repeated recompiles. diff --git a/assets/chrome-extension/README.md b/assets/chrome-extension/README.md deleted file mode 100644 index 4ee072c1f2b..00000000000 --- a/assets/chrome-extension/README.md +++ /dev/null @@ -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`). diff --git a/assets/chrome-extension/background-utils.js b/assets/chrome-extension/background-utils.js deleted file mode 100644 index 82d43359c0a..00000000000 --- a/assets/chrome-extension/background-utils.js +++ /dev/null @@ -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; -} diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js deleted file mode 100644 index 9031a156489..00000000000 --- a/assets/chrome-extension/background.js +++ /dev/null @@ -1,1025 +0,0 @@ -import { - buildRelayWsUrl, - isLastRemainingTab, - isMissingTabError, - isRetryableReconnectError, - reconnectDelayMs, -} from './background-utils.js' - -const DEFAULT_PORT = 18792 - -const BADGE = { - on: { text: 'ON', color: '#FF5A36' }, - off: { text: '', color: '#000000' }, - connecting: { text: '…', color: '#F59E0B' }, - error: { text: '!', color: '#B91C1C' }, -} - -/** @type {WebSocket|null} */ -let relayWs = null -/** @type {Promise|null} */ -let relayConnectPromise = null -let relayGatewayToken = '' -/** @type {string|null} */ -let relayConnectRequestId = null - -let nextSession = 1 - -/** @type {Map} */ -const tabs = new Map() -/** @type {Map} */ -const tabBySession = new Map() -/** @type {Map} */ -const childSessionToTab = new Map() - -/** @type {Mapvoid, reject:(e:Error)=>void}>} */ -const pending = new Map() - -// Per-tab operation locks prevent double-attach races. -/** @type {Set} */ -const tabOperationLocks = new Set() - -// Tabs currently in a detach/re-attach cycle after navigation. -/** @type {Set} */ -const reattachPending = new Set() - -// Reconnect state for exponential backoff. -let reconnectAttempt = 0 -let reconnectTimer = null - -const TAB_VALIDATION_ATTEMPTS = 2 -const TAB_VALIDATION_RETRY_DELAY_MS = 1000 - -function nowStack() { - try { - return new Error().stack || '' - } catch { - return '' - } -} - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -async function validateAttachedTab(tabId) { - try { - await chrome.tabs.get(tabId) - } catch { - return false - } - - for (let attempt = 0; attempt < TAB_VALIDATION_ATTEMPTS; attempt++) { - try { - await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { - expression: '1', - returnByValue: true, - }) - return true - } catch (err) { - if (isMissingTabError(err)) { - return false - } - if (attempt < TAB_VALIDATION_ATTEMPTS - 1) { - await sleep(TAB_VALIDATION_RETRY_DELAY_MS) - } - } - } - - return false -} - -async function getRelayPort() { - const stored = await chrome.storage.local.get(['relayPort']) - const raw = stored.relayPort - const n = Number.parseInt(String(raw || ''), 10) - if (!Number.isFinite(n) || n <= 0 || n > 65535) return DEFAULT_PORT - return n -} - -async function getGatewayToken() { - const stored = await chrome.storage.local.get(['gatewayToken']) - const token = String(stored.gatewayToken || '').trim() - return token || '' -} - -function setBadge(tabId, kind) { - const cfg = BADGE[kind] - void chrome.action.setBadgeText({ tabId, text: cfg.text }) - void chrome.action.setBadgeBackgroundColor({ tabId, color: cfg.color }) - void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {}) -} - -// Persist attached tab state to survive MV3 service worker restarts. -async function persistState() { - try { - const tabEntries = [] - for (const [tabId, tab] of tabs.entries()) { - if (tab.state === 'connected' && tab.sessionId && tab.targetId) { - tabEntries.push({ tabId, sessionId: tab.sessionId, targetId: tab.targetId, attachOrder: tab.attachOrder }) - } - } - await chrome.storage.session.set({ - persistedTabs: tabEntries, - nextSession, - }) - } catch { - // chrome.storage.session may not be available in all contexts. - } -} - -// Rehydrate tab state on service worker startup. Fast path — just restores -// maps and badges. Relay reconnect happens separately in background. -async function rehydrateState() { - try { - const stored = await chrome.storage.session.get(['persistedTabs', 'nextSession']) - if (stored.nextSession) { - nextSession = Math.max(nextSession, stored.nextSession) - } - const entries = stored.persistedTabs || [] - // Phase 1: optimistically restore state and badges. - for (const entry of entries) { - tabs.set(entry.tabId, { - state: 'connected', - sessionId: entry.sessionId, - targetId: entry.targetId, - attachOrder: entry.attachOrder, - }) - tabBySession.set(entry.sessionId, entry.tabId) - setBadge(entry.tabId, 'on') - } - // Retry once so transient busy/navigation states do not permanently drop - // a still-attached tab after a service worker restart. - for (const entry of entries) { - const valid = await validateAttachedTab(entry.tabId) - if (!valid) { - tabs.delete(entry.tabId) - tabBySession.delete(entry.sessionId) - setBadge(entry.tabId, 'off') - } - } - } catch { - // Ignore rehydration errors. - } -} - -async function ensureRelayConnection() { - if (relayWs && relayWs.readyState === WebSocket.OPEN) return - if (relayConnectPromise) return await relayConnectPromise - - relayConnectPromise = (async () => { - const port = await getRelayPort() - const gatewayToken = await getGatewayToken() - const httpBase = `http://127.0.0.1:${port}` - const wsUrl = await buildRelayWsUrl(port, gatewayToken) - - // Fast preflight: is the relay server up? - try { - await fetch(`${httpBase}/`, { method: 'HEAD', signal: AbortSignal.timeout(2000) }) - } catch (err) { - throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`) - } - - const ws = new WebSocket(wsUrl) - relayWs = ws - relayGatewayToken = gatewayToken - // Bind message handler before open so an immediate first frame (for example - // gateway connect.challenge) cannot be missed. - ws.onmessage = (event) => { - if (ws !== relayWs) return - void whenReady(() => onRelayMessage(String(event.data || ''))) - } - - await new Promise((resolve, reject) => { - const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000) - ws.onopen = () => { - clearTimeout(t) - resolve() - } - ws.onerror = () => { - clearTimeout(t) - reject(new Error('WebSocket connect failed')) - } - ws.onclose = (ev) => { - clearTimeout(t) - reject(new Error(`WebSocket closed (${ev.code} ${ev.reason || 'no reason'})`)) - } - }) - - // Bind permanent handlers. Guard against stale socket: if this WS was - // replaced before its close fires, the handler is a no-op. - ws.onclose = () => { - if (ws !== relayWs) return - onRelayClosed('closed') - } - ws.onerror = () => { - if (ws !== relayWs) return - onRelayClosed('error') - } - })() - - try { - await relayConnectPromise - reconnectAttempt = 0 - } finally { - relayConnectPromise = null - } -} - -// Relay closed — update badges, reject pending requests, auto-reconnect. -// Debugger sessions are kept alive so they survive transient WS drops. -function onRelayClosed(reason) { - relayWs = null - relayGatewayToken = '' - relayConnectRequestId = null - - for (const [id, p] of pending.entries()) { - pending.delete(id) - p.reject(new Error(`Relay disconnected (${reason})`)) - } - - reattachPending.clear() - - for (const [tabId, tab] of tabs.entries()) { - if (tab.state === 'connected') { - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: relay reconnecting…', - }) - } - } - - scheduleReconnect() -} - -function scheduleReconnect() { - if (reconnectTimer) { - clearTimeout(reconnectTimer) - reconnectTimer = null - } - - const delay = reconnectDelayMs(reconnectAttempt) - reconnectAttempt++ - - console.log(`Scheduling reconnect attempt ${reconnectAttempt} in ${Math.round(delay)}ms`) - - reconnectTimer = setTimeout(async () => { - reconnectTimer = null - try { - await ensureRelayConnection() - reconnectAttempt = 0 - console.log('Reconnected successfully') - await reannounceAttachedTabs() - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - console.warn(`Reconnect attempt ${reconnectAttempt} failed: ${message}`) - if (!isRetryableReconnectError(err)) { - return - } - scheduleReconnect() - } - }, delay) -} - -function cancelReconnect() { - if (reconnectTimer) { - clearTimeout(reconnectTimer) - reconnectTimer = null - } - reconnectAttempt = 0 -} - -// Re-announce all attached tabs to the relay after reconnect. -async function reannounceAttachedTabs() { - for (const [tabId, tab] of tabs.entries()) { - if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue - - // Retry once here as well; reconnect races can briefly make an otherwise - // healthy tab look unavailable. - const valid = await validateAttachedTab(tabId) - if (!valid) { - tabs.delete(tabId) - if (tab.sessionId) tabBySession.delete(tab.sessionId) - setBadge(tabId, 'off') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay (click to attach/detach)', - }) - continue - } - - // Send fresh attach event to relay. - // Split into two try-catch blocks so debugger failures and relay send - // failures are handled independently. Previously, a relay send failure - // would fall into the outer catch and set the badge to 'on' even though - // the relay had no record of the tab — causing every subsequent browser - // tool call to fail with "no tab connected" until the next reconnect cycle. - let targetInfo - try { - const info = /** @type {any} */ ( - await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo') - ) - targetInfo = info?.targetInfo - } catch { - // Target.getTargetInfo failed. Preserve at least targetId from - // cached tab state so relay receives a stable identifier. - targetInfo = tab.targetId ? { targetId: tab.targetId } : undefined - } - - try { - sendToRelay({ - method: 'forwardCDPEvent', - params: { - method: 'Target.attachedToTarget', - params: { - sessionId: tab.sessionId, - targetInfo: { ...targetInfo, attached: true }, - waitingForDebugger: false, - }, - }, - }) - - setBadge(tabId, 'on') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: attached (click to detach)', - }) - } catch { - // Relay send failed (e.g. WS closed in the gap between ensureRelayConnection - // resolving and this loop executing). The tab is still valid — leave badge - // as 'connecting' so the reconnect/keepalive cycle will retry rather than - // showing a false-positive 'on' that hides the broken state from the user. - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: relay reconnecting…', - }) - } - } - - await persistState() -} - -function sendToRelay(payload) { - const ws = relayWs - if (!ws || ws.readyState !== WebSocket.OPEN) { - throw new Error('Relay not connected') - } - ws.send(JSON.stringify(payload)) -} - -function ensureGatewayHandshakeStarted(payload) { - if (relayConnectRequestId) return - const nonce = typeof payload?.nonce === 'string' ? payload.nonce.trim() : '' - relayConnectRequestId = `ext-connect-${Date.now()}-${Math.random().toString(16).slice(2, 8)}` - sendToRelay({ - type: 'req', - id: relayConnectRequestId, - method: 'connect', - params: { - minProtocol: 3, - maxProtocol: 3, - client: { - id: 'chrome-relay-extension', - version: '1.0.0', - platform: 'chrome-extension', - mode: 'webchat', - }, - role: 'operator', - scopes: ['operator.read', 'operator.write'], - caps: [], - commands: [], - nonce: nonce || undefined, - auth: relayGatewayToken ? { token: relayGatewayToken } : undefined, - }, - }) -} - -async function maybeOpenHelpOnce() { - try { - const stored = await chrome.storage.local.get(['helpOnErrorShown']) - if (stored.helpOnErrorShown === true) return - await chrome.storage.local.set({ helpOnErrorShown: true }) - await chrome.runtime.openOptionsPage() - } catch { - // ignore - } -} - -function requestFromRelay(command) { - const id = command.id - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - pending.delete(id) - reject(new Error('Relay request timeout (30s)')) - }, 30000) - pending.set(id, { - resolve: (v) => { clearTimeout(timer); resolve(v) }, - reject: (e) => { clearTimeout(timer); reject(e) }, - }) - try { - sendToRelay(command) - } catch (err) { - clearTimeout(timer) - pending.delete(id) - reject(err instanceof Error ? err : new Error(String(err))) - } - }) -} - -async function onRelayMessage(text) { - /** @type {any} */ - let msg - try { - msg = JSON.parse(text) - } catch { - return - } - - if (msg && msg.type === 'event' && msg.event === 'connect.challenge') { - try { - ensureGatewayHandshakeStarted(msg.payload) - } catch (err) { - console.warn('gateway connect handshake start failed', err instanceof Error ? err.message : String(err)) - relayConnectRequestId = null - const ws = relayWs - if (ws && ws.readyState === WebSocket.OPEN) { - ws.close(1008, 'gateway connect failed') - } - } - return - } - - if (msg && msg.type === 'res' && relayConnectRequestId && msg.id === relayConnectRequestId) { - relayConnectRequestId = null - if (!msg.ok) { - const detail = msg?.error?.message || msg?.error || 'gateway connect failed' - console.warn('gateway connect handshake rejected', String(detail)) - const ws = relayWs - if (ws && ws.readyState === WebSocket.OPEN) { - ws.close(1008, 'gateway connect failed') - } - } - return - } - - if (msg && msg.method === 'ping') { - try { - sendToRelay({ method: 'pong' }) - } catch { - // ignore - } - return - } - - if (msg && typeof msg.id === 'number' && (msg.result !== undefined || msg.error !== undefined)) { - const p = pending.get(msg.id) - if (!p) return - pending.delete(msg.id) - if (msg.error) p.reject(new Error(String(msg.error))) - else p.resolve(msg.result) - return - } - - if (msg && typeof msg.id === 'number' && msg.method === 'forwardCDPCommand') { - try { - const result = await handleForwardCdpCommand(msg) - sendToRelay({ id: msg.id, result }) - } catch (err) { - sendToRelay({ id: msg.id, error: err instanceof Error ? err.message : String(err) }) - } - } -} - -function getTabBySessionId(sessionId) { - const direct = tabBySession.get(sessionId) - if (direct) return { tabId: direct, kind: 'main' } - const child = childSessionToTab.get(sessionId) - if (child) return { tabId: child, kind: 'child' } - return null -} - -function getTabByTargetId(targetId) { - for (const [tabId, tab] of tabs.entries()) { - if (tab.targetId === targetId) return tabId - } - return null -} - -async function attachTab(tabId, opts = {}) { - const debuggee = { tabId } - await chrome.debugger.attach(debuggee, '1.3') - await chrome.debugger.sendCommand(debuggee, 'Page.enable').catch(() => {}) - - const info = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo')) - const targetInfo = info?.targetInfo - const targetId = String(targetInfo?.targetId || '').trim() - if (!targetId) { - throw new Error('Target.getTargetInfo returned no targetId') - } - - const sid = nextSession++ - const sessionId = `cb-tab-${sid}` - const attachOrder = sid - - tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder }) - tabBySession.set(sessionId, tabId) - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: attached (click to detach)', - }) - - if (!opts.skipAttachedEvent) { - sendToRelay({ - method: 'forwardCDPEvent', - params: { - method: 'Target.attachedToTarget', - params: { - sessionId, - targetInfo: { ...targetInfo, attached: true }, - waitingForDebugger: false, - }, - }, - }) - } - - setBadge(tabId, 'on') - await persistState() - - return { sessionId, targetId } -} - -async function detachTab(tabId, reason) { - const tab = tabs.get(tabId) - - // Send detach events for child sessions first. - for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { - if (parentTabId === tabId) { - try { - sendToRelay({ - method: 'forwardCDPEvent', - params: { - method: 'Target.detachedFromTarget', - params: { sessionId: childSessionId, reason: 'parent_detached' }, - }, - }) - } catch { - // Relay may be down. - } - childSessionToTab.delete(childSessionId) - } - } - - // Send detach event for main session. - if (tab?.sessionId && tab?.targetId) { - try { - sendToRelay({ - method: 'forwardCDPEvent', - params: { - method: 'Target.detachedFromTarget', - params: { sessionId: tab.sessionId, targetId: tab.targetId, reason }, - }, - }) - } catch { - // Relay may be down. - } - } - - if (tab?.sessionId) tabBySession.delete(tab.sessionId) - tabs.delete(tabId) - - try { - await chrome.debugger.detach({ tabId }) - } catch { - // May already be detached. - } - - setBadge(tabId, 'off') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay (click to attach/detach)', - }) - - await persistState() -} - -async function connectOrToggleForActiveTab() { - const [active] = await chrome.tabs.query({ active: true, currentWindow: true }) - const tabId = active?.id - if (!tabId) return - - // Prevent concurrent operations on the same tab. - if (tabOperationLocks.has(tabId)) return - tabOperationLocks.add(tabId) - - try { - if (reattachPending.has(tabId)) { - reattachPending.delete(tabId) - setBadge(tabId, 'off') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay (click to attach/detach)', - }) - return - } - - const existing = tabs.get(tabId) - if (existing?.state === 'connected') { - await detachTab(tabId, 'toggle') - return - } - - // User is manually connecting — cancel any pending reconnect. - cancelReconnect() - - tabs.set(tabId, { state: 'connecting' }) - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: connecting to local relay…', - }) - - try { - await ensureRelayConnection() - await attachTab(tabId) - } catch (err) { - tabs.delete(tabId) - setBadge(tabId, 'error') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: relay not running (open options for setup)', - }) - void maybeOpenHelpOnce() - const message = err instanceof Error ? err.message : String(err) - console.warn('attach failed', message, nowStack()) - } - } finally { - tabOperationLocks.delete(tabId) - } -} - -async function handleForwardCdpCommand(msg) { - const method = String(msg?.params?.method || '').trim() - const params = msg?.params?.params || undefined - const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined - - const bySession = sessionId ? getTabBySessionId(sessionId) : null - const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined - const tabId = - bySession?.tabId || - (targetId ? getTabByTargetId(targetId) : null) || - (() => { - for (const [id, tab] of tabs.entries()) { - if (tab.state === 'connected') return id - } - return null - })() - - if (!tabId) throw new Error(`No attached tab for method ${method}`) - - /** @type {chrome.debugger.DebuggerSession} */ - const debuggee = { tabId } - - if (method === 'Runtime.enable') { - try { - await chrome.debugger.sendCommand(debuggee, 'Runtime.disable') - await new Promise((r) => setTimeout(r, 50)) - } catch { - // ignore - } - return await chrome.debugger.sendCommand(debuggee, 'Runtime.enable', params) - } - - if (method === 'Target.createTarget') { - const url = typeof params?.url === 'string' ? params.url : 'about:blank' - const tab = await chrome.tabs.create({ url, active: false }) - if (!tab.id) throw new Error('Failed to create tab') - await new Promise((r) => setTimeout(r, 100)) - const attached = await attachTab(tab.id) - return { targetId: attached.targetId } - } - - if (method === 'Target.closeTarget') { - const target = typeof params?.targetId === 'string' ? params.targetId : '' - const toClose = target ? getTabByTargetId(target) : tabId - if (!toClose) return { success: false } - try { - const allTabs = await chrome.tabs.query({}) - if (isLastRemainingTab(allTabs, toClose)) { - console.warn('Refusing to close the last tab: this would kill the browser process') - return { success: false, error: 'Cannot close the last tab' } - } - await chrome.tabs.remove(toClose) - } catch { - return { success: false } - } - return { success: true } - } - - if (method === 'Target.activateTarget') { - const target = typeof params?.targetId === 'string' ? params.targetId : '' - const toActivate = target ? getTabByTargetId(target) : tabId - if (!toActivate) return {} - const tab = await chrome.tabs.get(toActivate).catch(() => null) - if (!tab) return {} - if (tab.windowId) { - await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {}) - } - await chrome.tabs.update(toActivate, { active: true }).catch(() => {}) - return {} - } - - const tabState = tabs.get(tabId) - const mainSessionId = tabState?.sessionId - const debuggerSession = - sessionId && mainSessionId && sessionId !== mainSessionId - ? { ...debuggee, sessionId } - : debuggee - - return await chrome.debugger.sendCommand(debuggerSession, method, params) -} - -function onDebuggerEvent(source, method, params) { - const tabId = source.tabId - if (!tabId) return - const tab = tabs.get(tabId) - if (!tab?.sessionId) return - - if (method === 'Target.attachedToTarget' && params?.sessionId) { - childSessionToTab.set(String(params.sessionId), tabId) - } - - if (method === 'Target.detachedFromTarget' && params?.sessionId) { - childSessionToTab.delete(String(params.sessionId)) - } - - try { - sendToRelay({ - method: 'forwardCDPEvent', - params: { - sessionId: source.sessionId || tab.sessionId, - method, - params, - }, - }) - } catch { - // Relay may be down. - } -} - -async function onDebuggerDetach(source, reason) { - const tabId = source.tabId - if (!tabId) return - if (!tabs.has(tabId)) return - - // User explicitly cancelled or DevTools replaced the connection — respect their intent - if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') { - void detachTab(tabId, reason) - return - } - - // Check if tab still exists — distinguishes navigation from tab close - let tabInfo - try { - tabInfo = await chrome.tabs.get(tabId) - } catch { - // Tab is gone (closed) — normal cleanup - void detachTab(tabId, reason) - return - } - - if (tabInfo.url?.startsWith('chrome://') || tabInfo.url?.startsWith('chrome-extension://')) { - void detachTab(tabId, reason) - return - } - - if (reattachPending.has(tabId)) return - - const oldTab = tabs.get(tabId) - const oldSessionId = oldTab?.sessionId - const oldTargetId = oldTab?.targetId - - if (oldSessionId) tabBySession.delete(oldSessionId) - tabs.delete(tabId) - for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { - if (parentTabId === tabId) childSessionToTab.delete(childSessionId) - } - - if (oldSessionId && oldTargetId) { - try { - sendToRelay({ - method: 'forwardCDPEvent', - params: { - method: 'Target.detachedFromTarget', - params: { sessionId: oldSessionId, targetId: oldTargetId, reason: 'navigation-reattach' }, - }, - }) - } catch { - // Relay may be down. - } - } - - reattachPending.add(tabId) - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: re-attaching after navigation…', - }) - - // Extend re-attach window from 2.5 s to ~7.7 s (5 attempts). - // SPAs and pages with heavy JS can take >2.5 s before the Chrome debugger - // is attachable, causing all three original attempts to fail and leaving - // the badge permanently off after every navigation. - const delays = [200, 500, 1000, 2000, 4000] - for (let attempt = 0; attempt < delays.length; attempt++) { - await new Promise((r) => setTimeout(r, delays[attempt])) - - if (!reattachPending.has(tabId)) return - - try { - await chrome.tabs.get(tabId) - } catch { - reattachPending.delete(tabId) - setBadge(tabId, 'off') - return - } - - const relayUp = relayWs && relayWs.readyState === WebSocket.OPEN - - try { - // When relay is down, still attach the debugger but skip sending the - // relay event. reannounceAttachedTabs() will notify the relay once it - // reconnects, so the tab stays tracked across transient relay drops. - await attachTab(tabId, { skipAttachedEvent: !relayUp }) - reattachPending.delete(tabId) - if (!relayUp) { - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: attached, waiting for relay reconnect…', - }) - } - return - } catch { - // continue retries - } - } - - reattachPending.delete(tabId) - setBadge(tabId, 'off') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: re-attach failed (click to retry)', - }) -} - -// Tab lifecycle listeners — clean up stale entries. -chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => { - reattachPending.delete(tabId) - if (!tabs.has(tabId)) return - const tab = tabs.get(tabId) - if (tab?.sessionId) tabBySession.delete(tab.sessionId) - tabs.delete(tabId) - for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { - if (parentTabId === tabId) childSessionToTab.delete(childSessionId) - } - if (tab?.sessionId && tab?.targetId) { - try { - sendToRelay({ - method: 'forwardCDPEvent', - params: { - method: 'Target.detachedFromTarget', - params: { sessionId: tab.sessionId, targetId: tab.targetId, reason: 'tab_closed' }, - }, - }) - } catch { - // Relay may be down. - } - } - void persistState() -})) - -chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => void whenReady(() => { - const tab = tabs.get(removedTabId) - if (!tab) return - tabs.delete(removedTabId) - tabs.set(addedTabId, tab) - if (tab.sessionId) { - tabBySession.set(tab.sessionId, addedTabId) - } - for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { - if (parentTabId === removedTabId) { - childSessionToTab.set(childSessionId, addedTabId) - } - } - setBadge(addedTabId, 'on') - void persistState() -})) - -// Register debugger listeners at module scope so detach/event handling works -// even when the relay WebSocket is down. -chrome.debugger.onEvent.addListener((...args) => void whenReady(() => onDebuggerEvent(...args))) -chrome.debugger.onDetach.addListener((...args) => void whenReady(() => onDebuggerDetach(...args))) - -chrome.action.onClicked.addListener(() => void whenReady(() => connectOrToggleForActiveTab())) - -// Refresh badge after navigation completes — service worker may have restarted -// during navigation, losing ephemeral badge state. -chrome.webNavigation.onCompleted.addListener(({ tabId, frameId }) => void whenReady(() => { - if (frameId !== 0) return - const tab = tabs.get(tabId) - if (tab?.state === 'connected') { - setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting') - } -})) - -// Refresh badge when user switches to an attached tab. -chrome.tabs.onActivated.addListener(({ tabId }) => void whenReady(() => { - const tab = tabs.get(tabId) - if (tab?.state === 'connected') { - setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting') - } -})) - -chrome.runtime.onInstalled.addListener(() => { - void chrome.runtime.openOptionsPage() -}) - -// MV3 keepalive via chrome.alarms — more reliable than setInterval across -// service worker restarts. Checks relay health and refreshes badges. -chrome.alarms.create('relay-keepalive', { periodInMinutes: 0.5 }) - -chrome.alarms.onAlarm.addListener(async (alarm) => { - if (alarm.name !== 'relay-keepalive') return - await initPromise - - if (tabs.size === 0) return - - // Refresh badges (ephemeral in MV3). - for (const [tabId, tab] of tabs.entries()) { - if (tab.state === 'connected') { - setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting') - } - } - - // If relay is down and no reconnect is in progress, trigger one. - if (!relayWs || relayWs.readyState !== WebSocket.OPEN) { - if (!relayConnectPromise && !reconnectTimer) { - console.log('Keepalive: WebSocket unhealthy, triggering reconnect') - await ensureRelayConnection().catch(() => { - // ensureRelayConnection may throw without triggering onRelayClosed - // (e.g. preflight fetch fails before WS is created), so ensure - // reconnect is always scheduled on failure. - if (!reconnectTimer) { - scheduleReconnect() - } - }) - } - } -}) - -// Rehydrate state on service worker startup. Split: rehydration is the gate -// (fast), relay reconnect runs in background (slow, non-blocking). -const initPromise = rehydrateState() - -initPromise.then(() => { - if (tabs.size > 0) { - ensureRelayConnection().then(() => { - reconnectAttempt = 0 - return reannounceAttachedTabs() - }).catch(() => { - scheduleReconnect() - }) - } -}) - -// Shared gate: all state-dependent handlers await this before accessing maps. -async function whenReady(fn) { - await initPromise - return fn() -} - -// Relay check handler for the options page. The service worker has -// host_permissions and bypasses CORS preflight, so the options page -// delegates token-validation requests here. -chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { - if (msg?.type !== 'relayCheck') return false - const { url, token } = msg - const headers = token ? { 'x-openclaw-relay-token': token } : {} - fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(2000) }) - .then(async (res) => { - const contentType = String(res.headers.get('content-type') || '') - let json = null - if (contentType.includes('application/json')) { - try { - json = await res.json() - } catch { - json = null - } - } - sendResponse({ status: res.status, ok: res.ok, contentType, json }) - }) - .catch((err) => sendResponse({ status: 0, ok: false, error: String(err) })) - return true -}) diff --git a/assets/chrome-extension/manifest.json b/assets/chrome-extension/manifest.json deleted file mode 100644 index 62038276cd7..00000000000 --- a/assets/chrome-extension/manifest.json +++ /dev/null @@ -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 } -} diff --git a/assets/chrome-extension/options-validation.js b/assets/chrome-extension/options-validation.js deleted file mode 100644 index 53e2cd55014..00000000000 --- a/assets/chrome-extension/options-validation.js +++ /dev/null @@ -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.`, - } -} diff --git a/assets/chrome-extension/options.html b/assets/chrome-extension/options.html deleted file mode 100644 index 17fc6a79eed..00000000000 --- a/assets/chrome-extension/options.html +++ /dev/null @@ -1,200 +0,0 @@ - - - - - - OpenClaw Browser Relay - - - -
-
- -
-

OpenClaw Browser Relay

-

Click the toolbar button on a tab to attach / detach.

-
-
- -
-
-

Getting started

-

- If you see a red ! 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. -

-

- Full guide (install, remote Gateway, security): docs.openclaw.ai/tools/chrome-extension -

-
- -
-

Relay connection

- -
- -
- -
- - -
-
- Default port: 18792. Extension connects to: http://127.0.0.1:<port>/. - Gateway token must match gateway.auth.token (or OPENCLAW_GATEWAY_TOKEN). -
-
-
-
- - -
- - diff --git a/assets/chrome-extension/options.js b/assets/chrome-extension/options.js deleted file mode 100644 index aa6fcc4901f..00000000000 --- a/assets/chrome-extension/options.js +++ /dev/null @@ -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() diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index 8a30b9c6fde..d162ba7a92e 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -8035,21 +8035,7 @@ "storage" ], "label": "Browser Profile Driver", - "help": "Per-profile browser driver mode: \"openclaw\" (or legacy \"clawd\") or \"extension\" depending on connection/runtime strategy. Use the driver that matches your browser control stack to avoid protocol mismatches.", - "hasChildren": false - }, - { - "path": "browser.relayBindHost", - "kind": "core", - "type": "string", - "required": false, - "deprecated": false, - "sensitive": false, - "tags": [ - "advanced" - ], - "label": "Browser Relay Bind Address", - "help": "Bind IP address for the Chrome extension relay listener. Leave unset for loopback-only access, or set an explicit non-loopback IP such as 0.0.0.0 only when the relay must be reachable across network namespaces (for example WSL2) and the surrounding network is already trusted.", + "help": "Per-profile browser driver mode. Use \"openclaw\" (or legacy \"clawd\") for CDP-based profiles, or use \"existing-session\" for host-local Chrome MCP attachment.", "hasChildren": false }, { diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index f8a5068394e..80b18c4cc4b 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -707,8 +707,7 @@ {"recordType":"path","path":"browser.profiles.*.cdpPort","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile CDP Port","help":"Per-profile local CDP port used when connecting to browser instances by port instead of URL. Use unique ports per profile to avoid connection collisions.","hasChildren":false} {"recordType":"path","path":"browser.profiles.*.cdpUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile CDP URL","help":"Per-profile CDP websocket URL used for explicit remote browser routing by profile name. Use this when profile connections terminate on remote hosts or tunnels.","hasChildren":false} {"recordType":"path","path":"browser.profiles.*.color","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile Accent Color","help":"Per-profile accent color for visual differentiation in dashboards and browser-related UI hints. Use distinct colors for high-signal operator recognition of active profiles.","hasChildren":false} -{"recordType":"path","path":"browser.profiles.*.driver","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile Driver","help":"Per-profile browser driver mode: \"openclaw\" (or legacy \"clawd\") or \"extension\" depending on connection/runtime strategy. Use the driver that matches your browser control stack to avoid protocol mismatches.","hasChildren":false} -{"recordType":"path","path":"browser.relayBindHost","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Browser Relay Bind Address","help":"Bind IP address for the Chrome extension relay listener. Leave unset for loopback-only access, or set an explicit non-loopback IP such as 0.0.0.0 only when the relay must be reachable across network namespaces (for example WSL2) and the surrounding network is already trusted.","hasChildren":false} +{"recordType":"path","path":"browser.profiles.*.driver","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile Driver","help":"Per-profile browser driver mode. Use \"openclaw\" (or legacy \"clawd\") for CDP-based profiles, or use \"existing-session\" for host-local Chrome MCP attachment.","hasChildren":false} {"recordType":"path","path":"browser.remoteCdpHandshakeTimeoutMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Remote CDP Handshake Timeout (ms)","help":"Timeout in milliseconds for post-connect CDP handshake readiness checks against remote browser targets. Raise this for slow-start remote browsers and lower to fail fast in automation loops.","hasChildren":false} {"recordType":"path","path":"browser.remoteCdpTimeoutMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Remote CDP Timeout (ms)","help":"Timeout in milliseconds for connecting to a remote CDP endpoint before failing the browser attach attempt. Increase for high-latency tunnels, or lower for faster failure detection.","hasChildren":false} {"recordType":"path","path":"browser.snapshotDefaults","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Browser Snapshot Defaults","help":"Default snapshot capture configuration used when callers do not provide explicit snapshot options. Tune this for consistent capture behavior across channels and automation paths.","hasChildren":true} diff --git a/docs/cli/browser.md b/docs/cli/browser.md index f9ddc151717..42af08f84f3 100644 --- a/docs/cli/browser.md +++ b/docs/cli/browser.md @@ -1,9 +1,9 @@ --- -summary: "CLI reference for `openclaw browser` (profiles, tabs, actions, extension relay)" +summary: "CLI reference for `openclaw browser` (profiles, tabs, actions, Chrome MCP, and CDP)" read_when: - You use `openclaw browser` and want examples for common tasks - You want to control a browser running on another machine via a node host - - You want to use the Chrome extension relay (attach/detach via toolbar button) + - You want to attach to your local signed-in Chrome via Chrome MCP title: "browser" --- @@ -14,7 +14,6 @@ Manage OpenClaw’s browser control server and run browser actions (tabs, snapsh Related: - Browser tool + API: [Browser tool](/tools/browser) -- Chrome extension relay: [Chrome extension](/tools/chrome-extension) ## Common flags @@ -37,13 +36,14 @@ openclaw browser --browser-profile openclaw snapshot Profiles are named browser routing configs. In practice: -- `openclaw`: launches/attaches to a dedicated OpenClaw-managed Chrome instance (isolated user data dir). +- `openclaw`: launches or attaches to a dedicated OpenClaw-managed Chrome instance (isolated user data dir). - `user`: controls your existing signed-in Chrome session via Chrome DevTools MCP. -- `chrome-relay`: controls your existing Chrome tab(s) via the Chrome extension relay. +- custom CDP profiles: point at a local or remote CDP endpoint. ```bash openclaw browser profiles openclaw browser create-profile --name work --color "#FF5A36" +openclaw browser create-profile --name chrome-live --driver existing-session openclaw browser delete-profile --name work ``` @@ -84,20 +84,17 @@ openclaw browser click openclaw browser type "hello" ``` -## Chrome extension relay (attach via toolbar button) +## Existing Chrome via MCP -This mode lets the agent control an existing Chrome tab that you attach manually (it does not auto-attach). - -Install the unpacked extension to a stable path: +Use the built-in `user` profile, or create your own `existing-session` profile: ```bash -openclaw browser extension install -openclaw browser extension path +openclaw browser --browser-profile user tabs +openclaw browser create-profile --name chrome-live --driver existing-session +openclaw browser --browser-profile chrome-live tabs ``` -Then Chrome → `chrome://extensions` → enable “Developer mode” → “Load unpacked” → select the printed folder. - -Full guide: [Chrome extension](/tools/chrome-extension) +This path is host-only. For Docker, headless servers, Browserless, or other remote setups, use a CDP profile instead. ## Remote browser control (node host proxy) diff --git a/docs/cli/docs.md b/docs/cli/docs.md index 6b79aabe6f1..744c50e1432 100644 --- a/docs/cli/docs.md +++ b/docs/cli/docs.md @@ -10,6 +10,6 @@ title: "docs" Search the live docs index. ```bash -openclaw docs browser extension +openclaw docs browser existing-session openclaw docs sandbox allowHostControl ``` diff --git a/docs/docs.json b/docs/docs.json index 229699ec37e..80409046397 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1018,7 +1018,6 @@ "pages": [ "tools/browser", "tools/browser-login", - "tools/chrome-extension", "tools/browser-linux-troubleshooting" ] }, @@ -1613,7 +1612,6 @@ "pages": [ "zh-CN/tools/browser", "zh-CN/tools/browser-login", - "zh-CN/tools/chrome-extension", "zh-CN/tools/browser-linux-troubleshooting" ] }, diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 0653fd3834f..a46f342a360 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2442,13 +2442,13 @@ See [Plugins](/tools/plugin). profiles: { openclaw: { cdpPort: 18800, color: "#FF4500" }, work: { cdpPort: 18801, color: "#0066CC" }, + user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" }, }, color: "#FF4500", // headless: false, // noSandbox: false, // extraArgs: [], - // relayBindHost: "0.0.0.0", // only when the extension relay must be reachable across namespaces (for example WSL2) // executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", // attachOnly: false, }, @@ -2462,11 +2462,11 @@ See [Plugins](/tools/plugin). - `ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias. - In strict mode, use `ssrfPolicy.hostnameAllowlist` and `ssrfPolicy.allowedHostnames` for explicit exceptions. - Remote profiles are attach-only (start/stop/reset disabled). +- `existing-session` profiles are host-only and use Chrome MCP instead of CDP. - Auto-detect order: default browser if Chromium-based → Chrome → Brave → Edge → Chromium → Chrome Canary. - Control service: loopback only (port derived from `gateway.port`, default `18791`). - `extraArgs` appends extra launch flags to local Chromium startup (for example `--disable-gpu`, window sizing, or debug flags). -- `relayBindHost` changes where the Chrome extension relay listens. Leave unset for loopback-only access; set an explicit non-loopback bind address such as `0.0.0.0` only when the relay must cross a namespace boundary (for example WSL2) and the host network is already trusted. --- diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 95027906750..6c0711c7aea 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -63,6 +63,7 @@ cat ~/.openclaw/openclaw.json - Health check + restart prompt. - Skills status summary (eligible/missing/blocked). - Config normalization for legacy values. +- Browser migration checks for legacy Chrome extension configs and Chrome MCP readiness. - OpenCode provider override warnings (`models.providers.opencode` / `models.providers.opencode-go`). - Legacy on-disk state migration (sessions/agent dir/WhatsApp auth). - Legacy cron store migration (`jobId`, `schedule.cron`, top-level delivery/payload fields, payload `provider`, simple `notify: true` webhook fallback jobs). @@ -128,6 +129,8 @@ Current migrations: - `agent.model`/`allowedModels`/`modelAliases`/`modelFallbacks`/`imageModelFallbacks` → `agents.defaults.models` + `agents.defaults.model.primary/fallbacks` + `agents.defaults.imageModel.primary/fallbacks` - `browser.ssrfPolicy.allowPrivateNetwork` → `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` +- `browser.profiles.*.driver: "extension"` → `"existing-session"` +- remove `browser.relayBindHost` (legacy extension relay setting) Doctor warnings also include account-default guidance for multi-account channels: @@ -141,6 +144,33 @@ manually, it overrides the built-in OpenCode catalog from `@mariozechner/pi-ai`. That can force models onto the wrong API or zero out costs. Doctor warns so you can remove the override and restore per-model API routing + costs. +### 2c) Browser migration and Chrome MCP readiness + +If your browser config still points at the removed Chrome extension path, doctor +normalizes it to the current host-local Chrome MCP attach model: + +- `browser.profiles.*.driver: "extension"` becomes `"existing-session"` +- `browser.relayBindHost` is removed + +Doctor also audits the host-local Chrome MCP path when you use `defaultProfile: +"user"` or a configured `existing-session` profile: + +- checks whether Google Chrome is installed on the same host +- checks the detected Chrome version and warns when it is below Chrome 144 +- reminds you to enable remote debugging in Chrome at + `chrome://inspect/#remote-debugging` + +Doctor cannot enable the Chrome-side setting for you. Host-local Chrome MCP +still requires: + +- Google Chrome 144+ on the gateway/node host +- Chrome running locally +- remote debugging enabled in Chrome +- approving the first attach consent prompt in Chrome + +This check does **not** apply to Docker, sandbox, remote-browser, or other +headless flows. Those continue to use raw CDP. + ### 3) Legacy state migrations (disk layout) Doctor can migrate older on-disk layouts into the current structure: diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 595e50f2628..68be08fbed5 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -990,10 +990,9 @@ access those accounts and data. Treat browser profiles as **sensitive state**: - Treat browser downloads as untrusted input; prefer an isolated downloads directory. - Disable browser sync/password managers in the agent profile if possible (reduces blast radius). - For remote gateways, assume “browser control” is equivalent to “operator access” to whatever that profile can reach. -- Keep the Gateway and node hosts tailnet-only; avoid exposing relay/control ports to LAN or public Internet. -- The Chrome extension relay’s CDP endpoint is auth-gated; only OpenClaw clients can connect. +- Keep the Gateway and node hosts tailnet-only; avoid exposing browser control ports to LAN or public Internet. - Disable browser proxy routing when you don’t need it (`gateway.nodes.browser.mode="off"`). -- Chrome extension relay mode is **not** “safer”; it can take over your existing Chrome tabs. Assume it can act as you in whatever that tab/profile can reach. +- Chrome MCP existing-session mode is **not** “safer”; it can act as you in whatever that host Chrome profile can reach. ### Browser SSRF policy (trusted-network default) diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 41c697a67f1..aa75b9cf2b5 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -289,19 +289,18 @@ Look for: - Valid browser executable path. - CDP profile reachability. -- Extension relay tab attachment (if an extension relay profile is configured). +- Local Chrome availability for `existing-session` / `user` profiles. Common signatures: - `Failed to start Chrome CDP on port` → browser process failed to launch. - `browser.executablePath not found` → configured path is invalid. -- `Chrome extension relay is running, but no tab is connected` → extension relay not attached. +- `No Chrome tabs found for profile="user"` → the Chrome MCP attach profile has no open local Chrome tabs. - `Browser attachOnly is enabled ... not reachable` → attach-only profile has no reachable target. Related: - [/tools/browser-linux-troubleshooting](/tools/browser-linux-troubleshooting) -- [/tools/chrome-extension](/tools/chrome-extension) - [/tools/browser](/tools/browser) ## If you upgraded and something suddenly broke diff --git a/docs/help/faq.md b/docs/help/faq.md index 8fdf39ab5c1..b32b1aac8c5 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -80,7 +80,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [Can OpenClaw run tasks on a schedule or continuously in the background?](#can-openclaw-run-tasks-on-a-schedule-or-continuously-in-the-background) - [Can I run Apple macOS-only skills from Linux?](#can-i-run-apple-macos-only-skills-from-linux) - [Do you have a Notion or HeyGen integration?](#do-you-have-a-notion-or-heygen-integration) - - [How do I install the Chrome extension for browser takeover?](#how-do-i-install-the-chrome-extension-for-browser-takeover) + - [How do I use my existing signed-in Chrome with OpenClaw?](#how-do-i-use-my-existing-signed-in-chrome-with-openclaw) - [Sandboxing and memory](#sandboxing-and-memory) - [Is there a dedicated sandboxing doc?](#is-there-a-dedicated-sandboxing-doc) - [How do I bind a host folder into the sandbox?](#how-do-i-bind-a-host-folder-into-the-sandbox) @@ -1214,22 +1214,23 @@ clawhub update --all ClawHub installs into `./skills` under your current directory (or falls back to your configured OpenClaw workspace); OpenClaw treats that as `/skills` on the next session. For shared skills across agents, place them in `~/.openclaw/skills//SKILL.md`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills) and [ClawHub](/tools/clawhub). -### How do I install the Chrome extension for browser takeover +### How do I use my existing signed-in Chrome with OpenClaw -Use the built-in installer, then load the unpacked extension in Chrome: +Use the built-in `user` browser profile, which attaches through Chrome DevTools MCP: ```bash -openclaw browser extension install -openclaw browser extension path +openclaw browser --browser-profile user tabs +openclaw browser --browser-profile user snapshot ``` -Then Chrome → `chrome://extensions` → enable "Developer mode" → "Load unpacked" → pick that folder. +If you want a custom name, create an explicit MCP profile: -Full guide (including remote Gateway + security notes): [Chrome extension](/tools/chrome-extension) +```bash +openclaw browser create-profile --name chrome-live --driver existing-session +openclaw browser --browser-profile chrome-live tabs +``` -If the Gateway runs on the same machine as Chrome (default setup), you usually **do not** need anything extra. -If the Gateway runs elsewhere, run a node host on the browser machine so the Gateway can proxy browser actions. -You still need to click the extension button on the tab you want to control (it doesn't auto-attach). +This path is host-local. If the Gateway runs elsewhere, either run a node host on the browser machine or use remote CDP instead. ## Sandboxing and memory @@ -1665,13 +1666,12 @@ setup is an always-on host plus your laptop as a node. - **No inbound SSH required.** Nodes connect out to the Gateway WebSocket and use device pairing. - **Safer execution controls.** `system.run` is gated by node allowlists/approvals on that laptop. - **More device tools.** Nodes expose `canvas`, `camera`, and `screen` in addition to `system.run`. -- **Local browser automation.** Keep the Gateway on a VPS, but run Chrome locally and relay control - with the Chrome extension + a node host on the laptop. +- **Local browser automation.** Keep the Gateway on a VPS, but run Chrome locally through a node host on the laptop, or attach to local Chrome on the host via Chrome MCP. SSH is fine for ad-hoc shell access, but nodes are simpler for ongoing agent workflows and device automation. -Docs: [Nodes](/nodes), [Nodes CLI](/cli/nodes), [Chrome extension](/tools/chrome-extension). +Docs: [Nodes](/nodes), [Nodes CLI](/cli/nodes), [Browser](/tools/browser). ### Should I install on a second laptop or just add a node @@ -2039,18 +2039,18 @@ Yes. Use **Multi-Agent Routing** to run multiple isolated agents and route inbou channel/account/peer. Slack is supported as a channel and can be bound to specific agents. Browser access is powerful but not "do anything a human can" - anti-bot, CAPTCHAs, and MFA can -still block automation. For the most reliable browser control, use the Chrome extension relay -on the machine that runs the browser (and keep the Gateway anywhere). +still block automation. For the most reliable browser control, use local Chrome MCP on the host, +or use CDP on the machine that actually runs the browser. Best-practice setup: - Always-on Gateway host (VPS/Mac mini). - One agent per role (bindings). - Slack channel(s) bound to those agents. -- Local browser via extension relay (or a node) when needed. +- Local browser via Chrome MCP or a node when needed. Docs: [Multi-Agent Routing](/concepts/multi-agent), [Slack](/channels/slack), -[Browser](/tools/browser), [Chrome extension](/tools/chrome-extension), [Nodes](/nodes). +[Browser](/tools/browser), [Nodes](/nodes). ## Models: defaults, selection, aliases, switching diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md index a3988c4ea58..1660100ba8c 100644 --- a/docs/help/troubleshooting.md +++ b/docs/help/troubleshooting.md @@ -278,13 +278,13 @@ flowchart TD Good output looks like: - Browser status shows `running: true` and a chosen browser/profile. - - `openclaw` profile starts or `chrome` relay has an attached tab. + - `openclaw` starts, or `user` can see local Chrome tabs. Common log signatures: - `Failed to start Chrome CDP on port` → local browser launch failed. - `browser.executablePath not found` → configured binary path is wrong. - - `Chrome extension relay is running, but no tab is connected` → extension not attached. + - `No Chrome tabs found for profile="user"` → the Chrome MCP attach profile has no open local Chrome tabs. - `Browser attachOnly is enabled ... not reachable` → attach-only profile has no live CDP target. Deep pages: @@ -292,7 +292,6 @@ flowchart TD - [/gateway/troubleshooting#browser-tool-fails](/gateway/troubleshooting#browser-tool-fails) - [/tools/browser-linux-troubleshooting](/tools/browser-linux-troubleshooting) - [/tools/browser-wsl2-windows-remote-cdp-troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting) - - [/tools/chrome-extension](/tools/chrome-extension) diff --git a/docs/install/docker.md b/docs/install/docker.md index a3827075202..a9f6b578bd0 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -713,6 +713,7 @@ an optional noVNC observer (headful via Xvfb). Notes: +- Docker and other headless/container browser flows stay on raw CDP. Chrome MCP `existing-session` is for host-local Chrome, not container takeover. - Headful (Xvfb) reduces bot blocking vs headless. - Headless can still be used by setting `agents.defaults.sandbox.browser.headless=true`. - No full desktop environment (GNOME) is needed; Xvfb provides the display. diff --git a/docs/tools/browser-linux-troubleshooting.md b/docs/tools/browser-linux-troubleshooting.md index 6f9940c1c67..2a5196c3739 100644 --- a/docs/tools/browser-linux-troubleshooting.md +++ b/docs/tools/browser-linux-troubleshooting.md @@ -121,19 +121,18 @@ curl -s http://127.0.0.1:18791/tabs | `browser.attachOnly` | Don't launch browser, only attach to existing | `false` | | `browser.cdpPort` | Chrome DevTools Protocol port | `18800` | -### Problem: "Chrome extension relay is running, but no tab is connected" +### Problem: "No Chrome tabs found for profile=\"user\"" -You're using an extension relay profile. It expects the OpenClaw -browser extension to be attached to a live tab. +You're using an `existing-session` / Chrome MCP profile. OpenClaw can see local Chrome, +but there are no open tabs available to attach to. Fix options: 1. **Use the managed browser:** `openclaw browser start --browser-profile openclaw` (or set `browser.defaultProfile: "openclaw"`). -2. **Use the extension relay:** install the extension, open a tab, and click the - OpenClaw extension icon to attach it. +2. **Use Chrome MCP:** make sure local Chrome is running with at least one open tab, then retry with `--browser-profile user`. Notes: -- The `chrome-relay` profile uses your **system default Chromium browser** when possible. +- `user` is host-only. For Linux servers, containers, or remote hosts, prefer CDP profiles. - Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl`; only set those for remote CDP. diff --git a/docs/tools/browser-login.md b/docs/tools/browser-login.md index d570b3b2e87..52135a80673 100644 --- a/docs/tools/browser-login.md +++ b/docs/tools/browser-login.md @@ -24,7 +24,6 @@ For agent browser tool calls: - Default choice: the agent should use its isolated `openclaw` browser. - Use `profile="user"` only when existing logged-in sessions matter and the user is at the computer to click/approve any attach prompt. -- Use `profile="chrome-relay"` only for the Chrome extension / toolbar-button attach flow. - If you have multiple user-browser profiles, specify the profile explicitly instead of guessing. Two easy ways to access it: diff --git a/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md b/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md index 2e7844860aa..6824cee6788 100644 --- a/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md +++ b/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md @@ -1,9 +1,9 @@ --- -summary: "Troubleshoot WSL2 Gateway + Windows Chrome remote CDP and extension-relay setups in layers" +summary: "Troubleshoot WSL2 Gateway + Windows Chrome remote CDP in layers" read_when: - Running OpenClaw Gateway in WSL2 while Chrome lives on Windows - Seeing overlapping browser/control-ui errors across WSL2 and Windows - - Deciding between raw remote CDP and the Chrome extension relay in split-host setups + - Deciding between host-local Chrome MCP and raw remote CDP in split-host setups title: "WSL2 + Windows + remote Chrome CDP troubleshooting" --- @@ -21,27 +21,27 @@ It also covers the layered failure pattern from [issue #39369](https://github.co You have two valid patterns: -### Option 1: Raw remote CDP +### Option 1: Raw remote CDP from WSL2 to Windows Use a remote browser profile that points from WSL2 to a Windows Chrome CDP endpoint. Choose this when: -- you only need browser control -- you are comfortable exposing Chrome remote debugging to WSL2 -- you do not need the Chrome extension relay +- the Gateway stays inside WSL2 +- Chrome runs on Windows +- you need browser control to cross the WSL2/Windows boundary -### Option 2: Chrome extension relay +### Option 2: Host-local Chrome MCP -Use the built-in `chrome-relay` profile plus the OpenClaw Chrome extension. +Use `existing-session` / `user` only when the Gateway itself runs on the same host as Chrome. Choose this when: -- you want to attach to an existing Windows Chrome tab with the toolbar button -- you want extension-based control instead of raw `--remote-debugging-port` -- the relay itself must be reachable across the WSL2/Windows boundary +- OpenClaw and Chrome are on the same machine +- you want the local signed-in browser state +- you do not need cross-host browser transport -If you use the extension relay across namespaces, `browser.relayBindHost` is the important setting introduced in [Browser](/tools/browser) and [Chrome extension](/tools/chrome-extension). +For WSL2 Gateway + Windows Chrome, prefer raw remote CDP. Chrome MCP is host-local, not a WSL2-to-Windows bridge. ## Working architecture @@ -62,7 +62,6 @@ Several failures can overlap: - `gateway.controlUi.allowedOrigins` does not match the page origin - token or pairing is missing - the browser profile points at the wrong address -- the extension relay is still loopback-only when you actually need cross-namespace access Because of that, fixing one layer can still leave a different error visible. @@ -145,31 +144,7 @@ Notes: - keep `attachOnly: true` for externally managed browsers - test the same URL with `curl` before expecting OpenClaw to succeed -### Layer 4: If you use the Chrome extension relay instead - -If the browser machine and the Gateway are separated by a namespace boundary, the relay may need a non-loopback bind address. - -Example: - -```json5 -{ - browser: { - enabled: true, - defaultProfile: "chrome-relay", - relayBindHost: "0.0.0.0", - }, -} -``` - -Use this only when needed: - -- default behavior is safer because the relay stays loopback-only -- `0.0.0.0` expands exposure surface -- keep Gateway auth, node pairing, and the surrounding network private - -If you do not need the extension relay, prefer the raw remote CDP profile above. - -### Layer 5: Verify the Control UI layer separately +### Layer 4: Verify the Control UI layer separately Open the UI from Windows: @@ -185,7 +160,7 @@ Helpful page: - [Control UI](/web/control-ui) -### Layer 6: Verify end-to-end browser control +### Layer 5: Verify end-to-end browser control From WSL2: @@ -194,12 +169,6 @@ openclaw browser open https://example.com --browser-profile remote openclaw browser tabs --browser-profile remote ``` -For the extension relay: - -```bash -openclaw browser tabs --browser-profile chrome-relay -``` - Good result: - the tab opens in Windows Chrome @@ -220,8 +189,8 @@ Treat each message as a layer-specific clue: - WSL2 cannot reach the configured `cdpUrl` - `gateway timeout after 1500ms` - often still CDP reachability or a slow/unreachable remote endpoint -- `Chrome extension relay is running, but no tab is connected` - - extension relay profile selected, but no attached tab exists yet +- `No Chrome tabs found for profile="user"` + - local Chrome MCP profile selected where no host-local tabs are available ## Fast triage checklist @@ -229,11 +198,11 @@ Treat each message as a layer-specific clue: 2. WSL2: does `curl http://WINDOWS_HOST_OR_IP:9222/json/version` work? 3. OpenClaw config: does `browser.profiles..cdpUrl` use that exact WSL2-reachable address? 4. Control UI: are you opening `http://127.0.0.1:18789/` instead of a LAN IP? -5. Extension relay only: do you actually need `browser.relayBindHost`, and if so is it set explicitly? +5. Are you trying to use `existing-session` across WSL2 and Windows instead of raw remote CDP? ## Practical takeaway -The setup is usually viable. The hard part is that browser transport, Control UI origin security, token/pairing, and extension-relay topology can each fail independently while looking similar from the user side. +The setup is usually viable. The hard part is that browser transport, Control UI origin security, and token/pairing can each fail independently while looking similar from the user side. When in doubt: diff --git a/docs/tools/browser.md b/docs/tools/browser.md index c760c23998c..19ee23a25ca 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -18,8 +18,7 @@ Beginner view: - Think of it as a **separate, agent-only browser**. - The `openclaw` profile does **not** touch your personal browser profile. - The agent can **open tabs, read pages, click, and type** in a safe lane. -- The built-in `user` profile attaches to your real signed-in Chrome session; - `chrome-relay` is the explicit extension-relay profile. +- The built-in `user` profile attaches to your real signed-in Chrome session via Chrome MCP. ## What you get @@ -43,21 +42,17 @@ openclaw browser --browser-profile openclaw snapshot If you get “Browser disabled”, enable it in config (see below) and restart the Gateway. -## Profiles: `openclaw` vs `user` vs `chrome-relay` +## Profiles: `openclaw` vs `user` - `openclaw`: managed, isolated browser (no extension required). - `user`: built-in Chrome MCP attach profile for your **real signed-in Chrome** session. -- `chrome-relay`: extension relay to your **system browser** (requires the - OpenClaw extension to be attached to a tab). For agent browser tool calls: - Default: use the isolated `openclaw` browser. - Prefer `profile="user"` when existing logged-in sessions matter and the user is at the computer to click/approve any attach prompt. -- Use `profile="chrome-relay"` only when the user explicitly wants the Chrome - extension / toolbar-button attach flow. - `profile` is the explicit override when you want a specific browser mode. Set `browser.defaultProfile: "openclaw"` if you want managed mode by default. @@ -93,11 +88,6 @@ Browser settings live in `~/.openclaw/openclaw.json`. attachOnly: true, color: "#00AA00", }, - "chrome-relay": { - driver: "extension", - cdpUrl: "http://127.0.0.1:18792", - color: "#00AA00", - }, remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" }, }, }, @@ -107,10 +97,10 @@ Browser settings live in `~/.openclaw/openclaw.json`. Notes: - The browser control service binds to loopback on a port derived from `gateway.port` - (default: `18791`, which is gateway + 2). The relay uses the next port (`18792`). + (default: `18791`, which is gateway + 2). - If you override the Gateway port (`gateway.port` or `OPENCLAW_GATEWAY_PORT`), the derived browser ports shift to stay in the same “family”. -- `cdpUrl` defaults to the relay port when unset. +- `cdpUrl` defaults to the managed local CDP port when unset. - `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP reachability checks. - `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket reachability checks. - Browser navigation/open-tab is SSRF-guarded before navigation and best-effort re-checked on final `http(s)` URL after navigation. @@ -119,7 +109,7 @@ Notes: - `browser.ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias for compatibility. - `attachOnly: true` means “never launch a local browser; only attach if it is already running.” - `color` + per-profile `color` tint the browser UI so you can see which profile is active. -- Default profile is `openclaw` (OpenClaw-managed standalone browser). Use `defaultProfile: "user"` to opt into the signed-in user browser, or `defaultProfile: "chrome-relay"` for the extension relay. +- Default profile is `openclaw` (OpenClaw-managed standalone browser). Use `defaultProfile: "user"` to opt into the signed-in user browser. - Auto-detect order: system default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary. - Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl` — set those only for remote CDP. - `driver: "existing-session"` uses Chrome DevTools MCP instead of raw CDP. Do @@ -287,77 +277,18 @@ OpenClaw supports multiple named profiles (routing configs). Profiles can be: - **openclaw-managed**: a dedicated Chromium-based browser instance with its own user data directory + CDP port - **remote**: an explicit CDP URL (Chromium-based browser running elsewhere) -- **extension relay**: your existing Chrome tab(s) via the local relay + Chrome extension - **existing session**: your existing Chrome profile via Chrome DevTools MCP auto-connect Defaults: - The `openclaw` profile is auto-created if missing. -- The `chrome-relay` profile is built-in for the Chrome extension relay (points at `http://127.0.0.1:18792` by default). -- Existing-session profiles are opt-in; create them with `--driver existing-session`. +- The `user` profile is built-in for Chrome MCP existing-session attach. +- Existing-session profiles are opt-in beyond `user`; create them with `--driver existing-session`. - Local CDP ports allocate from **18800–18899** by default. - Deleting a profile moves its local data directory to Trash. All control endpoints accept `?profile=`; the CLI uses `--browser-profile`. -## Chrome extension relay (use your existing Chrome) - -OpenClaw can also drive **your existing Chrome tabs** (no separate “openclaw” Chrome instance) via a local CDP relay + a Chrome extension. - -Full guide: [Chrome extension](/tools/chrome-extension) - -Flow: - -- The Gateway runs locally (same machine) or a node host runs on the browser machine. -- A local **relay server** listens at a loopback `cdpUrl` (default: `http://127.0.0.1:18792`). -- You click the **OpenClaw Browser Relay** extension icon on a tab to attach (it does not auto-attach). -- The agent controls that tab via the normal `browser` tool, by selecting the right profile. - -If the Gateway runs elsewhere, run a node host on the browser machine so the Gateway can proxy browser actions. - -### Sandboxed sessions - -If the agent session is sandboxed, the `browser` tool may default to `target="sandbox"` (sandbox browser). -Chrome extension relay takeover requires host browser control, so either: - -- run the session unsandboxed, or -- set `agents.defaults.sandbox.browser.allowHostControl: true` and use `target="host"` when calling the tool. - -### Setup - -1. Load the extension (dev/unpacked): - -```bash -openclaw browser extension install -``` - -- Chrome → `chrome://extensions` → enable “Developer mode” -- “Load unpacked” → select the directory printed by `openclaw browser extension path` -- Pin the extension, then click it on the tab you want to control (badge shows `ON`). - -2. Use it: - -- CLI: `openclaw browser --browser-profile chrome-relay tabs` -- Agent tool: `browser` with `profile="chrome-relay"` - -Optional: if you want a different name or relay port, create your own profile: - -```bash -openclaw browser create-profile \ - --name my-chrome \ - --driver extension \ - --cdp-url http://127.0.0.1:18792 \ - --color "#00AA00" -``` - -Notes: - -- This mode relies on Playwright-on-CDP for most operations (screenshots/snapshots/actions). -- Detach by clicking the extension icon again. -- Agent use: prefer `profile="user"` for logged-in sites. Use `profile="chrome-relay"` - only when you specifically want the extension flow. The user must be present - to click the extension and attach the tab. - ## Chrome existing-session via MCP OpenClaw can also attach to a running Chrome profile through the official @@ -404,13 +335,14 @@ What to check if attach does not work: - Chrome is version `144+` - remote debugging is enabled at `chrome://inspect/#remote-debugging` - Chrome showed and you accepted the attach consent prompt +- `openclaw doctor` migrates old extension-based browser config and checks that + Chrome is installed locally with a compatible version, but it cannot enable + Chrome-side remote debugging for you Agent use: - Use `profile="user"` when you need the user’s logged-in browser state. - If you use a custom existing-session profile, pass that explicit profile name. -- Prefer `profile="user"` over `profile="chrome-relay"` unless the user - explicitly wants the extension / attach-tab flow. - Only choose this mode when the user is at the computer to approve the attach prompt. - the Gateway or node host can spawn `npx chrome-devtools-mcp@latest --autoConnect` @@ -427,21 +359,10 @@ Notes: captures from snapshots, but not CSS `--element` selectors. - Existing-session `wait --url` supports exact, substring, and glob patterns like other browser drivers. `wait --load networkidle` is not supported yet. -- Some features still require the extension relay or managed browser path, such - as PDF export and download interception. -- Leave the relay loopback-only by default. If the relay must be reachable from a different network namespace (for example Gateway in WSL2, Chrome on Windows), set `browser.relayBindHost` to an explicit bind address such as `0.0.0.0` while keeping the surrounding network private and authenticated. - -WSL2 / cross-namespace example: - -```json5 -{ - browser: { - enabled: true, - relayBindHost: "0.0.0.0", - defaultProfile: "chrome-relay", - }, -} -``` +- Some features still require the managed browser path, such as PDF export and + download interception. +- Existing-session is host-local. If Chrome lives on a different machine or a + different network namespace, use remote CDP or a node host instead. ## Isolation guarantees @@ -496,7 +417,6 @@ If gateway auth is configured, browser HTTP routes require auth too: Some features (navigate/act/AI snapshot/role snapshot, element screenshots, PDF) require Playwright. If Playwright isn’t installed, those endpoints return a clear 501 error. ARIA snapshots and basic screenshots still work for openclaw-managed Chrome. -For the Chrome extension relay driver, ARIA snapshots and screenshots require Playwright. If you see `Playwright is not available in this gateway build`, install the full Playwright package (not `playwright-core`) and restart the gateway, or reinstall diff --git a/docs/tools/chrome-extension.md b/docs/tools/chrome-extension.md deleted file mode 100644 index 831897b9bde..00000000000 --- a/docs/tools/chrome-extension.md +++ /dev/null @@ -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) diff --git a/docs/tools/index.md b/docs/tools/index.md index dbca6cd26bf..deb42b0d76a 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -318,8 +318,7 @@ Common parameters: - All actions accept optional `profile` parameter for multi-instance support. - Omit `profile` for the safe default: isolated OpenClaw-managed browser (`openclaw`). - Use `profile="user"` for the real local host browser when existing logins/cookies matter and the user is present to click/approve any attach prompt. -- Use `profile="chrome-relay"` only for the Chrome extension / toolbar-button attach flow. -- `profile="user"` and `profile="chrome-relay"` are host-only; do not combine them with sandbox/node targets. +- `profile="user"` is host-only; do not combine it with sandbox/node targets. - When `profile` is omitted, uses `browser.defaultProfile` (defaults to `openclaw`). - Profile names: lowercase alphanumeric + hyphens only (max 64 chars). - Port range: 18800-18899 (~100 profiles max). diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index c818344f886..76a0be3b466 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -60,7 +60,6 @@ const unitIsolatedFilesRaw = [ // Skills discovery/snapshot suites are filesystem-heavy and high-variance in vmForks lanes. "src/agents/skills.test.ts", "src/agents/skills.buildworkspaceskillsnapshot.test.ts", - "src/browser/extension-relay.test.ts", "extensions/acpx/src/runtime.test.ts", // Shell-heavy script harness can contend under vmForks startup bursts. "test/scripts/ios-team-id.test.ts", diff --git a/src/agents/tools/browser-tool.actions.ts b/src/agents/tools/browser-tool.actions.ts index 0d0f5e26abb..dc78557eab2 100644 --- a/src/agents/tools/browser-tool.actions.ts +++ b/src/agents/tools/browser-tool.actions.ts @@ -1,7 +1,9 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { browserAct, browserConsoleMessages } from "../../browser/client-actions.js"; import { browserSnapshot, browserTabs } from "../../browser/client.js"; +import { resolveBrowserConfig, resolveProfile } from "../../browser/config.js"; import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../browser/constants.js"; +import { getBrowserProfileCapabilities } from "../../browser/profile-capabilities.js"; import { loadConfig } from "../../config/config.js"; import { wrapExternalContent } from "../../security/external-content.js"; import { imageResultFromFile, jsonResult } from "./common.js"; @@ -74,7 +76,17 @@ function formatConsoleToolResult(result: { } function isChromeStaleTargetError(profile: string | undefined, err: unknown): boolean { - if (profile !== "chrome-relay" && profile !== "chrome" && profile !== "user") { + if (!profile) { + return false; + } + if (profile === "user") { + const msg = String(err); + return msg.includes("404:") && msg.includes("tab not found"); + } + const cfg = loadConfig(); + const resolved = resolveBrowserConfig(cfg.browser, cfg); + const browserProfile = resolveProfile(resolved, profile); + if (!browserProfile || !getBrowserProfileCapabilities(browserProfile).usesChromeMcp) { return false; } const msg = String(err); @@ -334,12 +346,8 @@ export async function executeActAction(params: { } } if (!tabs.length) { - // Extension relay profiles need the toolbar icon click; Chrome MCP just needs Chrome running. - const isRelayProfile = profile === "chrome-relay" || profile === "chrome"; throw new Error( - isRelayProfile - ? "No Chrome tabs are attached via the OpenClaw Browser Relay extension. Click the toolbar icon on the tab you want to control (badge ON), then retry." - : `No Chrome tabs found for profile="${profile}". Make sure Chrome (v146+) is running and has open tabs, then retry.`, + `No Chrome tabs found for profile="${profile}". Make sure Chrome (v144+) is running and has open tabs, then retry.`, { cause: err }, ); } diff --git a/src/agents/tools/browser-tool.test.ts b/src/agents/tools/browser-tool.test.ts index b938d177624..622fa7ed8b3 100644 --- a/src/agents/tools/browser-tool.test.ts +++ b/src/agents/tools/browser-tool.test.ts @@ -64,12 +64,7 @@ const browserConfigMocks = vi.hoisted(() => ({ if (!profile) { return null; } - const driver = - profile.driver === "extension" - ? "extension" - : profile.driver === "existing-session" - ? "existing-session" - : "openclaw"; + const driver = profile.driver === "existing-session" ? "existing-session" : "openclaw"; if (driver === "existing-session") { return { name, @@ -287,29 +282,6 @@ describe("browser tool snapshot maxChars", () => { expect(opts?.mode).toBeUndefined(); }); - it("defaults to host when using an explicit extension relay profile (even in sandboxed sessions)", async () => { - setResolvedBrowserProfiles({ - relay: { - driver: "extension", - cdpUrl: "http://127.0.0.1:18792", - color: "#0066CC", - }, - }); - const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" }); - await tool.execute?.("call-1", { - action: "snapshot", - profile: "relay", - snapshotFormat: "ai", - }); - - expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith( - undefined, - expect.objectContaining({ - profile: "relay", - }), - ); - }); - it("defaults to host when using profile=user (even in sandboxed sessions)", async () => { setResolvedBrowserProfiles({ user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, diff --git a/src/agents/tools/browser-tool.ts b/src/agents/tools/browser-tool.ts index 54ddab2cb1f..c0111ab9977 100644 --- a/src/agents/tools/browser-tool.ts +++ b/src/agents/tools/browser-tool.ts @@ -290,7 +290,7 @@ function shouldPreferHostForProfile(profileName: string | undefined) { return false; } const capabilities = getBrowserProfileCapabilities(profile); - return capabilities.requiresRelay || capabilities.usesChromeMcp; + return capabilities.usesChromeMcp; } export function createBrowserTool(opts?: { @@ -307,7 +307,7 @@ export function createBrowserTool(opts?: { description: [ "Control the browser via OpenClaw's browser control server (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions).", "Browser choice: omit profile by default for the isolated OpenClaw-managed browser (`openclaw`).", - 'For the logged-in user browser on the local host, use profile="user". Chrome (v146+) must be running. Use only when existing logins/cookies matter and the user is present.', + 'For the logged-in user browser on the local host, use profile="user". Chrome (v144+) must be running. Use only when existing logins/cookies matter and the user is present.', 'When a node-hosted browser proxy is available, the tool may auto-route to it. Pin a node with node= or target="node".', "When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc).", 'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.', @@ -326,7 +326,7 @@ export function createBrowserTool(opts?: { if (requestedNode && target && target !== "node") { throw new Error('node is only supported with target="node".'); } - // User-browser profiles (existing-session, extension relay) are host-only. + // User-browser profiles (existing-session) are host-only. const isUserBrowserProfile = shouldPreferHostForProfile(profile); if (isUserBrowserProfile) { if (requestedNode || target === "node") { diff --git a/src/browser/browser-utils.test.ts b/src/browser/browser-utils.test.ts index accd36ba7ac..5bd45952321 100644 --- a/src/browser/browser-utils.test.ts +++ b/src/browser/browser-utils.test.ts @@ -7,15 +7,10 @@ import { import { __test } from "./client-fetch.js"; import { resolveBrowserConfig, resolveProfile } from "./config.js"; import { shouldRejectBrowserMutation } from "./csrf.js"; -import { - ensureChromeExtensionRelayServer, - stopChromeExtensionRelayServer, -} from "./extension-relay.js"; import { toBoolean } from "./routes/utils.js"; import type { BrowserServerState } from "./server-context.js"; import { listKnownProfileNames } from "./server-context.js"; import { resolveTargetIdFromTabs } from "./target-id.js"; -import { getFreePort } from "./test-port.js"; describe("toBoolean", () => { it("parses yes/no and 1/0", () => { @@ -195,29 +190,8 @@ describe("cdp.helpers", () => { expect(headers.Authorization).toBe("Bearer token"); }); - it("does not add relay header for unknown loopback ports", () => { - const headers = getHeadersWithAuth("http://127.0.0.1:19444/json/version"); - expect(headers["x-openclaw-relay-token"]).toBeUndefined(); - }); - - it("adds relay header for known relay ports", async () => { - const port = await getFreePort(); - const cdpUrl = `http://127.0.0.1:${port}`; - const prev = process.env.OPENCLAW_GATEWAY_TOKEN; - process.env.OPENCLAW_GATEWAY_TOKEN = "test-gateway-token"; - try { - await ensureChromeExtensionRelayServer({ cdpUrl }); - const headers = getHeadersWithAuth(`${cdpUrl}/json/version`); - expect(headers["x-openclaw-relay-token"]).toBeTruthy(); - expect(headers["x-openclaw-relay-token"]).not.toBe("test-gateway-token"); - } finally { - await stopChromeExtensionRelayServer({ cdpUrl }).catch(() => {}); - if (prev === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = prev; - } - } + it("does not add custom headers when none are required", () => { + expect(getHeadersWithAuth("http://127.0.0.1:19444/json/version")).toEqual({}); }); }); diff --git a/src/browser/cdp.helpers.ts b/src/browser/cdp.helpers.ts index 399f0582d88..3bc02362b55 100644 --- a/src/browser/cdp.helpers.ts +++ b/src/browser/cdp.helpers.ts @@ -6,7 +6,6 @@ import { redactSensitiveText } from "../logging/redact.js"; import { getDirectAgentForCdp, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js"; import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js"; import { resolveBrowserRateLimitMessage } from "./client-fetch.js"; -import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js"; export { isLoopbackHost }; @@ -76,8 +75,7 @@ export type CdpSendFn = ( ) => Promise; export function getHeadersWithAuth(url: string, headers: Record = {}) { - const relayHeaders = getChromeExtensionRelayAuthHeaders(url); - const mergedHeaders = { ...relayHeaders, ...headers }; + const mergedHeaders = { ...headers }; try { const parsed = new URL(url); const hasAuthHeader = Object.keys(mergedHeaders).some( diff --git a/src/browser/chrome-extension-background-utils.test.ts b/src/browser/chrome-extension-background-utils.test.ts deleted file mode 100644 index b22b602116c..00000000000 --- a/src/browser/chrome-extension-background-utils.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { createRequire } from "node:module"; -import { describe, expect, it } from "vitest"; - -type BackgroundUtilsModule = { - buildRelayWsUrl: (port: number, gatewayToken: string) => Promise; - deriveRelayToken: (gatewayToken: string, port: number) => Promise; - 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 { - 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); - }); -}); diff --git a/src/browser/chrome-extension-manifest.test.ts b/src/browser/chrome-extension-manifest.test.ts deleted file mode 100644 index 4d4a0321724..00000000000 --- a/src/browser/chrome-extension-manifest.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/src/browser/chrome-extension-options-validation.test.ts b/src/browser/chrome-extension-options-validation.test.ts deleted file mode 100644 index 23aa6d1ce06..00000000000 --- a/src/browser/chrome-extension-options-validation.test.ts +++ /dev/null @@ -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 { - 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.", - }); - }); -}); diff --git a/src/browser/chrome-mcp.ts b/src/browser/chrome-mcp.ts index c649fe53633..a673feb2c27 100644 --- a/src/browser/chrome-mcp.ts +++ b/src/browser/chrome-mcp.ts @@ -193,7 +193,7 @@ async function createRealSession(profileName: string): Promise await client.close().catch(() => {}); throw new BrowserProfileUnavailableError( `Chrome MCP existing-session attach failed for profile "${profileName}". ` + - `Make sure Chrome (v146+) is running. ` + + `Make sure Chrome (v144+) is running. ` + `Details: ${String(err)}`, ); } diff --git a/src/browser/chrome.executables.ts b/src/browser/chrome.executables.ts index 729127c9df9..6ef7bc0b155 100644 --- a/src/browser/chrome.executables.ts +++ b/src/browser/chrome.executables.ts @@ -9,6 +9,8 @@ export type BrowserExecutable = { path: string; }; +const CHROME_VERSION_RE = /(\d+)(?:\.\d+){0,3}/; + const CHROMIUM_BUNDLE_IDS = new Set([ "com.google.Chrome", "com.google.Chrome.beta", @@ -453,6 +455,22 @@ function findFirstExecutable(candidates: Array): BrowserExecu return null; } +function findFirstChromeExecutable(candidates: string[]): BrowserExecutable | null { + for (const candidate of candidates) { + if (exists(candidate)) { + return { + kind: + candidate.toLowerCase().includes("sxs") || candidate.toLowerCase().includes("canary") + ? "canary" + : "chrome", + path: candidate, + }; + } + } + + return null; +} + export function findChromeExecutableMac(): BrowserExecutable | null { const candidates: Array = [ { @@ -506,6 +524,18 @@ export function findChromeExecutableMac(): BrowserExecutable | null { return findFirstExecutable(candidates); } +export function findGoogleChromeExecutableMac(): BrowserExecutable | null { + return findFirstChromeExecutable([ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + path.join(os.homedir(), "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), + "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + path.join( + os.homedir(), + "Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + ), + ]); +} + export function findChromeExecutableLinux(): BrowserExecutable | null { const candidates: Array = [ { kind: "chrome", path: "/usr/bin/google-chrome" }, @@ -525,6 +555,16 @@ export function findChromeExecutableLinux(): BrowserExecutable | null { return findFirstExecutable(candidates); } +export function findGoogleChromeExecutableLinux(): BrowserExecutable | null { + return findFirstChromeExecutable([ + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome-beta", + "/usr/bin/google-chrome-unstable", + "/snap/bin/google-chrome", + ]); +} + export function findChromeExecutableWindows(): BrowserExecutable | null { const localAppData = process.env.LOCALAPPDATA ?? ""; const programFiles = process.env.ProgramFiles ?? "C:\\Program Files"; @@ -596,6 +636,56 @@ export function findChromeExecutableWindows(): BrowserExecutable | null { return findFirstExecutable(candidates); } +export function findGoogleChromeExecutableWindows(): BrowserExecutable | null { + const localAppData = process.env.LOCALAPPDATA ?? ""; + const programFiles = process.env.ProgramFiles ?? "C:\\Program Files"; + const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)"; + const joinWin = path.win32.join; + const candidates: string[] = []; + + if (localAppData) { + candidates.push(joinWin(localAppData, "Google", "Chrome", "Application", "chrome.exe")); + candidates.push(joinWin(localAppData, "Google", "Chrome SxS", "Application", "chrome.exe")); + } + + candidates.push(joinWin(programFiles, "Google", "Chrome", "Application", "chrome.exe")); + candidates.push(joinWin(programFilesX86, "Google", "Chrome", "Application", "chrome.exe")); + + return findFirstChromeExecutable(candidates); +} + +export function resolveGoogleChromeExecutableForPlatform( + platform: NodeJS.Platform, +): BrowserExecutable | null { + if (platform === "darwin") { + return findGoogleChromeExecutableMac(); + } + if (platform === "linux") { + return findGoogleChromeExecutableLinux(); + } + if (platform === "win32") { + return findGoogleChromeExecutableWindows(); + } + return null; +} + +export function readBrowserVersion(executablePath: string): string | null { + const output = execText(executablePath, ["--version"], 2000); + if (!output) { + return null; + } + return output.replace(/\s+/g, " ").trim(); +} + +export function parseBrowserMajorVersion(rawVersion: string | null | undefined): number | null { + const match = String(rawVersion ?? "").match(CHROME_VERSION_RE); + if (!match?.[1]) { + return null; + } + const major = Number.parseInt(match[1], 10); + return Number.isFinite(major) ? major : null; +} + export function resolveBrowserExecutableForPlatform( resolved: ResolvedBrowserConfig, platform: NodeJS.Platform, diff --git a/src/browser/client.ts b/src/browser/client.ts index 8e30762bfb1..7791b4405be 100644 --- a/src/browser/client.ts +++ b/src/browser/client.ts @@ -5,7 +5,7 @@ export type BrowserTransport = "cdp" | "chrome-mcp"; export type BrowserStatus = { enabled: boolean; profile?: string; - driver?: "openclaw" | "extension" | "existing-session"; + driver?: "openclaw" | "existing-session"; transport?: BrowserTransport; running: boolean; cdpReady?: boolean; @@ -31,7 +31,7 @@ export type ProfileStatus = { cdpPort: number | null; cdpUrl: string | null; color: string; - driver: "openclaw" | "extension" | "existing-session"; + driver: "openclaw" | "existing-session"; running: boolean; tabCount: number; isDefault: boolean; @@ -172,7 +172,7 @@ export async function browserCreateProfile( name: string; color?: string; cdpUrl?: string; - driver?: "openclaw" | "extension" | "existing-session"; + driver?: "openclaw" | "existing-session"; }, ): Promise { return await fetchBrowserJson( diff --git a/src/browser/config.test.ts b/src/browser/config.test.ts index 947cf10c0fa..7f80c4389a1 100644 --- a/src/browser/config.test.ts +++ b/src/browser/config.test.ts @@ -188,13 +188,6 @@ describe("browser config", () => { expect(profile?.cdpIsLoopback).toBe(true); }); - it("trims relayBindHost when configured", () => { - const resolved = resolveBrowserConfig({ - relayBindHost: " 0.0.0.0 ", - }); - expect(resolved.relayBindHost).toBe("0.0.0.0"); - }); - it("rejects unsupported protocols", () => { expect(() => resolveBrowserConfig({ cdpUrl: "ftp://127.0.0.1:18791" })).toThrow( "must be http(s) or ws(s)", @@ -289,7 +282,6 @@ describe("browser config", () => { const resolved = resolveBrowserConfig({ profiles: { "chrome-live": { driver: "existing-session", attachOnly: true, color: "#00AA00" }, - relay: { driver: "extension", cdpUrl: "http://127.0.0.1:18792", color: "#0066CC" }, work: { cdpPort: 18801, color: "#0066CC" }, }, }); @@ -300,9 +292,6 @@ describe("browser config", () => { const managed = resolveProfile(resolved, "openclaw")!; expect(getBrowserProfileCapabilities(managed).usesChromeMcp).toBe(false); - const extension = resolveProfile(resolved, "relay")!; - expect(getBrowserProfileCapabilities(extension).usesChromeMcp).toBe(false); - const work = resolveProfile(resolved, "work")!; expect(getBrowserProfileCapabilities(work).usesChromeMcp).toBe(false); }); diff --git a/src/browser/config.ts b/src/browser/config.ts index e535b926a96..64fffce865c 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -36,7 +36,6 @@ export type ResolvedBrowserConfig = { profiles: Record; ssrfPolicy?: SsrFPolicy; extraArgs: string[]; - relayBindHost?: string; }; export type ResolvedBrowserProfile = { @@ -46,7 +45,7 @@ export type ResolvedBrowserProfile = { cdpHost: string; cdpIsLoopback: boolean; color: string; - driver: "openclaw" | "extension" | "existing-session"; + driver: "openclaw" | "existing-session"; attachOnly: boolean; }; @@ -279,8 +278,6 @@ export function resolveBrowserConfig( ? cfg.extraArgs.filter((a): a is string => typeof a === "string" && a.trim().length > 0) : []; const ssrfPolicy = resolveBrowserSsrFPolicy(cfg); - const relayBindHost = cfg?.relayBindHost?.trim() || undefined; - return { enabled, evaluateEnabled, @@ -301,7 +298,6 @@ export function resolveBrowserConfig( profiles, ssrfPolicy, extraArgs, - relayBindHost, }; } @@ -322,12 +318,7 @@ export function resolveProfile( let cdpHost = resolved.cdpHost; let cdpPort = profile.cdpPort ?? 0; let cdpUrl = ""; - const driver = - profile.driver === "extension" - ? "extension" - : profile.driver === "existing-session" - ? "existing-session" - : "openclaw"; + const driver = profile.driver === "existing-session" ? "existing-session" : "openclaw"; if (driver === "existing-session") { // existing-session uses Chrome MCP auto-connect; no CDP port/URL needed diff --git a/src/browser/extension-relay-auth.secretref.test.ts b/src/browser/extension-relay-auth.secretref.test.ts deleted file mode 100644 index 7976064f35e..00000000000 --- a/src/browser/extension-relay-auth.secretref.test.ts +++ /dev/null @@ -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(); - - 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"); - }); -}); diff --git a/src/browser/extension-relay-auth.test.ts b/src/browser/extension-relay-auth.test.ts deleted file mode 100644 index c052e31a209..00000000000 --- a/src/browser/extension-relay-auth.test.ts +++ /dev/null @@ -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, -) { - const port = await getFreePort(); - const server = createServer(handler); - await new Promise((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((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 { - 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); - }, - ); - }); -}); diff --git a/src/browser/extension-relay-auth.ts b/src/browser/extension-relay-auth.ts deleted file mode 100644 index 7143a6c716e..00000000000 --- a/src/browser/extension-relay-auth.ts +++ /dev/null @@ -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 { - 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 { - 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 { - return (await resolveRelayAcceptedTokensForPort(port))[0]; -} - -export async function probeAuthenticatedOpenClawRelay(params: { - baseUrl: string; - relayAuthHeader: string; - relayAuthToken: string; - timeoutMs?: number; -}): Promise { - 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); - } -} diff --git a/src/browser/extension-relay.bind-host.test.ts b/src/browser/extension-relay.bind-host.test.ts deleted file mode 100644 index a029a2f1a95..00000000000 --- a/src/browser/extension-relay.bind-host.test.ts +++ /dev/null @@ -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; - - 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); - }); -}); diff --git a/src/browser/extension-relay.test.ts b/src/browser/extension-relay.test.ts deleted file mode 100644 index f6e14ee8803..00000000000 --- a/src/browser/extension-relay.test.ts +++ /dev/null @@ -1,1224 +0,0 @@ -import { createServer } from "node:http"; -import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; -import WebSocket from "ws"; -import { captureEnv } from "../test-utils/env.js"; -import { - ensureChromeExtensionRelayServer, - getChromeExtensionRelayAuthHeaders, - stopChromeExtensionRelayServer, -} from "./extension-relay.js"; -import { getFreePort } from "./test-port.js"; - -const RELAY_MESSAGE_TIMEOUT_MS = 1_200; -const RELAY_LIST_MATCH_TIMEOUT_MS = 1_000; -const RELAY_TEST_TIMEOUT_MS = 10_000; - -function waitForOpen(ws: WebSocket) { - return new Promise((resolve, reject) => { - ws.once("open", () => resolve()); - ws.once("error", reject); - }); -} - -function waitForError(ws: WebSocket) { - return new Promise((resolve, reject) => { - ws.once("error", (err) => resolve(err instanceof Error ? err : new Error(String(err)))); - ws.once("open", () => reject(new Error("expected websocket error"))); - }); -} - -function waitForClose(ws: WebSocket, timeoutMs = RELAY_MESSAGE_TIMEOUT_MS) { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error("timeout")); - }, timeoutMs); - ws.once("close", () => { - clearTimeout(timer); - resolve(); - }); - ws.once("error", (err) => { - clearTimeout(timer); - reject(err instanceof Error ? err : new Error(String(err))); - }); - }); -} - -function relayAuthHeaders(url: string) { - return getChromeExtensionRelayAuthHeaders(url); -} - -function createMessageQueue(ws: WebSocket) { - const queue: string[] = []; - let waiter: ((value: string) => void) | null = null; - let waiterReject: ((err: Error) => void) | null = null; - let waiterTimer: NodeJS.Timeout | null = null; - - const flushWaiter = (value: string) => { - if (!waiter) { - return false; - } - const resolve = waiter; - waiter = null; - const reject = waiterReject; - waiterReject = null; - if (waiterTimer) { - clearTimeout(waiterTimer); - } - waiterTimer = null; - if (reject) { - // no-op (kept for symmetry) - } - resolve(value); - return true; - }; - - ws.on("message", (data) => { - const text = - typeof data === "string" - ? data - : Buffer.isBuffer(data) - ? data.toString("utf8") - : Array.isArray(data) - ? Buffer.concat(data).toString("utf8") - : Buffer.from(data).toString("utf8"); - if (flushWaiter(text)) { - return; - } - queue.push(text); - }); - - ws.on("error", (err) => { - if (!waiterReject) { - return; - } - const reject = waiterReject; - waiterReject = null; - waiter = null; - if (waiterTimer) { - clearTimeout(waiterTimer); - } - waiterTimer = null; - reject(err instanceof Error ? err : new Error(String(err))); - }); - - const next = (timeoutMs = RELAY_MESSAGE_TIMEOUT_MS) => - new Promise((resolve, reject) => { - const existing = queue.shift(); - if (existing !== undefined) { - return resolve(existing); - } - waiter = resolve; - waiterReject = reject; - waiterTimer = setTimeout(() => { - waiter = null; - waiterReject = null; - waiterTimer = null; - reject(new Error("timeout")); - }, timeoutMs); - }); - - return { next }; -} - -async function waitForListMatch( - fetchList: () => Promise, - predicate: (value: T) => boolean, - timeoutMs = RELAY_LIST_MATCH_TIMEOUT_MS, - intervalMs = 20, -): Promise { - const deadline = Date.now() + timeoutMs; - let latest: T | null = null; - while (Date.now() <= deadline) { - latest = await fetchList(); - if (predicate(latest)) { - return latest; - } - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - throw new Error("timeout waiting for list match"); -} - -describe("chrome extension relay server", () => { - const TEST_GATEWAY_TOKEN = "test-gateway-token"; - let cdpUrl = ""; - let sharedCdpUrl = ""; - let envSnapshot: ReturnType; - - beforeEach(() => { - envSnapshot = captureEnv([ - "OPENCLAW_GATEWAY_TOKEN", - "OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS", - "OPENCLAW_EXTENSION_RELAY_COMMAND_RECONNECT_WAIT_MS", - ]); - process.env.OPENCLAW_GATEWAY_TOKEN = TEST_GATEWAY_TOKEN; - delete process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS; - delete process.env.OPENCLAW_EXTENSION_RELAY_COMMAND_RECONNECT_WAIT_MS; - }); - - afterEach(async () => { - if (cdpUrl) { - await stopChromeExtensionRelayServer({ cdpUrl }).catch(() => {}); - cdpUrl = ""; - } - envSnapshot.restore(); - }); - - afterAll(async () => { - if (!sharedCdpUrl) { - return; - } - await stopChromeExtensionRelayServer({ cdpUrl: sharedCdpUrl }).catch(() => {}); - sharedCdpUrl = ""; - }); - - async function ensureSharedRelayServer() { - if (sharedCdpUrl) { - return sharedCdpUrl; - } - const port = await getFreePort(); - sharedCdpUrl = `http://127.0.0.1:${port}`; - await ensureChromeExtensionRelayServer({ cdpUrl: sharedCdpUrl }); - return sharedCdpUrl; - } - - async function startRelayWithExtension() { - const port = await getFreePort(); - cdpUrl = `http://127.0.0.1:${port}`; - await ensureChromeExtensionRelayServer({ cdpUrl }); - const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), - }); - await waitForOpen(ext); - return { port, ext }; - } - - it("advertises CDP WS only when extension is connected", async () => { - const port = await getFreePort(); - cdpUrl = `http://127.0.0.1:${port}`; - await ensureChromeExtensionRelayServer({ cdpUrl }); - - const v1 = (await fetch(`${cdpUrl}/json/version`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as { - webSocketDebuggerUrl?: string; - }; - expect(v1.webSocketDebuggerUrl).toBeUndefined(); - - const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), - }); - await waitForOpen(ext); - - const v2 = (await fetch(`${cdpUrl}/json/version`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as { - webSocketDebuggerUrl?: string; - }; - expect(String(v2.webSocketDebuggerUrl ?? "")).toContain(`/cdp`); - - ext.close(); - }); - - it("uses relay-scoped token only for known relay ports", async () => { - const port = await getFreePort(); - const unknown = getChromeExtensionRelayAuthHeaders(`http://127.0.0.1:${port}`); - expect(unknown).toEqual({}); - - const sharedUrl = await ensureSharedRelayServer(); - - const headers = getChromeExtensionRelayAuthHeaders(sharedUrl); - expect(Object.keys(headers)).toContain("x-openclaw-relay-token"); - expect(headers["x-openclaw-relay-token"]).not.toBe(TEST_GATEWAY_TOKEN); - }); - - it("rejects CDP access without relay auth token", async () => { - const sharedUrl = await ensureSharedRelayServer(); - const sharedPort = new URL(sharedUrl).port; - - const res = await fetch(`${sharedUrl}/json/version`); - expect(res.status).toBe(401); - - const cdp = new WebSocket(`ws://127.0.0.1:${sharedPort}/cdp`); - const err = await waitForError(cdp); - expect(err.message).toContain("401"); - }); - - it("returns 400 for malformed percent-encoding in target action routes", async () => { - const sharedUrl = await ensureSharedRelayServer(); - - const res = await fetch(`${sharedUrl}/json/activate/%E0%A4%A`, { - headers: relayAuthHeaders(sharedUrl), - }); - expect(res.status).toBe(400); - expect(await res.text()).toContain("invalid targetId encoding"); - }); - - it("deduplicates concurrent relay starts for the same requested port", async () => { - const sharedUrl = await ensureSharedRelayServer(); - const port = Number(new URL(sharedUrl).port); - const [first, second] = await Promise.all([ - ensureChromeExtensionRelayServer({ cdpUrl: sharedUrl }), - ensureChromeExtensionRelayServer({ cdpUrl: sharedUrl }), - ]); - expect(first).toBe(second); - expect(first.port).toBe(port); - }); - - it("allows CORS preflight from chrome-extension origins", async () => { - const sharedUrl = await ensureSharedRelayServer(); - - const origin = "chrome-extension://abcdefghijklmnop"; - const res = await fetch(`${sharedUrl}/json/version`, { - method: "OPTIONS", - headers: { - Origin: origin, - "Access-Control-Request-Method": "GET", - "Access-Control-Request-Headers": "x-openclaw-relay-token", - }, - }); - - expect(res.status).toBe(204); - expect(res.headers.get("access-control-allow-origin")).toBe(origin); - expect(res.headers.get("access-control-allow-headers") ?? "").toContain( - "x-openclaw-relay-token", - ); - }); - - it("rejects CORS preflight from non-extension origins", async () => { - const sharedUrl = await ensureSharedRelayServer(); - - const res = await fetch(`${sharedUrl}/json/version`, { - method: "OPTIONS", - headers: { - Origin: "https://example.com", - "Access-Control-Request-Method": "GET", - }, - }); - - expect(res.status).toBe(403); - }); - - it("returns CORS headers on JSON responses for extension origins", async () => { - const sharedUrl = await ensureSharedRelayServer(); - - const origin = "chrome-extension://abcdefghijklmnop"; - const res = await fetch(`${sharedUrl}/json/version`, { - headers: { - Origin: origin, - ...relayAuthHeaders(sharedUrl), - }, - }); - - expect(res.status).toBe(200); - expect(res.headers.get("access-control-allow-origin")).toBe(origin); - }); - - it("rejects extension websocket access without relay auth token", async () => { - const sharedUrl = await ensureSharedRelayServer(); - const sharedPort = new URL(sharedUrl).port; - - const ext = new WebSocket(`ws://127.0.0.1:${sharedPort}/extension`); - const err = await waitForError(ext); - expect(err.message).toContain("401"); - }); - - it("rejects a second live extension connection with 409", async () => { - const port = await getFreePort(); - cdpUrl = `http://127.0.0.1:${port}`; - await ensureChromeExtensionRelayServer({ cdpUrl }); - - const ext1 = new WebSocket(`ws://127.0.0.1:${port}/extension`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), - }); - await waitForOpen(ext1); - - const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), - }); - const err = await waitForError(ext2); - expect(err.message).toContain("409"); - - ext1.close(); - }); - - it("allows immediate reconnect when prior extension socket is closing", async () => { - const port = await getFreePort(); - cdpUrl = `http://127.0.0.1:${port}`; - await ensureChromeExtensionRelayServer({ cdpUrl }); - - const ext1 = new WebSocket(`ws://127.0.0.1:${port}/extension`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), - }); - await waitForOpen(ext1); - const ext1Closed = new Promise((resolve) => ext1.once("close", () => resolve())); - - ext1.close(); - const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), - }); - await waitForOpen(ext2); - await ext1Closed; - - const status = (await fetch(`${cdpUrl}/extension/status`).then((r) => r.json())) as { - connected?: boolean; - }; - expect(status.connected).toBe(true); - - ext2.close(); - }); - - it("keeps CDP clients alive across a brief extension reconnect", async () => { - const { port, ext: ext1 } = await startRelayWithExtension(); - const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`), - }); - await waitForOpen(cdp); - - let cdpClosed = false; - cdp.once("close", () => { - cdpClosed = true; - }); - - const ext1Closed = waitForClose(ext1, 2_000); - ext1.close(); - await ext1Closed; - const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), - }); - await waitForOpen(ext2); - expect(cdpClosed).toBe(false); - - cdp.close(); - ext2.close(); - }); - - it("keeps /json/version websocket endpoint during short extension disconnects", async () => { - const { port, ext } = await startRelayWithExtension(); - ext.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.attachedToTarget", - params: { - sessionId: "cb-tab-disconnect", - targetInfo: { - targetId: "t-disconnect", - type: "page", - title: "Disconnect test", - url: "https://example.com", - }, - waitingForDebugger: false, - }, - }, - }), - ); - - await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string }>, - (list) => list.some((entry) => entry.id === "t-disconnect"), - ); - - const extClosed = waitForClose(ext, 2_000); - ext.close(); - await extClosed; - - const version = (await fetch(`${cdpUrl}/json/version`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as { - webSocketDebuggerUrl?: string; - }; - expect(String(version.webSocketDebuggerUrl ?? "")).toContain("/cdp"); - - const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`), - }); - await waitForOpen(cdp); - cdp.close(); - }); - - it("accepts re-announce attach events with minimal targetInfo", async () => { - const { ext } = await startRelayWithExtension(); - ext.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.attachedToTarget", - params: { - sessionId: "cb-tab-minimal", - targetInfo: { - targetId: "t-minimal", - }, - waitingForDebugger: false, - }, - }, - }), - ); - - await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string }>, - (entries) => entries.some((entry) => entry.id === "t-minimal"), - ); - }); - - it("waits briefly for extension reconnect before failing CDP commands", async () => { - const { port, ext: ext1 } = await startRelayWithExtension(); - const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`), - }); - await waitForOpen(cdp); - const cdpQueue = createMessageQueue(cdp); - - const ext1Closed = waitForClose(ext1, 2_000); - ext1.close(); - await ext1Closed; - - cdp.send(JSON.stringify({ id: 41, method: "Runtime.enable" })); - await new Promise((r) => setTimeout(r, 30)); - - const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), - }); - const ext2Queue = createMessageQueue(ext2); - await waitForOpen(ext2); - - while (true) { - const msg = JSON.parse(await ext2Queue.next(4_000)) as { - id?: number; - method?: string; - }; - if (msg.method === "ping") { - ext2.send(JSON.stringify({ method: "pong" })); - continue; - } - if (msg.method === "forwardCDPCommand" && typeof msg.id === "number") { - ext2.send(JSON.stringify({ id: msg.id, result: { ok: true } })); - break; - } - } - - const response = JSON.parse(await cdpQueue.next(6_000)) as { - id?: number; - result?: { ok?: boolean }; - error?: { message?: string }; - }; - expect(response.id).toBe(41); - expect(response.error).toBeUndefined(); - expect(response.result?.ok).toBe(true); - - cdp.close(); - ext2.close(); - }); - - it("closes CDP clients after reconnect grace when extension stays disconnected", async () => { - process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS = "150"; - - const { port, ext } = await startRelayWithExtension(); - const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`), - }); - await waitForOpen(cdp); - - ext.close(); - await waitForClose(cdp, 2_000); - }); - - it("stops advertising websocket endpoint after reconnect grace expires", async () => { - process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS = "120"; - - const { ext } = await startRelayWithExtension(); - ext.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.attachedToTarget", - params: { - sessionId: "cb-tab-grace-expire", - targetInfo: { - targetId: "t-grace-expire", - type: "page", - title: "Grace expire", - url: "https://example.com", - }, - waitingForDebugger: false, - }, - }, - }), - ); - - await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string }>, - (list) => list.some((entry) => entry.id === "t-grace-expire"), - ); - - ext.close(); - await expect - .poll( - async () => { - const version = (await fetch(`${cdpUrl}/json/version`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as { webSocketDebuggerUrl?: string }; - return version.webSocketDebuggerUrl === undefined; - }, - { timeout: 800, interval: 20 }, - ) - .toBe(true); - }); - - it("accepts extension websocket access with relay token query param", async () => { - const sharedUrl = await ensureSharedRelayServer(); - const sharedPort = new URL(sharedUrl).port; - - const token = relayAuthHeaders(`ws://127.0.0.1:${sharedPort}/extension`)[ - "x-openclaw-relay-token" - ]; - expect(token).toBeTruthy(); - const ext = new WebSocket( - `ws://127.0.0.1:${sharedPort}/extension?token=${encodeURIComponent(String(token))}`, - ); - await waitForOpen(ext); - ext.close(); - }); - - it("accepts /json endpoints with relay token query param", async () => { - const sharedUrl = await ensureSharedRelayServer(); - - const token = relayAuthHeaders(sharedUrl)["x-openclaw-relay-token"]; - expect(token).toBeTruthy(); - const versionRes = await fetch( - `${sharedUrl}/json/version?token=${encodeURIComponent(String(token))}`, - ); - expect(versionRes.status).toBe(200); - }); - - it("accepts raw gateway token for relay auth compatibility", async () => { - const sharedUrl = await ensureSharedRelayServer(); - const sharedPort = new URL(sharedUrl).port; - - const versionRes = await fetch(`${sharedUrl}/json/version`, { - headers: { "x-openclaw-relay-token": TEST_GATEWAY_TOKEN }, - }); - expect(versionRes.status).toBe(200); - - const ext = new WebSocket( - `ws://127.0.0.1:${sharedPort}/extension?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`, - ); - await waitForOpen(ext); - ext.close(); - }); - - it( - "tracks attached page targets and exposes them via CDP + /json/list", - async () => { - const { port, ext } = await startRelayWithExtension(); - - // Simulate a tab attach coming from the extension. - ext.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.attachedToTarget", - params: { - sessionId: "cb-tab-1", - targetInfo: { - targetId: "t1", - type: "page", - title: "Example", - url: "https://example.com", - }, - waitingForDebugger: false, - }, - }, - }), - ); - - const list = (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ - id?: string; - url?: string; - title?: string; - }>; - expect(list.some((t) => t.id === "t1" && t.url === "https://example.com")).toBe(true); - - // Simulate navigation updating tab metadata. - ext.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.targetInfoChanged", - params: { - targetInfo: { - targetId: "t1", - type: "page", - title: "DER STANDARD", - url: "https://www.derstandard.at/", - }, - }, - }, - }), - ); - - await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ - id?: string; - url?: string; - title?: string; - }>, - (list) => - list.some( - (t) => - t.id === "t1" && - t.url === "https://www.derstandard.at/" && - t.title === "DER STANDARD", - ), - ); - - const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`), - }); - await waitForOpen(cdp); - const q = createMessageQueue(cdp); - - cdp.send(JSON.stringify({ id: 1, method: "Target.getTargets" })); - const res1 = JSON.parse(await q.next()) as { id: number; result?: unknown }; - expect(res1.id).toBe(1); - const targetInfos = ( - res1.result as { targetInfos?: Array<{ targetId?: string }> } | undefined - )?.targetInfos; - expect((targetInfos ?? []).some((target) => target.targetId === "t1")).toBe(true); - - cdp.send( - JSON.stringify({ - id: 2, - method: "Target.attachToTarget", - params: { targetId: "t1" }, - }), - ); - const received: Array<{ - id?: number; - method?: string; - result?: unknown; - params?: unknown; - }> = []; - received.push(JSON.parse(await q.next()) as never); - received.push(JSON.parse(await q.next()) as never); - - const res2 = received.find((m) => m.id === 2); - expect(res2?.id).toBe(2); - expect((res2?.result as { sessionId?: string } | undefined)?.sessionId).toBe("cb-tab-1"); - - const evt = received.find((m) => m.method === "Target.attachedToTarget"); - expect(evt?.method).toBe("Target.attachedToTarget"); - expect( - (evt?.params as { targetInfo?: { targetId?: string } } | undefined)?.targetInfo?.targetId, - ).toBe("t1"); - - cdp.close(); - ext.close(); - }, - RELAY_TEST_TIMEOUT_MS, - ); - - it("removes cached targets from /json/list when targetDestroyed arrives", async () => { - const { ext } = await startRelayWithExtension(); - - ext.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.attachedToTarget", - params: { - sessionId: "cb-tab-1", - targetInfo: { - targetId: "t1", - type: "page", - title: "Example", - url: "https://example.com", - }, - waitingForDebugger: false, - }, - }, - }), - ); - - await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string }>, - (list) => list.some((target) => target.id === "t1"), - ); - - ext.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.targetDestroyed", - params: { targetId: "t1" }, - }, - }), - ); - - await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string }>, - (list) => list.every((target) => target.id !== "t1"), - ); - ext.close(); - }); - - it("prunes stale cached targets after target-not-found command errors", async () => { - const { port, ext } = await startRelayWithExtension(); - const extQueue = createMessageQueue(ext); - - ext.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.attachedToTarget", - params: { - sessionId: "cb-tab-1", - targetInfo: { - targetId: "t1", - type: "page", - title: "Example", - url: "https://example.com", - }, - waitingForDebugger: false, - }, - }, - }), - ); - - await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string }>, - (list) => list.some((target) => target.id === "t1"), - ); - - const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`), - }); - await waitForOpen(cdp); - const cdpQueue = createMessageQueue(cdp); - - cdp.send( - JSON.stringify({ - id: 77, - method: "Runtime.evaluate", - sessionId: "cb-tab-1", - params: { expression: "1+1" }, - }), - ); - - let forwardedId: number | null = null; - for (let attempt = 0; attempt < 6; attempt++) { - const msg = JSON.parse(await extQueue.next()) as { method?: string; id?: number }; - if (msg.method === "forwardCDPCommand" && typeof msg.id === "number") { - forwardedId = msg.id; - break; - } - } - expect(forwardedId).not.toBeNull(); - - ext.send( - JSON.stringify({ - id: forwardedId, - error: "No target with given id", - }), - ); - - let response: { id?: number; error?: { message?: string } } | null = null; - for (let attempt = 0; attempt < 6; attempt++) { - const msg = JSON.parse(await cdpQueue.next()) as { - id?: number; - error?: { message?: string }; - }; - if (msg.id === 77) { - response = msg; - break; - } - } - expect(response?.id).toBe(77); - expect(response?.error?.message ?? "").toContain("No target with given id"); - - await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string }>, - (list) => list.every((target) => target.id !== "t1"), - ); - - cdp.close(); - ext.close(); - }); - - it("rebroadcasts attach when a session id is reused for a new target", async () => { - const { port, ext } = await startRelayWithExtension(); - - const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`), - }); - await waitForOpen(cdp); - const q = createMessageQueue(cdp); - - ext.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.attachedToTarget", - params: { - sessionId: "shared-session", - targetInfo: { - targetId: "t1", - type: "page", - title: "First", - url: "https://example.com", - }, - waitingForDebugger: false, - }, - }, - }), - ); - - const first = JSON.parse(await q.next()) as { method?: string; params?: unknown }; - expect(first.method).toBe("Target.attachedToTarget"); - expect( - (first.params as { targetInfo?: { targetId?: string } } | undefined)?.targetInfo?.targetId, - ).toBe("t1"); - - ext.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.attachedToTarget", - params: { - sessionId: "shared-session", - targetInfo: { - targetId: "t2", - type: "page", - title: "Second", - url: "https://example.org", - }, - waitingForDebugger: false, - }, - }, - }), - ); - - const received: Array<{ method?: string; params?: unknown }> = []; - received.push(JSON.parse(await q.next()) as never); - received.push(JSON.parse(await q.next()) as never); - - const detached = received.find((m) => m.method === "Target.detachedFromTarget"); - const attached = received.find((m) => m.method === "Target.attachedToTarget"); - expect((detached?.params as { targetId?: string } | undefined)?.targetId).toBe("t1"); - expect( - (attached?.params as { targetInfo?: { targetId?: string } } | undefined)?.targetInfo - ?.targetId, - ).toBe("t2"); - - cdp.close(); - ext.close(); - }); - - it("reuses an already-bound relay port when another process owns it", async () => { - const port = await getFreePort(); - let probeToken: string | undefined; - const fakeRelay = createServer((req, res) => { - if (req.url?.startsWith("/json/version")) { - const header = req.headers["x-openclaw-relay-token"]; - probeToken = Array.isArray(header) ? header[0] : header; - if (!probeToken) { - res.writeHead(401); - res.end("Unauthorized"); - return; - } - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ Browser: "OpenClaw/extension-relay" })); - return; - } - if (req.url?.startsWith("/extension/status")) { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ connected: false })); - return; - } - res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); - res.end("OK"); - }); - await new Promise((resolve, reject) => { - fakeRelay.listen(port, "127.0.0.1", () => resolve()); - fakeRelay.once("error", reject); - }); - - try { - cdpUrl = `http://127.0.0.1:${port}`; - const relay = await ensureChromeExtensionRelayServer({ cdpUrl }); - expect(relay.port).toBe(port); - const status = (await fetch(`${cdpUrl}/extension/status`).then((r) => r.json())) as { - connected?: boolean; - }; - expect(status.connected).toBe(false); - expect(probeToken).toBeTruthy(); - expect(probeToken).not.toBe("test-gateway-token"); - } finally { - await new Promise((resolve) => fakeRelay.close(() => resolve())); - } - }); - - it( - "restores tabs after extension reconnects and re-announces", - async () => { - process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS = "200"; - - const { port, ext: ext1 } = await startRelayWithExtension(); - - ext1.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.attachedToTarget", - params: { - sessionId: "cb-tab-10", - targetInfo: { - targetId: "t10", - type: "page", - title: "My Page", - url: "https://example.com", - }, - waitingForDebugger: false, - }, - }, - }), - ); - - await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string }>, - (list) => list.some((t) => t.id === "t10"), - ); - - // Disconnect extension and wait for grace period cleanup. - const ext1Closed = waitForClose(ext1, 2_000); - ext1.close(); - await ext1Closed; - await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string }>, - (list) => list.length === 0, - ); - - // Reconnect and re-announce the same tab (simulates reannounceAttachedTabs). - const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), - }); - await waitForOpen(ext2); - - ext2.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.attachedToTarget", - params: { - sessionId: "cb-tab-10", - targetInfo: { - targetId: "t10", - type: "page", - title: "My Page", - url: "https://example.com", - }, - waitingForDebugger: false, - }, - }, - }), - ); - - const list2 = await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string; title?: string }>, - (list) => list.some((t) => t.id === "t10"), - ); - expect(list2.some((t) => t.id === "t10" && t.title === "My Page")).toBe(true); - - ext2.close(); - }, - RELAY_TEST_TIMEOUT_MS, - ); - - it( - "preserves tab across a fast extension reconnect within grace period", - async () => { - process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS = "2000"; - - const { port, ext: ext1 } = await startRelayWithExtension(); - - ext1.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.attachedToTarget", - params: { - sessionId: "cb-tab-20", - targetInfo: { - targetId: "t20", - type: "page", - title: "Persistent", - url: "https://example.org", - }, - waitingForDebugger: false, - }, - }, - }), - ); - - await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string }>, - (list) => list.some((t) => t.id === "t20"), - ); - - // Disconnect briefly (within grace period). - const ext1Closed = waitForClose(ext1, 2_000); - ext1.close(); - await ext1Closed; - - // Tab should still be listed during grace period. - const listDuringGrace = (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string }>; - expect(listDuringGrace.some((t) => t.id === "t20")).toBe(true); - - // Reconnect within grace and re-announce with updated info. - const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), - }); - await waitForOpen(ext2); - - ext2.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.attachedToTarget", - params: { - sessionId: "cb-tab-20", - targetInfo: { - targetId: "t20", - type: "page", - title: "Persistent Updated", - url: "https://example.org/new", - }, - waitingForDebugger: false, - }, - }, - }), - ); - - const list2 = await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string; title?: string; url?: string }>, - (list) => list.some((t) => t.id === "t20" && t.title === "Persistent Updated"), - ); - expect(list2.some((t) => t.id === "t20" && t.url === "https://example.org/new")).toBe(true); - - ext2.close(); - }, - RELAY_TEST_TIMEOUT_MS, - ); - - it("does not swallow EADDRINUSE when occupied port is not an openclaw relay", async () => { - const port = await getFreePort(); - const blocker = createServer((_, res) => { - res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); - res.end("not-relay"); - }); - await new Promise((resolve, reject) => { - blocker.listen(port, "127.0.0.1", () => resolve()); - blocker.once("error", reject); - }); - const blockedUrl = `http://127.0.0.1:${port}`; - await expect(ensureChromeExtensionRelayServer({ cdpUrl: blockedUrl })).rejects.toThrow( - /EADDRINUSE/i, - ); - await new Promise((resolve) => blocker.close(() => resolve())); - }); - - it( - "respects bindHost override to bind on a non-loopback address", - async () => { - const port = await getFreePort(); - cdpUrl = `http://127.0.0.1:${port}`; - const relay = await ensureChromeExtensionRelayServer({ - cdpUrl, - bindHost: "0.0.0.0", - }); - expect(relay.port).toBe(port); - // Verify the server actually bound to 0.0.0.0, not the cdpUrl host. - expect(relay.bindHost).toBe("0.0.0.0"); - - const res = await fetch(`http://127.0.0.1:${port}/`); - expect(res.status).toBe(200); - }, - RELAY_TEST_TIMEOUT_MS, - ); - - it( - "defaults bindHost to cdpUrl host when not specified", - async () => { - const port = await getFreePort(); - cdpUrl = `http://127.0.0.1:${port}`; - const relay = await ensureChromeExtensionRelayServer({ cdpUrl }); - expect(relay.host).toBe("127.0.0.1"); - expect(relay.bindHost).toBe("127.0.0.1"); - - const res = await fetch(`http://127.0.0.1:${port}/`); - expect(res.status).toBe(200); - }, - RELAY_TEST_TIMEOUT_MS, - ); - - it( - "restarts the relay when bindHost changes for the same port", - async () => { - const port = await getFreePort(); - cdpUrl = `http://127.0.0.1:${port}`; - - const initial = await ensureChromeExtensionRelayServer({ cdpUrl }); - expect(initial.bindHost).toBe("127.0.0.1"); - - const rebound = await ensureChromeExtensionRelayServer({ - cdpUrl, - bindHost: "0.0.0.0", - }); - expect(rebound.bindHost).toBe("0.0.0.0"); - expect(rebound.port).toBe(port); - }, - RELAY_TEST_TIMEOUT_MS, - ); -}); diff --git a/src/browser/extension-relay.ts b/src/browser/extension-relay.ts deleted file mode 100644 index 5a87670605e..00000000000 --- a/src/browser/extension-relay.ts +++ /dev/null @@ -1,1068 +0,0 @@ -import type { IncomingMessage } from "node:http"; -import { createServer } from "node:http"; -import type { AddressInfo } from "node:net"; -import type { Duplex } from "node:stream"; -import WebSocket, { WebSocketServer } from "ws"; -import { isLoopbackAddress, isLoopbackHost } from "../gateway/net.js"; -import { rawDataToString } from "../infra/ws.js"; -import { - probeAuthenticatedOpenClawRelay, - resolveRelayAcceptedTokensForPort, - resolveRelayAuthTokenForPort, -} from "./extension-relay-auth.js"; - -type CdpCommand = { - id: number; - method: string; - params?: unknown; - sessionId?: string; -}; - -type CdpResponse = { - id: number; - result?: unknown; - error?: { message: string }; - sessionId?: string; -}; - -type CdpEvent = { - method: string; - params?: unknown; - sessionId?: string; -}; - -type ExtensionForwardCommandMessage = { - id: number; - method: "forwardCDPCommand"; - params: { method: string; params?: unknown; sessionId?: string }; -}; - -type ExtensionResponseMessage = { - id: number; - result?: unknown; - error?: string; -}; - -type ExtensionForwardEventMessage = { - method: "forwardCDPEvent"; - params: { method: string; params?: unknown; sessionId?: string }; -}; - -type ExtensionPingMessage = { method: "ping" }; -type ExtensionPongMessage = { method: "pong" }; - -type ExtensionMessage = - | ExtensionResponseMessage - | ExtensionForwardEventMessage - | ExtensionPongMessage; - -type TargetInfo = { - targetId: string; - type?: string; - title?: string; - url?: string; - attached?: boolean; -}; - -type AttachedToTargetEvent = { - sessionId: string; - targetInfo: TargetInfo; - waitingForDebugger?: boolean; -}; - -type DetachedFromTargetEvent = { - sessionId: string; - targetId?: string; -}; - -type ConnectedTarget = { - sessionId: string; - targetId: string; - targetInfo: TargetInfo; -}; - -const RELAY_AUTH_HEADER = "x-openclaw-relay-token"; -const DEFAULT_EXTENSION_RECONNECT_GRACE_MS = 20_000; -const DEFAULT_EXTENSION_COMMAND_RECONNECT_WAIT_MS = 3_000; - -function headerValue(value: string | string[] | undefined): string | undefined { - if (!value) { - return undefined; - } - if (Array.isArray(value)) { - return value[0]; - } - return value; -} - -function getHeader(req: IncomingMessage, name: string): string | undefined { - return headerValue(req.headers[name.toLowerCase()]); -} - -function getRelayAuthTokenFromRequest(req: IncomingMessage, url?: URL): string | undefined { - const headerToken = getHeader(req, RELAY_AUTH_HEADER)?.trim(); - if (headerToken) { - return headerToken; - } - const queryToken = url?.searchParams.get("token")?.trim(); - if (queryToken) { - return queryToken; - } - return undefined; -} - -export type ChromeExtensionRelayServer = { - host: string; - bindHost: string; - port: number; - baseUrl: string; - cdpWsUrl: string; - extensionConnected: () => boolean; - stop: () => Promise; -}; - -type RelayRuntime = { - server: ChromeExtensionRelayServer; - relayAuthToken: string; -}; - -function parseUrlPort(parsed: URL): number | null { - const port = - parsed.port?.trim() !== "" ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80; - if (!Number.isFinite(port) || port <= 0 || port > 65535) { - return null; - } - return port; -} - -function parseBaseUrl(raw: string): { - host: string; - port: number; - baseUrl: string; -} { - const parsed = new URL(raw.trim().replace(/\/$/, "")); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error(`extension relay cdpUrl must be http(s), got ${parsed.protocol}`); - } - const host = parsed.hostname; - const port = parseUrlPort(parsed); - if (!port) { - throw new Error(`extension relay cdpUrl has invalid port: ${parsed.port || "(empty)"}`); - } - return { host, port, baseUrl: parsed.toString().replace(/\/$/, "") }; -} - -function text(res: Duplex, status: number, bodyText: string) { - const body = Buffer.from(bodyText); - res.write( - `HTTP/1.1 ${status} ${status === 200 ? "OK" : "ERR"}\r\n` + - "Content-Type: text/plain; charset=utf-8\r\n" + - `Content-Length: ${body.length}\r\n` + - "Connection: close\r\n" + - "\r\n", - ); - res.write(body); - res.end(); -} - -function rejectUpgrade(socket: Duplex, status: number, bodyText: string) { - text(socket, status, bodyText); - try { - socket.destroy(); - } catch { - // ignore - } -} - -function envMsOrDefault(name: string, fallback: number): number { - const raw = process.env[name]; - if (!raw || raw.trim() === "") { - return fallback; - } - const parsed = Number.parseInt(raw, 10); - if (!Number.isFinite(parsed) || parsed <= 0) { - return fallback; - } - return parsed; -} - -const relayRuntimeByPort = new Map(); -const relayInitByPort = new Map>(); - -function isAddrInUseError(err: unknown): boolean { - return ( - typeof err === "object" && - err !== null && - "code" in err && - (err as { code?: unknown }).code === "EADDRINUSE" - ); -} - -function relayAuthTokenForUrl(url: string): string | null { - try { - const parsed = new URL(url); - if (!isLoopbackHost(parsed.hostname)) { - return null; - } - const port = parseUrlPort(parsed); - if (!port) { - return null; - } - return relayRuntimeByPort.get(port)?.relayAuthToken ?? null; - } catch { - return null; - } -} - -export function getChromeExtensionRelayAuthHeaders(url: string): Record { - const token = relayAuthTokenForUrl(url); - if (!token) { - return {}; - } - return { [RELAY_AUTH_HEADER]: token }; -} - -export async function ensureChromeExtensionRelayServer(opts: { - cdpUrl: string; - bindHost?: string; -}): Promise { - const info = parseBaseUrl(opts.cdpUrl); - if (!isLoopbackHost(info.host)) { - throw new Error(`extension relay requires loopback cdpUrl host (got ${info.host})`); - } - const bindHost = opts.bindHost ?? info.host; - - const existing = relayRuntimeByPort.get(info.port); - if (existing) { - if (existing.server.bindHost !== bindHost) { - await existing.server.stop(); - } else { - return existing.server; - } - } - - const inFlight = relayInitByPort.get(info.port); - if (inFlight) { - const server = await inFlight; - if (server.bindHost === bindHost) { - return server; - } - await server.stop(); - } - - const extensionReconnectGraceMs = envMsOrDefault( - "OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS", - DEFAULT_EXTENSION_RECONNECT_GRACE_MS, - ); - const extensionCommandReconnectWaitMs = envMsOrDefault( - "OPENCLAW_EXTENSION_RELAY_COMMAND_RECONNECT_WAIT_MS", - DEFAULT_EXTENSION_COMMAND_RECONNECT_WAIT_MS, - ); - - const initPromise = (async (): Promise => { - const relayAuthToken = await resolveRelayAuthTokenForPort(info.port); - const relayAuthTokens = new Set(await resolveRelayAcceptedTokensForPort(info.port)); - - let extensionWs: WebSocket | null = null; - const cdpClients = new Set(); - const connectedTargets = new Map(); - const extensionConnected = () => extensionWs?.readyState === WebSocket.OPEN; - const hasConnectedTargets = () => connectedTargets.size > 0; - let extensionDisconnectCleanupTimer: NodeJS.Timeout | null = null; - const extensionReconnectWaiters = new Set<(connected: boolean) => void>(); - - const flushExtensionReconnectWaiters = (connected: boolean) => { - if (extensionReconnectWaiters.size === 0) { - return; - } - const waiters = Array.from(extensionReconnectWaiters); - extensionReconnectWaiters.clear(); - for (const waiter of waiters) { - waiter(connected); - } - }; - - const clearExtensionDisconnectCleanupTimer = () => { - if (!extensionDisconnectCleanupTimer) { - return; - } - clearTimeout(extensionDisconnectCleanupTimer); - extensionDisconnectCleanupTimer = null; - }; - - const closeCdpClientsAfterExtensionDisconnect = () => { - connectedTargets.clear(); - for (const client of cdpClients) { - try { - client.close(1011, "extension disconnected"); - } catch { - // ignore - } - } - cdpClients.clear(); - flushExtensionReconnectWaiters(false); - }; - - const scheduleExtensionDisconnectCleanup = () => { - clearExtensionDisconnectCleanupTimer(); - extensionDisconnectCleanupTimer = setTimeout(() => { - extensionDisconnectCleanupTimer = null; - if (extensionConnected()) { - return; - } - closeCdpClientsAfterExtensionDisconnect(); - }, extensionReconnectGraceMs); - }; - - const waitForExtensionReconnect = async (timeoutMs: number): Promise => { - if (extensionConnected()) { - return true; - } - return await new Promise((resolve) => { - let settled = false; - const waiter = (connected: boolean) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - extensionReconnectWaiters.delete(waiter); - resolve(connected); - }; - const timer = setTimeout(() => { - waiter(false); - }, timeoutMs); - extensionReconnectWaiters.add(waiter); - }); - }; - - const pendingExtension = new Map< - number, - { - resolve: (v: unknown) => void; - reject: (e: Error) => void; - timer: NodeJS.Timeout; - } - >(); - let nextExtensionId = 1; - - const sendToExtension = async (payload: ExtensionForwardCommandMessage): Promise => { - const ws = extensionWs; - if (!ws || ws.readyState !== WebSocket.OPEN) { - throw new Error("Chrome extension not connected"); - } - ws.send(JSON.stringify(payload)); - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - pendingExtension.delete(payload.id); - reject(new Error(`extension request timeout: ${payload.params.method}`)); - }, 30_000); - pendingExtension.set(payload.id, { resolve, reject, timer }); - }); - }; - - const broadcastToCdpClients = (evt: CdpEvent) => { - const msg = JSON.stringify(evt); - for (const ws of cdpClients) { - if (ws.readyState !== WebSocket.OPEN) { - continue; - } - ws.send(msg); - } - }; - - const sendResponseToCdp = (ws: WebSocket, res: CdpResponse) => { - if (ws.readyState !== WebSocket.OPEN) { - return; - } - ws.send(JSON.stringify(res)); - }; - - const dropConnectedTargetSession = (sessionId: string): ConnectedTarget | undefined => { - const existing = connectedTargets.get(sessionId); - if (!existing) { - return undefined; - } - connectedTargets.delete(sessionId); - return existing; - }; - - const dropConnectedTargetsByTargetId = (targetId: string): ConnectedTarget[] => { - const removed: ConnectedTarget[] = []; - for (const [sessionId, target] of connectedTargets) { - if (target.targetId !== targetId) { - continue; - } - connectedTargets.delete(sessionId); - removed.push(target); - } - return removed; - }; - - const broadcastDetachedTarget = (target: ConnectedTarget, targetId?: string) => { - broadcastToCdpClients({ - method: "Target.detachedFromTarget", - params: { - sessionId: target.sessionId, - targetId: targetId ?? target.targetId, - }, - sessionId: target.sessionId, - }); - }; - - const isMissingTargetError = (err: unknown) => { - const message = (err instanceof Error ? err.message : String(err)).toLowerCase(); - return ( - message.includes("target not found") || - message.includes("no target with given id") || - message.includes("session not found") || - message.includes("cannot find session") - ); - }; - - const pruneStaleTargetsFromCommandFailure = (cmd: CdpCommand, err: unknown) => { - if (!isMissingTargetError(err)) { - return; - } - if (cmd.sessionId) { - const removed = dropConnectedTargetSession(cmd.sessionId); - if (removed) { - broadcastDetachedTarget(removed); - return; - } - } - const params = (cmd.params ?? {}) as { targetId?: unknown }; - const targetId = typeof params.targetId === "string" ? params.targetId : undefined; - if (!targetId) { - return; - } - const removedTargets = dropConnectedTargetsByTargetId(targetId); - for (const removed of removedTargets) { - broadcastDetachedTarget(removed, targetId); - } - }; - - const ensureTargetEventsForClient = (ws: WebSocket, mode: "autoAttach" | "discover") => { - for (const target of connectedTargets.values()) { - if (mode === "autoAttach") { - ws.send( - JSON.stringify({ - method: "Target.attachedToTarget", - params: { - sessionId: target.sessionId, - targetInfo: { ...target.targetInfo, attached: true }, - waitingForDebugger: false, - }, - } satisfies CdpEvent), - ); - } else { - ws.send( - JSON.stringify({ - method: "Target.targetCreated", - params: { targetInfo: { ...target.targetInfo, attached: true } }, - } satisfies CdpEvent), - ); - } - } - }; - - const routeCdpCommand = async (cmd: CdpCommand): Promise => { - switch (cmd.method) { - case "Browser.getVersion": - return { - protocolVersion: "1.3", - product: "Chrome/OpenClaw-Extension-Relay", - revision: "0", - userAgent: "OpenClaw-Extension-Relay", - jsVersion: "V8", - }; - case "Browser.setDownloadBehavior": - return {}; - case "Target.setAutoAttach": - case "Target.setDiscoverTargets": - return {}; - case "Target.getTargets": - return { - targetInfos: Array.from(connectedTargets.values()).map((t) => ({ - ...t.targetInfo, - attached: true, - })), - }; - case "Target.getTargetInfo": { - const params = (cmd.params ?? {}) as { targetId?: string }; - const targetId = typeof params.targetId === "string" ? params.targetId : undefined; - if (targetId) { - for (const t of connectedTargets.values()) { - if (t.targetId === targetId) { - return { targetInfo: t.targetInfo }; - } - } - } - if (cmd.sessionId && connectedTargets.has(cmd.sessionId)) { - const t = connectedTargets.get(cmd.sessionId); - if (t) { - return { targetInfo: t.targetInfo }; - } - } - const first = Array.from(connectedTargets.values())[0]; - return { targetInfo: first?.targetInfo }; - } - case "Target.attachToTarget": { - const params = (cmd.params ?? {}) as { targetId?: string }; - const targetId = typeof params.targetId === "string" ? params.targetId : undefined; - if (!targetId) { - throw new Error("targetId required"); - } - for (const t of connectedTargets.values()) { - if (t.targetId === targetId) { - return { sessionId: t.sessionId }; - } - } - throw new Error("target not found"); - } - default: { - const id = nextExtensionId++; - return await sendToExtension({ - id, - method: "forwardCDPCommand", - params: { - method: cmd.method, - sessionId: cmd.sessionId, - params: cmd.params, - }, - }); - } - } - }; - - const server = createServer((req, res) => { - const url = new URL(req.url ?? "/", info.baseUrl); - const path = url.pathname; - const origin = getHeader(req, "origin"); - const isChromeExtensionOrigin = - typeof origin === "string" && origin.startsWith("chrome-extension://"); - - if (isChromeExtensionOrigin && origin) { - // Let extension pages call relay HTTP endpoints cross-origin. - res.setHeader("Access-Control-Allow-Origin", origin); - res.setHeader("Vary", "Origin"); - } - - // Handle CORS preflight requests from the browser extension. - if (req.method === "OPTIONS") { - if (origin && !isChromeExtensionOrigin) { - res.writeHead(403); - res.end("Forbidden"); - return; - } - const requestedHeaders = (getHeader(req, "access-control-request-headers") ?? "") - .split(",") - .map((header) => header.trim().toLowerCase()) - .filter((header) => header.length > 0); - const allowedHeaders = new Set(["content-type", RELAY_AUTH_HEADER, ...requestedHeaders]); - res.writeHead(204, { - "Access-Control-Allow-Origin": origin ?? "*", - "Access-Control-Allow-Methods": "GET, PUT, POST, OPTIONS", - "Access-Control-Allow-Headers": Array.from(allowedHeaders).join(", "), - "Access-Control-Max-Age": "86400", - Vary: "Origin, Access-Control-Request-Headers", - }); - res.end(); - return; - } - - if (path.startsWith("/json")) { - const token = getRelayAuthTokenFromRequest(req, url); - if (!token || !relayAuthTokens.has(token)) { - res.writeHead(401); - res.end("Unauthorized"); - return; - } - } - - if (req.method === "HEAD" && path === "/") { - res.writeHead(200); - res.end(); - return; - } - - if (path === "/") { - res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); - res.end("OK"); - return; - } - - if (path === "/extension/status") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ connected: extensionConnected() })); - return; - } - - const hostHeader = req.headers.host?.trim() || `${info.host}:${info.port}`; - const wsHost = `ws://${hostHeader}`; - const cdpWsUrl = `${wsHost}/cdp`; - - if ( - (path === "/json/version" || path === "/json/version/") && - (req.method === "GET" || req.method === "PUT") - ) { - const payload: Record = { - Browser: "OpenClaw/extension-relay", - "Protocol-Version": "1.3", - }; - // Keep reporting CDP WS while attached targets are cached, so callers can - // reconnect through brief MV3 worker disconnects. - if (extensionConnected() || hasConnectedTargets()) { - payload.webSocketDebuggerUrl = cdpWsUrl; - } - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify(payload)); - return; - } - - const listPaths = new Set(["/json", "/json/", "/json/list", "/json/list/"]); - if (listPaths.has(path) && (req.method === "GET" || req.method === "PUT")) { - const list = Array.from(connectedTargets.values()).map((t) => ({ - id: t.targetId, - type: t.targetInfo.type ?? "page", - title: t.targetInfo.title ?? "", - description: t.targetInfo.title ?? "", - url: t.targetInfo.url ?? "", - webSocketDebuggerUrl: cdpWsUrl, - devtoolsFrontendUrl: `/devtools/inspector.html?ws=${cdpWsUrl.replace("ws://", "")}`, - })); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify(list)); - return; - } - - const handleTargetActionRoute = ( - match: RegExpMatchArray | null, - cdpMethod: "Target.activateTarget" | "Target.closeTarget", - ): boolean => { - if (!match || (req.method !== "GET" && req.method !== "PUT")) { - return false; - } - let targetId = ""; - try { - targetId = decodeURIComponent(match[1] ?? "").trim(); - } catch { - res.writeHead(400); - res.end("invalid targetId encoding"); - return true; - } - if (!targetId) { - res.writeHead(400); - res.end("targetId required"); - return true; - } - void (async () => { - try { - await sendToExtension({ - id: nextExtensionId++, - method: "forwardCDPCommand", - params: { method: cdpMethod, params: { targetId } }, - }); - } catch { - // ignore - } - })(); - res.writeHead(200); - res.end("OK"); - return true; - }; - - if ( - handleTargetActionRoute(path.match(/^\/json\/activate\/(.+)$/), "Target.activateTarget") - ) { - return; - } - if (handleTargetActionRoute(path.match(/^\/json\/close\/(.+)$/), "Target.closeTarget")) { - return; - } - - res.writeHead(404); - res.end("not found"); - }); - - const wssExtension = new WebSocketServer({ noServer: true }); - const wssCdp = new WebSocketServer({ noServer: true }); - - server.on("upgrade", (req, socket, head) => { - const url = new URL(req.url ?? "/", info.baseUrl); - const pathname = url.pathname; - const remote = req.socket.remoteAddress; - - // When bindHost is explicitly non-loopback (e.g. 0.0.0.0 for WSL2), - // allow non-loopback connections; otherwise enforce loopback-only. - if (!isLoopbackAddress(remote) && isLoopbackHost(bindHost)) { - rejectUpgrade(socket, 403, "Forbidden"); - return; - } - - const origin = headerValue(req.headers.origin); - if (origin && !origin.startsWith("chrome-extension://")) { - rejectUpgrade(socket, 403, "Forbidden: invalid origin"); - return; - } - - if (pathname === "/extension") { - const token = getRelayAuthTokenFromRequest(req, url); - if (!token || !relayAuthTokens.has(token)) { - rejectUpgrade(socket, 401, "Unauthorized"); - return; - } - // MV3 worker reconnect races can leave a stale non-OPEN socket reference. - if (extensionWs && extensionWs.readyState !== WebSocket.OPEN) { - try { - extensionWs.terminate(); - } catch { - // ignore - } - extensionWs = null; - } - if (extensionConnected()) { - rejectUpgrade(socket, 409, "Extension already connected"); - return; - } - wssExtension.handleUpgrade(req, socket, head, (ws) => { - wssExtension.emit("connection", ws, req); - }); - return; - } - - if (pathname === "/cdp") { - const token = getRelayAuthTokenFromRequest(req, url); - if (!token || !relayAuthTokens.has(token)) { - rejectUpgrade(socket, 401, "Unauthorized"); - return; - } - // Allow CDP clients to connect even during brief extension worker drops. - // Individual commands already wait briefly for extension reconnect. - wssCdp.handleUpgrade(req, socket, head, (ws) => { - wssCdp.emit("connection", ws, req); - }); - return; - } - - rejectUpgrade(socket, 404, "Not Found"); - }); - - wssExtension.on("connection", (ws) => { - extensionWs = ws; - clearExtensionDisconnectCleanupTimer(); - flushExtensionReconnectWaiters(true); - - const ping = setInterval(() => { - if (ws.readyState !== WebSocket.OPEN) { - return; - } - ws.send(JSON.stringify({ method: "ping" } satisfies ExtensionPingMessage)); - }, 5000); - - ws.on("message", (data) => { - if (extensionWs !== ws) { - return; - } - let parsed: ExtensionMessage | null = null; - try { - parsed = JSON.parse(rawDataToString(data)) as ExtensionMessage; - } catch { - return; - } - - if ( - parsed && - typeof parsed === "object" && - "id" in parsed && - typeof parsed.id === "number" - ) { - const pending = pendingExtension.get(parsed.id); - if (!pending) { - return; - } - pendingExtension.delete(parsed.id); - clearTimeout(pending.timer); - if ("error" in parsed && typeof parsed.error === "string" && parsed.error.trim()) { - pending.reject(new Error(parsed.error)); - } else { - pending.resolve(parsed.result); - } - return; - } - - if (parsed && typeof parsed === "object" && "method" in parsed) { - if ((parsed as ExtensionPongMessage).method === "pong") { - return; - } - if ((parsed as ExtensionForwardEventMessage).method !== "forwardCDPEvent") { - return; - } - const evt = parsed as ExtensionForwardEventMessage; - const method = evt.params?.method; - const params = evt.params?.params; - const sessionId = evt.params?.sessionId; - if (!method || typeof method !== "string") { - return; - } - - if (method === "Target.attachedToTarget") { - const attached = (params ?? {}) as AttachedToTargetEvent; - const targetType = attached?.targetInfo?.type ?? "page"; - if (targetType !== "page") { - return; - } - if (attached?.sessionId && attached?.targetInfo?.targetId) { - const prev = connectedTargets.get(attached.sessionId); - const nextTargetId = attached.targetInfo.targetId; - const prevTargetId = prev?.targetId; - const changedTarget = Boolean(prev && prevTargetId && prevTargetId !== nextTargetId); - connectedTargets.set(attached.sessionId, { - sessionId: attached.sessionId, - targetId: nextTargetId, - targetInfo: attached.targetInfo, - }); - if (changedTarget && prevTargetId) { - broadcastToCdpClients({ - method: "Target.detachedFromTarget", - params: { sessionId: attached.sessionId, targetId: prevTargetId }, - sessionId: attached.sessionId, - }); - } - if (!prev || changedTarget) { - broadcastToCdpClients({ method, params, sessionId }); - } - return; - } - } - - if (method === "Target.detachedFromTarget") { - const detached = (params ?? {}) as DetachedFromTargetEvent; - if (detached?.sessionId) { - dropConnectedTargetSession(detached.sessionId); - } else if (detached?.targetId) { - dropConnectedTargetsByTargetId(detached.targetId); - } - broadcastToCdpClients({ method, params, sessionId }); - return; - } - - if (method === "Target.targetDestroyed" || method === "Target.targetCrashed") { - const targetEvent = (params ?? {}) as { targetId?: string }; - if (targetEvent.targetId) { - dropConnectedTargetsByTargetId(targetEvent.targetId); - } - broadcastToCdpClients({ method, params, sessionId }); - return; - } - - // Keep cached tab metadata fresh for /json/list. - // After navigation, Chrome updates URL/title via Target.targetInfoChanged. - if (method === "Target.targetInfoChanged") { - const changed = (params ?? {}) as { targetInfo?: { targetId?: string; type?: string } }; - const targetInfo = changed?.targetInfo; - const targetId = targetInfo?.targetId; - if (targetId && (targetInfo?.type ?? "page") === "page") { - for (const [sid, target] of connectedTargets) { - if (target.targetId !== targetId) { - continue; - } - connectedTargets.set(sid, { - ...target, - targetInfo: { ...target.targetInfo, ...(targetInfo as object) }, - }); - } - } - } - - broadcastToCdpClients({ method, params, sessionId }); - } - }); - - ws.on("close", () => { - clearInterval(ping); - if (extensionWs !== ws) { - return; - } - extensionWs = null; - for (const [, pending] of pendingExtension) { - clearTimeout(pending.timer); - pending.reject(new Error("extension disconnected")); - } - pendingExtension.clear(); - scheduleExtensionDisconnectCleanup(); - }); - }); - - wssCdp.on("connection", (ws) => { - cdpClients.add(ws); - - ws.on("message", async (data) => { - let cmd: CdpCommand | null = null; - try { - cmd = JSON.parse(rawDataToString(data)) as CdpCommand; - } catch { - return; - } - if (!cmd || typeof cmd !== "object") { - return; - } - if (typeof cmd.id !== "number" || typeof cmd.method !== "string") { - return; - } - - if (!extensionConnected()) { - const reconnected = await waitForExtensionReconnect(extensionCommandReconnectWaitMs); - if (!reconnected || !extensionConnected()) { - sendResponseToCdp(ws, { - id: cmd.id, - sessionId: cmd.sessionId, - error: { message: "Extension not connected" }, - }); - return; - } - } - - try { - const result = await routeCdpCommand(cmd); - - if (cmd.method === "Target.setAutoAttach" && !cmd.sessionId) { - ensureTargetEventsForClient(ws, "autoAttach"); - } - if (cmd.method === "Target.setDiscoverTargets") { - const discover = (cmd.params ?? {}) as { discover?: boolean }; - if (discover.discover === true) { - ensureTargetEventsForClient(ws, "discover"); - } - } - if (cmd.method === "Target.attachToTarget") { - const params = (cmd.params ?? {}) as { targetId?: string }; - const targetId = typeof params.targetId === "string" ? params.targetId : undefined; - if (targetId) { - const target = Array.from(connectedTargets.values()).find( - (t) => t.targetId === targetId, - ); - if (target) { - ws.send( - JSON.stringify({ - method: "Target.attachedToTarget", - params: { - sessionId: target.sessionId, - targetInfo: { ...target.targetInfo, attached: true }, - waitingForDebugger: false, - }, - } satisfies CdpEvent), - ); - } - } - } - - sendResponseToCdp(ws, { id: cmd.id, sessionId: cmd.sessionId, result }); - } catch (err) { - pruneStaleTargetsFromCommandFailure(cmd, err); - sendResponseToCdp(ws, { - id: cmd.id, - sessionId: cmd.sessionId, - error: { message: err instanceof Error ? err.message : String(err) }, - }); - } - }); - - ws.on("close", () => { - cdpClients.delete(ws); - }); - }); - - try { - await new Promise((resolve, reject) => { - server.listen(info.port, bindHost, () => resolve()); - server.once("error", reject); - }); - } catch (err) { - if ( - isAddrInUseError(err) && - (await probeAuthenticatedOpenClawRelay({ - baseUrl: info.baseUrl, - relayAuthHeader: RELAY_AUTH_HEADER, - relayAuthToken, - })) - ) { - const existingRelay: ChromeExtensionRelayServer = { - host: info.host, - bindHost, - port: info.port, - baseUrl: info.baseUrl, - cdpWsUrl: `ws://${info.host}:${info.port}/cdp`, - extensionConnected: () => false, - stop: async () => { - relayRuntimeByPort.delete(info.port); - }, - }; - relayRuntimeByPort.set(info.port, { server: existingRelay, relayAuthToken }); - return existingRelay; - } - throw err; - } - - const addr = server.address() as AddressInfo | null; - const port = addr?.port ?? info.port; - const actualBindHost = addr?.address || bindHost; - const host = info.host; - const baseUrl = `${new URL(info.baseUrl).protocol}//${host}:${port}`; - - const relay: ChromeExtensionRelayServer = { - host, - bindHost: actualBindHost, - port, - baseUrl, - cdpWsUrl: `ws://${host}:${port}/cdp`, - extensionConnected, - stop: async () => { - relayRuntimeByPort.delete(port); - clearExtensionDisconnectCleanupTimer(); - flushExtensionReconnectWaiters(false); - for (const [, pending] of pendingExtension) { - clearTimeout(pending.timer); - pending.reject(new Error("server stopping")); - } - pendingExtension.clear(); - try { - extensionWs?.close(1001, "server stopping"); - } catch { - // ignore - } - for (const ws of cdpClients) { - try { - ws.close(1001, "server stopping"); - } catch { - // ignore - } - } - await new Promise((resolve) => { - server.close(() => resolve()); - }); - wssExtension.close(); - wssCdp.close(); - }, - }; - - relayRuntimeByPort.set(port, { server: relay, relayAuthToken }); - return relay; - })(); - relayInitByPort.set(info.port, initPromise); - try { - return await initPromise; - } finally { - relayInitByPort.delete(info.port); - } -} - -export async function stopChromeExtensionRelayServer(opts: { cdpUrl: string }): Promise { - const info = parseBaseUrl(opts.cdpUrl); - const existing = relayRuntimeByPort.get(info.port); - if (!existing) { - return false; - } - await existing.server.stop(); - return true; -} diff --git a/src/browser/profile-capabilities.ts b/src/browser/profile-capabilities.ts index b736a77d943..994894239d1 100644 --- a/src/browser/profile-capabilities.ts +++ b/src/browser/profile-capabilities.ts @@ -1,18 +1,12 @@ import type { ResolvedBrowserProfile } from "./config.js"; -export type BrowserProfileMode = - | "local-managed" - | "local-extension-relay" - | "local-existing-session" - | "remote-cdp"; +export type BrowserProfileMode = "local-managed" | "local-existing-session" | "remote-cdp"; export type BrowserProfileCapabilities = { mode: BrowserProfileMode; isRemote: boolean; /** Profile uses the Chrome DevTools MCP server (existing-session driver). */ usesChromeMcp: boolean; - requiresRelay: boolean; - requiresAttachedTab: boolean; usesPersistentPlaywright: boolean; supportsPerTabWs: boolean; supportsJsonTabEndpoints: boolean; @@ -23,28 +17,11 @@ export type BrowserProfileCapabilities = { export function getBrowserProfileCapabilities( profile: ResolvedBrowserProfile, ): BrowserProfileCapabilities { - if (profile.driver === "extension") { - return { - mode: "local-extension-relay", - isRemote: false, - usesChromeMcp: false, - requiresRelay: true, - requiresAttachedTab: true, - usesPersistentPlaywright: false, - supportsPerTabWs: false, - supportsJsonTabEndpoints: true, - supportsReset: true, - supportsManagedTabLimit: false, - }; - } - if (profile.driver === "existing-session") { return { mode: "local-existing-session", isRemote: false, usesChromeMcp: true, - requiresRelay: false, - requiresAttachedTab: false, usesPersistentPlaywright: false, supportsPerTabWs: false, supportsJsonTabEndpoints: false, @@ -58,8 +35,6 @@ export function getBrowserProfileCapabilities( mode: "remote-cdp", isRemote: true, usesChromeMcp: false, - requiresRelay: false, - requiresAttachedTab: false, usesPersistentPlaywright: true, supportsPerTabWs: false, supportsJsonTabEndpoints: false, @@ -72,8 +47,6 @@ export function getBrowserProfileCapabilities( mode: "local-managed", isRemote: false, usesChromeMcp: false, - requiresRelay: false, - requiresAttachedTab: false, usesPersistentPlaywright: false, supportsPerTabWs: true, supportsJsonTabEndpoints: true, @@ -96,9 +69,6 @@ export function resolveDefaultSnapshotFormat(params: { } const capabilities = getBrowserProfileCapabilities(params.profile); - if (capabilities.mode === "local-extension-relay") { - return "aria"; - } if (capabilities.mode === "local-existing-session") { return "ai"; } @@ -112,16 +82,12 @@ export function shouldUsePlaywrightForScreenshot(params: { ref?: string; element?: string; }): boolean { - const capabilities = getBrowserProfileCapabilities(params.profile); - return ( - capabilities.requiresRelay || !params.wsUrl || Boolean(params.ref) || Boolean(params.element) - ); + return !params.wsUrl || Boolean(params.ref) || Boolean(params.element); } export function shouldUsePlaywrightForAriaSnapshot(params: { profile: ResolvedBrowserProfile; wsUrl?: string; }): boolean { - const capabilities = getBrowserProfileCapabilities(params.profile); - return capabilities.requiresRelay || !params.wsUrl; + return !params.wsUrl; } diff --git a/src/browser/profiles-service.test.ts b/src/browser/profiles-service.test.ts index 13bbdf27c49..b726ad3fbdb 100644 --- a/src/browser/profiles-service.test.ts +++ b/src/browser/profiles-service.test.ts @@ -136,37 +136,6 @@ describe("BrowserProfilesService", () => { ); }); - it("rejects driver=extension with non-loopback cdpUrl", async () => { - const resolved = resolveBrowserConfig({}); - const { ctx } = createCtx(resolved); - vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } }); - - const service = createBrowserProfilesService(ctx); - - await expect( - service.createProfile({ - name: "chrome-remote", - driver: "extension", - cdpUrl: "http://10.0.0.42:9222", - }), - ).rejects.toThrow(/loopback cdpUrl host/i); - }); - - it("rejects driver=extension without an explicit cdpUrl", async () => { - const resolved = resolveBrowserConfig({}); - const { ctx } = createCtx(resolved); - vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } }); - - const service = createBrowserProfilesService(ctx); - - await expect( - service.createProfile({ - name: "chrome-extension", - driver: "extension", - }), - ).rejects.toThrow(/requires an explicit loopback cdpUrl/i); - }); - it("creates existing-session profiles as attach-only local entries", async () => { const resolved = resolveBrowserConfig({}); const { ctx, state } = createCtx(resolved); diff --git a/src/browser/profiles-service.ts b/src/browser/profiles-service.ts index 86321006e98..af747015e45 100644 --- a/src/browser/profiles-service.ts +++ b/src/browser/profiles-service.ts @@ -3,7 +3,6 @@ import path from "node:path"; import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js"; import { loadConfig, writeConfigFile } from "../config/config.js"; import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js"; -import { isLoopbackHost } from "../gateway/net.js"; import { resolveOpenClawUserDataDir } from "./chrome.js"; import { parseHttpUrl, resolveProfile } from "./config.js"; import { @@ -27,7 +26,7 @@ export type CreateProfileParams = { name: string; color?: string; cdpUrl?: string; - driver?: "openclaw" | "extension" | "existing-session"; + driver?: "openclaw" | "existing-session"; }; export type CreateProfileResult = { @@ -80,12 +79,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { const createProfile = async (params: CreateProfileParams): Promise => { const name = params.name.trim(); const rawCdpUrl = params.cdpUrl?.trim() || undefined; - const driver = - params.driver === "extension" - ? "extension" - : params.driver === "existing-session" - ? "existing-session" - : undefined; + const driver = params.driver === "existing-session" ? "existing-session" : undefined; if (!isValidProfileName(name)) { throw new BrowserValidationError( @@ -117,18 +111,6 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { } catch (err) { throw new BrowserValidationError(String(err)); } - if (driver === "extension") { - if (!isLoopbackHost(parsed.parsed.hostname)) { - throw new BrowserValidationError( - `driver=extension requires a loopback cdpUrl host, got: ${parsed.parsed.hostname}`, - ); - } - if (parsed.parsed.protocol !== "http:" && parsed.parsed.protocol !== "https:") { - throw new BrowserValidationError( - `driver=extension requires an http(s) cdpUrl, got: ${parsed.parsed.protocol.replace(":", "")}`, - ); - } - } if (driver === "existing-session") { throw new BrowserValidationError( "driver=existing-session does not accept cdpUrl; it attaches via the Chrome MCP auto-connect flow", @@ -140,9 +122,6 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { color: profileColor, }; } else { - if (driver === "extension") { - throw new BrowserValidationError("driver=extension requires an explicit loopback cdpUrl"); - } if (driver === "existing-session") { // existing-session uses Chrome MCP auto-connect; no CDP port needed profileConfig = { diff --git a/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts b/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts index 8f64b2bf575..2b3bdb32bd8 100644 --- a/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts +++ b/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts @@ -52,7 +52,7 @@ function createExtensionFallbackBrowserHarness(options?: { } describe("pw-session getPageForTargetId", () => { - it("falls back to the only page when CDP session attachment is blocked (extension relays)", async () => { + it("falls back to the only page when Playwright cannot resolve target ids", async () => { const { browserClose, pages } = createExtensionFallbackBrowserHarness(); const [page] = pages; @@ -94,26 +94,20 @@ describe("pw-session getPageForTargetId", () => { } }); - it("resolves extension-relay pages from /json/list without probing page CDP sessions first", async () => { + it("resolves pages from /json/list when page CDP probing fails", async () => { const { newCDPSession, pages } = createExtensionFallbackBrowserHarness({ urls: ["https://alpha.example", "https://beta.example"], newCDPSessionError: "Target.attachToBrowserTarget: Not allowed", }); const [, pageB] = pages; - const fetchSpy = vi.spyOn(globalThis, "fetch"); - fetchSpy - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ Browser: "OpenClaw/extension-relay" }), - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => [ - { id: "TARGET_A", url: "https://alpha.example" }, - { id: "TARGET_B", url: "https://beta.example" }, - ], - } as Response); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => [ + { id: "TARGET_A", url: "https://alpha.example" }, + { id: "TARGET_B", url: "https://beta.example" }, + ], + } as Response); try { const resolved = await getPageForTargetId({ @@ -121,7 +115,7 @@ describe("pw-session getPageForTargetId", () => { targetId: "TARGET_B", }); expect(resolved).toBe(pageB); - expect(newCDPSession).not.toHaveBeenCalled(); + expect(newCDPSession).toHaveBeenCalled(); } finally { fetchSpy.mockRestore(); } diff --git a/src/browser/pw-session.page-cdp.test.ts b/src/browser/pw-session.page-cdp.test.ts index 1347cca20a1..c00f8af5a02 100644 --- a/src/browser/pw-session.page-cdp.test.ts +++ b/src/browser/pw-session.page-cdp.test.ts @@ -1,61 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; - -const cdpHelperMocks = vi.hoisted(() => ({ - fetchJson: vi.fn(), - withCdpSocket: vi.fn(), -})); - -const chromeMocks = vi.hoisted(() => ({ - getChromeWebSocketUrl: vi.fn(async () => "ws://127.0.0.1:18792/cdp"), -})); - -vi.mock("./cdp.helpers.js", async () => { - const actual = await vi.importActual("./cdp.helpers.js"); - return { - ...actual, - fetchJson: cdpHelperMocks.fetchJson, - withCdpSocket: cdpHelperMocks.withCdpSocket, - }; -}); - -vi.mock("./chrome.js", () => chromeMocks); - -import { isExtensionRelayCdpEndpoint, withPageScopedCdpClient } from "./pw-session.page-cdp.js"; +import { withPageScopedCdpClient } from "./pw-session.page-cdp.js"; describe("pw-session page-scoped CDP client", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("uses raw relay /cdp commands for extension endpoints when targetId is known", async () => { - cdpHelperMocks.fetchJson.mockResolvedValue({ Browser: "OpenClaw/extension-relay" }); - const send = vi.fn(async () => ({ ok: true })); - cdpHelperMocks.withCdpSocket.mockImplementation(async (_wsUrl, fn) => await fn(send)); - const newCDPSession = vi.fn(); - const page = { - context: () => ({ - newCDPSession, - }), - }; - - await withPageScopedCdpClient({ - cdpUrl: "http://127.0.0.1:18792", - page: page as never, - targetId: "tab-1", - fn: async (pageSend) => { - await pageSend("Page.bringToFront", { foo: "bar" }); - }, - }); - - expect(send).toHaveBeenCalledWith("Page.bringToFront", { - foo: "bar", - targetId: "tab-1", - }); - expect(newCDPSession).not.toHaveBeenCalled(); - }); - - it("falls back to Playwright page sessions for non-relay endpoints", async () => { - cdpHelperMocks.fetchJson.mockResolvedValue({ Browser: "Chrome/145.0" }); + it("uses Playwright page sessions", async () => { const sessionSend = vi.fn(async () => ({ ok: true })); const sessionDetach = vi.fn(async () => {}); const newCDPSession = vi.fn(async () => ({ @@ -80,15 +31,5 @@ describe("pw-session page-scoped CDP client", () => { expect(newCDPSession).toHaveBeenCalledWith(page); expect(sessionSend).toHaveBeenCalledWith("Emulation.setLocaleOverride", { locale: "en-US" }); expect(sessionDetach).toHaveBeenCalledTimes(1); - expect(cdpHelperMocks.withCdpSocket).not.toHaveBeenCalled(); - }); - - it("caches extension-relay endpoint detection by cdpUrl", async () => { - cdpHelperMocks.fetchJson.mockResolvedValue({ Browser: "OpenClaw/extension-relay" }); - - await expect(isExtensionRelayCdpEndpoint("http://127.0.0.1:19992")).resolves.toBe(true); - await expect(isExtensionRelayCdpEndpoint("http://127.0.0.1:19992/")).resolves.toBe(true); - - expect(cdpHelperMocks.fetchJson).toHaveBeenCalledTimes(1); }); }); diff --git a/src/browser/pw-session.page-cdp.ts b/src/browser/pw-session.page-cdp.ts index 8c2109293cd..ccfc2ee7f34 100644 --- a/src/browser/pw-session.page-cdp.ts +++ b/src/browser/pw-session.page-cdp.ts @@ -1,44 +1,7 @@ import type { CDPSession, Page } from "playwright-core"; -import { - appendCdpPath, - fetchJson, - normalizeCdpHttpBaseForJsonEndpoints, - withCdpSocket, -} from "./cdp.helpers.js"; -import { getChromeWebSocketUrl } from "./chrome.js"; - -const OPENCLAW_EXTENSION_RELAY_BROWSER = "OpenClaw/extension-relay"; type PageCdpSend = (method: string, params?: Record) => Promise; -const extensionRelayByCdpUrl = new Map(); - -function normalizeCdpUrl(raw: string) { - return raw.replace(/\/$/, ""); -} - -export async function isExtensionRelayCdpEndpoint(cdpUrl: string): Promise { - 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( page: Page, fn: (session: CDPSession) => Promise, @@ -57,17 +20,6 @@ export async function withPageScopedCdpClient(opts: { targetId?: string; fn: (send: PageCdpSend) => Promise; }): Promise { - const targetId = opts.targetId?.trim(); - if (targetId && (await isExtensionRelayCdpEndpoint(opts.cdpUrl))) { - const wsUrl = await getChromeWebSocketUrl(opts.cdpUrl, 2000); - if (!wsUrl) { - throw new Error("CDP websocket unavailable"); - } - return await withCdpSocket(wsUrl, async (send) => { - return await opts.fn((method, params) => send(method, { ...params, targetId })); - }); - } - return await withPlaywrightPageCdpSession(opts.page, async (session) => { return await opts.fn((method, params) => ( diff --git a/src/browser/pw-session.ts b/src/browser/pw-session.ts index 2e63d190dea..97677557543 100644 --- a/src/browser/pw-session.ts +++ b/src/browser/pw-session.ts @@ -26,7 +26,7 @@ import { assertBrowserNavigationResultAllowed, withBrowserNavigationPolicy, } from "./navigation-guard.js"; -import { isExtensionRelayCdpEndpoint, withPageScopedCdpClient } from "./pw-session.page-cdp.js"; +import { withPageScopedCdpClient } from "./pw-session.page-cdp.js"; export type BrowserConsoleMessage = { type: string; @@ -454,21 +454,6 @@ async function findPageByTargetId( cdpUrl?: string, ): Promise { const pages = await getAllPages(browser); - const isExtensionRelay = cdpUrl - ? await isExtensionRelayCdpEndpoint(cdpUrl).catch(() => false) - : false; - if (cdpUrl && isExtensionRelay) { - try { - const matched = await findPageByTargetIdViaTargetList(pages, targetId, cdpUrl); - if (matched) { - return matched; - } - } catch { - // Ignore fetch errors and fall through to best-effort single-page fallback. - } - return pages.length === 1 ? (pages[0] ?? null) : null; - } - let resolvedViaCdp = false; for (const page of pages) { let tid: string | null = null; @@ -522,9 +507,7 @@ export async function getPageForTargetId(opts: { } const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl); if (!found) { - // Extension relays can block CDP attachment APIs (e.g. Target.attachToBrowserTarget), - // which prevents us from resolving a page's targetId via newCDPSession(). If Playwright - // only exposes a single Page, use it as a best-effort fallback. + // If Playwright only exposes a single Page, use it as a best-effort fallback. if (pages.length === 1) { return first; } diff --git a/src/browser/routes/agent.snapshot.plan.test.ts b/src/browser/routes/agent.snapshot.plan.test.ts index 384e24a1c71..1fa8c54b81e 100644 --- a/src/browser/routes/agent.snapshot.plan.test.ts +++ b/src/browser/routes/agent.snapshot.plan.test.ts @@ -3,15 +3,15 @@ import { resolveBrowserConfig, resolveProfile } from "../config.js"; import { resolveSnapshotPlan } from "./agent.snapshot.plan.js"; describe("resolveSnapshotPlan", () => { - it("defaults extension relay snapshots to aria when format is omitted", () => { + it("defaults existing-session snapshots to ai when format is omitted", () => { const resolved = resolveBrowserConfig({ profiles: { - relay: { driver: "extension", cdpUrl: "http://127.0.0.1:18792", color: "#0066CC" }, + user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, }, }); - const profile = resolveProfile(resolved, "relay"); + const profile = resolveProfile(resolved, "user"); expect(profile).toBeTruthy(); - expect(profile?.driver).toBe("extension"); + expect(profile?.driver).toBe("existing-session"); const plan = resolveSnapshotPlan({ profile: profile as NonNullable, @@ -19,7 +19,7 @@ describe("resolveSnapshotPlan", () => { hasPlaywright: true, }); - expect(plan.format).toBe("aria"); + expect(plan.format).toBe("ai"); }); it("keeps ai snapshots for managed browsers when Playwright is available", () => { diff --git a/src/browser/routes/basic.ts b/src/browser/routes/basic.ts index f6123ac4cf0..c4f5db47a59 100644 --- a/src/browser/routes/basic.ts +++ b/src/browser/routes/basic.ts @@ -176,15 +176,18 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow const name = toStringOrEmpty((req.body as { name?: unknown })?.name); const color = toStringOrEmpty((req.body as { color?: unknown })?.color); const cdpUrl = toStringOrEmpty((req.body as { cdpUrl?: unknown })?.cdpUrl); - const driver = toStringOrEmpty((req.body as { driver?: unknown })?.driver) as - | "openclaw" - | "extension" - | "existing-session" - | ""; + const driver = toStringOrEmpty((req.body as { driver?: unknown })?.driver); if (!name) { return jsonError(res, 400, "name is required"); } + if (driver && driver !== "openclaw" && driver !== "clawd" && driver !== "existing-session") { + return jsonError( + res, + 400, + `unsupported profile driver "${driver}"; use "openclaw", "clawd", or "existing-session"`, + ); + } await withProfilesServiceMutation({ res, @@ -195,10 +198,10 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow color: color || undefined, cdpUrl: cdpUrl || undefined, driver: - driver === "extension" - ? "extension" - : driver === "existing-session" - ? "existing-session" + driver === "existing-session" + ? "existing-session" + : driver === "openclaw" || driver === "clawd" + ? "openclaw" : undefined, }), }); diff --git a/src/browser/server-context.availability.ts b/src/browser/server-context.availability.ts index a0281d53d9f..d7d33fd0fde 100644 --- a/src/browser/server-context.availability.ts +++ b/src/browser/server-context.availability.ts @@ -15,11 +15,7 @@ import { stopOpenClawChrome, } from "./chrome.js"; import type { ResolvedBrowserProfile } from "./config.js"; -import { BrowserConfigurationError, BrowserProfileUnavailableError } from "./errors.js"; -import { - ensureChromeExtensionRelayServer, - stopChromeExtensionRelayServer, -} from "./extension-relay.js"; +import { BrowserProfileUnavailableError } from "./errors.js"; import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; import { CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS, @@ -124,9 +120,6 @@ export function createProfileAvailability({ await stopOpenClawChrome(profileState.running).catch(() => {}); setProfileRunning(null); } - if (previousProfile.driver === "extension") { - await stopChromeExtensionRelayServer({ cdpUrl: previousProfile.cdpUrl }).catch(() => false); - } if (getBrowserProfileCapabilities(previousProfile).usesChromeMcp) { await closeChromeMcpSession(previousProfile.name).catch(() => false); } @@ -166,33 +159,9 @@ export function createProfileAvailability({ const current = state(); const remoteCdp = capabilities.isRemote; const attachOnly = profile.attachOnly; - const isExtension = capabilities.requiresRelay; const profileState = getProfileState(); const httpReachable = await isHttpReachable(); - if (isExtension && remoteCdp) { - throw new BrowserConfigurationError( - `Profile "${profile.name}" uses driver=extension but cdpUrl is not loopback (${profile.cdpUrl}).`, - ); - } - - if (isExtension) { - if (!httpReachable) { - await ensureChromeExtensionRelayServer({ - cdpUrl: profile.cdpUrl, - bindHost: current.resolved.relayBindHost, - }); - if (!(await isHttpReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS))) { - throw new BrowserProfileUnavailableError( - `Chrome extension relay for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`, - ); - } - } - // Browser startup should only ensure relay availability. - // Tab attachment is checked when a tab is actually required. - return; - } - if (!httpReachable) { if ((attachOnly || remoteCdp) && opts.onEnsureAttachTarget) { await opts.onEnsureAttachTarget(profile); @@ -267,12 +236,6 @@ export function createProfileAvailability({ const stopped = await closeChromeMcpSession(profile.name); return { stopped }; } - if (capabilities.requiresRelay) { - const stopped = await stopChromeExtensionRelayServer({ - cdpUrl: profile.cdpUrl, - }); - return { stopped }; - } const profileState = getProfileState(); if (!profileState.running) { return { stopped: false }; diff --git a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts deleted file mode 100644 index d3760bd460d..00000000000 --- a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts +++ /dev/null @@ -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(); - } - }); -}); diff --git a/src/browser/server-context.reset.test.ts b/src/browser/server-context.reset.test.ts index 7e74ffd3881..9fcbc1c2a7b 100644 --- a/src/browser/server-context.reset.test.ts +++ b/src/browser/server-context.reset.test.ts @@ -4,10 +4,6 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createProfileResetOps } from "./server-context.reset.js"; -const relayMocks = vi.hoisted(() => ({ - stopChromeExtensionRelayServer: vi.fn(async () => true), -})); - const trashMocks = vi.hoisted(() => ({ movePathToTrash: vi.fn(async (from: string) => `${from}.trashed`), })); @@ -16,7 +12,6 @@ const pwAiMocks = vi.hoisted(() => ({ closePlaywrightBrowserConnection: vi.fn(async () => {}), })); -vi.mock("./extension-relay.js", () => relayMocks); vi.mock("./trash.js", () => trashMocks); vi.mock("./pw-ai.js", () => pwAiMocks); @@ -54,23 +49,6 @@ function createStatelessResetOps(profile: Parameters { - it("stops extension relay for extension profiles", async () => { - const ops = createStatelessResetOps({ - ...localOpenClawProfile(), - name: "chrome", - driver: "extension", - }); - - await expect(ops.resetProfile()).resolves.toEqual({ - moved: false, - from: "http://127.0.0.1:18800", - }); - expect(relayMocks.stopChromeExtensionRelayServer).toHaveBeenCalledWith({ - cdpUrl: "http://127.0.0.1:18800", - }); - expect(trashMocks.movePathToTrash).not.toHaveBeenCalled(); - }); - it("rejects remote non-extension profiles", async () => { const ops = createStatelessResetOps({ ...localOpenClawProfile(), diff --git a/src/browser/server-context.reset.ts b/src/browser/server-context.reset.ts index 09bc31cbf38..ea478f56f31 100644 --- a/src/browser/server-context.reset.ts +++ b/src/browser/server-context.reset.ts @@ -1,7 +1,6 @@ import fs from "node:fs"; import type { ResolvedBrowserProfile } from "./config.js"; import { BrowserResetUnsupportedError } from "./errors.js"; -import { stopChromeExtensionRelayServer } from "./extension-relay.js"; import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; import type { ProfileRuntimeState } from "./server-context.types.js"; import { movePathToTrash } from "./trash.js"; @@ -36,10 +35,6 @@ export function createProfileResetOps({ }: ResetDeps): ResetOps { const capabilities = getBrowserProfileCapabilities(profile); const resetProfile = async () => { - if (capabilities.requiresRelay) { - await stopChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch(() => {}); - return { moved: false, from: profile.cdpUrl }; - } if (!capabilities.supportsReset) { throw new BrowserResetUnsupportedError( `reset-profile is only supported for local profiles (profile "${profile.name}" is remote).`, diff --git a/src/browser/server-context.selection.ts b/src/browser/server-context.selection.ts index f0ce3e25e06..1a744e06b09 100644 --- a/src/browser/server-context.selection.ts +++ b/src/browser/server-context.selection.ts @@ -36,28 +36,9 @@ export function createProfileSelectionOps({ const ensureTabAvailable = async (targetId?: string): Promise => { await ensureBrowserAvailable(); const profileState = getProfileState(); - let tabs1 = await listTabs(); + const tabs1 = await listTabs(); if (tabs1.length === 0) { - if (capabilities.requiresAttachedTab) { - // Chrome extension relay can briefly drop its WebSocket connection (MV3 service worker - // lifecycle, relay restart). If we previously had a target selected, wait briefly for - // the extension to reconnect and re-announce its attached tabs before failing. - if (profileState.lastTargetId?.trim()) { - const deadlineAt = Date.now() + 3_000; - while (tabs1.length === 0 && Date.now() < deadlineAt) { - await new Promise((resolve) => setTimeout(resolve, 200)); - tabs1 = await listTabs(); - } - } - if (tabs1.length === 0) { - throw new BrowserTabNotFoundError( - `tab not found (no attached Chrome tabs for profile "${profile.name}"). ` + - "Click the OpenClaw Browser Relay toolbar icon on the tab you want to control (badge ON).", - ); - } - } else { - await openTab("about:blank"); - } + await openTab("about:blank"); } const tabs = await listTabs(); diff --git a/src/browser/server-lifecycle.test.ts b/src/browser/server-lifecycle.test.ts index 5ef331f1784..c64e054f96a 100644 --- a/src/browser/server-lifecycle.test.ts +++ b/src/browser/server-lifecycle.test.ts @@ -1,13 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { resolveProfileMock, ensureChromeExtensionRelayServerMock } = vi.hoisted(() => ({ - resolveProfileMock: vi.fn(), - ensureChromeExtensionRelayServerMock: vi.fn(), -})); - -const { stopOpenClawChromeMock, stopChromeExtensionRelayServerMock } = vi.hoisted(() => ({ +const { stopOpenClawChromeMock } = vi.hoisted(() => ({ stopOpenClawChromeMock: vi.fn(async () => {}), - stopChromeExtensionRelayServerMock: vi.fn(async () => true), })); const { createBrowserRouteContextMock, listKnownProfileNamesMock } = vi.hoisted(() => ({ @@ -19,15 +13,6 @@ vi.mock("./chrome.js", () => ({ stopOpenClawChrome: stopOpenClawChromeMock, })); -vi.mock("./config.js", () => ({ - resolveProfile: resolveProfileMock, -})); - -vi.mock("./extension-relay.js", () => ({ - ensureChromeExtensionRelayServer: ensureChromeExtensionRelayServerMock, - stopChromeExtensionRelayServer: stopChromeExtensionRelayServerMock, -})); - vi.mock("./server-context.js", () => ({ createBrowserRouteContext: createBrowserRouteContextMock, listKnownProfileNames: listKnownProfileNamesMock, @@ -36,49 +21,13 @@ vi.mock("./server-context.js", () => ({ import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js"; describe("ensureExtensionRelayForProfiles", () => { - beforeEach(() => { - resolveProfileMock.mockClear(); - ensureChromeExtensionRelayServerMock.mockClear(); - }); - - it("starts relay only for extension profiles", async () => { - resolveProfileMock.mockImplementation((_resolved: unknown, name: string) => { - if (name === "chrome-relay") { - return { driver: "extension", cdpUrl: "http://127.0.0.1:18888" }; - } - return { driver: "openclaw", cdpUrl: "http://127.0.0.1:18889" }; - }); - ensureChromeExtensionRelayServerMock.mockResolvedValue(undefined); - - await ensureExtensionRelayForProfiles({ - resolved: { - profiles: { - "chrome-relay": {}, - openclaw: {}, - }, - } as never, - onWarn: vi.fn(), - }); - - expect(ensureChromeExtensionRelayServerMock).toHaveBeenCalledTimes(1); - expect(ensureChromeExtensionRelayServerMock).toHaveBeenCalledWith({ - cdpUrl: "http://127.0.0.1:18888", - }); - }); - - it("reports relay startup errors", async () => { - resolveProfileMock.mockReturnValue({ driver: "extension", cdpUrl: "http://127.0.0.1:18888" }); - ensureChromeExtensionRelayServerMock.mockRejectedValue(new Error("boom")); - const onWarn = vi.fn(); - - await ensureExtensionRelayForProfiles({ - resolved: { profiles: { "chrome-relay": {} } } as never, - onWarn, - }); - - expect(onWarn).toHaveBeenCalledWith( - 'Chrome extension relay init failed for profile "chrome-relay": Error: boom', - ); + it("is a no-op after removing the Chrome extension relay path", async () => { + await expect( + ensureExtensionRelayForProfiles({ + resolved: { profiles: {} } as never, + onWarn: vi.fn(), + }), + ).resolves.toBeUndefined(); }); }); @@ -87,14 +36,13 @@ describe("stopKnownBrowserProfiles", () => { createBrowserRouteContextMock.mockClear(); listKnownProfileNamesMock.mockClear(); stopOpenClawChromeMock.mockClear(); - stopChromeExtensionRelayServerMock.mockClear(); }); it("stops all known profiles and ignores per-profile failures", async () => { - listKnownProfileNamesMock.mockReturnValue(["openclaw", "chrome-relay"]); + listKnownProfileNamesMock.mockReturnValue(["openclaw", "user"]); const stopMap: Record> = { openclaw: vi.fn(async () => {}), - "chrome-relay": vi.fn(async () => { + user: vi.fn(async () => { throw new Error("profile stop failed"); }), }; @@ -112,12 +60,12 @@ describe("stopKnownBrowserProfiles", () => { }); expect(stopMap.openclaw).toHaveBeenCalledTimes(1); - expect(stopMap["chrome-relay"]).toHaveBeenCalledTimes(1); + expect(stopMap.user).toHaveBeenCalledTimes(1); expect(onWarn).not.toHaveBeenCalled(); }); it("stops tracked runtime browsers even when the profile no longer resolves", async () => { - listKnownProfileNamesMock.mockReturnValue(["deleted-local", "deleted-extension"]); + listKnownProfileNamesMock.mockReturnValue(["deleted-local"]); createBrowserRouteContextMock.mockReturnValue({ forProfile: vi.fn(() => { throw new Error("profile not found"); @@ -134,18 +82,7 @@ describe("stopKnownBrowserProfiles", () => { }, }; const launchedBrowser = localRuntime.running; - const extensionRuntime = { - profile: { - name: "deleted-extension", - driver: "extension", - cdpUrl: "http://127.0.0.1:19999", - }, - running: null, - }; - const profiles = new Map([ - ["deleted-local", localRuntime], - ["deleted-extension", extensionRuntime], - ]); + const profiles = new Map([["deleted-local", localRuntime]]); const state = { resolved: { profiles: {} }, profiles, @@ -158,9 +95,6 @@ describe("stopKnownBrowserProfiles", () => { expect(stopOpenClawChromeMock).toHaveBeenCalledWith(launchedBrowser); expect(localRuntime.running).toBeNull(); - expect(stopChromeExtensionRelayServerMock).toHaveBeenCalledWith({ - cdpUrl: "http://127.0.0.1:19999", - }); }); it("warns when profile enumeration fails", async () => { diff --git a/src/browser/server-lifecycle.ts b/src/browser/server-lifecycle.ts index 7053d924b6d..1dd322f2bc9 100644 --- a/src/browser/server-lifecycle.ts +++ b/src/browser/server-lifecycle.ts @@ -1,32 +1,18 @@ import { stopOpenClawChrome } from "./chrome.js"; import type { ResolvedBrowserConfig } from "./config.js"; -import { resolveProfile } from "./config.js"; -import { - ensureChromeExtensionRelayServer, - stopChromeExtensionRelayServer, -} from "./extension-relay.js"; import { type BrowserServerState, createBrowserRouteContext, listKnownProfileNames, } from "./server-context.js"; -export async function ensureExtensionRelayForProfiles(params: { +export async function ensureExtensionRelayForProfiles(_params: { resolved: ResolvedBrowserConfig; onWarn: (message: string) => void; }) { - for (const name of Object.keys(params.resolved.profiles)) { - const profile = resolveProfile(params.resolved, name); - if (!profile || profile.driver !== "extension") { - continue; - } - await ensureChromeExtensionRelayServer({ - cdpUrl: profile.cdpUrl, - bindHost: params.resolved.relayBindHost, - }).catch((err) => { - params.onWarn(`Chrome extension relay init failed for profile "${name}": ${String(err)}`); - }); - } + // Intentional no-op: the Chrome extension relay path has been removed. + // runtime-lifecycle still calls this helper, so keep the stub until the next + // breaking cleanup rather than changing the call graph in a patch release. } export async function stopKnownBrowserProfiles(params: { @@ -50,12 +36,6 @@ export async function stopKnownBrowserProfiles(params: { runtime.running = null; continue; } - if (runtime?.profile.driver === "extension") { - await stopChromeExtensionRelayServer({ cdpUrl: runtime.profile.cdpUrl }).catch( - () => false, - ); - continue; - } await ctx.forProfile(name).stopRunningBrowser(); } catch { // ignore diff --git a/src/browser/server.control-server.test-harness.ts b/src/browser/server.control-server.test-harness.ts index 118c83dbb73..57b8d191655 100644 --- a/src/browser/server.control-server.test-harness.ts +++ b/src/browser/server.control-server.test-harness.ts @@ -18,7 +18,7 @@ type HarnessState = { cdpPort?: number; cdpUrl?: string; color: string; - driver?: "openclaw" | "extension" | "existing-session"; + driver?: "openclaw" | "existing-session"; attachOnly?: boolean; } >; diff --git a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts index 8d84ef3c7a8..5ad1d5f7bd2 100644 --- a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts +++ b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts @@ -116,18 +116,29 @@ describe("profile CRUD endpoints", () => { const createBadRemoteBody = (await createBadRemote.json()) as { error: string }; expect(createBadRemoteBody.error).toContain("cdpUrl"); - const createBadExtension = await realFetch(`${base}/profiles/create`, { + const createClawd = await realFetch(`${base}/profiles/create`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: "badextension", - driver: "extension", - cdpUrl: "http://10.0.0.42:9222", - }), + body: JSON.stringify({ name: "legacyclawd", driver: "clawd" }), }); - expect(createBadExtension.status).toBe(400); - const createBadExtensionBody = (await createBadExtension.json()) as { error: string }; - expect(createBadExtensionBody.error).toContain("loopback cdpUrl host"); + expect(createClawd.status).toBe(200); + const createClawdBody = (await createClawd.json()) as { + profile?: string; + transport?: string; + cdpPort?: number | null; + }; + expect(createClawdBody.profile).toBe("legacyclawd"); + expect(createClawdBody.transport).toBe("cdp"); + expect(createClawdBody.cdpPort).toBeTypeOf("number"); + + const createLegacyDriver = await realFetch(`${base}/profiles/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "legacy", driver: "extension" }), + }); + expect(createLegacyDriver.status).toBe(400); + const createLegacyDriverBody = (await createLegacyDriver.json()) as { error: string }; + expect(createLegacyDriverBody.error).toContain('unsupported profile driver "extension"'); const deleteMissing = await realFetch(`${base}/profiles/nonexistent`, { method: "DELETE", diff --git a/src/cli/browser-cli-extension.test.ts b/src/cli/browser-cli-extension.test.ts deleted file mode 100644 index 1c8c74d8c6e..00000000000 --- a/src/cli/browser-cli-extension.test.ts +++ /dev/null @@ -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(), - 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(); - 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); - }); - }); -}); diff --git a/src/cli/browser-cli-extension.ts b/src/cli/browser-cli-extension.ts deleted file mode 100644 index a04059dfda6..00000000000 --- a/src/cli/browser-cli-extension.ts +++ /dev/null @@ -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.")); - } - }); -} diff --git a/src/cli/browser-cli-manage.ts b/src/cli/browser-cli-manage.ts index ddf207b28f0..e13b7af003a 100644 --- a/src/cli/browser-cli-manage.ts +++ b/src/cli/browser-cli-manage.ts @@ -105,14 +105,14 @@ function logBrowserTabs(tabs: BrowserTab[], json?: boolean) { function usesChromeMcpTransport(params: { transport?: BrowserTransport; - driver?: "openclaw" | "extension" | "existing-session"; + driver?: "openclaw" | "existing-session"; }): boolean { return params.transport === "chrome-mcp" || params.driver === "existing-session"; } function formatBrowserConnectionSummary(params: { transport?: BrowserTransport; - driver?: "openclaw" | "extension" | "existing-session"; + driver?: "openclaw" | "existing-session"; isRemote?: boolean; cdpPort?: number | null; cdpUrl?: string | null; @@ -455,10 +455,7 @@ export function registerBrowserManageCommands( .requiredOption("--name ", "Profile name (lowercase, numbers, hyphens)") .option("--color ", "Profile color (hex format, e.g. #0066CC)") .option("--cdp-url ", "CDP URL for remote Chrome (http/https)") - .option( - "--driver ", - "Profile driver (openclaw|extension|existing-session). Default: openclaw", - ) + .option("--driver ", "Profile driver (openclaw|existing-session). Default: openclaw") .action( async (opts: { name: string; color?: string; cdpUrl?: string; driver?: string }, cmd) => { const parent = parentOpts(cmd); @@ -472,12 +469,7 @@ export function registerBrowserManageCommands( name: opts.name, color: opts.color, cdpUrl: opts.cdpUrl, - driver: - opts.driver === "extension" - ? "extension" - : opts.driver === "existing-session" - ? "existing-session" - : undefined, + driver: opts.driver === "existing-session" ? "existing-session" : undefined, }, }, { timeoutMs: 10_000 }, @@ -489,11 +481,7 @@ export function registerBrowserManageCommands( defaultRuntime.log( info( `🦞 Created profile "${result.profile}"\n${loc}\n color: ${result.color}${ - opts.driver === "extension" - ? "\n driver: extension" - : opts.driver === "existing-session" - ? "\n driver: existing-session" - : "" + opts.driver === "existing-session" ? "\n driver: existing-session" : "" }`, ), ); diff --git a/src/cli/browser-cli.ts b/src/cli/browser-cli.ts index 085d06cad80..fd4c9a4a8b3 100644 --- a/src/cli/browser-cli.ts +++ b/src/cli/browser-cli.ts @@ -7,7 +7,6 @@ import { registerBrowserActionInputCommands } from "./browser-cli-actions-input. import { registerBrowserActionObserveCommands } from "./browser-cli-actions-observe.js"; import { registerBrowserDebugCommands } from "./browser-cli-debug.js"; import { browserActionExamples, browserCoreExamples } from "./browser-cli-examples.js"; -import { registerBrowserExtensionCommands } from "./browser-cli-extension.js"; import { registerBrowserInspectCommands } from "./browser-cli-inspect.js"; import { registerBrowserManageCommands } from "./browser-cli-manage.js"; import type { BrowserParentOpts } from "./browser-cli-shared.js"; @@ -46,7 +45,6 @@ export function registerBrowserCli(program: Command) { const parentOpts = (cmd: Command) => cmd.parent?.opts?.() as BrowserParentOpts; registerBrowserManageCommands(browser, parentOpts); - registerBrowserExtensionCommands(browser, parentOpts); registerBrowserInspectCommands(browser, parentOpts); registerBrowserActionInputCommands(browser, parentOpts); registerBrowserActionObserveCommands(browser, parentOpts); diff --git a/src/commands/doctor-browser.test.ts b/src/commands/doctor-browser.test.ts new file mode 100644 index 00000000000..da59fe5ed9a --- /dev/null +++ b/src/commands/doctor-browser.test.ts @@ -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", + ); + }); +}); diff --git a/src/commands/doctor-browser.ts b/src/commands/doctor-browser.ts new file mode 100644 index 00000000000..482e370b052 --- /dev/null +++ b/src/commands/doctor-browser.ts @@ -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 | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function collectChromeMcpProfileNames(cfg: OpenClawConfig): string[] { + const browser = asRecord(cfg.browser); + if (!browser) { + return []; + } + + const names = new Set(); + 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"); +} diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 265c90197e2..a1b204b5990 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -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 }).browser ?? {}; + expect(browser.relayBindHost).toBeUndefined(); + expect( + ((browser.profiles as Record)?.chromeLive ?? {}).driver, + ).toBe("existing-session"); + }); + + it("notes legacy browser extension migration changes", async () => { + const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + try { + await runDoctorConfigWithInput({ + config: { + browser: { + relayBindHost: "127.0.0.1", + profiles: { + chromeLive: { + driver: "extension", + color: "#00AA00", + }, + }, + }, + }, + run: loadAndMaybeMigrateDoctorConfig, + }); + + const messages = noteSpy.mock.calls + .filter((call) => call[1] === "Doctor changes") + .map((call) => String(call[0])); + expect( + messages.some((line) => line.includes('browser.profiles.chromeLive.driver "extension"')), + ).toBe(true); + expect(messages.some((line) => line.includes("browser.relayBindHost"))).toBe(true); + } finally { + noteSpy.mockRestore(); + } + }); + it("preserves discord streaming intent while stripping unsupported keys on repair", async () => { const result = await runDoctorConfigWithInput({ repair: true, diff --git a/src/commands/doctor-legacy-config.ts b/src/commands/doctor-legacy-config.ts index 50c9f38eb40..2d6bfa83a11 100644 --- a/src/commands/doctor-legacy-config.ts +++ b/src/commands/doctor-legacy-config.ts @@ -291,6 +291,67 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { } }; + const normalizeLegacyBrowserProfiles = () => { + const rawBrowser = next.browser; + if (!isRecord(rawBrowser)) { + return; + } + + const browser = structuredClone(rawBrowser); + let browserChanged = false; + + if ("relayBindHost" in browser) { + delete browser.relayBindHost; + browserChanged = true; + changes.push( + "Removed browser.relayBindHost (legacy Chrome extension relay setting; host-local Chrome now uses Chrome MCP existing-session attach).", + ); + } + + const rawProfiles = browser.profiles; + if (!isRecord(rawProfiles)) { + if (!browserChanged) { + return; + } + next = { ...next, browser }; + return; + } + + const profiles = { ...rawProfiles }; + let profilesChanged = false; + for (const [profileName, rawProfile] of Object.entries(rawProfiles)) { + if (!isRecord(rawProfile)) { + continue; + } + const rawDriver = typeof rawProfile.driver === "string" ? rawProfile.driver.trim() : ""; + if (rawDriver !== "extension") { + continue; + } + profiles[profileName] = { + ...rawProfile, + driver: "existing-session", + }; + profilesChanged = true; + changes.push( + `Moved browser.profiles.${profileName}.driver "extension" → "existing-session" (Chrome MCP attach).`, + ); + } + + if (profilesChanged) { + browser.profiles = profiles; + browserChanged = true; + } + + if (!browserChanged) { + return; + } + + next = { + ...next, + browser, + }; + }; + const seedMissingDefaultAccountsFromSingleAccountBase = () => { const channels = next.channels as Record | undefined; if (!channels) { @@ -365,6 +426,7 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { normalizeProvider("slack"); normalizeProvider("discord"); seedMissingDefaultAccountsFromSingleAccountBase(); + normalizeLegacyBrowserProfiles(); const normalizeBrowserSsrFPolicyAlias = () => { const rawBrowser = next.browser; diff --git a/src/commands/doctor.fast-path-mocks.ts b/src/commands/doctor.fast-path-mocks.ts index f05e3d929a7..f9d25da73d8 100644 --- a/src/commands/doctor.fast-path-mocks.ts +++ b/src/commands/doctor.fast-path-mocks.ts @@ -8,6 +8,10 @@ vi.mock("./doctor-bootstrap-size.js", () => ({ noteBootstrapFileSize: vi.fn().mockResolvedValue(undefined), })); +vi.mock("./doctor-browser.js", () => ({ + noteChromeMcpBrowserReadiness: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("./doctor-gateway-daemon-flow.js", () => ({ maybeRepairGatewayDaemon: vi.fn().mockResolvedValue(undefined), })); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index bdde2781ff9..3e4cbebe5d0 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -29,6 +29,7 @@ import { noteAuthProfileHealth, } from "./doctor-auth.js"; import { noteBootstrapFileSize } from "./doctor-bootstrap-size.js"; +import { noteChromeMcpBrowserReadiness } from "./doctor-browser.js"; import { doctorShellCompletion } from "./doctor-completion.js"; import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; import { maybeRepairLegacyCronStore } from "./doctor-cron.js"; @@ -236,6 +237,7 @@ export async function doctorCommand( await noteMacLaunchctlGatewayEnvOverrides(cfg); await noteSecurityWarnings(cfg); + await noteChromeMcpBrowserReadiness(cfg); await noteOpenAIOAuthTlsPrerequisites({ cfg, deep: options.deep === true, diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 7de4e592b23..2680013a717 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -422,7 +422,7 @@ const ENUM_EXPECTATIONS: Record = { "gateway.bind": ['"auto"', '"lan"', '"loopback"', '"custom"', '"tailnet"'], "gateway.auth.mode": ['"none"', '"token"', '"password"', '"trusted-proxy"'], "gateway.tailscale.mode": ['"off"', '"serve"', '"funnel"'], - "browser.profiles.*.driver": ['"openclaw"', '"clawd"', '"extension"'], + "browser.profiles.*.driver": ['"openclaw"', '"clawd"', '"existing-session"'], "discovery.mdns.mode": ['"off"', '"minimal"', '"full"'], "wizard.lastRunMode": ['"local"', '"remote"'], "diagnostics.otel.protocol": ['"http/protobuf"', '"grpc"'], diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 8a71c0e9035..46cce98d193 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -254,8 +254,6 @@ export const FIELD_HELP: Record = { "Starting local CDP port used for auto-allocated browser profile ports. Increase this when host-level port defaults conflict with other local services.", "browser.defaultProfile": "Default browser profile name selected when callers do not explicitly choose a profile. Use a stable low-privilege profile as the default to reduce accidental cross-context state use.", - "browser.relayBindHost": - "Bind IP address for the Chrome extension relay listener. Leave unset for loopback-only access, or set an explicit non-loopback IP such as 0.0.0.0 only when the relay must be reachable across network namespaces (for example WSL2) and the surrounding network is already trusted.", "browser.profiles": "Named browser profile connection map used for explicit routing to CDP ports or URLs with optional metadata. Keep profile names consistent and avoid overlapping endpoint definitions.", "browser.profiles.*.cdpPort": @@ -263,7 +261,7 @@ export const FIELD_HELP: Record = { "browser.profiles.*.cdpUrl": "Per-profile CDP websocket URL used for explicit remote browser routing by profile name. Use this when profile connections terminate on remote hosts or tunnels.", "browser.profiles.*.driver": - 'Per-profile browser driver mode: "openclaw" (or legacy "clawd") or "extension" depending on connection/runtime strategy. Use the driver that matches your browser control stack to avoid protocol mismatches.', + 'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, or use "existing-session" for host-local Chrome MCP attachment.', "browser.profiles.*.attachOnly": "Per-profile attach-only override that skips local browser launch and only attaches to an existing CDP endpoint. Useful when one profile is externally managed but others are locally launched.", "browser.profiles.*.color": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index c8fb887924b..6843b8f410f 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -120,7 +120,6 @@ export const FIELD_LABELS: Record = { "browser.attachOnly": "Browser Attach-only Mode", "browser.cdpPortRangeStart": "Browser CDP Port Range Start", "browser.defaultProfile": "Browser Default Profile", - "browser.relayBindHost": "Browser Relay Bind Address", "browser.profiles": "Browser Profiles", "browser.profiles.*.cdpPort": "Browser Profile CDP Port", "browser.profiles.*.cdpUrl": "Browser Profile CDP URL", diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts index 5f8e28a0ebe..b50795fd9d0 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -4,7 +4,7 @@ export type BrowserProfileConfig = { /** CDP URL for this profile (use for remote Chrome). */ cdpUrl?: string; /** Profile driver (default: openclaw). */ - driver?: "openclaw" | "clawd" | "extension" | "existing-session"; + driver?: "openclaw" | "clawd" | "existing-session"; /** If true, never launch a browser for this profile; only attach. Falls back to browser.attachOnly. */ attachOnly?: boolean; /** Profile color (hex). Auto-assigned at creation. */ @@ -66,10 +66,4 @@ export type BrowserConfig = { * Example: ["--window-size=1920,1080", "--disable-infobars"] */ extraArgs?: string[]; - /** - * Bind address for the Chrome extension relay server. - * Default: "127.0.0.1". Set to "0.0.0.0" for WSL2 or other environments where - * the relay must be reachable from a different network namespace. - */ - relayBindHost?: string; }; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 7c9b510080f..345c86b3097 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -360,12 +360,7 @@ export const OpenClawSchema = z cdpPort: z.number().int().min(1).max(65535).optional(), cdpUrl: z.string().optional(), driver: z - .union([ - z.literal("openclaw"), - z.literal("clawd"), - z.literal("extension"), - z.literal("existing-session"), - ]) + .union([z.literal("openclaw"), z.literal("clawd"), z.literal("existing-session")]) .optional(), attachOnly: z.boolean().optional(), color: HexColorSchema, @@ -380,7 +375,6 @@ export const OpenClawSchema = z ) .optional(), extraArgs: z.array(z.string()).optional(), - relayBindHost: z.union([z.string().ipv4(), z.string().ipv6()]).optional(), }) .strict() .optional(), diff --git a/src/node-host/invoke-browser.test.ts b/src/node-host/invoke-browser.test.ts index c1dd0d1df76..7f8c51a2b39 100644 --- a/src/node-host/invoke-browser.test.ts +++ b/src/node-host/invoke-browser.test.ts @@ -70,12 +70,12 @@ describe("runBrowserProxyCommand", () => { JSON.stringify({ method: "GET", path: "/snapshot", - profile: "chrome-relay", + profile: "openclaw", timeoutMs: 5, }), ), ).rejects.toThrow( - /browser proxy timed out for GET \/snapshot after 5ms; ws-backed browser action; profile=chrome-relay; status\(running=true, cdpHttp=true, cdpReady=false, cdpUrl=http:\/\/127\.0\.0\.1:18792\)/, + /browser proxy timed out for GET \/snapshot after 5ms; ws-backed browser action; profile=openclaw; status\(running=true, cdpHttp=true, cdpReady=false, cdpUrl=http:\/\/127\.0\.0\.1:18792\)/, ); }); @@ -150,7 +150,7 @@ describe("runBrowserProxyCommand", () => { JSON.stringify({ method: "POST", path: "/act", - profile: "chrome-relay", + profile: "openclaw", timeoutMs: 50, }), ),