mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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:
parent
ae6dc52da9
commit
d6801f23d4
52 changed files with 3231 additions and 45 deletions
|
|
@ -1352,6 +1352,7 @@
|
|||
"group": "Web browser",
|
||||
"pages": [
|
||||
"tools/browser",
|
||||
"tools/chrome-extension",
|
||||
"tools/browser-control",
|
||||
"tools/browser-login",
|
||||
"tools/browser-linux-troubleshooting",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
130
docs/tools/chrome-extension.md
Normal file
130
docs/tools/chrome-extension.md
Normal 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.
|
||||
433
extensions/browser/chrome-extension/background.js
Normal file
433
extensions/browser/chrome-extension/background.js
Normal 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();
|
||||
BIN
extensions/browser/chrome-extension/icons/icon128.png
Normal file
BIN
extensions/browser/chrome-extension/icons/icon128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 614 B |
BIN
extensions/browser/chrome-extension/icons/icon16.png
Normal file
BIN
extensions/browser/chrome-extension/icons/icon16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 265 B |
BIN
extensions/browser/chrome-extension/icons/icon32.png
Normal file
BIN
extensions/browser/chrome-extension/icons/icon32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 318 B |
BIN
extensions/browser/chrome-extension/icons/icon48.png
Normal file
BIN
extensions/browser/chrome-extension/icons/icon48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 370 B |
25
extensions/browser/chrome-extension/manifest.json
Normal file
25
extensions/browser/chrome-extension/manifest.json
Normal 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"
|
||||
}
|
||||
21
extensions/browser/chrome-extension/modules/relay-core.d.ts
vendored
Normal file
21
extensions/browser/chrome-extension/modules/relay-core.d.ts
vendored
Normal 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 };
|
||||
94
extensions/browser/chrome-extension/modules/relay-core.js
Normal file
94
extensions/browser/chrome-extension/modules/relay-core.js
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
80
extensions/browser/chrome-extension/popup.html
Normal file
80
extensions/browser/chrome-extension/popup.html
Normal 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>
|
||||
78
extensions/browser/chrome-extension/popup.js
Normal file
78
extensions/browser/chrome-extension/popup.js
Normal 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);
|
||||
|
|
@ -19,6 +19,9 @@
|
|||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
],
|
||||
"assetScripts": {
|
||||
"copy": "node scripts/copy-chrome-extension.mjs"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
52
extensions/browser/scripts/copy-chrome-extension.mjs
Normal file
52
extensions/browser/scripts/copy-chrome-extension.mjs
Normal 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);
|
||||
}
|
||||
|
|
@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1109,6 +1109,8 @@ describe("browser chrome launch args", () => {
|
|||
cdpIsLoopback: true,
|
||||
cdpPortRangeStart: 18800,
|
||||
cdpPortRangeEnd: 18810,
|
||||
extensionRelayDefaultPort: 18808,
|
||||
extensionRelayPorts: {},
|
||||
evaluateEnabled: false,
|
||||
remoteCdpTimeoutMs: 1500,
|
||||
remoteCdpHandshakeTimeoutMs: 3000,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
12
extensions/browser/src/browser/extension-relay.runtime.ts
Normal file
12
extensions/browser/src/browser/extension-relay.runtime.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
85
extensions/browser/src/browser/extension-relay/relay-auth.ts
Normal file
85
extensions/browser/src/browser/extension-relay/relay-auth.ts
Normal 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(),
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
779
extensions/browser/src/browser/extension-relay/relay-bridge.ts
Normal file
779
extensions/browser/src/browser/extension-relay/relay-bridge.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
128
extensions/browser/src/browser/extension-relay/relay-protocol.ts
Normal file
128
extensions/browser/src/browser/extension-relay/relay-protocol.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
238
extensions/browser/src/browser/extension-relay/relay-server.ts
Normal file
238
extensions/browser/src/browser/extension-relay/relay-server.ts
Normal 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)}`);
|
||||
});
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ export function makeBrowserServerState(params?: {
|
|||
cdpIsLoopback: profile.cdpIsLoopback,
|
||||
cdpPortRangeStart: 18800,
|
||||
cdpPortRangeEnd: 18810,
|
||||
extensionRelayDefaultPort: 18808,
|
||||
extensionRelayPorts: {},
|
||||
evaluateEnabled: false,
|
||||
remoteCdpTimeoutMs: 1500,
|
||||
remoteCdpHandshakeTimeoutMs: 3000,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
105
extensions/browser/src/cli/browser-cli-extension.ts
Normal file
105
extensions/browser/src/cli/browser-cli-extension.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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)"}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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})`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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).",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue