diff --git a/docs/docs.json b/docs/docs.json index b8f67c66d82..94ea79b5fdc 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1352,6 +1352,7 @@ "group": "Web browser", "pages": [ "tools/browser", + "tools/chrome-extension", "tools/browser-control", "tools/browser-login", "tools/browser-linux-troubleshooting", diff --git a/docs/docs_map.md b/docs/docs_map.md index 5af09dffdf5..b2b69d140f1 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -8985,7 +8985,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Plugin control - H2: Agent guidance - H2: Missing browser command or tool - - H2: Profiles: openclaw vs user + - H2: Profiles: openclaw, user, chrome - H2: Configuration - H3: Screenshot vision (text-only model support) - H2: Use Brave or another Chromium-based browser @@ -9025,6 +9025,18 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Headings: - H2: Related +## tools/chrome-extension.md + +- Route: /tools/chrome-extension +- Headings: + - H1: Chrome extension + - H2: How it works + - H2: Install and pair + - H2: Use it + - H2: Remote browser nodes + - H2: Diagnostics + - H2: Security model + ## tools/clawhub.md - Route: /tools/clawhub diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 78a4b00f9b9..4c4a7348e92 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -118,17 +118,24 @@ channel config behavior. `plugins.entries.browser.enabled=true` and `tools.alsoAllow: ["browser"]` do not substitute for allowlist membership by themselves. Removing `plugins.allow` entirely also restores the default. -## Profiles: `openclaw` vs `user` +## Profiles: `openclaw`, `user`, `chrome` - `openclaw`: managed, isolated browser (no extension required). - `user`: built-in Chrome DevTools MCP attach profile for your **real - signed-in Chrome** session. + signed-in Chrome** session. Chrome shows a blocking "Allow remote debugging?" + prompt the first time OpenClaw attaches, so someone must be at the computer. +- `chrome`: built-in [Chrome extension](/tools/chrome-extension) profile for + your **real signed-in Chrome** session. Works from a phone with nobody at the + desk because it drives tabs through the OpenClaw browser extension instead of + the remote-debugging port, so there is no "Allow remote debugging?" prompt. 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. +- Prefer `profile="chrome"` (extension) when existing logged-in sessions matter + and the user is **away from the computer** (Telegram, WhatsApp, etc.). +- Prefer `profile="user"` (Chrome MCP) when existing logged-in sessions matter + and the user is **at the computer** to approve the attach prompt. - `profile` is the explicit override when you want a specific browser mode. Set `browser.defaultProfile: "openclaw"` if you want managed mode by default. @@ -310,6 +317,7 @@ main model can read the screenshot directly. - Default profile is `openclaw` (managed standalone). 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. - `driver: "existing-session"` uses Chrome DevTools MCP instead of raw CDP. It can attach through Chrome MCP auto-connect, or through `cdpUrl` when you already have a DevTools endpoint for the running browser. +- `driver: "extension"` drives your signed-in Chrome through the [OpenClaw Chrome extension](/tools/chrome-extension). The relay owns its loopback endpoint, so these profiles do not accept `cdpUrl`. This is the only signed-in-browser mode that works with nobody at the computer. - Set `browser.profiles..userDataDir` when an existing-session profile should attach to a non-default Chromium user profile (Brave, Edge, etc.). This path also accepts `~` for your OS home directory. diff --git a/docs/tools/chrome-extension.md b/docs/tools/chrome-extension.md new file mode 100644 index 00000000000..099a8e4c1e1 --- /dev/null +++ b/docs/tools/chrome-extension.md @@ -0,0 +1,130 @@ +--- +summary: "Chrome extension: let OpenClaw drive your signed-in Chrome with no remote-debugging prompt" +read_when: + - You want an agent to drive your real signed-in Chrome from your phone + - You keep hitting the Chrome "Allow remote debugging?" prompt with nobody at the desk + - You want to understand the security model of browser takeover via the extension +title: "Chrome Extension" +--- + +# Chrome extension + +The OpenClaw Chrome extension lets an agent control your **signed-in Chrome +tabs** without launching a separate managed browser, and **without** Chrome's +blocking "Allow remote debugging?" prompt. + +This matters when you drive OpenClaw from a phone (Telegram, WhatsApp, etc.): +the [`user` profile](/tools/browser#profiles-openclaw-user-chrome) attaches over +Chrome's remote-debugging port, which pops a desktop consent dialog nobody can +click when you are away. The extension uses the `chrome.debugger` API instead, +so the only in-page hint is Chrome's dismissible "OpenClaw started debugging +this browser" banner. + +This is the same shape used by Anthropic's Claude in Chrome and OpenAI's Codex +Chrome extensions. + +## How it works + +Three parts: + +- **Browser control service** (Gateway or node host): the API the `browser` + tool calls. +- **Extension relay** (loopback WebSocket): a small server the control service + starts on `127.0.0.1`. It presents a Chrome DevTools Protocol endpoint to + OpenClaw and speaks to the extension. Both sides authenticate with a + host-local token (see below). +- **OpenClaw Chrome extension** (MV3): attaches to tabs with `chrome.debugger`, + forwards CDP traffic, and manages the **OpenClaw tab group**. + +OpenClaw only sees and controls tabs that are in the **OpenClaw tab group**. The +group is the consent boundary: drag a tab in to share it, drag it out (or click +the toolbar button) to revoke access instantly. + +## Install and pair + +1. Print the unpacked extension path: + + ```bash + openclaw browser extension path + ``` + +2. Open `chrome://extensions`, enable **Developer mode**, click **Load + unpacked**, and select the printed directory. + +3. Print the pairing string: + + ```bash + openclaw browser extension pair + ``` + +4. Click the OpenClaw toolbar icon and paste the pairing string into the popup. + The badge turns **ON** when the extension connects to the relay. + +The pairing token is a **host-local secret** created on first use and stored +under `credentials/` in the state directory (mode `0600`). Each machine that +runs a browser — the Gateway host and every browser node host — owns its own +token, so no credential has to travel between machines. To rotate it, delete the +`browser-extension-relay.secret` file and pair again. + +## Use it + +Select the built-in `chrome` profile in a `browser` tool call, or make it the +default: + +```bash +openclaw config set browser.defaultProfile chrome +``` + +```json5 +{ + browser: { + profiles: { + chrome: { driver: "extension", color: "#FF4500" }, + }, + }, +} +``` + +- Share a tab: click the OpenClaw toolbar button on that tab (it joins the + OpenClaw tab group), or drag any tab into the group. +- The agent can also open new tabs; those land in the group automatically. +- Revoke: click the button again, drag the tab out of the group, or dismiss + Chrome's debugging banner. The agent loses access to that tab immediately. + +## Remote browser nodes + +The extension works whether Chrome runs on the Gateway host or on a separate +[browser node host](/tools/browser#local-vs-remote-control). The relay is always +loopback-only and runs **on the machine with the browser**: + +- **Same host** (Gateway + Chrome on one machine): pair on that machine. +- **Remote node** (Chrome on a node, Gateway elsewhere): run + `openclaw browser extension path` / `pair` **on the node**, load and pair the + extension there. The Gateway proxies browser actions to the node over its + existing authenticated node link; the node's local relay drives the extension. + No new inbound port is opened on the node. + +The pairing token is per host, so each node prints its own string. + +## Diagnostics + +```bash +openclaw browser status --browser-profile chrome +openclaw browser doctor --browser-profile chrome +``` + +`doctor` reports the **Chrome extension relay** check as failing until the +extension popup shows **Connected**. + +## Security model + +- The relay binds loopback only; both WebSocket sides are authenticated with the + derived token, and the extension side is origin-checked to `chrome-extension://`. +- The agent can only see and drive tabs in the **OpenClaw tab group**. Your + other tabs stay private. +- Compared with the `user` (Chrome MCP) profile, which exposes your whole + signed-in browser once you approve the remote-debugging prompt, the extension + keeps the shared surface scoped to a tab group you control at a glance. + +See also: [Browser](/tools/browser) for the full profile model and the +managed `openclaw` and Chrome MCP `user` profiles. diff --git a/extensions/browser/chrome-extension/background.js b/extensions/browser/chrome-extension/background.js new file mode 100644 index 00000000000..b53a6906e4b --- /dev/null +++ b/extensions/browser/chrome-extension/background.js @@ -0,0 +1,433 @@ +// OpenClaw extension service worker. +// +// Thin transport between the OpenClaw extension relay (loopback WebSocket) and +// chrome.debugger. All CDP target synthesis lives server-side in the relay +// bridge; this worker only attaches tabs, forwards frames, and keeps the +// OpenClaw tab group in sync. Membership in that group is the user-visible +// consent boundary: only grouped tabs are reported to (and driven by) OpenClaw. +import { + OPENCLAW_TAB_GROUP_TITLE, + buildRelayWsUrl, + nearestGroupColor, + parsePairingString, + reconnectDelayMs, + toRelayTabInfo, +} from "./modules/relay-core.js"; + +const BADGE = { + off: { text: "", color: "#000000" }, + connecting: { text: "…", color: "#F59E0B" }, + on: { text: "ON", color: "#0F9D58" }, + error: { text: "!", color: "#B91C1C" }, +}; + +/** @type {WebSocket|null} */ +let relayWs = null; +let relayState = "off"; // off | connecting | on | error +let reconnectAttempt = 0; +let reconnectTimer = null; +/** Tab ids with an active chrome.debugger attachment. */ +const attachedTabs = new Set(); +/** In-flight attach promises per tab id (coalesces concurrent attaches). */ +const attachingTabs = new Map(); +/** Debounce handle for tab-list refreshes. */ +let tabsSyncTimer = null; + +function setBadge(kind) { + relayState = kind; + const cfg = BADGE[kind] ?? BADGE.off; + void chrome.action.setBadgeText({ text: cfg.text }); + void chrome.action.setBadgeBackgroundColor({ color: cfg.color }); +} + +async function getConfig() { + const stored = await chrome.storage.local.get(["relayUrl", "token", "groupColor"]); + return { + relayUrl: typeof stored.relayUrl === "string" ? stored.relayUrl : "", + token: typeof stored.token === "string" ? stored.token : "", + groupColor: typeof stored.groupColor === "string" ? stored.groupColor : "orange", + }; +} + +// --------------------------------------------------------------------------- +// Tab group management (the consent boundary) +// --------------------------------------------------------------------------- + +async function findOpenClawGroups() { + try { + return await chrome.tabGroups.query({ title: OPENCLAW_TAB_GROUP_TITLE }); + } catch { + return []; + } +} + +async function listSharedTabs() { + const groups = await findOpenClawGroups(); + const tabs = []; + for (const group of groups) { + const groupTabs = await chrome.tabs.query({ groupId: group.id }); + tabs.push(...groupTabs); + } + return tabs.filter((tab) => typeof tab.id === "number"); +} + +async function addTabToOpenClawGroup(tabId) { + const tab = await chrome.tabs.get(tabId); + const groups = await findOpenClawGroups(); + const sameWindowGroup = groups.find((group) => group.windowId === tab.windowId); + if (sameWindowGroup) { + await chrome.tabs.group({ tabIds: [tabId], groupId: sameWindowGroup.id }); + return; + } + const { groupColor } = await getConfig(); + const groupId = await chrome.tabs.group({ tabIds: [tabId] }); + await chrome.tabGroups.update(groupId, { + title: OPENCLAW_TAB_GROUP_TITLE, + color: groupColor, + }); +} + +async function removeTabFromOpenClawGroup(tabId) { + try { + await chrome.tabs.ungroup([tabId]); + } catch { + // tab may already be gone + } +} + +async function isTabShared(tabId) { + const shared = await listSharedTabs(); + return shared.some((tab) => tab.id === tabId); +} + +function scheduleTabsSync() { + if (tabsSyncTimer) { + return; + } + tabsSyncTimer = setTimeout(() => { + tabsSyncTimer = null; + void syncTabsToRelay(); + }, 150); +} + +async function syncTabsToRelay() { + if (!relayWs || relayWs.readyState !== WebSocket.OPEN) { + return; + } + const shared = await listSharedTabs(); + // Detach tabs the user pulled out of the group; leaving the group revokes + // agent access immediately (and clears the per-tab debugger state). + const sharedIds = new Set(shared.map((tab) => tab.id)); + for (const tabId of attachedTabs) { + if (!sharedIds.has(tabId)) { + void detachDebugger(tabId); + } + } + send({ type: "tabs", tabs: shared.map(toRelayTabInfo) }); +} + +// --------------------------------------------------------------------------- +// chrome.debugger transport +// --------------------------------------------------------------------------- + +async function attachDebugger(tabId) { + if (!(await isTabShared(tabId))) { + throw new Error(`tab ${tabId} is not in the ${OPENCLAW_TAB_GROUP_TITLE} tab group`); + } + // Coalesce concurrent attaches for one tab. Two relay attach commands (or an + // auto-attach racing an explicit share) would otherwise both call + // chrome.debugger.attach and the second throws "Another debugger is already + // attached". The bridge and this worker can also disagree after an MV3 restart. + const inFlight = attachingTabs.get(tabId); + if (inFlight) { + return await inFlight; + } + const attach = (async () => { + if (!attachedTabs.has(tabId)) { + try { + await chrome.debugger.attach({ tabId }, "1.3"); + } catch (err) { + // Treat an existing attachment as success; our own debugger is already on. + if (!String(err?.message ?? err).includes("Another debugger is already attached")) { + throw err; + } + } + attachedTabs.add(tabId); + } + const targets = await chrome.debugger.getTargets(); + const target = targets.find((candidate) => candidate.tabId === tabId && candidate.attached); + return { targetId: target?.id ?? `tab-${tabId}` }; + })(); + attachingTabs.set(tabId, attach); + try { + return await attach; + } finally { + attachingTabs.delete(tabId); + } +} + +async function detachDebugger(tabId) { + attachedTabs.delete(tabId); + try { + await chrome.debugger.detach({ tabId }); + } catch { + // already detached or tab gone + } +} + +chrome.debugger.onEvent.addListener((source, method, params) => { + if (typeof source.tabId !== "number") { + return; + } + send({ + type: "cdpEvent", + tabId: source.tabId, + ...(source.sessionId ? { sessionId: source.sessionId } : {}), + method, + params, + }); +}); + +chrome.debugger.onDetach.addListener((source, reason) => { + if (typeof source.tabId !== "number") { + return; + } + attachedTabs.delete(source.tabId); + send({ type: "detached", tabId: source.tabId, reason }); + if (reason === "canceled_by_user") { + // The user hit "Cancel" on Chrome's debugging infobar: treat it as a + // revocation and pull the tab out of the shared group so the agent does + // not immediately re-attach. + void removeTabFromOpenClawGroup(source.tabId).then(scheduleTabsSync); + } +}); + +// --------------------------------------------------------------------------- +// Relay connection +// --------------------------------------------------------------------------- + +function send(message) { + if (relayWs && relayWs.readyState === WebSocket.OPEN) { + relayWs.send(JSON.stringify(message)); + } +} + +async function handleRelayCommand(msg) { + const { seq } = msg; + try { + switch (msg.type) { + case "ping": + send({ type: "pong" }); + return; + case "attach": { + const result = await attachDebugger(msg.tabId); + send({ type: "result", seq, result }); + return; + } + case "detach": { + await detachDebugger(msg.tabId); + send({ type: "result", seq, result: {} }); + return; + } + case "cdp": { + const target = msg.sessionId + ? { tabId: msg.tabId, sessionId: msg.sessionId } + : { tabId: msg.tabId }; + const result = await chrome.debugger.sendCommand(target, msg.method, msg.params ?? {}); + send({ type: "result", seq, result: result ?? {} }); + return; + } + case "createTab": { + const tab = await chrome.tabs.create({ url: msg.url, active: msg.background !== true }); + await addTabToOpenClawGroup(tab.id); + scheduleTabsSync(); + send({ type: "result", seq, result: { tabId: tab.id } }); + return; + } + case "closeTab": { + await detachDebugger(msg.tabId); + await chrome.tabs.remove(msg.tabId); + send({ type: "result", seq, result: {} }); + return; + } + case "activateTab": { + const tab = await chrome.tabs.get(msg.tabId); + await chrome.tabs.update(msg.tabId, { active: true }); + if (typeof tab.windowId === "number") { + await chrome.windows.update(tab.windowId, { focused: true }); + } + send({ type: "result", seq, result: {} }); + return; + } + default: + if (typeof seq === "number") { + send({ type: "error", seq, message: `unknown relay command: ${msg.type}` }); + } + } + } catch (err) { + if (typeof seq === "number") { + send({ type: "error", seq, message: err instanceof Error ? err.message : String(err) }); + } + } +} + +async function sendHello() { + const shared = await listSharedTabs(); + const uaMatch = /Chrom(?:e|ium)\/[\d.]+/.exec(navigator.userAgent); + send({ + type: "hello", + userAgent: navigator.userAgent, + browserVersion: uaMatch ? uaMatch[0] : "Chrome/unknown", + extensionVersion: chrome.runtime.getManifest().version, + tabs: shared.map(toRelayTabInfo), + }); +} + +async function connectRelay() { + const { relayUrl, token } = await getConfig(); + if (!relayUrl || !token) { + setBadge("off"); + return; + } + if ( + relayWs && + (relayWs.readyState === WebSocket.OPEN || relayWs.readyState === WebSocket.CONNECTING) + ) { + return; + } + setBadge("connecting"); + let ws; + try { + ws = new WebSocket(buildRelayWsUrl(relayUrl, token)); + } catch { + setBadge("error"); + scheduleReconnect(); + return; + } + relayWs = ws; + ws.addEventListener("open", () => { + reconnectAttempt = 0; + setBadge("on"); + void sendHello(); + }); + ws.addEventListener("message", (event) => { + let msg; + try { + msg = JSON.parse(String(event.data)); + } catch { + return; + } + void handleRelayCommand(msg); + }); + ws.addEventListener("close", () => { + if (relayWs === ws) { + relayWs = null; + setBadge("error"); + scheduleReconnect(); + } + }); + // onclose follows onerror and drives the reconnect, so no error handler needed. +} + +function scheduleReconnect() { + if (reconnectTimer) { + return; + } + const delay = reconnectDelayMs(reconnectAttempt); + reconnectAttempt += 1; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + void connectRelay(); + }, delay); +} + +// --------------------------------------------------------------------------- +// Popup messaging + lifecycle +// --------------------------------------------------------------------------- + +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + void (async () => { + switch (msg?.type) { + case "getStatus": { + const { relayUrl } = await getConfig(); + const shared = await listSharedTabs(); + sendResponse({ + paired: Boolean(relayUrl), + state: relayState, + sharedTabCount: shared.length, + }); + return; + } + case "pair": { + const parsed = parsePairingString(msg.pairingString); + if (!parsed) { + sendResponse({ ok: false, error: "Invalid pairing string." }); + return; + } + await chrome.storage.local.set({ + relayUrl: parsed.relayUrl, + token: parsed.token, + groupColor: nearestGroupColor(msg.groupColor), + }); + reconnectAttempt = 0; + relayWs?.close(); + relayWs = null; + await connectRelay(); + sendResponse({ ok: true }); + return; + } + case "unpair": { + await chrome.storage.local.remove(["relayUrl", "token"]); + relayWs?.close(); + relayWs = null; + setBadge("off"); + sendResponse({ ok: true }); + return; + } + case "toggleShareTab": { + const tabId = msg.tabId; + if (typeof tabId !== "number") { + sendResponse({ ok: false, error: "No tab." }); + return; + } + if (await isTabShared(tabId)) { + await detachDebugger(tabId); + await removeTabFromOpenClawGroup(tabId); + scheduleTabsSync(); + sendResponse({ ok: true, shared: false }); + } else { + await addTabToOpenClawGroup(tabId); + scheduleTabsSync(); + sendResponse({ ok: true, shared: true }); + } + return; + } + case "isTabShared": { + sendResponse({ shared: await isTabShared(msg.tabId) }); + return; + } + default: + sendResponse({ ok: false, error: "unknown message" }); + } + })(); + return true; // keep sendResponse alive for the async path +}); + +chrome.tabs.onRemoved.addListener((tabId) => { + attachedTabs.delete(tabId); + scheduleTabsSync(); +}); +chrome.tabs.onUpdated.addListener(() => scheduleTabsSync()); +chrome.tabGroups.onUpdated.addListener(() => scheduleTabsSync()); +chrome.tabGroups.onRemoved.addListener(() => scheduleTabsSync()); + +// Watchdog: MV3 can stop this worker; the alarm revives it and re-connects. +chrome.alarms.create("openclaw-relay-watchdog", { periodInMinutes: 0.5 }); +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === "openclaw-relay-watchdog") { + void connectRelay(); + } +}); +chrome.runtime.onStartup.addListener(() => void connectRelay()); +chrome.runtime.onInstalled.addListener(() => void connectRelay()); +void connectRelay(); diff --git a/extensions/browser/chrome-extension/icons/icon128.png b/extensions/browser/chrome-extension/icons/icon128.png new file mode 100644 index 00000000000..533cc812de7 Binary files /dev/null and b/extensions/browser/chrome-extension/icons/icon128.png differ diff --git a/extensions/browser/chrome-extension/icons/icon16.png b/extensions/browser/chrome-extension/icons/icon16.png new file mode 100644 index 00000000000..1be23ae89b4 Binary files /dev/null and b/extensions/browser/chrome-extension/icons/icon16.png differ diff --git a/extensions/browser/chrome-extension/icons/icon32.png b/extensions/browser/chrome-extension/icons/icon32.png new file mode 100644 index 00000000000..f4c1be8a6a0 Binary files /dev/null and b/extensions/browser/chrome-extension/icons/icon32.png differ diff --git a/extensions/browser/chrome-extension/icons/icon48.png b/extensions/browser/chrome-extension/icons/icon48.png new file mode 100644 index 00000000000..d2a278af59e Binary files /dev/null and b/extensions/browser/chrome-extension/icons/icon48.png differ diff --git a/extensions/browser/chrome-extension/manifest.json b/extensions/browser/chrome-extension/manifest.json new file mode 100644 index 00000000000..28842a197af --- /dev/null +++ b/extensions/browser/chrome-extension/manifest.json @@ -0,0 +1,25 @@ +{ + "manifest_version": 3, + "name": "OpenClaw", + "version": "2.0.0", + "description": "Let OpenClaw agents browse with you. Tabs shared with OpenClaw live in a colored tab group; drag a tab out to revoke access.", + "icons": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "permissions": ["debugger", "tabs", "tabGroups", "storage", "alarms"], + "background": { "service_worker": "background.js", "type": "module" }, + "action": { + "default_title": "OpenClaw", + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "minimum_chrome_version": "125" +} diff --git a/extensions/browser/chrome-extension/modules/relay-core.d.ts b/extensions/browser/chrome-extension/modules/relay-core.d.ts new file mode 100644 index 00000000000..dcc83c0523e --- /dev/null +++ b/extensions/browser/chrome-extension/modules/relay-core.d.ts @@ -0,0 +1,21 @@ +// Types for the extension's pure-logic module (the runtime is plain ESM JS so +// it can load unbundled in Chrome). Kept in sync with relay-core.js. + +export const OPENCLAW_TAB_GROUP_TITLE: string; + +export function parsePairingString( + raw: unknown, +): { relayUrl: string; token: string } | null; + +export function buildRelayWsUrl(relayUrl: string, token: string): string; + +export function reconnectDelayMs(attempt: number): number; + +export function nearestGroupColor(hex: unknown): string; + +export function toRelayTabInfo(tab: { + id: number; + url?: string; + title?: string; + active?: boolean; +}): { tabId: number; url: string; title: string; active: boolean }; diff --git a/extensions/browser/chrome-extension/modules/relay-core.js b/extensions/browser/chrome-extension/modules/relay-core.js new file mode 100644 index 00000000000..ea46313d725 --- /dev/null +++ b/extensions/browser/chrome-extension/modules/relay-core.js @@ -0,0 +1,94 @@ +// Pure helpers for the OpenClaw extension: pairing-string parsing, reconnect +// backoff, and Chrome tab-group color mapping. No chrome.* usage here so the +// repo's vitest suite can exercise the logic directly. + +/** Tab group shown to the user; membership == what the agent may touch. */ +export const OPENCLAW_TAB_GROUP_TITLE = "OpenClaw"; + +const CHROME_GROUP_COLORS = { + grey: [128, 128, 128], + blue: [66, 133, 244], + red: [219, 68, 55], + yellow: [244, 180, 0], + green: [15, 157, 88], + pink: [233, 30, 99], + purple: [156, 39, 176], + cyan: [0, 188, 212], + orange: [255, 112, 32], +}; + +/** + * Parse a pairing string printed by `openclaw browser extension pair`. + * Shape: ws://127.0.0.1:/extension# + * Returns { relayUrl, token } or null when malformed. + */ +export function parsePairingString(raw) { + const trimmed = String(raw ?? "").trim(); + const hashIndex = trimmed.indexOf("#"); + if (hashIndex <= 0) { + return null; + } + const relayUrl = trimmed.slice(0, hashIndex); + const token = trimmed.slice(hashIndex + 1).trim(); + if (!token) { + return null; + } + let parsed; + try { + parsed = new URL(relayUrl); + } catch { + return null; + } + if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") { + return null; + } + if (!parsed.pathname.endsWith("/extension")) { + return null; + } + return { relayUrl, token }; +} + +/** Build the authenticated relay WebSocket URL (token travels as query). */ +export function buildRelayWsUrl(relayUrl, token) { + const url = new URL(relayUrl); + url.searchParams.set("token", token); + return url.toString(); +} + +/** Exponential reconnect backoff: 1s, 2s, 4s ... capped at 30s. */ +export function reconnectDelayMs(attempt) { + const capped = Math.min(Math.max(0, attempt), 5); + return Math.min(1000 * 2 ** capped, 30_000); +} + +/** Map a hex color to the closest Chrome tab-group color name. */ +export function nearestGroupColor(hex) { + const match = /^#?([0-9a-f]{6})$/i.exec(String(hex ?? "").trim()); + if (!match) { + return "orange"; + } + const value = Number.parseInt(match[1], 16); + const r = (value >> 16) & 0xff; + const g = (value >> 8) & 0xff; + const b = value & 0xff; + let best = "orange"; + let bestDistance = Number.POSITIVE_INFINITY; + for (const [name, [cr, cg, cb]] of Object.entries(CHROME_GROUP_COLORS)) { + const distance = (r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2; + if (distance < bestDistance) { + bestDistance = distance; + best = name; + } + } + return best; +} + +/** Normalize a chrome.tabs.Tab into the relay's tab info shape. */ +export function toRelayTabInfo(tab) { + return { + tabId: tab.id, + url: tab.url ?? "", + title: tab.title ?? "", + active: tab.active === true, + }; +} diff --git a/extensions/browser/chrome-extension/modules/relay-core.test.ts b/extensions/browser/chrome-extension/modules/relay-core.test.ts new file mode 100644 index 00000000000..ff920bcc87c --- /dev/null +++ b/extensions/browser/chrome-extension/modules/relay-core.test.ts @@ -0,0 +1,63 @@ +// Pure-logic tests for the OpenClaw Chrome extension. Runs under the +// extension-browser vitest glob (extensions/browser/**/*.test.ts). +import { describe, expect, it } from "vitest"; +import { + buildRelayWsUrl, + nearestGroupColor, + parsePairingString, + reconnectDelayMs, +} from "./relay-core.js"; + +describe("parsePairingString", () => { + it("parses a valid pairing string the CLI emits", () => { + const parsed = parsePairingString("ws://127.0.0.1:18797/extension#deadbeefcafe"); + expect(parsed).toEqual({ + relayUrl: "ws://127.0.0.1:18797/extension", + token: "deadbeefcafe", + }); + }); + + it("round-trips with the CLI pairing format", () => { + const port = 18797; + const token = "abc123"; + const pairing = `ws://127.0.0.1:${port}/extension#${token}`; + const parsed = parsePairingString(pairing); + if (!parsed) { + throw new Error("expected pairing string to parse"); + } + expect(buildRelayWsUrl(parsed.relayUrl, parsed.token)).toBe( + `ws://127.0.0.1:${port}/extension?token=${token}`, + ); + }); + + it("rejects malformed strings", () => { + expect(parsePairingString("")).toBeNull(); + expect(parsePairingString("http://127.0.0.1/extension#tok")).toBeNull(); + expect(parsePairingString("ws://127.0.0.1/other#tok")).toBeNull(); + expect(parsePairingString("ws://127.0.0.1/extension#")).toBeNull(); + expect(parsePairingString("ws://127.0.0.1/extension")).toBeNull(); + }); +}); + +describe("reconnectDelayMs", () => { + it("backs off exponentially and caps at 30s", () => { + expect(reconnectDelayMs(0)).toBe(1000); + expect(reconnectDelayMs(1)).toBe(2000); + expect(reconnectDelayMs(4)).toBe(16_000); + expect(reconnectDelayMs(5)).toBe(30_000); + expect(reconnectDelayMs(50)).toBe(30_000); + }); +}); + +describe("nearestGroupColor", () => { + it("maps hex accents to Chrome tab-group color names", () => { + expect(nearestGroupColor("#FF4500")).toBe("orange"); + expect(nearestGroupColor("#00AA00")).toBe("green"); + expect(nearestGroupColor("#4285F4")).toBe("blue"); + }); + + it("falls back to orange for invalid input", () => { + expect(nearestGroupColor("not-a-color")).toBe("orange"); + expect(nearestGroupColor(undefined)).toBe("orange"); + }); +}); diff --git a/extensions/browser/chrome-extension/popup.html b/extensions/browser/chrome-extension/popup.html new file mode 100644 index 00000000000..327da2ee1bf --- /dev/null +++ b/extensions/browser/chrome-extension/popup.html @@ -0,0 +1,80 @@ + + + + + + + +

OpenClaw

+
+

+ Run openclaw browser extension pair on this machine and + paste the pairing string: +

+ + + +
+ + + + diff --git a/extensions/browser/chrome-extension/popup.js b/extensions/browser/chrome-extension/popup.js new file mode 100644 index 00000000000..bb6ed00c014 --- /dev/null +++ b/extensions/browser/chrome-extension/popup.js @@ -0,0 +1,78 @@ +// Popup: pairing, connection status, and per-tab share toggle. + +const statusDot = document.getElementById("statusDot"); +const pairSection = document.getElementById("pairSection"); +const connectedSection = document.getElementById("connectedSection"); +const pairingInput = document.getElementById("pairingString"); +const pairButton = document.getElementById("pairButton"); +const unpairButton = document.getElementById("unpairButton"); +const shareButton = document.getElementById("shareButton"); +const statusLine = document.getElementById("statusLine"); +const errorLine = document.getElementById("error"); + +const STATE_LABEL = { + on: "Connected to OpenClaw", + connecting: "Connecting…", + error: "Relay unreachable — is the OpenClaw gateway running?", + off: "Not connected", +}; + +async function activeTab() { + const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); + return tab ?? null; +} + +async function refresh() { + const status = await chrome.runtime.sendMessage({ type: "getStatus" }); + statusDot.className = `dot ${status.state}`; + pairSection.classList.toggle("hidden", status.paired); + connectedSection.classList.toggle("hidden", !status.paired); + if (!status.paired) { + return; + } + const label = STATE_LABEL[status.state] ?? STATE_LABEL.off; + statusLine.textContent = `${label} · ${status.sharedTabCount} tab${status.sharedTabCount === 1 ? "" : "s"} shared`; + const tab = await activeTab(); + if (tab?.id === undefined) { + shareButton.classList.add("hidden"); + return; + } + const { shared } = await chrome.runtime.sendMessage({ type: "isTabShared", tabId: tab.id }); + shareButton.classList.remove("hidden"); + shareButton.textContent = shared ? "Stop sharing this tab" : "Share this tab with OpenClaw"; + shareButton.dataset.tabId = String(tab.id); +} + +async function onPair() { + errorLine.classList.add("hidden"); + const result = await chrome.runtime.sendMessage({ + type: "pair", + pairingString: pairingInput.value, + }); + if (!result.ok) { + errorLine.textContent = result.error ?? "Pairing failed."; + errorLine.classList.remove("hidden"); + return; + } + await refresh(); +} + +async function onUnpair() { + await chrome.runtime.sendMessage({ type: "unpair" }); + await refresh(); +} + +async function onToggleShare() { + const tabId = Number.parseInt(shareButton.dataset.tabId ?? "", 10); + if (Number.isFinite(tabId)) { + await chrome.runtime.sendMessage({ type: "toggleShareTab", tabId }); + } + await refresh(); +} + +pairButton.addEventListener("click", () => void onPair()); +unpairButton.addEventListener("click", () => void onUnpair()); +shareButton.addEventListener("click", () => void onToggleShare()); + +void refresh(); +setInterval(() => void refresh(), 2000); diff --git a/extensions/browser/package.json b/extensions/browser/package.json index 2e438f27edb..234df49633b 100644 --- a/extensions/browser/package.json +++ b/extensions/browser/package.json @@ -19,6 +19,9 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "assetScripts": { + "copy": "node scripts/copy-chrome-extension.mjs" + } } } diff --git a/extensions/browser/scripts/copy-chrome-extension.mjs b/extensions/browser/scripts/copy-chrome-extension.mjs new file mode 100644 index 00000000000..51245b564a9 --- /dev/null +++ b/extensions/browser/scripts/copy-chrome-extension.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/** + * Copies the unpacked OpenClaw Chrome extension into the browser plugin dist so + * `openclaw browser extension path` resolves a stable location for + * chrome://extensions "Load unpacked". + */ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const rootDir = path.resolve(pluginDir, "../.."); + +const srcDir = process.env.OPENCLAW_CHROME_EXT_SRC_DIR ?? path.join(pluginDir, "chrome-extension"); +const outDir = + process.env.OPENCLAW_CHROME_EXT_OUT_DIR ?? + path.join(rootDir, "dist", "extensions", "browser", "chrome-extension"); + +async function pathExists(target) { + try { + await fs.stat(target); + return true; + } catch { + return false; + } +} + +async function main() { + if (!(await pathExists(srcDir))) { + if ( + process.env.OPENCLAW_SPARSE_PROFILE || + process.env.OPENCLAW_CHROME_EXT_SKIP_MISSING === "1" + ) { + return; + } + throw new Error(`Chrome extension source not found: ${srcDir}`); + } + await fs.rm(outDir, { recursive: true, force: true }); + await fs.mkdir(path.dirname(outDir), { recursive: true }); + // Ship only the runtime extension; colocated *.test.ts and *.d.ts stay out. + await fs.cp(srcDir, outDir, { + recursive: true, + filter: (source) => !source.endsWith(".test.ts") && !source.endsWith(".d.ts"), + }); +} + +try { + await main(); +} catch (err) { + console.error(`[copy-chrome-extension] ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +} diff --git a/extensions/browser/src/browser/browser-utils.test.ts b/extensions/browser/src/browser/browser-utils.test.ts index a98a9fffaba..a62534aec2e 100644 --- a/extensions/browser/src/browser/browser-utils.test.ts +++ b/extensions/browser/src/browser/browser-utils.test.ts @@ -263,6 +263,12 @@ describe("browser server-context listKnownProfileNames", () => { ]), }; - expect(listKnownProfileNames(state).toSorted()).toEqual(["openclaw", "stale-removed", "user"]); + // "chrome" is the built-in Chrome extension-relay profile. + expect(listKnownProfileNames(state).toSorted()).toEqual([ + "chrome", + "openclaw", + "stale-removed", + "user", + ]); }); }); diff --git a/extensions/browser/src/browser/chrome.test.ts b/extensions/browser/src/browser/chrome.test.ts index 6294959f764..b76f9040071 100644 --- a/extensions/browser/src/browser/chrome.test.ts +++ b/extensions/browser/src/browser/chrome.test.ts @@ -1109,6 +1109,8 @@ describe("browser chrome launch args", () => { cdpIsLoopback: true, cdpPortRangeStart: 18800, cdpPortRangeEnd: 18810, + extensionRelayDefaultPort: 18808, + extensionRelayPorts: {}, evaluateEnabled: false, remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, diff --git a/extensions/browser/src/browser/client.ts b/extensions/browser/src/browser/client.ts index 13a802d0f59..c701de77202 100644 --- a/extensions/browser/src/browser/client.ts +++ b/extensions/browser/src/browser/client.ts @@ -80,7 +80,7 @@ export type ProfileStatus = { cdpPort: number | null; cdpUrl: string | null; color: string; - driver: "openclaw" | "existing-session"; + driver: "openclaw" | "existing-session" | "extension"; running: boolean; tabCount: number; isDefault: boolean; diff --git a/extensions/browser/src/browser/client.types.ts b/extensions/browser/src/browser/client.types.ts index 002428d14fe..f3076993b69 100644 --- a/extensions/browser/src/browser/client.types.ts +++ b/extensions/browser/src/browser/client.types.ts @@ -4,7 +4,7 @@ * Shared by the browser control client, CLI, and Browser agent tool. */ /** Browser transport backing the selected profile. */ -export type BrowserTransport = "cdp" | "chrome-mcp"; +export type BrowserTransport = "cdp" | "chrome-mcp" | "extension"; type BrowserHeadlessSource = | "request" | "env" @@ -17,7 +17,7 @@ type BrowserHeadlessSource = export type BrowserStatus = { enabled: boolean; profile?: string; - driver?: "openclaw" | "existing-session"; + driver?: "openclaw" | "existing-session" | "extension"; transport?: BrowserTransport; running: boolean; cdpReady?: boolean; diff --git a/extensions/browser/src/browser/config.test.ts b/extensions/browser/src/browser/config.test.ts index 3f8e989c096..c36c7e09b23 100644 --- a/extensions/browser/src/browser/config.test.ts +++ b/extensions/browser/src/browser/config.test.ts @@ -1,8 +1,9 @@ // Browser tests cover config plugin behavior. +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { BrowserConfig } from "../config/config.js"; import { resolveUserPath } from "../utils.js"; import { @@ -14,6 +15,30 @@ import { } from "./config.js"; import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; +// Isolate the extension relay secret (read from stateDir/credentials) so the +// extension-token assertions do not pick up a developer's real secret file. +let isolatedStateDir = ""; +const prevStateDir = process.env.OPENCLAW_STATE_DIR; +beforeEach(() => { + isolatedStateDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cfg-"))); + process.env.OPENCLAW_STATE_DIR = isolatedStateDir; +}); +afterEach(() => { + if (prevStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = prevStateDir; + } + fs.rmSync(isolatedStateDir, { recursive: true, force: true }); +}); + +/** Write a relay secret into the isolated state dir's credentials directory. */ +function writeRelaySecret(token: string): void { + const dir = path.join(isolatedStateDir, "credentials"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "browser-extension-relay.secret"), `${token}\n`); +} + function withEnv(env: Record, fn: () => T): T { const snapshot = new Map(); for (const [key] of Object.entries(env)) { @@ -21,7 +46,8 @@ function withEnv(env: Record, fn: () => T): T { } try { - for (const [key, value] of Object.entries(env)) { + for (const key of Object.keys(env)) { + const value = env[key]; if (value === undefined) { delete process.env[key]; } else { @@ -76,6 +102,58 @@ describe("browser config", () => { }); }); + it("provides a built-in chrome extension-relay profile with a derived loopback port", () => { + const resolved = resolveBrowserConfig(undefined); + const chrome = resolveProfile(resolved, "chrome"); + expect(chrome?.driver).toBe("extension"); + expect(chrome?.attachOnly).toBe(true); + // Relay port sits just below the CDP allocation range (controlPort + 8). + expect(chrome?.cdpPort).toBe(resolved.extensionRelayDefaultPort); + expect(resolved.extensionRelayDefaultPort).toBe(resolved.controlPort + 8); + // No host-local relay secret exists yet (isolated state dir), so the relay + // cdpUrl carries no Basic credentials until pairing/startup creates one. + expect(chrome?.cdpUrl).toBe(`http://127.0.0.1:${resolved.extensionRelayDefaultPort}`); + expect(chrome?.cdpIsLoopback).toBe(true); + }); + + it("assigns distinct relay ports to multiple extension profiles", () => { + const resolved = resolveBrowserConfig({ + profiles: { + work: { driver: "extension", color: "#00AA00" }, + }, + }); + const chrome = resolveProfile(resolved, "chrome"); + const work = resolveProfile(resolved, "work"); + // Both are extension profiles without an explicit cdpPort; they must not + // collide on one relay port (the second would fail to bind). + expect(chrome?.cdpPort).not.toBe(work?.cdpPort); + expect(new Set([chrome?.cdpPort, work?.cdpPort]).size).toBe(2); + // Ports count down from the default, staying below the CDP allocation band. + expect(Math.max(chrome?.cdpPort ?? 0, work?.cdpPort ?? 0)).toBe( + resolved.extensionRelayDefaultPort, + ); + }); + + it("honors an explicit cdpPort on an extension profile", () => { + const resolved = resolveBrowserConfig({ + profiles: { + work: { driver: "extension", cdpPort: 20123, color: "#00AA00" }, + }, + }); + expect(resolveProfile(resolved, "work")?.cdpPort).toBe(20123); + }); + + it("embeds the host-local relay secret as Basic auth in the extension cdpUrl", () => { + const token = "a".repeat(64); + writeRelaySecret(token); + const resolved = resolveBrowserConfig(undefined); + expect(resolved.extensionRelayToken).toBe(token); + const chrome = resolveProfile(resolved, "chrome"); + expect(chrome?.cdpUrl).toBe( + `http://openclaw:${token}@127.0.0.1:${resolved.extensionRelayDefaultPort}`, + ); + }); + it("derives default ports from OPENCLAW_GATEWAY_PORT when unset", () => { withEnv({ OPENCLAW_GATEWAY_PORT: "19001" }, () => { const resolved = resolveBrowserConfig(undefined); diff --git a/extensions/browser/src/browser/config.ts b/extensions/browser/src/browser/config.ts index 4fe63e6c392..2570138d44f 100644 --- a/extensions/browser/src/browser/config.ts +++ b/extensions/browser/src/browser/config.ts @@ -36,6 +36,7 @@ import { DEFAULT_OPENCLAW_BROWSER_ENABLED, DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, } from "./constants.js"; +import { resolveExtensionRelayToken } from "./extension-relay/relay-auth.js"; import { DEFAULT_UPLOAD_DIR } from "./paths.js"; export { @@ -86,6 +87,12 @@ export type ResolvedBrowserConfig = { tabCleanup: ResolvedBrowserTabCleanupConfig; ssrfPolicy?: SsrFPolicy; extraArgs: string[]; + /** Default loopback port for extension-driver relay servers. */ + extensionRelayDefaultPort: number; + /** Assigned loopback relay port per extension-driver profile (no explicit cdpPort). */ + extensionRelayPorts: Record; + /** Derived bearer token for extension relay auth (absent until gateway auth exists). */ + extensionRelayToken?: string; }; /** Normalized tab-cleanup settings for session-owned browser tabs. */ @@ -107,7 +114,7 @@ export type ResolvedBrowserProfile = { mcpCommand?: string; mcpArgs?: string[]; color: string; - driver: "openclaw" | "existing-session"; + driver: "openclaw" | "existing-session" | "extension"; executablePath?: string; headless: boolean; headlessSource?: "profile" | "config" | "default"; @@ -115,6 +122,14 @@ export type ResolvedBrowserProfile = { }; const DEFAULT_BROWSER_CDP_PORT_RANGE_START = 18800; +/** + * Default extension relay port offset from the browser control port. Sits just + * below the CDP allocation range (controlPort+9..) so profile port allocation + * can never hand this port to a managed profile. + */ +const EXTENSION_RELAY_PORT_OFFSET = 8; +/** Username half of the relay's Basic credential; the password is the derived token. */ +const EXTENSION_RELAY_CDP_USER = "openclaw"; const MAX_BROWSER_STARTUP_TIMEOUT_MS = 120_000; /** Environment variable that overrides managed Chrome headless mode. */ export const OPENCLAW_BROWSER_HEADLESS_ENV = "OPENCLAW_BROWSER_HEADLESS"; @@ -345,6 +360,43 @@ function ensureDefaultUserBrowserProfile( return result; } +/** Built-in profile for the Chrome extension relay (user's signed-in browser). */ +function ensureDefaultChromeExtensionProfile( + profiles: Record, +): Record { + const result = { ...profiles }; + if (result.chrome) { + return result; + } + result.chrome = { + driver: "extension", + color: DEFAULT_OPENCLAW_BROWSER_COLOR, + }; + return result; +} + +/** + * Assign a distinct loopback relay port to each extension-driver profile that + * does not pin its own cdpPort. Ports count down from the default (controlPort+8) + * — below the managed CDP allocation band (controlPort+9..) — so extension + * relays and managed Chrome never contend, and two extension profiles never + * share one port. Deterministic (sorted names) so restarts keep the same URLs. + */ +function resolveExtensionRelayPorts( + profiles: Record, + defaultPort: number, +): Record { + const names = Object.entries(profiles) + .filter(([, profile]) => profile.driver === "extension" && profile.cdpPort == null) + .map(([name]) => name) + .toSorted(); + const ports: Record = {}; + names.forEach((name, index) => { + ports[name] = defaultPort - index; + }); + return ports; +} + function applyLegacyCdpUrlToExistingSessionDefaultProfile( profiles: Record, defaultProfile: string, @@ -434,6 +486,9 @@ export function resolveBrowserConfig( const headless = cfg?.headless === true; const headlessSource = typeof cfg?.headless === "boolean" ? "config" : "default"; + // Host-local relay secret (created lazily by relay startup / pairing). Null + // here just means the extension driver has not been used on this host yet. + const extensionRelayToken = resolveExtensionRelayToken() ?? undefined; const noSandbox = cfg?.noSandbox === true; const attachOnly = cfg?.attachOnly === true; const executablePath = normalizeExecutablePath(cfg?.executablePath); @@ -442,13 +497,15 @@ export function resolveBrowserConfig( const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined; const isWsUrl = cdpInfo.parsed.protocol === "ws:" || cdpInfo.parsed.protocol === "wss:"; const legacyCdpUrl = rawCdpUrl && isWsUrl ? cdpInfo.normalized : undefined; - let profiles = ensureDefaultUserBrowserProfile( - ensureDefaultProfile( - cfg?.profiles, - defaultColor, - legacyCdpPort, - cdpPortRangeStart, - legacyCdpUrl, + let profiles = ensureDefaultChromeExtensionProfile( + ensureDefaultUserBrowserProfile( + ensureDefaultProfile( + cfg?.profiles, + defaultColor, + legacyCdpPort, + cdpPortRangeStart, + legacyCdpUrl, + ), ), ); const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http"; @@ -497,6 +554,12 @@ export function resolveBrowserConfig( tabCleanup: resolveBrowserTabCleanupConfig(cfg), ssrfPolicy: resolveBrowserSsrFPolicy(cfg), extraArgs, + extensionRelayDefaultPort: controlPort + EXTENSION_RELAY_PORT_OFFSET, + extensionRelayPorts: resolveExtensionRelayPorts( + profiles, + controlPort + EXTENSION_RELAY_PORT_OFFSET, + ), + ...(extensionRelayToken ? { extensionRelayToken } : {}), }; } @@ -514,12 +577,45 @@ export function resolveProfile( let cdpHost = resolved.cdpHost; let cdpPort = profile.cdpPort ?? 0; let cdpUrl; - const driver = profile.driver === "existing-session" ? "existing-session" : "openclaw"; + const driver = + profile.driver === "existing-session" || profile.driver === "extension" + ? profile.driver + : "openclaw"; const headless = profile.headless ?? resolved.headless; const headlessSource = typeof profile.headless === "boolean" ? "profile" : resolved.headlessSource; const executablePath = normalizeExecutablePath(profile.executablePath) ?? resolved.executablePath; + if (driver === "extension") { + // Each extension profile needs its own loopback relay port. Explicit + // profile.cdpPort wins; otherwise a distinct port is assigned per profile + // (see resolveExtensionRelayPorts) so multiple extension profiles never + // collide on the same port and silently fail to bind. + const relayPort = + profile.cdpPort ?? + resolved.extensionRelayPorts[profileName] ?? + resolved.extensionRelayDefaultPort; + const token = resolved.extensionRelayToken; + // Userinfo credentials flow through getHeadersWithAuth into /json/version + // and /cdp requests, so the relay is authenticated with zero extra plumbing. + const relayCdpUrl = token + ? `http://${EXTENSION_RELAY_CDP_USER}:${encodeURIComponent(token)}@127.0.0.1:${relayPort}` + : `http://127.0.0.1:${relayPort}`; + return { + name: profileName, + cdpPort: relayPort, + cdpUrl: relayCdpUrl, + cdpHost: "127.0.0.1", + cdpIsLoopback: true, + color: profile.color, + driver, + executablePath, + headless: false, + headlessSource: "default", + attachOnly: true, + }; + } + if (driver === "existing-session") { const existingSessionCdp = normalizeExistingSessionCdpUrl(rawProfileUrl, profileName); return { diff --git a/extensions/browser/src/browser/doctor.ts b/extensions/browser/src/browser/doctor.ts index 4af9349b6f4..49944e90b8c 100644 --- a/extensions/browser/src/browser/doctor.ts +++ b/extensions/browser/src/browser/doctor.ts @@ -35,7 +35,12 @@ export function buildBrowserDoctorReport(params: { }): BrowserDoctorReport { const status = params.status; const checks: BrowserDoctorCheck[] = []; - const transport: BrowserTransport = status.transport === "chrome-mcp" ? "chrome-mcp" : "cdp"; + const transport: BrowserTransport = + status.transport === "chrome-mcp" + ? "chrome-mcp" + : status.transport === "extension" + ? "extension" + : "cdp"; checks.push({ id: "plugin-enabled", @@ -67,6 +72,21 @@ export function buildBrowserDoctorReport(params: { "Keep the matching Chromium browser running, enable remote debugging in chrome://inspect, and accept the attach prompt.", }), }); + } else if (transport === "extension") { + checks.push({ + id: "extension-relay", + label: "Chrome extension relay", + status: status.running ? "pass" : "fail", + summary: status.running + ? "OpenClaw Chrome extension is connected" + : "OpenClaw Chrome extension is not connected", + ...(status.running + ? {} + : { + fixHint: + "Install the OpenClaw Chrome extension (openclaw browser extension path), run openclaw browser extension pair, and paste the pairing string into the extension popup.", + }), + }); } else { checks.push({ id: "managed-executable", diff --git a/extensions/browser/src/browser/extension-relay.runtime.ts b/extensions/browser/src/browser/extension-relay.runtime.ts new file mode 100644 index 00000000000..4751e808321 --- /dev/null +++ b/extensions/browser/src/browser/extension-relay.runtime.ts @@ -0,0 +1,12 @@ +/** + * Lazy boundary for the extension relay (pulls in the ws server dependency). + */ +let modPromise: Promise | null = null; + +/** Load the extension relay lifecycle module on demand. */ +export function getExtensionRelayModule(): Promise< + typeof import("./extension-relay/relay-lifecycle.js") +> { + modPromise ??= import("./extension-relay/relay-lifecycle.js"); + return modPromise; +} diff --git a/extensions/browser/src/browser/extension-relay/relay-auth.test.ts b/extensions/browser/src/browser/extension-relay/relay-auth.test.ts new file mode 100644 index 00000000000..5b974e3a278 --- /dev/null +++ b/extensions/browser/src/browser/extension-relay/relay-auth.test.ts @@ -0,0 +1,70 @@ +// Extension relay host-local token secret. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + ensureExtensionRelayToken, + extensionRelayTokenMatches, + readExtensionRelayToken, + resolveExtensionRelayToken, +} from "./relay-auth.js"; + +let stateDir = ""; +const prevStateDir = process.env.OPENCLAW_STATE_DIR; + +beforeEach(() => { + stateDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-relay-auth-"))); + process.env.OPENCLAW_STATE_DIR = stateDir; +}); +afterEach(() => { + if (prevStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = prevStateDir; + } + fs.rmSync(stateDir, { recursive: true, force: true }); +}); + +describe("extension relay host-local secret", () => { + it("returns null before the secret is created", () => { + expect(readExtensionRelayToken()).toBeNull(); + expect(resolveExtensionRelayToken()).toBeNull(); + }); + + it("creates a 64-hex secret on ensure and persists it privately", () => { + const token = ensureExtensionRelayToken(); + expect(token).toMatch(/^[0-9a-f]{64}$/); + const secretPath = path.join(stateDir, "credentials", "browser-extension-relay.secret"); + expect(fs.existsSync(secretPath)).toBe(true); + if (process.platform !== "win32") { + expect(fs.statSync(secretPath).mode & 0o777).toBe(0o600); + } + }); + + it("is stable across calls (does not rotate on read)", () => { + const first = ensureExtensionRelayToken(); + expect(ensureExtensionRelayToken()).toBe(first); + expect(readExtensionRelayToken()).toBe(first); + }); + + it("gives different hosts (state dirs) different secrets", () => { + const a = ensureExtensionRelayToken(); + const otherDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-relay-auth-2-")), + ); + try { + const b = ensureExtensionRelayToken({ ...process.env, OPENCLAW_STATE_DIR: otherDir }); + expect(b).not.toBe(a); + } finally { + fs.rmSync(otherDir, { recursive: true, force: true }); + } + }); + + it("matches tokens in constant time and rejects mismatches", () => { + const token = ensureExtensionRelayToken(); + expect(extensionRelayTokenMatches(token, token)).toBe(true); + expect(extensionRelayTokenMatches(token, `${token}x`)).toBe(false); + expect(extensionRelayTokenMatches(token, "short")).toBe(false); + }); +}); diff --git a/extensions/browser/src/browser/extension-relay/relay-auth.ts b/extensions/browser/src/browser/extension-relay/relay-auth.ts new file mode 100644 index 00000000000..ce3c32fbafe --- /dev/null +++ b/extensions/browser/src/browser/extension-relay/relay-auth.ts @@ -0,0 +1,85 @@ +/** + * Extension relay auth material. + * + * The relay authenticates the loopback link between OpenClaw and the paired + * Chrome extension with a host-local secret. It is persisted per machine in the + * credentials dir, so the gateway host and every browser node host each own an + * independent token — the extension pairs with whichever machine runs its + * Chrome, and no gateway credential ever has to travel to a node. + */ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { resolveOAuthDir } from "openclaw/plugin-sdk/state-paths"; + +const RELAY_SECRET_FILE = "browser-extension-relay.secret"; + +// resolveOAuthDir returns `${stateDir}/credentials`, the shared credentials dir. +function resolveExtensionRelaySecretPath(env: NodeJS.ProcessEnv = process.env): string { + return path.join(resolveOAuthDir(env), RELAY_SECRET_FILE); +} + +function normalizeToken(raw: string): string | null { + const value = raw.trim(); + return /^[0-9a-f]{64}$/.test(value) ? value : null; +} + +/** Read the host-local relay token, or null when it has not been created yet. */ +export function readExtensionRelayToken(env: NodeJS.ProcessEnv = process.env): string | null { + try { + return normalizeToken(fs.readFileSync(resolveExtensionRelaySecretPath(env), "utf8")); + } catch { + return null; + } +} + +/** + * Read the host-local relay token, creating it on first use. Called from relay + * startup and `openclaw browser extension pair` — both run on the machine that + * hosts the browser, so they resolve the same per-host secret. + * + * The create is atomic (O_CREAT|O_EXCL): the gateway service and the pair CLI + * are separate processes that can race on a fresh host, and a non-atomic + * read-then-write would let each mint a distinct token (relay expects one, the + * printed pairing string carries the other → 401). On EEXIST the winner's token + * is re-read. + */ +export function ensureExtensionRelayToken(env: NodeJS.ProcessEnv = process.env): string { + const secretPath = resolveExtensionRelaySecretPath(env); + const existing = readExtensionRelayToken(env); + if (existing) { + return existing; + } + const token = crypto.randomBytes(32).toString("hex"); + fs.mkdirSync(path.dirname(secretPath), { recursive: true, mode: 0o700 }); + try { + fs.writeFileSync(secretPath, `${token}\n`, { mode: 0o600, flag: "wx" }); + return token; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") { + throw err; + } + // Another process created it first; adopt its token. + const winner = readExtensionRelayToken(env); + if (!winner) { + throw new Error("extension relay secret exists but is unreadable/malformed", { cause: err }); + } + return winner; + } +} + +/** Resolve the relay token for config (read-only; null until first ensured). */ +export function resolveExtensionRelayToken(env: NodeJS.ProcessEnv = process.env): string | null { + return readExtensionRelayToken(env); +} + +/** + * Constant-time token comparison. Both sides are hashed to a fixed length + * before timingSafeEqual so no length short-circuit leaks token length. + */ +export function extensionRelayTokenMatches(expected: string, candidate: string): boolean { + return crypto.timingSafeEqual( + crypto.createHash("sha256").update(expected).digest(), + crypto.createHash("sha256").update(candidate).digest(), + ); +} diff --git a/extensions/browser/src/browser/extension-relay/relay-bridge.test.ts b/extensions/browser/src/browser/extension-relay/relay-bridge.test.ts new file mode 100644 index 00000000000..aec6861b1f4 --- /dev/null +++ b/extensions/browser/src/browser/extension-relay/relay-bridge.test.ts @@ -0,0 +1,279 @@ +// Extension relay bridge: CDP target synthesis and extension command routing. +import { describe, expect, it } from "vitest"; +import { ExtensionRelayBridge, type BridgeSocket } from "./relay-bridge.js"; +import type { ExtensionToRelayMessage, RelayToExtensionMessage } from "./relay-protocol.js"; + +/** In-memory socket capturing every frame the bridge sends. */ +class FakeSocket implements BridgeSocket { + readonly sent: unknown[] = []; + closed = false; + closeCode?: number; + send(data: string): void { + this.sent.push(JSON.parse(data)); + } + close(code?: number): void { + this.closed = true; + this.closeCode = code; + } + /** Frames of a given method (client CDP responses/events). */ + frames(): Array> { + return this.sent as Array>; + } +} + +/** + * Scripted extension: auto-answers relay commands so the bridge can complete + * attach/CDP round-trips. Attach returns a deterministic targetId per tab. + */ +function wireExtension(bridge: ExtensionRelayBridge) { + const socket = new FakeSocket(); + const handlers = bridge.attachExtensionSocket(socket); + // Auto-reply to commands the bridge issues to the extension. + const originalSend = socket.send.bind(socket); + socket.send = (data: string) => { + originalSend(data); + const msg = JSON.parse(data) as RelayToExtensionMessage; + if (msg.type === "ping") { + return; + } + queueMicrotask(() => { + const reply = replyFor(msg); + if (reply) { + handlers.onMessage(JSON.stringify(reply)); + } + }); + }; + return { socket, handlers }; +} + +function replyFor(msg: RelayToExtensionMessage): ExtensionToRelayMessage | null { + switch (msg.type) { + case "attach": + return { type: "result", seq: msg.seq, result: { targetId: `target-${msg.tabId}` } }; + case "detach": + case "activateTab": + case "closeTab": + return { type: "result", seq: msg.seq, result: {} }; + case "createTab": + return { type: "result", seq: msg.seq, result: { tabId: 999 } }; + case "cdp": + return { type: "result", seq: msg.seq, result: { ok: true, echoed: msg.method } }; + default: + return null; + } +} + +function sendHello(handlers: { onMessage: (raw: string) => void }, tabs = defaultTabs()) { + handlers.onMessage( + JSON.stringify({ + type: "hello", + userAgent: "Mozilla/5.0 Chrome/144.0.0.0", + browserVersion: "Chrome/144.0.0.0", + extensionVersion: "2.0.0", + tabs, + }), + ); +} + +function defaultTabs() { + return [{ tabId: 1, url: "https://example.com", title: "Example", active: true }]; +} + +const flush = () => + new Promise((resolve) => { + setTimeout(resolve, 0); + }); + +describe("ExtensionRelayBridge", () => { + it("reports the paired browser identity through Browser.getVersion", async () => { + const bridge = new ExtensionRelayBridge(); + const { handlers } = wireExtension(bridge); + sendHello(handlers); + expect(bridge.extensionConnected).toBe(true); + + const client = new FakeSocket(); + const cdp = bridge.attachCdpClientSocket(client); + cdp.onMessage(JSON.stringify({ id: 1, method: "Browser.getVersion" })); + await flush(); + + const response = client.frames().find((frame) => frame.id === 1); + expect(response?.result).toMatchObject({ + protocolVersion: "1.3", + product: "Chrome/144.0.0.0", + }); + }); + + it("attaches shared tabs and announces targets on Target.setAutoAttach", async () => { + const bridge = new ExtensionRelayBridge(); + const { handlers } = wireExtension(bridge); + sendHello(handlers); + + const client = new FakeSocket(); + const cdp = bridge.attachCdpClientSocket(client); + cdp.onMessage( + JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: { autoAttach: true } }), + ); + await flush(); + + const attached = client.frames().find((frame) => frame.method === "Target.attachedToTarget"); + expect(attached).toBeTruthy(); + const params = attached?.params as { targetInfo?: { targetId?: string }; sessionId?: string }; + expect(params.targetInfo?.targetId).toBe("target-1"); + expect(typeof params.sessionId).toBe("string"); + }); + + it("routes session-scoped CDP commands to the owning tab", async () => { + const bridge = new ExtensionRelayBridge(); + const { socket: extSocket, handlers } = wireExtension(bridge); + sendHello(handlers); + + const client = new FakeSocket(); + const cdp = bridge.attachCdpClientSocket(client); + cdp.onMessage( + JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: { autoAttach: true } }), + ); + await flush(); + const attached = client.frames().find((frame) => frame.method === "Target.attachedToTarget"); + expect(attached).toBeTruthy(); + const sessionId = (attached?.params as { sessionId: string })?.sessionId; + + cdp.onMessage( + JSON.stringify({ + id: 2, + sessionId, + method: "Page.navigate", + params: { url: "https://x.test" }, + }), + ); + await flush(); + + // The extension received a session-forwarded cdp command for tab 1. + const forwarded = extSocket + .frames() + .find((frame) => frame.type === "cdp" && frame.method === "Page.navigate"); + expect(forwarded).toMatchObject({ tabId: 1, method: "Page.navigate" }); + const response = client.frames().find((frame) => frame.id === 2); + expect(response?.result).toMatchObject({ ok: true }); + }); + + it("creates a tab inside the group and returns its synthetic target", async () => { + const bridge = new ExtensionRelayBridge(); + const { handlers } = wireExtension(bridge); + sendHello(handlers); + + const client = new FakeSocket(); + const cdp = bridge.attachCdpClientSocket(client); + cdp.onMessage( + JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: { autoAttach: true } }), + ); + await flush(); + cdp.onMessage( + JSON.stringify({ id: 2, method: "Target.createTarget", params: { url: "https://new.test" } }), + ); + await flush(); + + const response = client.frames().find((frame) => frame.id === 2); + expect(response?.result).toMatchObject({ targetId: "target-999" }); + }); + + it("emits Target.detachedFromTarget when a shared tab leaves the group", async () => { + const bridge = new ExtensionRelayBridge(); + const { handlers } = wireExtension(bridge); + sendHello(handlers); + + const client = new FakeSocket(); + const cdp = bridge.attachCdpClientSocket(client); + cdp.onMessage( + JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: { autoAttach: true } }), + ); + await flush(); + + // Tab 1 removed from the shared set. + handlers.onMessage(JSON.stringify({ type: "tabs", tabs: [] })); + await flush(); + + const detached = client.frames().find((frame) => frame.method === "Target.detachedFromTarget"); + expect(detached).toBeTruthy(); + expect(bridge.sharedTabs()).toHaveLength(0); + }); + + it("rejects isolated browser contexts (real profile only)", async () => { + const bridge = new ExtensionRelayBridge(); + const { handlers } = wireExtension(bridge); + sendHello(handlers); + + const client = new FakeSocket(); + const cdp = bridge.attachCdpClientSocket(client); + cdp.onMessage(JSON.stringify({ id: 1, method: "Target.createBrowserContext" })); + await flush(); + + const response = client.frames().find((frame) => frame.id === 1); + expect(response?.error).toBeTruthy(); + }); + + it("fails pending commands when the extension disconnects", async () => { + const bridge = new ExtensionRelayBridge(); + const { handlers } = wireExtension(bridge); + sendHello(handlers); + + const client = new FakeSocket(); + const cdp = bridge.attachCdpClientSocket(client); + cdp.onMessage( + JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: { autoAttach: true } }), + ); + await flush(); + + handlers.onClose(); + // A subsequent session command should surface a clean error, not hang. + cdp.onMessage(JSON.stringify({ id: 2, sessionId: "openclaw-tab-1-1", method: "Page.reload" })); + await flush(); + const response = client.frames().find((frame) => frame.id === 2); + expect(response?.error).toBeTruthy(); + expect(bridge.extensionConnected).toBe(false); + }); + + it("reaps child sessions when a tab leaves the group (no stale routing)", async () => { + const bridge = new ExtensionRelayBridge(); + const { handlers } = wireExtension(bridge); + sendHello(handlers); + + const client = new FakeSocket(); + const cdp = bridge.attachCdpClientSocket(client); + cdp.onMessage( + JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: { autoAttach: true } }), + ); + await flush(); + + // Extension reports a child (iframe) session for tab 1. + handlers.onMessage( + JSON.stringify({ + type: "cdpEvent", + tabId: 1, + sessionId: "child-abc", + method: "Page.frameNavigated", + params: {}, + }), + ); + await flush(); + + // Tab 1 leaves the OpenClaw group. + handlers.onMessage(JSON.stringify({ type: "tabs", tabs: [] })); + await flush(); + + // A command addressed to the now-stale child session must not route to a + // reused tab; it should surface a clean "session not found" error. + cdp.onMessage(JSON.stringify({ id: 2, sessionId: "child-abc", method: "Page.reload" })); + await flush(); + const response = client.frames().find((frame) => frame.id === 2); + expect(response?.error).toBeTruthy(); + }); + + it("requires a hello frame before other extension messages", () => { + const bridge = new ExtensionRelayBridge(); + const socket = new FakeSocket(); + const handlers = bridge.attachExtensionSocket(socket); + handlers.onMessage(JSON.stringify({ type: "tabs", tabs: [] })); + expect(socket.closed).toBe(true); + expect(bridge.extensionConnected).toBe(false); + }); +}); diff --git a/extensions/browser/src/browser/extension-relay/relay-bridge.ts b/extensions/browser/src/browser/extension-relay/relay-bridge.ts new file mode 100644 index 00000000000..f5278d8ea1f --- /dev/null +++ b/extensions/browser/src/browser/extension-relay/relay-bridge.ts @@ -0,0 +1,779 @@ +/** + * Extension relay CDP bridge. + * + * Presents a CDP browser endpoint (compatible with Playwright connectOverCDP) + * on one side and the OpenClaw Chrome extension's chrome.debugger transport on + * the other. The bridge owns all Target.* synthesis so the extension stays a + * thin forwarder — the old assets/chrome-extension put this logic in an + * untestable MV3 service worker, which is why it rotted and was removed. + */ +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { + type ExtensionToRelayMessage, + parseExtensionMessage, + type RelayCommandBody, + type RelayTabInfo, + type RelayToExtensionMessage, +} from "./relay-protocol.js"; + +const log = createSubsystemLogger("browser").child("extension-relay"); + +/** Default timeout for commands forwarded to the extension. */ +const EXTENSION_COMMAND_TIMEOUT_MS = 15_000; +/** App-level keepalive interval; message traffic keeps the MV3 worker alive. */ +const EXTENSION_PING_INTERVAL_MS = 20_000; + +/** Synthetic targetId for the emulated browser target. */ +const BROWSER_TARGET_ID = "openclaw-extension-relay"; + +/** Minimal socket seam so tests can drive the bridge without real WebSockets. */ +export type BridgeSocket = { + send: (data: string) => void; + close: (code?: number, reason?: string) => void; +}; + +type CdpRequest = { + id: number; + method: string; + params?: Record; + sessionId?: string; +}; + +type PendingExtensionCommand = { + resolve: (result: unknown) => void; + reject: (err: Error) => void; + timer: NodeJS.Timeout; +}; + +type TabState = { + info: RelayTabInfo; + /** Set while chrome.debugger is attached: real CDP targetId + synthetic root sessionId. */ + attached?: { targetId: string; sessionId: string }; + attaching?: Promise<{ targetId: string; sessionId: string }>; +}; + +type CdpClientState = { + socket: BridgeSocket; + autoAttach: boolean; + /** Session ids this client has been told about (root and child sessions). */ + announcedSessions: Set; +}; + +/** Browser identity reported by the paired extension. */ +export type ExtensionIdentity = { + userAgent: string; + browserVersion: string; + extensionVersion: string; +}; + +function toErrorPayload(id: number, sessionId: string | undefined, message: string, code = -32000) { + return JSON.stringify({ id, ...(sessionId ? { sessionId } : {}), error: { code, message } }); +} + +/** + * One relay bridge per extension-driver profile. Accepts at most one extension + * connection (a newer one replaces the old — MV3 workers restart freely) and + * any number of CDP clients (pw-session caches one per cdpUrl in practice). + */ +export class ExtensionRelayBridge { + private extension: { socket: BridgeSocket; identity: ExtensionIdentity } | null = null; + private readonly clients = new Set(); + private readonly tabs = new Map(); + /** Child debugger sessions (iframes/workers) mapped to their owning tab. */ + private readonly childSessions = new Map(); + private readonly pendingExtension = new Map(); + private nextSeq = 1; + private nextSessionOrdinal = 1; + private pingTimer: NodeJS.Timeout | null = null; + private readonly onStateChange?: () => void; + + constructor(opts: { onStateChange?: () => void } = {}) { + this.onStateChange = opts.onStateChange; + } + + /** True once an extension socket completed its hello handshake. */ + get extensionConnected(): boolean { + return this.extension !== null; + } + + /** Identity of the paired browser, when connected. */ + get identity(): ExtensionIdentity | null { + return this.extension?.identity ?? null; + } + + /** Tabs currently shared with OpenClaw (the extension's tab group). */ + sharedTabs(): RelayTabInfo[] { + return [...this.tabs.values()].map((tab) => tab.info); + } + + /** Number of connected CDP clients (diagnostics). */ + get cdpClientCount(): number { + return this.clients.size; + } + + // --------------------------------------------------------------------- + // Extension side + // --------------------------------------------------------------------- + + /** Wire up a newly accepted extension WebSocket. */ + attachExtensionSocket(socket: BridgeSocket): { + onMessage: (raw: string) => void; + onClose: () => void; + } { + if (this.extension) { + // Replace the previous connection: MV3 service workers restart and the + // stale socket may linger half-open. Newest connection wins. + log.info("extension reconnected; replacing previous relay connection"); + this.extension.socket.close(4000, "replaced by newer extension connection"); + this.handleExtensionGone(); + } + let helloSeen = false; + const onMessage = (raw: string) => { + const msg = parseExtensionMessage(raw); + if (!msg) { + log.warn("dropping malformed extension relay frame"); + return; + } + if (!helloSeen) { + if (msg.type !== "hello") { + socket.close(4001, "expected hello"); + return; + } + helloSeen = true; + this.extension = { + socket, + identity: { + userAgent: msg.userAgent, + browserVersion: msg.browserVersion, + extensionVersion: msg.extensionVersion, + }, + }; + this.syncTabs(msg.tabs); + this.startPing(); + this.onStateChange?.(); + return; + } + this.handleExtensionMessage(msg); + }; + const onClose = () => { + if (this.extension?.socket === socket) { + this.handleExtensionGone(); + this.onStateChange?.(); + } + }; + return { onMessage, onClose }; + } + + private handleExtensionMessage(msg: ExtensionToRelayMessage): void { + switch (msg.type) { + case "result": { + const pending = this.pendingExtension.get(msg.seq); + if (pending) { + this.pendingExtension.delete(msg.seq); + clearTimeout(pending.timer); + pending.resolve(msg.result); + } + return; + } + case "error": { + const pending = this.pendingExtension.get(msg.seq); + if (pending) { + this.pendingExtension.delete(msg.seq); + clearTimeout(pending.timer); + pending.reject(new Error(msg.message)); + } + return; + } + case "cdpEvent": { + this.forwardExtensionEvent(msg.tabId, msg.sessionId, msg.method, msg.params); + return; + } + case "tabs": { + this.syncTabs(msg.tabs); + return; + } + case "detached": { + const tab = this.tabs.get(msg.tabId); + if (tab?.attached) { + this.emitDetachedFromTarget(msg.tabId, tab.attached.sessionId, tab.attached.targetId); + tab.attached = undefined; + } + return; + } + case "pong": + case "hello": + break; + } + } + + private handleExtensionGone(): void { + this.extension = null; + this.stopPing(); + for (const pending of this.pendingExtension.values()) { + clearTimeout(pending.timer); + pending.reject(new Error("extension disconnected")); + } + this.pendingExtension.clear(); + // Tell CDP clients their pages are gone; the tab list itself survives so a + // reconnecting extension can re-expose the same tabs. + for (const [tabId, tab] of this.tabs) { + if (tab.attached) { + this.emitDetachedFromTarget(tabId, tab.attached.sessionId, tab.attached.targetId); + tab.attached = undefined; + } + } + this.childSessions.clear(); + } + + private startPing(): void { + this.stopPing(); + this.pingTimer = setInterval(() => { + this.sendToExtension({ type: "ping" }); + }, EXTENSION_PING_INTERVAL_MS); + this.pingTimer.unref?.(); + } + + private stopPing(): void { + if (this.pingTimer) { + clearInterval(this.pingTimer); + this.pingTimer = null; + } + } + + private sendToExtension(msg: RelayToExtensionMessage): void { + if (!this.extension) { + throw new Error("OpenClaw Chrome extension is not connected to the relay"); + } + this.extension.socket.send(JSON.stringify(msg)); + } + + private callExtension( + command: RelayCommandBody, + timeoutMs = EXTENSION_COMMAND_TIMEOUT_MS, + ): Promise { + const seq = this.nextSeq++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingExtension.delete(seq); + reject(new Error(`extension relay command timed out: ${command.type}`)); + }, timeoutMs); + timer.unref?.(); + this.pendingExtension.set(seq, { resolve, reject, timer }); + try { + this.sendToExtension({ ...command, seq }); + } catch (err) { + this.pendingExtension.delete(seq); + clearTimeout(timer); + reject(err instanceof Error ? err : new Error(String(err))); + } + }); + } + + private syncTabs(tabs: RelayTabInfo[]): void { + const nextIds = new Set(tabs.map((tab) => tab.tabId)); + for (const [tabId, tab] of this.tabs) { + if (!nextIds.has(tabId)) { + if (tab.attached) { + this.emitDetachedFromTarget(tabId, tab.attached.sessionId, tab.attached.targetId); + } + this.tabs.delete(tabId); + } + } + for (const info of tabs) { + const existing = this.tabs.get(info.tabId); + if (existing) { + existing.info = info; + } else { + this.tabs.set(info.tabId, { info }); + // Newly shared tab: expose it to auto-attach clients right away so an + // agent mid-session sees tabs the user shares via the toolbar action. + if ([...this.clients].some((client) => client.autoAttach)) { + void this.ensureTabAttached(info.tabId) + .then(({ targetId, sessionId }) => { + this.announceAttachedTab(info.tabId, targetId, sessionId, { onlyAutoAttach: true }); + }) + .catch((err: unknown) => { + log.warn(`auto-attach of shared tab ${info.tabId} failed: ${String(err)}`); + }); + } + } + } + } + + private async ensureTabAttached(tabId: number): Promise<{ targetId: string; sessionId: string }> { + const tab = this.tabs.get(tabId); + if (!tab) { + throw new Error(`tab ${tabId} is not shared with OpenClaw`); + } + if (tab.attached) { + return tab.attached; + } + if (tab.attaching) { + return await tab.attaching; + } + const attaching = (async () => { + const result = (await this.callExtension({ type: "attach", tabId })) as { + targetId?: unknown; + } | null; + const targetId = typeof result?.targetId === "string" ? result.targetId : `tab-${tabId}`; + const sessionId = `openclaw-tab-${tabId}-${this.nextSessionOrdinal++}`; + const attached = { targetId, sessionId }; + // Identity check, not just presence: the tab could have left the group and + // rejoined under the same tabId while this attach was in flight, replacing + // the TabState. Writing onto the new TabState would bind stale attach data. + const current = this.tabs.get(tabId); + if (current !== tab) { + // Original tab vanished (or was recreated); best-effort detach the banner. + void this.callExtension({ type: "detach", tabId }).catch(() => {}); + throw new Error(`tab ${tabId} closed during attach`); + } + current.attached = attached; + return attached; + })(); + tab.attaching = attaching; + try { + return await attaching; + } finally { + tab.attaching = undefined; + } + } + + private targetInfoForTab(tab: TabState, targetId: string): Record { + return { + targetId, + type: "page", + title: tab.info.title, + url: tab.info.url, + attached: true, + canAccessOpener: false, + }; + } + + private announceAttachedTab( + tabId: number, + targetId: string, + sessionId: string, + opts: { onlyAutoAttach: boolean; onlyClient?: CdpClientState }, + ): void { + const tab = this.tabs.get(tabId); + if (!tab) { + return; + } + const event = { + method: "Target.attachedToTarget", + params: { + sessionId, + targetInfo: this.targetInfoForTab(tab, targetId), + waitingForDebugger: false, + }, + }; + const recipients = opts.onlyClient + ? [opts.onlyClient] + : [...this.clients].filter((client) => !opts.onlyAutoAttach || client.autoAttach); + for (const client of recipients) { + if (client.announcedSessions.has(sessionId)) { + continue; + } + client.announcedSessions.add(sessionId); + client.socket.send(JSON.stringify(event)); + } + } + + private emitDetachedFromTarget(tabId: number, sessionId: string, targetId: string): void { + const event = JSON.stringify({ + method: "Target.detachedFromTarget", + params: { sessionId, targetId }, + }); + for (const client of this.clients) { + if (client.announcedSessions.delete(sessionId)) { + client.socket.send(event); + } + } + // Reap this tab's child sessions (iframes/workers) by owner tabId. Callers + // clear tab.attached before/around this, so matching on the root sessionId + // would miss every child and leak the childSessions map. Deleting the + // current key during Map iteration is safe. + for (const [childSessionId, ownerTabId] of this.childSessions) { + if (ownerTabId !== tabId) { + continue; + } + this.childSessions.delete(childSessionId); + for (const client of this.clients) { + client.announcedSessions.delete(childSessionId); + } + } + } + + private forwardExtensionEvent( + tabId: number, + childSessionId: string | undefined, + method: string, + params: unknown, + ): void { + const tab = this.tabs.get(tabId); + const rootSessionId = tab?.attached?.sessionId; + if (!rootSessionId) { + return; + } + const sessionId = childSessionId ?? rootSessionId; + if (childSessionId) { + this.childSessions.set(childSessionId, tabId); + } + // Child sessions announced through a parent's Target.attachedToTarget event + // must stay routable for clients that saw the parent announcement. + if (method === "Target.attachedToTarget") { + const announced = (params as { sessionId?: unknown } | null)?.sessionId; + if (typeof announced === "string") { + this.childSessions.set(announced, tabId); + for (const client of this.clients) { + if (client.announcedSessions.has(sessionId)) { + client.announcedSessions.add(announced); + } + } + } + } + const frame = JSON.stringify({ sessionId, method, params }); + for (const client of this.clients) { + if (client.announcedSessions.has(sessionId)) { + client.socket.send(frame); + } + } + } + + // --------------------------------------------------------------------- + // CDP client side (Playwright connectOverCDP) + // --------------------------------------------------------------------- + + /** Wire up a newly accepted CDP client WebSocket. */ + attachCdpClientSocket(socket: BridgeSocket): { + onMessage: (raw: string) => void; + onClose: () => void; + } { + const client: CdpClientState = { socket, autoAttach: false, announcedSessions: new Set() }; + this.clients.add(client); + const onMessage = (raw: string) => { + let request: CdpRequest; + try { + request = JSON.parse(raw) as CdpRequest; + } catch { + return; + } + if (typeof request?.id !== "number" || typeof request?.method !== "string") { + return; + } + void this.handleCdpRequest(client, request); + }; + const onClose = () => { + this.clients.delete(client); + this.detachAllWhenIdle(); + }; + return { onMessage, onClose }; + } + + /** + * Drop chrome.debugger sessions once no CDP client is connected so the + * "OpenClaw is debugging this browser" infobar only spans active automation. + */ + private detachAllWhenIdle(): void { + if (this.clients.size > 0 || !this.extension) { + return; + } + for (const [tabId, tab] of this.tabs) { + if (tab.attached) { + const { sessionId, targetId } = tab.attached; + tab.attached = undefined; + this.emitDetachedFromTarget(tabId, sessionId, targetId); + void this.callExtension({ type: "detach", tabId }).catch(() => {}); + } + } + } + + private respond(client: CdpClientState, request: CdpRequest, result: unknown): void { + client.socket.send( + JSON.stringify({ + id: request.id, + ...(request.sessionId ? { sessionId: request.sessionId } : {}), + result: result ?? {}, + }), + ); + } + + private respondError( + client: CdpClientState, + request: CdpRequest, + message: string, + code = -32000, + ): void { + client.socket.send(toErrorPayload(request.id, request.sessionId, message, code)); + } + + private tabBySessionId(sessionId: string): { tabId: number; child: boolean } | null { + for (const [tabId, tab] of this.tabs) { + if (tab.attached?.sessionId === sessionId) { + return { tabId, child: false }; + } + } + const childOwner = this.childSessions.get(sessionId); + if (childOwner !== undefined) { + return { tabId: childOwner, child: true }; + } + return null; + } + + private tabByTargetId(targetId: string): { tabId: number; tab: TabState } | null { + for (const [tabId, tab] of this.tabs) { + if (tab.attached?.targetId === targetId) { + return { tabId, tab }; + } + } + return null; + } + + private async handleCdpRequest(client: CdpClientState, request: CdpRequest): Promise { + try { + if (request.sessionId) { + await this.handleSessionScopedRequest(client, request); + return; + } + await this.handleBrowserScopedRequest(client, request); + } catch (err) { + this.respondError(client, request, err instanceof Error ? err.message : String(err)); + } + } + + private async handleSessionScopedRequest( + client: CdpClientState, + request: CdpRequest, + ): Promise { + const sessionId = request.sessionId as string; + const route = this.tabBySessionId(sessionId); + if (!route) { + this.respondError(client, request, `Session not found: ${sessionId}`, -32001); + return; + } + const result = await this.callExtension({ + type: "cdp", + tabId: route.tabId, + ...(route.child ? { sessionId } : {}), + method: request.method, + params: request.params, + }); + this.respond(client, request, result); + } + + private async handleBrowserScopedRequest( + client: CdpClientState, + request: CdpRequest, + ): Promise { + switch (request.method) { + case "Browser.getVersion": { + const identity = this.extension?.identity; + this.respond(client, request, { + protocolVersion: "1.3", + product: identity?.browserVersion ?? "Chrome/unknown", + revision: "openclaw-extension-relay", + userAgent: identity?.userAgent ?? "unknown", + jsVersion: "", + }); + return; + } + case "Browser.close": { + // Never close the user's real browser; end this automation client only. + this.respond(client, request, {}); + client.socket.close(1000, "Browser.close"); + return; + } + // Browser-level knobs chrome.debugger cannot reach; acknowledging keeps + // Playwright's default-context bootstrap happy with browser defaults. + case "Browser.setDownloadBehavior": + case "Target.setDiscoverTargets": { + this.respond(client, request, {}); + return; + } + case "Target.getTargetInfo": { + const targetId = request.params?.targetId as string | undefined; + if (!targetId || targetId === BROWSER_TARGET_ID) { + this.respond(client, request, { + targetInfo: { + targetId: BROWSER_TARGET_ID, + type: "browser", + title: "OpenClaw Extension Relay", + url: "", + attached: true, + canAccessOpener: false, + }, + }); + return; + } + const found = this.tabByTargetId(targetId); + if (!found) { + this.respondError(client, request, `No target with given id found: ${targetId}`, -32602); + return; + } + this.respond(client, request, { + targetInfo: this.targetInfoForTab(found.tab, targetId), + }); + return; + } + case "Target.getTargets": { + const targetInfos = [...this.tabs.values()] + .filter((tab) => tab.attached) + .map((tab) => this.targetInfoForTab(tab, tab.attached?.targetId ?? "")); + this.respond(client, request, { targetInfos }); + return; + } + case "Target.setAutoAttach": { + const autoAttach = request.params?.autoAttach !== false; + client.autoAttach = autoAttach; + if (autoAttach) { + const attachResults = await Promise.allSettled( + [...this.tabs.keys()].map(async (tabId) => { + const { targetId, sessionId } = await this.ensureTabAttached(tabId); + return { tabId, targetId, sessionId }; + }), + ); + for (const settled of attachResults) { + if (settled.status === "fulfilled") { + this.announceAttachedTab( + settled.value.tabId, + settled.value.targetId, + settled.value.sessionId, + { + onlyAutoAttach: false, + onlyClient: client, + }, + ); + } else { + log.warn(`setAutoAttach attach failed: ${String(settled.reason)}`); + } + } + } + this.respond(client, request, {}); + return; + } + case "Target.attachToTarget": { + const targetId = request.params?.targetId as string | undefined; + const found = targetId ? this.tabByTargetId(targetId) : null; + // Also allow attach by tab that is shared but not yet debugger-attached. + if (!found && targetId) { + this.respondError(client, request, `No target with given id found: ${targetId}`, -32602); + return; + } + if (!found) { + this.respondError(client, request, "targetId is required", -32602); + return; + } + const attached = await this.ensureTabAttached(found.tabId); + this.announceAttachedTab(found.tabId, attached.targetId, attached.sessionId, { + onlyAutoAttach: false, + onlyClient: client, + }); + this.respond(client, request, { sessionId: attached.sessionId }); + return; + } + case "Target.detachFromTarget": { + const sessionId = request.params?.sessionId as string | undefined; + const route = sessionId ? this.tabBySessionId(sessionId) : null; + if (route && !route.child) { + const tab = this.tabs.get(route.tabId); + if (tab?.attached) { + const { sessionId: rootSession, targetId } = tab.attached; + tab.attached = undefined; + this.emitDetachedFromTarget(route.tabId, rootSession, targetId); + await this.callExtension({ type: "detach", tabId: route.tabId }).catch(() => {}); + } + } + this.respond(client, request, {}); + return; + } + case "Target.createTarget": { + const url = typeof request.params?.url === "string" ? request.params.url : "about:blank"; + const created = (await this.callExtension({ type: "createTab", url })) as { + tabId?: unknown; + } | null; + if (typeof created?.tabId !== "number") { + this.respondError(client, request, "extension did not return a tabId for createTab"); + return; + } + const tabId = created.tabId; + if (!this.tabs.has(tabId)) { + this.tabs.set(tabId, { + info: { tabId, url, title: "", active: false }, + }); + } + const attached = await this.ensureTabAttached(tabId); + // Announce before responding, mirroring Chrome's event-then-result order. + this.announceAttachedTab(tabId, attached.targetId, attached.sessionId, { + onlyAutoAttach: true, + }); + this.announceAttachedTab(tabId, attached.targetId, attached.sessionId, { + onlyAutoAttach: false, + onlyClient: client, + }); + this.respond(client, request, { targetId: attached.targetId }); + return; + } + case "Target.closeTarget": { + const targetId = request.params?.targetId as string | undefined; + const found = targetId ? this.tabByTargetId(targetId) : null; + if (!found) { + this.respondError( + client, + request, + `No target with given id found: ${String(targetId)}`, + -32602, + ); + return; + } + await this.callExtension({ type: "closeTab", tabId: found.tabId }); + this.respond(client, request, { success: true }); + return; + } + case "Target.activateTarget": { + const targetId = request.params?.targetId as string | undefined; + const found = targetId ? this.tabByTargetId(targetId) : null; + if (!found) { + this.respondError( + client, + request, + `No target with given id found: ${String(targetId)}`, + -32602, + ); + return; + } + await this.callExtension({ type: "activateTab", tabId: found.tabId }); + this.respond(client, request, {}); + return; + } + case "Target.createBrowserContext": { + this.respondError( + client, + request, + "The OpenClaw extension relay drives the user's real browser profile; isolated browser contexts are not supported.", + ); + return; + } + default: { + this.respondError(client, request, `'${request.method}' wasn't found`, -32601); + } + } + } + + /** Close all sockets and reject pending work (relay shutdown). */ + dispose(): void { + this.stopPing(); + for (const pending of this.pendingExtension.values()) { + clearTimeout(pending.timer); + pending.reject(new Error("extension relay stopped")); + } + this.pendingExtension.clear(); + this.extension?.socket.close(1001, "relay stopped"); + this.extension = null; + for (const client of this.clients) { + client.socket.close(1001, "relay stopped"); + } + this.clients.clear(); + this.tabs.clear(); + this.childSessions.clear(); + } +} diff --git a/extensions/browser/src/browser/extension-relay/relay-lifecycle.ts b/extensions/browser/src/browser/extension-relay/relay-lifecycle.ts new file mode 100644 index 00000000000..1f42235b94d --- /dev/null +++ b/extensions/browser/src/browser/extension-relay/relay-lifecycle.ts @@ -0,0 +1,121 @@ +/** + * Extension relay lifecycle: one relay server per extension-driver profile, + * owned by the browser control runtime state. + */ +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import type { ResolvedBrowserProfile } from "../config.js"; +import type { BrowserServerState } from "../server-context.types.js"; +import { type ExtensionRelayHandle, startExtensionRelayServer } from "./relay-server.js"; + +const log = createSubsystemLogger("browser").child("extension-relay"); + +/** Human guidance for a relay without a paired/connected extension. */ +export const EXTENSION_PAIRING_HINT = + "Install the OpenClaw Chrome extension, then run `openclaw browser extension pair` and paste the pairing string into the extension popup."; + +function relays(state: BrowserServerState): Map { + if (!state.extensionRelays) { + state.extensionRelays = new Map(); + } + return state.extensionRelays; +} + +/** + * Start the relay server for one extension-driver profile, reconciling any + * existing one. Idempotency is keyed on profile name, but the desired (port, + * token) can drift when the host-local relay secret is rotated or the profile's + * cdpPort changes — a stale relay would then authenticate the extension against + * the old token or listen on the wrong port. When the desired config differs, + * the old relay is closed and a fresh one bound. + */ +export async function ensureExtensionRelayForProfile( + state: BrowserServerState, + profile: ResolvedBrowserProfile, +): Promise { + const map = relays(state); + // The host-local relay secret is created at browser-service startup and when + // pairing; ensure it here too so a relay started on demand always has a token. + const { ensureExtensionRelayToken } = await import("./relay-auth.js"); + const token = state.resolved.extensionRelayToken ?? ensureExtensionRelayToken(); + const existing = map.get(profile.name); + if (existing) { + if (existing.port === profile.cdpPort && existing.token === token) { + return existing; + } + // Port or token changed under this profile; rebind against the new config. + await existing.close().catch((err: unknown) => { + log.warn( + `stale extension relay for profile "${profile.name}" failed to stop: ${String(err)}`, + ); + }); + map.delete(profile.name); + } + const handle = await startExtensionRelayServer({ + port: profile.cdpPort, + token, + }); + map.set(profile.name, handle); + log.info(`extension relay for profile "${profile.name}" listening on 127.0.0.1:${handle.port}`); + return handle; +} + +/** + * Close relays whose profile was removed or is no longer an extension profile. + * Prevents orphaned loopback listeners after a profile is deleted or renamed. + */ +export async function pruneRemovedExtensionRelays( + state: BrowserServerState, + isActiveExtensionProfile: (name: string) => boolean, +): Promise { + const map = state.extensionRelays; + if (!map) { + return; + } + for (const [name, handle] of map) { + if (isActiveExtensionProfile(name)) { + continue; + } + map.delete(name); + await handle.close().catch((err: unknown) => { + log.warn(`removed extension relay for profile "${name}" failed to stop: ${String(err)}`); + }); + } +} + +/** Start relays for every extension-driver profile (control service startup). */ +export async function startConfiguredExtensionRelays( + state: BrowserServerState, + resolveProfile: (name: string) => ResolvedBrowserProfile | null, + onWarn: (message: string) => void, +): Promise { + for (const [name, profile] of Object.entries(state.resolved.profiles)) { + if (profile.driver !== "extension") { + continue; + } + const resolved = resolveProfile(name); + if (!resolved) { + continue; + } + try { + await ensureExtensionRelayForProfile(state, resolved); + } catch (err) { + onWarn(`extension relay for profile "${name}" failed to start: ${String(err)}`); + } + } +} + +/** Stop every running relay (runtime shutdown). */ +export async function stopExtensionRelays(state: BrowserServerState): Promise { + const map = state.extensionRelays; + if (!map) { + return; + } + for (const [name, handle] of map) { + try { + await handle.close(); + } catch (err) { + log.warn(`extension relay for profile "${name}" failed to stop: ${String(err)}`); + } + } + map.clear(); +} diff --git a/extensions/browser/src/browser/extension-relay/relay-protocol.test.ts b/extensions/browser/src/browser/extension-relay/relay-protocol.test.ts new file mode 100644 index 00000000000..52dcb3558e3 --- /dev/null +++ b/extensions/browser/src/browser/extension-relay/relay-protocol.test.ts @@ -0,0 +1,19 @@ +// Extension relay protocol frame parsing. +import { describe, expect, it } from "vitest"; +import { parseExtensionMessage } from "./relay-protocol.js"; + +describe("parseExtensionMessage", () => { + it("accepts known frame types", () => { + expect(parseExtensionMessage(JSON.stringify({ type: "pong" }))).toEqual({ type: "pong" }); + expect( + parseExtensionMessage(JSON.stringify({ type: "result", seq: 3, result: { ok: true } })), + ).toMatchObject({ type: "result", seq: 3 }); + }); + + it("rejects malformed or unknown frames", () => { + expect(parseExtensionMessage("not json")).toBeNull(); + expect(parseExtensionMessage(JSON.stringify({ type: "evil" }))).toBeNull(); + expect(parseExtensionMessage(JSON.stringify({ noType: true }))).toBeNull(); + expect(parseExtensionMessage(JSON.stringify(42))).toBeNull(); + }); +}); diff --git a/extensions/browser/src/browser/extension-relay/relay-protocol.ts b/extensions/browser/src/browser/extension-relay/relay-protocol.ts new file mode 100644 index 00000000000..5014fcc61dc --- /dev/null +++ b/extensions/browser/src/browser/extension-relay/relay-protocol.ts @@ -0,0 +1,128 @@ +/** + * Wire protocol between the extension relay server and the OpenClaw Chrome + * extension. The extension stays a dumb transport: it attaches chrome.debugger, + * forwards CDP traffic, and manages the OpenClaw tab group. All CDP target + * semantics (Target.* synthesis for Playwright) live server-side in the bridge. + */ + +/** Tab snapshot reported by the extension for tabs shared with OpenClaw. */ +export type RelayTabInfo = { + tabId: number; + url: string; + title: string; + active: boolean; +}; + +/** First message the extension sends after the WebSocket opens. */ +export type ExtensionHelloMessage = { + type: "hello"; + userAgent: string; + /** Full browser product string, e.g. "Chrome/144.0.7204.49". */ + browserVersion: string; + extensionVersion: string; + tabs: RelayTabInfo[]; +}; + +/** Full refresh of shared tabs; sent on any group membership or tab change. */ +export type ExtensionTabsMessage = { + type: "tabs"; + tabs: RelayTabInfo[]; +}; + +/** CDP event emitted by an attached tab (child sessions carry sessionId). */ +export type ExtensionCdpEventMessage = { + type: "cdpEvent"; + tabId: number; + sessionId?: string; + method: string; + params?: unknown; +}; + +/** Successful response to a relay command (cdp/attach/createTab/...). */ +export type ExtensionResultMessage = { + type: "result"; + seq: number; + result?: unknown; +}; + +/** Failed response to a relay command. */ +export type ExtensionErrorMessage = { + type: "error"; + seq: number; + message: string; +}; + +/** chrome.debugger detached outside relay control (infobar cancel, tab gone). */ +export type ExtensionDetachedMessage = { + type: "detached"; + tabId: number; + reason: string; +}; + +/** Keepalive reply; message traffic keeps the MV3 service worker alive. */ +export type ExtensionPongMessage = { + type: "pong"; +}; + +export type ExtensionToRelayMessage = + | ExtensionHelloMessage + | ExtensionTabsMessage + | ExtensionCdpEventMessage + | ExtensionResultMessage + | ExtensionErrorMessage + | ExtensionDetachedMessage + | ExtensionPongMessage; + +/** + * Command bodies sent to the extension. The bridge assigns the `seq` used to + * correlate the extension's result/error reply. + */ +export type RelayCommandBody = + /** Forward a CDP command into an attached tab (or one of its child sessions). */ + | { type: "cdp"; tabId: number; sessionId?: string; method: string; params?: unknown } + /** Attach chrome.debugger to a shared tab. Result: { targetId: string }. */ + | { type: "attach"; tabId: number } + /** Detach chrome.debugger from a tab (tab left the group or client detached). */ + | { type: "detach"; tabId: number } + /** Open a new tab inside the OpenClaw tab group. Result: { tabId: number }. */ + | { type: "createTab"; url: string; background?: boolean } + /** Close a shared tab. Result: {}. */ + | { type: "closeTab"; tabId: number } + /** Focus a shared tab (window + tab activation). Result: {}. */ + | { type: "activateTab"; tabId: number }; + +/** Keepalive probe; the extension answers with pong. */ +export type RelayPingMessage = { + type: "ping"; +}; + +export type RelayToExtensionMessage = (RelayCommandBody & { seq: number }) | RelayPingMessage; + +/** Parse one extension frame; returns null for malformed input. */ +export function parseExtensionMessage(raw: string): ExtensionToRelayMessage | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object") { + return null; + } + const type = (parsed as { type?: unknown }).type; + if (typeof type !== "string") { + return null; + } + switch (type) { + case "hello": + case "tabs": + case "cdpEvent": + case "result": + case "error": + case "detached": + case "pong": + return parsed as ExtensionToRelayMessage; + default: + return null; + } +} diff --git a/extensions/browser/src/browser/extension-relay/relay-server.ts b/extensions/browser/src/browser/extension-relay/relay-server.ts new file mode 100644 index 00000000000..b5839488b8d --- /dev/null +++ b/extensions/browser/src/browser/extension-relay/relay-server.ts @@ -0,0 +1,238 @@ +/** + * Extension relay HTTP/WebSocket server. + * + * Loopback-only endpoint that pairs the OpenClaw Chrome extension with the + * browser control service: + * GET /json/version -> CDP discovery for pw-session (503 until paired) + * WS /cdp -> CDP browser endpoint (Playwright connectOverCDP) + * WS /extension -> the Chrome extension's relay transport + * Both sides authenticate with the derived relay token: CDP clients send it as + * Basic auth (flows from the profile cdpUrl userinfo via getHeadersWithAuth), + * the extension sends `Authorization: Bearer` or `?token=`. + */ +import http, { type IncomingMessage, type Server } from "node:http"; +import type { Duplex } from "node:stream"; +import { WebSocketServer, type WebSocket } from "ws"; +import { isLoopbackHost } from "../../gateway/net.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { extensionRelayTokenMatches } from "./relay-auth.js"; +import { ExtensionRelayBridge } from "./relay-bridge.js"; + +const log = createSubsystemLogger("browser").child("extension-relay"); + +/** + * Cap relay frame size to bound memory from a hostile/buggy peer while leaving + * headroom for CDP payloads (base64 screenshots, DOM snapshots, network bodies). + */ +const EXTENSION_RELAY_MAX_PAYLOAD_BYTES = 64 * 1024 * 1024; + +/** Running relay server handle owned by the profile runtime state. */ +export type ExtensionRelayHandle = { + port: number; + /** Auth token this relay validates against; used to detect auth rotation. */ + token: string; + bridge: ExtensionRelayBridge; + close: () => Promise; +}; + +function firstHeader(value: string | string[] | undefined): string { + return Array.isArray(value) ? (value[0] ?? "") : (value ?? ""); +} + +function requestToken(req: IncomingMessage): string { + const auth = firstHeader(req.headers.authorization); + if (auth.startsWith("Bearer ")) { + return auth.slice("Bearer ".length).trim(); + } + if (auth.startsWith("Basic ")) { + const decoded = Buffer.from(auth.slice("Basic ".length), "base64").toString("utf8"); + const separator = decoded.indexOf(":"); + return separator >= 0 ? decoded.slice(separator + 1) : decoded; + } + try { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + return url.searchParams.get("token") ?? ""; + } catch { + return ""; + } +} + +function isAuthorized(req: IncomingMessage, token: string): boolean { + const candidate = requestToken(req); + return candidate.length > 0 && extensionRelayTokenMatches(token, candidate); +} + +/** Reject cross-origin websocket upgrades; the extension side must come from Chrome. */ +function isAllowedExtensionOrigin(req: IncomingMessage): boolean { + const origin = firstHeader(req.headers.origin); + // Chrome MV3 service workers send their chrome-extension:// origin. Absent + // origin is allowed for non-browser clients such as tests and diagnostics. + return origin === "" || origin.startsWith("chrome-extension://"); +} + +/** Reject DNS-rebinding style requests that reach loopback with a foreign Host. */ +function hasLoopbackHostHeader(req: IncomingMessage): boolean { + const host = firstHeader(req.headers.host); + if (!host) { + return true; + } + try { + return isLoopbackHost(new URL(`http://${host}`).hostname); + } catch { + return false; + } +} + +function destroySocket(socket: Duplex, response: string): void { + socket.write(response); + socket.destroy(); +} + +/** Start the relay server for one extension-driver profile. */ +export async function startExtensionRelayServer(params: { + port: number; + token: string; + onStateChange?: () => void; +}): Promise { + const bridge = new ExtensionRelayBridge({ onStateChange: params.onStateChange }); + const wss = new WebSocketServer({ + noServer: true, + maxPayload: EXTENSION_RELAY_MAX_PAYLOAD_BYTES, + }); + + const server: Server = http.createServer((req, res) => { + if (!hasLoopbackHostHeader(req)) { + res.writeHead(403).end("Forbidden"); + return; + } + if (!isAuthorized(req, params.token)) { + res.writeHead(401, { "WWW-Authenticate": 'Basic realm="openclaw-extension-relay"' }); + res.end("Unauthorized"); + return; + } + const path = (req.url ?? "/").split("?")[0]; + if (req.method === "GET" && (path === "/json/version" || path === "/json/version/")) { + if (!bridge.extensionConnected) { + res.writeHead(503, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + error: + "OpenClaw Chrome extension is not connected. Install the extension and pair it with `openclaw browser extension pair`.", + }), + ); + return; + } + const identity = bridge.identity; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + Browser: identity?.browserVersion ?? "Chrome/unknown", + "Protocol-Version": "1.3", + "User-Agent": identity?.userAgent ?? "unknown", + webSocketDebuggerUrl: `ws://127.0.0.1:${resolvedPort()}/cdp`, + }), + ); + return; + } + if (req.method === "GET" && (path === "/json" || path === "/json/list")) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(bridge.sharedTabs())); + return; + } + res.writeHead(404).end("Not found"); + }); + + server.on("upgrade", (req, socket, head) => { + const path = (req.url ?? "/").split("?")[0]; + if (!hasLoopbackHostHeader(req)) { + destroySocket(socket, "HTTP/1.1 403 Forbidden\r\n\r\n"); + return; + } + if (!isAuthorized(req, params.token)) { + destroySocket(socket, "HTTP/1.1 401 Unauthorized\r\n\r\n"); + return; + } + if (path === "/extension") { + if (!isAllowedExtensionOrigin(req)) { + destroySocket(socket, "HTTP/1.1 403 Forbidden\r\n\r\n"); + return; + } + wss.handleUpgrade(req, socket, head, (ws) => { + bindSocket(ws, bridge.attachExtensionSocket(toBridgeSocket(ws))); + log.info("extension connected to relay"); + }); + return; + } + if (path === "/cdp") { + wss.handleUpgrade(req, socket, head, (ws) => { + bindSocket(ws, bridge.attachCdpClientSocket(toBridgeSocket(ws))); + }); + return; + } + destroySocket(socket, "HTTP/1.1 404 Not Found\r\n\r\n"); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(params.port, "127.0.0.1", () => resolve()); + }); + + const resolvedPort = () => { + const address = server.address(); + return typeof address === "object" && address ? address.port : params.port; + }; + + return { + port: resolvedPort(), + token: params.token, + bridge, + close: async () => { + bridge.dispose(); + wss.close(); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + }, + }; +} + +function toBridgeSocket(ws: WebSocket) { + return { + send: (data: string) => { + if (ws.readyState === ws.OPEN) { + ws.send(data); + } + }, + close: (code?: number, reason?: string) => { + try { + ws.close(code, reason); + } catch { + // already closing + } + }, + }; +} + +/** Decode a ws frame (string | Buffer | Buffer[] | ArrayBuffer) to text. */ +function decodeWsData(data: import("ws").RawData | string): string { + if (typeof data === "string") { + return data; + } + if (Array.isArray(data)) { + return Buffer.concat(data).toString("utf8"); + } + return Buffer.from(data as ArrayBuffer).toString("utf8"); +} + +function bindSocket( + ws: WebSocket, + handlers: { onMessage: (raw: string) => void; onClose: () => void }, +): void { + ws.on("message", (data) => { + handlers.onMessage(decodeWsData(data)); + }); + ws.on("close", handlers.onClose); + ws.on("error", (err) => { + log.warn(`relay socket error: ${String(err)}`); + }); +} diff --git a/extensions/browser/src/browser/profile-capabilities.ts b/extensions/browser/src/browser/profile-capabilities.ts index aac784a0ea8..428518bc6ed 100644 --- a/extensions/browser/src/browser/profile-capabilities.ts +++ b/extensions/browser/src/browser/profile-capabilities.ts @@ -6,7 +6,11 @@ */ import type { ResolvedBrowserProfile } from "./config.js"; -type BrowserProfileMode = "local-managed" | "local-existing-session" | "remote-cdp"; +type BrowserProfileMode = + | "local-managed" + | "local-existing-session" + | "local-extension" + | "remote-cdp"; type BrowserProfileCapabilities = { mode: BrowserProfileMode; @@ -37,6 +41,22 @@ export function getBrowserProfileCapabilities( }; } + // Extension relay profiles drive the user's signed-in browser through the + // paired Chrome extension. Ops run over persistent Playwright exactly like + // remote CDP, but the endpoint is the loopback relay server. + if (profile.driver === "extension") { + return { + mode: "local-extension", + isRemote: false, + usesChromeMcp: false, + usesPersistentPlaywright: true, + supportsPerTabWs: false, + supportsJsonTabEndpoints: false, + supportsReset: false, + supportsManagedTabLimit: false, + }; + } + if (!profile.cdpIsLoopback) { return { mode: "remote-cdp", diff --git a/extensions/browser/src/browser/routes/basic.ts b/extensions/browser/src/browser/routes/basic.ts index cbd3c6cd746..cbb985822c4 100644 --- a/extensions/browser/src/browser/routes/basic.ts +++ b/extensions/browser/src/browser/routes/basic.ts @@ -195,7 +195,11 @@ async function buildBrowserStatus(req: BrowserRequest, ctx: BrowserRouteContext) enabled: current.resolved.enabled, profile: profileCtx.profile.name, driver: profileCtx.profile.driver, - transport: capabilities.usesChromeMcp ? ("chrome-mcp" as const) : ("cdp" as const), + transport: capabilities.usesChromeMcp + ? ("chrome-mcp" as const) + : capabilities.mode === "local-extension" + ? ("extension" as const) + : ("cdp" as const), running: cdpReady, cdpReady, cdpHttp, diff --git a/extensions/browser/src/browser/runtime-lifecycle.ts b/extensions/browser/src/browser/runtime-lifecycle.ts index d50b50df78e..a7cff521ace 100644 --- a/extensions/browser/src/browser/runtime-lifecycle.ts +++ b/extensions/browser/src/browser/runtime-lifecycle.ts @@ -2,6 +2,7 @@ * Browser plugin runtime lifecycle helpers for startup and shutdown cleanup. */ import type { Server } from "node:http"; +import { getExtensionRelayModule } from "./extension-relay.runtime.js"; import { getPwAiModule } from "./pw-ai-module.js"; import { isPwAiLoaded } from "./pw-ai-state.js"; import type { BrowserServerState } from "./server-context.js"; @@ -50,6 +51,11 @@ export async function stopBrowserRuntime(params: { onWarn: params.onWarn, }); + if (params.current.extensionRelays?.size) { + const { stopExtensionRelays } = await getExtensionRelayModule(); + await stopExtensionRelays(params.current); + } + if (params.closeServer && params.current.server) { await new Promise((resolve) => { params.current?.server?.close(() => resolve()); diff --git a/extensions/browser/src/browser/server-context.availability.ts b/extensions/browser/src/browser/server-context.availability.ts index 355ffcc79ac..c835cd52340 100644 --- a/extensions/browser/src/browser/server-context.availability.ts +++ b/extensions/browser/src/browser/server-context.availability.ts @@ -23,6 +23,7 @@ import { } from "./chrome.js"; import type { ResolvedBrowserProfile } from "./config.js"; import { BrowserProfileUnavailableError } from "./errors.js"; +import { getExtensionRelayModule } from "./extension-relay.runtime.js"; import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; import { CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS, @@ -158,10 +159,28 @@ export function createProfileAvailability({ const getCdpReachabilityPolicy = () => resolveCdpReachabilityPolicy(profile, state().resolved.ssrfPolicy); + // Extension profiles probe against the relay server, so it must be listening + // before any reachability check; starting it reconciles port/token drift and + // is cheap and idempotent. Pruning here reaps relays for profiles removed or + // renamed since the last refresh. + const ensureExtensionRelay = async () => { + if (capabilities.mode !== "local-extension") { + return; + } + const { ensureExtensionRelayForProfile, pruneRemovedExtensionRelays } = + await getExtensionRelayModule(); + const current = state(); + await pruneRemovedExtensionRelays( + current, + (name) => current.resolved.profiles[name]?.driver === "extension", + ); + await ensureExtensionRelayForProfile(current, profile); + }; const isReachable = async ( timeoutMs?: number, options?: { ephemeral?: boolean; signal?: AbortSignal }, ) => { + await ensureExtensionRelay(); if (capabilities.usesChromeMcp) { // listChromeMcpTabs creates the session if needed — no separate ensureChromeMcpAvailable call required. // Status probes opt into ephemeral so they reuse a cached attach session if one exists, @@ -205,6 +224,7 @@ export function createProfileAvailability({ if (capabilities.usesChromeMcp) { return await isTransportAvailable(timeoutMs); } + await ensureExtensionRelay(); const { httpTimeoutMs } = resolveTimeouts(timeoutMs); return await isChromeReachable(profile.cdpUrl, httpTimeoutMs, getCdpReachabilityPolicy()); }; @@ -374,6 +394,13 @@ export function createProfileAvailability({ } } if (attachOnly || remoteCdp) { + if (capabilities.mode === "local-extension") { + const { EXTENSION_PAIRING_HINT } = await getExtensionRelayModule(); + throw new BrowserProfileUnavailableError( + `The OpenClaw Chrome extension is not connected for profile "${profile.name}". ` + + `Open Chrome on this machine and check the extension popup shows "Connected". ${EXTENSION_PAIRING_HINT}`, + ); + } throw new BrowserProfileUnavailableError( remoteCdp ? `Remote CDP for profile "${profile.name}" is not reachable at ${redactedProfileCdpUrl}.` @@ -412,6 +439,12 @@ export function createProfileAvailability({ if (remoteCdp && (await isReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS))) { return; } + if (capabilities.mode === "local-extension") { + const { EXTENSION_PAIRING_HINT } = await getExtensionRelayModule(); + throw new BrowserProfileUnavailableError( + `The extension relay for profile "${profile.name}" is running but the OpenClaw Chrome extension is not connected. ${EXTENSION_PAIRING_HINT}`, + ); + } const detail = await describeCdpFailure(PROFILE_ATTACH_RETRY_TIMEOUT_MS); throw new BrowserProfileUnavailableError( remoteCdp diff --git a/extensions/browser/src/browser/server-context.existing-session.test.ts b/extensions/browser/src/browser/server-context.existing-session.test.ts index d592d142bbd..5e0f5f40429 100644 --- a/extensions/browser/src/browser/server-context.existing-session.test.ts +++ b/extensions/browser/src/browser/server-context.existing-session.test.ts @@ -47,6 +47,8 @@ function makeState(): BrowserServerState { controlPort: 18791, cdpPortRangeStart: 18800, cdpPortRangeEnd: 18899, + extensionRelayDefaultPort: 18799, + extensionRelayPorts: {}, cdpProtocol: "http", cdpHost: "127.0.0.1", cdpIsLoopback: true, diff --git a/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts b/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts index f2944c41e92..fadfabb844f 100644 --- a/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts +++ b/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts @@ -24,6 +24,8 @@ export function makeState( controlPort: 18791, cdpPortRangeStart: 18800, cdpPortRangeEnd: 18899, + extensionRelayDefaultPort: 18799, + extensionRelayPorts: {}, cdpProtocol: profile === "remote" ? "https" : "http", cdpHost: profile === "remote" ? "1.1.1.1" : "127.0.0.1", cdpIsLoopback: profile !== "remote", diff --git a/extensions/browser/src/browser/server-context.test-harness.ts b/extensions/browser/src/browser/server-context.test-harness.ts index 3f8cfa6982d..f891826cd5f 100644 --- a/extensions/browser/src/browser/server-context.test-harness.ts +++ b/extensions/browser/src/browser/server-context.test-harness.ts @@ -39,6 +39,8 @@ export function makeBrowserServerState(params?: { cdpIsLoopback: profile.cdpIsLoopback, cdpPortRangeStart: 18800, cdpPortRangeEnd: 18810, + extensionRelayDefaultPort: 18808, + extensionRelayPorts: {}, evaluateEnabled: false, remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, diff --git a/extensions/browser/src/browser/server-context.ts b/extensions/browser/src/browser/server-context.ts index d677afbd518..91471e4b4c5 100644 --- a/extensions/browser/src/browser/server-context.ts +++ b/extensions/browser/src/browser/server-context.ts @@ -231,7 +231,11 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon result.push({ name, - transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp", + transport: capabilities.usesChromeMcp + ? "chrome-mcp" + : capabilities.mode === "local-extension" + ? "extension" + : "cdp", cdpPort: capabilities.usesChromeMcp ? null : profile.cdpPort, cdpUrl: profile.cdpUrl ? (redactCdpUrl(profile.cdpUrl) ?? null) : null, color: profile.color, diff --git a/extensions/browser/src/browser/server-context.types.ts b/extensions/browser/src/browser/server-context.types.ts index 127a2a0d358..2c8b0185818 100644 --- a/extensions/browser/src/browser/server-context.types.ts +++ b/extensions/browser/src/browser/server-context.types.ts @@ -6,6 +6,7 @@ import type { Server } from "node:http"; import type { RunningChrome } from "./chrome.js"; import type { BrowserTab, BrowserTransport } from "./client.types.js"; import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js"; +import type { ExtensionRelayHandle } from "./extension-relay/relay-server.js"; export type { BrowserTab }; @@ -39,6 +40,8 @@ export type BrowserServerState = { port: number; resolved: ResolvedBrowserConfig; profiles: Map; + /** Running extension relay servers keyed by profile name (extension driver). */ + extensionRelays?: Map; stopTrackedTabCleanup?: () => void; stopUnhandledRejectionHandler?: () => void; }; diff --git a/extensions/browser/src/cli/browser-cli-extension.ts b/extensions/browser/src/cli/browser-cli-extension.ts new file mode 100644 index 00000000000..0151bb2f130 --- /dev/null +++ b/extensions/browser/src/cli/browser-cli-extension.ts @@ -0,0 +1,105 @@ +/** + * `openclaw browser extension` CLI: locate the unpacked Chrome extension and + * print the pairing string that connects it to this install's relay. + */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Command } from "commander"; +import { ensureExtensionRelayToken } from "../browser/extension-relay/relay-auth.js"; +import type { BrowserParentOpts } from "./browser-cli-shared.js"; +import { + danger, + defaultRuntime, + getRuntimeConfig, + info, + resolveBrowserConfig, + runCommandWithRuntime, + theme, +} from "./core-api.js"; + +/** Absolute path to the bundled unpacked Chrome extension directory. */ +function resolveChromeExtensionDir(): string { + // extensions/browser/dist/cli/ -> extensions/browser/chrome-extension + const here = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(here, "..", "..", "chrome-extension"); +} + +function firstExtensionProfile(): { name: string; relayPort: number } | null { + const cfg = getRuntimeConfig(); + const resolved = resolveBrowserConfig(cfg.browser, cfg); + for (const [name, profile] of Object.entries(resolved.profiles)) { + if (profile.driver === "extension") { + return { name, relayPort: profile.cdpPort ?? resolved.extensionRelayDefaultPort }; + } + } + return null; +} + +function buildPairingString(): { pairing: string; relayPort: number } { + const cfg = getRuntimeConfig(); + const resolved = resolveBrowserConfig(cfg.browser, cfg); + // Create the host-local relay secret if this host has not used the extension + // driver yet, so pairing works on a fresh gateway or node host before the + // relay has started. Pairing must run on the machine that hosts the browser. + const token = ensureExtensionRelayToken(); + const profile = firstExtensionProfile(); + const relayPort = profile?.relayPort ?? resolved.extensionRelayDefaultPort; + return { + pairing: `ws://127.0.0.1:${relayPort}/extension#${token}`, + relayPort, + }; +} + +/** Register `openclaw browser extension {path,pair}`. */ +export function registerBrowserExtensionCommands( + browser: Command, + _parentOpts: (cmd: Command) => BrowserParentOpts, +) { + const extension = browser + .command("extension") + .description("Chrome extension: print the load path and pairing string"); + + extension + .command("path") + .description("Print the unpacked Chrome extension directory (Load unpacked)") + .action(() => { + defaultRuntime.log(resolveChromeExtensionDir()); + }); + + extension + .command("pair") + .description("Print the pairing string to paste into the OpenClaw extension popup") + .option("--json", "Print the pairing string as JSON") + .action(async (opts) => { + await runCommandWithRuntime( + defaultRuntime, + async () => { + const result = buildPairingString(); + if (opts.json === true) { + defaultRuntime.log( + JSON.stringify({ pairingString: result.pairing, relayPort: result.relayPort }), + ); + return; + } + defaultRuntime.log( + [ + info( + "Run this on the machine that hosts the browser (gateway host or browser node).", + ), + info("1. Load the extension: chrome://extensions → Developer mode → Load unpacked →"), + ` ${resolveChromeExtensionDir()}`, + info("2. Open the OpenClaw popup and paste this pairing string:"), + "", + theme.heading(result.pairing), + "", + info("The token is a host-local secret; keep it private."), + ].join("\n"), + ); + }, + (err: unknown) => { + defaultRuntime.error(danger(String(err))); + defaultRuntime.exit(1); + }, + ); + }); +} diff --git a/extensions/browser/src/cli/browser-cli-manage.ts b/extensions/browser/src/cli/browser-cli-manage.ts index d80d7ef3132..e70e0ffee1c 100644 --- a/extensions/browser/src/cli/browser-cli-manage.ts +++ b/extensions/browser/src/cli/browser-cli-manage.ts @@ -292,16 +292,25 @@ async function runBrowserDoctor(parent: BrowserParentOpts, profile?: string, dee return { ok: checks.every((check) => check.ok), checks, status }; } +type BrowserProfileDriver = "openclaw" | "existing-session" | "extension"; + function usesChromeMcpTransport(params: { transport?: BrowserTransport; - driver?: "openclaw" | "existing-session"; + driver?: BrowserProfileDriver; }): boolean { return params.transport === "chrome-mcp" || params.driver === "existing-session"; } +function usesExtensionTransport(params: { + transport?: BrowserTransport; + driver?: BrowserProfileDriver; +}): boolean { + return params.transport === "extension" || params.driver === "extension"; +} + function formatBrowserConnectionSummary(params: { transport?: BrowserTransport; - driver?: "openclaw" | "existing-session"; + driver?: BrowserProfileDriver; isRemote?: boolean; cdpPort?: number | null; cdpUrl?: string | null; @@ -316,6 +325,9 @@ function formatBrowserConnectionSummary(params: { ? `transport: chrome-mcp, userDataDir: ${userDataDir}` : "transport: chrome-mcp"; } + if (usesExtensionTransport(params)) { + return `transport: extension, relayPort: ${params.cdpPort ?? "(unset)"}`; + } if (params.isRemote) { return `cdpUrl: ${params.cdpUrl ? redactCdpUrl(params.cdpUrl) : "(unset)"}`; } diff --git a/extensions/browser/src/cli/browser-cli.ts b/extensions/browser/src/cli/browser-cli.ts index c8bde08ef38..1420717a373 100644 --- a/extensions/browser/src/cli/browser-cli.ts +++ b/extensions/browser/src/cli/browser-cli.ts @@ -139,6 +139,13 @@ const browserCommandGroupDefinitions: readonly BrowserCommandGroupDefinition[] = module.registerBrowserStateCommands(args.browser, args.parentOpts); }, }, + { + placeholders: [command("extension", "Chrome extension load path and pairing")], + register: async (args) => { + const module = await import("./browser-cli-extension.js"); + module.registerBrowserExtensionCommands(args.browser, args.parentOpts); + }, + }, ]; function buildBrowserCommandGroups(params: { diff --git a/extensions/browser/src/control-service.ts b/extensions/browser/src/control-service.ts index f9388b33573..90364031539 100644 --- a/extensions/browser/src/control-service.ts +++ b/extensions/browser/src/control-service.ts @@ -8,8 +8,9 @@ import { stopBrowserControlRuntime, } from "./browser-control-state.js"; import { loadBrowserConfigForRuntimeRefresh } from "./browser/config-refresh-source.js"; -import { resolveBrowserConfig } from "./browser/config.js"; +import { resolveBrowserConfig, resolveProfile } from "./browser/config.js"; import { ensureBrowserControlAuth } from "./browser/control-auth.js"; +import { getExtensionRelayModule } from "./browser/extension-relay.runtime.js"; import type { BrowserServerState } from "./browser/server-context.js"; import { getRuntimeConfig } from "./config/config.js"; import { createSubsystemLogger } from "./logging/subsystem.js"; @@ -30,7 +31,7 @@ export async function startBrowserControlServiceFromConfig(): Promise profile.driver === "extension", + ); + if (hasExtensionProfiles) { + const { ensureExtensionRelayToken } = await import("./browser/extension-relay/relay-auth.js"); + ensureExtensionRelayToken(); + const refreshed = loadBrowserConfigForRuntimeRefresh(); + resolved = resolveBrowserConfig(refreshed.browser, refreshed); + } + const state = await ensureBrowserControlRuntime({ server: null, port: resolved.controlPort, @@ -51,6 +65,17 @@ export async function startBrowserControlServiceFromConfig(): Promise logService.warn(message), }); + // Extension relays listen from service start so the Chrome extension can + // (re)connect before the first agent browser request arrives. + if (hasExtensionProfiles) { + const { startConfiguredExtensionRelays } = await getExtensionRelayModule(); + await startConfiguredExtensionRelays( + state, + (name) => resolveProfile(resolved, name), + (message) => logService.warn(message), + ); + } + logService.info( `Browser control service ready (profiles=${Object.keys(resolved.profiles).length})`, ); diff --git a/src/agents/sandbox/browser.create.test.ts b/src/agents/sandbox/browser.create.test.ts index 19d8b799766..3181d895ee7 100644 --- a/src/agents/sandbox/browser.create.test.ts +++ b/src/agents/sandbox/browser.create.test.ts @@ -455,6 +455,8 @@ describe("ensureSandboxBrowser create args", () => { cdpIsLoopback: true, cdpPortRangeStart: 18800, cdpPortRangeEnd: 18899, + extensionRelayDefaultPort: 18799, + extensionRelayPorts: {}, remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, localLaunchTimeoutMs: 15_000, @@ -517,6 +519,8 @@ describe("ensureSandboxBrowser create args", () => { cdpIsLoopback: true, cdpPortRangeStart: 18800, cdpPortRangeEnd: 18899, + extensionRelayDefaultPort: 18799, + extensionRelayPorts: {}, remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, localLaunchTimeoutMs: 15_000, diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index ac06d7e859d..c8e47406583 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -2004,9 +2004,10 @@ describe("doctor config flow", () => { }); const browser = (result.cfg as { browser?: Record }).browser ?? {}; expect(browser.relayBindHost).toBeUndefined(); + // driver "extension" is the live Chrome extension relay driver; repair keeps it. expect( ((browser.profiles as Record)?.chromeLive ?? {}).driver, - ).toBe("existing-session"); + ).toBe("extension"); expect(result.cfg.plugins?.allow).toEqual(["telegram", "browser", "codex"]); expect(result.cfg.plugins?.entries?.browser?.enabled).toBe(true); expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(true); diff --git a/src/commands/doctor-legacy-config.test.ts b/src/commands/doctor-legacy-config.test.ts index f2d3b61a4b3..e72eb72b20a 100644 --- a/src/commands/doctor-legacy-config.test.ts +++ b/src/commands/doctor-legacy-config.test.ts @@ -112,7 +112,7 @@ describe("normalizeCompatibilityConfigValues preview streaming aliases", () => { }); describe("normalizeCompatibilityConfigValues browser compatibility aliases", () => { - it("removes legacy browser relay bind host and migrates extension profiles", () => { + it("removes legacy browser relay bind host and stale extension relay cdpUrl", () => { const changes: string[] = []; const config = normalizeLegacyBrowserConfig( asLegacyConfig({ @@ -121,6 +121,7 @@ describe("normalizeCompatibilityConfigValues browser compatibility aliases", () profiles: { work: { driver: "extension", + cdpUrl: "http://127.0.0.1:18792", }, keep: { driver: "existing-session", @@ -134,11 +135,14 @@ describe("normalizeCompatibilityConfigValues browser compatibility aliases", () expect( (config.browser as { relayBindHost?: string } | undefined)?.relayBindHost, ).toBeUndefined(); - expect(config.browser?.profiles?.work?.driver).toBe("existing-session"); + // driver "extension" is the live Chrome extension relay driver again; only + // the retired relay endpoint URL gets stripped. + expect(config.browser?.profiles?.work?.driver).toBe("extension"); + expect(config.browser?.profiles?.work?.cdpUrl).toBeUndefined(); expect(config.browser?.profiles?.keep?.driver).toBe("existing-session"); expect(changes).toEqual([ - "Removed browser.relayBindHost (legacy Chrome extension relay setting; host-local Chrome now uses Chrome MCP existing-session attach).", - 'Moved browser.profiles.work.driver "extension" → "existing-session" (Chrome MCP attach).', + "Removed browser.relayBindHost (legacy Chrome extension relay setting; the extension relay binds loopback on the profile cdpPort).", + "Removed browser.profiles.work.cdpUrl (extension driver profiles own their relay endpoint).", ]); }); }); diff --git a/src/commands/doctor/shared/legacy-config-core-normalizers.ts b/src/commands/doctor/shared/legacy-config-core-normalizers.ts index 476a3f9db93..be23dd6d485 100644 --- a/src/commands/doctor/shared/legacy-config-core-normalizers.ts +++ b/src/commands/doctor/shared/legacy-config-core-normalizers.ts @@ -58,10 +58,14 @@ export function normalizeLegacyBrowserConfig( 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).", + "Removed browser.relayBindHost (legacy Chrome extension relay setting; the extension relay binds loopback on the profile cdpPort).", ); } + // driver "extension" is a live driver again (Chrome extension relay v2). Old + // relay-era profiles could carry a cdpUrl pointing at the retired gateway + // relay endpoint; the new driver owns its endpoint, so drop the stale URL + // instead of failing schema validation. const rawProfiles = browser.profiles; if (isRecord(rawProfiles)) { const profiles = { ...rawProfiles }; @@ -71,16 +75,15 @@ export function normalizeLegacyBrowserConfig( continue; } const rawDriver = normalizeOptionalString(rawProfile.driver) ?? ""; - if (rawDriver !== "extension") { + if (rawDriver !== "extension" || !normalizeOptionalString(rawProfile.cdpUrl)) { continue; } - profiles[profileName] = { - ...rawProfile, - driver: "existing-session", - }; + const nextProfile = { ...rawProfile }; + delete nextProfile.cdpUrl; + profiles[profileName] = nextProfile; profilesChanged = true; changes.push( - `Moved browser.profiles.${profileName}.driver "extension" → "existing-session" (Chrome MCP attach).`, + `Removed browser.profiles.${profileName}.cdpUrl (extension driver profiles own their relay endpoint).`, ); } if (profilesChanged) { diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts index ba5cbc6f365..78399f1e4ca 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -10,8 +10,11 @@ export type BrowserProfileConfig = { mcpCommand?: string; /** Extra Chrome MCP arguments for existing-session profiles. */ mcpArgs?: string[]; - /** Profile driver (default: openclaw). */ - driver?: "openclaw" | "clawd" | "existing-session"; + /** + * Profile driver (default: openclaw). "extension" attaches to the user's + * signed-in browser through the OpenClaw Chrome extension relay. + */ + driver?: "openclaw" | "clawd" | "existing-session" | "extension"; /** If true, launch this profile in headless mode. Falls back to browser.headless. */ headless?: boolean; /** Browser executable path for this profile. Falls back to browser.executablePath. */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 3a213cad369..bbc4cd88b2a 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -732,7 +732,12 @@ export const OpenClawSchema = z mcpCommand: z.string().optional(), mcpArgs: z.array(z.string()).optional(), driver: z - .union([z.literal("openclaw"), z.literal("clawd"), z.literal("existing-session")]) + .union([ + z.literal("openclaw"), + z.literal("clawd"), + z.literal("existing-session"), + z.literal("extension"), + ]) .optional(), headless: z.boolean().optional(), executablePath: z.string().optional(), @@ -741,13 +746,21 @@ export const OpenClawSchema = z }) .strict() .refine( - (value) => value.driver === "existing-session" || value.cdpPort || value.cdpUrl, + (value) => + value.driver === "existing-session" || + value.driver === "extension" || + value.cdpPort || + value.cdpUrl, { message: "Profile must set cdpPort or cdpUrl", }, ) .refine((value) => value.driver === "existing-session" || !value.userDataDir, { message: 'Profile userDataDir is only supported with driver="existing-session"', + }) + .refine((value) => value.driver !== "extension" || !value.cdpUrl, { + message: + 'Profile cdpUrl is not supported with driver="extension" (the relay owns the endpoint)', }), ) .optional(),