feat(browser): restore driver "extension" via a loopback Chrome extension relay (no remote-debugging prompt) (#100619)

* feat(browser): restore driver "extension" via loopback Chrome extension relay

Reintroduces browser profile driver "extension" (removed in 2026.3.22) as a
loopback relay that drives the user's signed-in Chrome through an MV3 extension
instead of the remote-debugging port. This avoids Chrome's blocking "Allow
remote debugging?" prompt, which cannot be clicked when the operator drives
OpenClaw from a phone. Automated tabs live in an "OpenClaw" tab group (the
consent boundary), mirroring the Codex/Claude-in-Chrome model.

- relay bridge synthesizes the CDP browser target surface for Playwright
  connectOverCDP and forwards session-scoped commands to chrome.debugger
- relay server binds loopback only; both sides authenticate with a token
  derived (HMAC-SHA256) from gateway auth, so the raw credential never reaches
  Chrome; extension origin + loopback Host checks guard the upgrade
- built-in "chrome" profile; distinct relay ports per extension profile;
  relay reconciles on auth rotation / cdpPort change and prunes removed profiles
- doctor + status surface the extension transport; doctor keeps repairing the
  retired relay endpoint URL on legacy "extension" profiles

Refs #53599

* feat(browser): bundle the OpenClaw MV3 Chrome extension

Thin MV3 extension (chrome-extension/): a WebSocket client to the loopback
relay plus chrome.debugger forwarding and OpenClaw tab-group management. All
CDP target synthesis lives server-side in the relay bridge, so the extension
stays a dumb transport (the removed 2026.3 extension put that logic in a
1000-line untestable service worker). Popup handles pairing and per-tab share
toggle; `openclaw browser extension path|pair` load and pair it. A build copy
hook stages it into dist so the load path is stable.

Refs #53599

* docs(browser): document the Chrome extension profile

Adds docs/tools/chrome-extension for the restored extension driver (install,
pair, tab-group consent model, security posture) and wires it into the browser
docs profile section and nav.

Refs #53599

* feat(browser): make the extension relay work on remote browser nodes

Derives the relay auth token from a host-local secret in the credentials dir
(created on first use) instead of gateway auth. Each machine that runs a
browser — the gateway host and every browser node host — owns its own token, so
the extension pairs with whichever machine hosts its Chrome and no gateway
credential travels to a node. The node host already runs the shared browser
control bootstrap, so this is all that was missing for cross-machine control.

Also removes the "relay needs gateway auth before it can start" failure mode:
startup and `openclaw browser extension pair` ensure the secret exists.

Refs #53599

* fix(browser): harden relay secret creation and satisfy CI lint/typecheck

- Make the host-local relay secret creation atomic (O_CREAT|O_EXCL + adopt the
  winner on EEXIST) so the gateway service and `extension pair` CLI cannot mint
  divergent tokens on a fresh host (would 401 until restart); credentials dir
  created mode 0700. (adversarial review finding)
- Resolve type-aware oxlint findings across the relay + extension: unknown catch
  vars, addEventListener over ws.on* in the MV3 worker, void async listeners,
  drop useless returns/spreads, Object.assign over map-spread, safe ws frame
  decode (Buffer[]/ArrayBuffer), toSorted.
- Add extensionRelayDefaultPort/extensionRelayPorts to remaining test config
  literals; type the extension relay-core module (.d.ts, excluded from dist);
  regenerate docs_map.

* fix(browser): satisfy OpenGrep security policy on the relay

- Hash both operands before timingSafeEqual so token comparison has no
  length short-circuit (GHSA-JJ6Q-RRRF-H66H).
- Bound the relay WebSocketServer with maxPayload (64 MiB, headroom for CDP
  screenshots/bodies) against oversized frames (GHSA-VW3H-Q6XQ-JJM5).
- Rewrite the config test env helper to avoid the skill-env-host-injection
  shape (GHSA-82G8-464F-2MV7); it is a test-only env swap.
This commit is contained in:
Peter Steinberger 2026-07-06 10:08:27 +01:00 committed by GitHub
parent ae6dc52da9
commit d6801f23d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 3231 additions and 45 deletions

View file

@ -1352,6 +1352,7 @@
"group": "Web browser",
"pages": [
"tools/browser",
"tools/chrome-extension",
"tools/browser-control",
"tools/browser-login",
"tools/browser-linux-troubleshooting",

View file

@ -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

View file

@ -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.<name>.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.
</Accordion>

View file

@ -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.

View file

@ -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();

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 B

View file

@ -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"
}

View file

@ -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 };

View file

@ -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:<port>/extension#<token>
* 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,
};
}

View file

@ -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");
});
});

View file

@ -0,0 +1,80 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<style>
body {
font-family: system-ui, sans-serif;
width: 320px;
margin: 0;
padding: 14px;
background: #1c1c1e;
color: #f2f2f7;
}
h1 {
font-size: 15px;
margin: 0 0 10px;
display: flex;
align-items: center;
gap: 8px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #6b7280;
}
.dot.on { background: #34c759; }
.dot.connecting { background: #f59e0b; }
.dot.error { background: #ff3b30; }
p { font-size: 12px; color: #98989f; margin: 6px 0; }
textarea {
width: 100%;
box-sizing: border-box;
background: #2c2c2e;
color: #f2f2f7;
border: 1px solid #3a3a3c;
border-radius: 6px;
font-family: ui-monospace, monospace;
font-size: 11px;
min-height: 52px;
resize: vertical;
}
button {
margin-top: 8px;
width: 100%;
padding: 7px 10px;
border-radius: 6px;
border: none;
font-size: 13px;
cursor: pointer;
background: #ff5a36;
color: white;
}
button.secondary {
background: #3a3a3c;
color: #f2f2f7;
}
.hidden { display: none; }
#error { color: #ff453a; }
</style>
</head>
<body>
<h1><span id="statusDot" class="dot"></span>OpenClaw</h1>
<div id="pairSection">
<p>
Run <code>openclaw browser extension pair</code> on this machine and
paste the pairing string:
</p>
<textarea id="pairingString" placeholder="ws://127.0.0.1:18797/extension#…"></textarea>
<p id="error" class="hidden"></p>
<button id="pairButton">Pair</button>
</div>
<div id="connectedSection" class="hidden">
<p id="statusLine"></p>
<button id="shareButton"></button>
<button id="unpairButton" class="secondary">Unpair</button>
</div>
<script src="popup.js" type="module"></script>
</body>
</html>

View file

@ -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);

View file

@ -19,6 +19,9 @@
"openclaw": {
"extensions": [
"./index.ts"
]
],
"assetScripts": {
"copy": "node scripts/copy-chrome-extension.mjs"
}
}
}

View file

@ -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);
}

View file

@ -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",
]);
});
});

View file

@ -1109,6 +1109,8 @@ describe("browser chrome launch args", () => {
cdpIsLoopback: true,
cdpPortRangeStart: 18800,
cdpPortRangeEnd: 18810,
extensionRelayDefaultPort: 18808,
extensionRelayPorts: {},
evaluateEnabled: false,
remoteCdpTimeoutMs: 1500,
remoteCdpHandshakeTimeoutMs: 3000,

View file

@ -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;

View file

@ -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;

View file

@ -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<T>(env: Record<string, string | undefined>, fn: () => T): T {
const snapshot = new Map<string, string | undefined>();
for (const [key] of Object.entries(env)) {
@ -21,7 +46,8 @@ function withEnv<T>(env: Record<string, string | undefined>, 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);

View file

@ -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<string, number>;
/** 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<string, BrowserProfileConfig>,
): Record<string, BrowserProfileConfig> {
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<string, BrowserProfileConfig>,
defaultPort: number,
): Record<string, number> {
const names = Object.entries(profiles)
.filter(([, profile]) => profile.driver === "extension" && profile.cdpPort == null)
.map(([name]) => name)
.toSorted();
const ports: Record<string, number> = {};
names.forEach((name, index) => {
ports[name] = defaultPort - index;
});
return ports;
}
function applyLegacyCdpUrlToExistingSessionDefaultProfile(
profiles: Record<string, BrowserProfileConfig>,
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 {

View file

@ -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",

View file

@ -0,0 +1,12 @@
/**
* Lazy boundary for the extension relay (pulls in the ws server dependency).
*/
let modPromise: Promise<typeof import("./extension-relay/relay-lifecycle.js")> | 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;
}

View file

@ -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);
});
});

View file

@ -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(),
);
}

View file

@ -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<Record<string, unknown>> {
return this.sent as Array<Record<string, unknown>>;
}
}
/**
* 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);
});
});

View file

@ -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<string, unknown>;
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<string>;
};
/** 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<CdpClientState>();
private readonly tabs = new Map<number, TabState>();
/** Child debugger sessions (iframes/workers) mapped to their owning tab. */
private readonly childSessions = new Map<string, number>();
private readonly pendingExtension = new Map<number, PendingExtensionCommand>();
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<unknown> {
const seq = this.nextSeq++;
return new Promise<unknown>((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<string, unknown> {
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<void> {
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<void> {
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<void> {
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();
}
}

View file

@ -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<string, ExtensionRelayHandle> {
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<ExtensionRelayHandle> {
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<void> {
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<void> {
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<void> {
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();
}

View file

@ -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();
});
});

View file

@ -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;
}
}

View file

@ -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<void>;
};
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<ExtensionRelayHandle> {
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<void>((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<void>((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)}`);
});
}

View file

@ -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",

View file

@ -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,

View file

@ -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<void>((resolve) => {
params.current?.server?.close(() => resolve());

View file

@ -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

View file

@ -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,

View file

@ -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",

View file

@ -39,6 +39,8 @@ export function makeBrowserServerState(params?: {
cdpIsLoopback: profile.cdpIsLoopback,
cdpPortRangeStart: 18800,
cdpPortRangeEnd: 18810,
extensionRelayDefaultPort: 18808,
extensionRelayPorts: {},
evaluateEnabled: false,
remoteCdpTimeoutMs: 1500,
remoteCdpHandshakeTimeoutMs: 3000,

View file

@ -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,

View file

@ -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<string, ProfileRuntimeState>;
/** Running extension relay servers keyed by profile name (extension driver). */
extensionRelays?: Map<string, ExtensionRelayHandle>;
stopTrackedTabCleanup?: () => void;
stopUnhandledRejectionHandler?: () => void;
};

View file

@ -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);
},
);
});
}

View file

@ -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)"}`;
}

View file

@ -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: {

View file

@ -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<BrowserSer
if (!isDefaultBrowserPluginEnabled(browserCfg)) {
return null;
}
const resolved = resolveBrowserConfig(browserCfg.browser, browserCfg);
let resolved = resolveBrowserConfig(browserCfg.browser, browserCfg);
if (!resolved.enabled) {
return null;
}
@ -43,6 +44,19 @@ export async function startBrowserControlServiceFromConfig(): Promise<BrowserSer
logService.warn(`failed to auto-configure browser auth: ${String(err)}`);
}
// Ensure the host-local relay secret exists before profiles are consumed so
// the extension cdpUrl carries auth. Works identically on the gateway host
// and on a browser node host — each owns its own secret.
const hasExtensionProfiles = Object.values(resolved.profiles).some(
(profile) => 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<BrowserSer
onWarn: (message) => 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})`,
);

View file

@ -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,

View file

@ -2004,9 +2004,10 @@ describe("doctor config flow", () => {
});
const browser = (result.cfg as { browser?: Record<string, unknown> }).browser ?? {};
expect(browser.relayBindHost).toBeUndefined();
// driver "extension" is the live Chrome extension relay driver; repair keeps it.
expect(
((browser.profiles as Record<string, { driver?: string }>)?.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);

View file

@ -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).",
]);
});
});

View file

@ -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) {

View file

@ -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. */

View file

@ -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(),