feat(browser): add relay authentication v2 (#120526)

* feat(browser): add relay authentication v2

* fix(browser): cap relay test WebSocket payload

* fix(browser): keep relay E2E inside extension boundary

* fix(browser): isolate relay admission and cleanup auth

* fix(browser): finish relay auth migration hardening

* fix(browser): keep preauth transport bounded through teardown
This commit is contained in:
Peter Steinberger
2026-08-08 05:48:24 -07:00
committed by GitHub
parent 4eec51a276
commit 83900e4683
70 changed files with 5602 additions and 609 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
{
"core": 2321,
"channel": 3692,
"core": 2324,
"channel": 3694,
"plugin": 4056
}
+4 -4
View File
@@ -1,4 +1,4 @@
1315dd0d8e904da70f5e26f6f055e493c1ddbc4b2ca0ad02bc8608f694e0d89f config-baseline.json
a99b96985ee0409f06f356e538eb3a68c0d0e2c7862a242e00ee919681582165 config-baseline.core.json
292a907b48f9fd7273f12947c8001077ab69978d29985cb287a71a33e837339a config-baseline.channel.json
5d5fe4c95346cb1e7555395d40a6244967e6595b30f13912ae7754209944b734 config-baseline.plugin.json
b5631ecbdb64894b82f48cbf0a82d74e396529619c83e942065778f119f9d496 config-baseline.json
208150d5d64a45d9e1ca3c72764e85f5cc7ecfcee110cae6aeabe8ca84c3ca52 config-baseline.core.json
d752cf8a7ecd2d31684557aeae4d2c08f2ec4ce5f091bce32d54a4685eefdf45 config-baseline.channel.json
6df755fceeafe28ded568b6f4bb1bd0bd49050a601732e52aacf4ab56e398b19 config-baseline.plugin.json
+28
View File
@@ -118,6 +118,34 @@ When the macOS app uses a local Gateway, it can offer this import once and make
System-profile import is enabled by default. Set `browser.allowSystemProfileImport=false` to disable both CLI and agent-triggered imports. Import is host-local and cannot run through the browser node proxy.
## Chrome extension relay
```bash
openclaw browser extension path
openclaw browser extension pair
openclaw browser extension pair --gateway-url wss://gateway.example.com
openclaw browser extension cdp
openclaw browser extension cdp --json
```
- `extension path` prints the unpacked extension directory for Chrome's **Load
unpacked** flow.
- `extension pair` creates the host-local relay key when needed and prints the
pairing string. `--gateway-url` creates a direct remote-Gateway pairing URL;
non-loopback URLs must use `wss://`.
- `extension cdp` prints non-secret Browser Relay Authentication v2 metadata:
the loopback browser/CDP endpoints, protocol version, key ID, and fixed
challenge/complete binding. It never prints the relay key or an authorization
header by default.
`extension cdp --legacy-bearer` is a temporary migration escape hatch. It
prints the old Bearer header with a warning only while
`browser.extensionRelay.allowLegacyAuth=true`; otherwise it exits with an error
without printing a credential. Use `--json` for machine output; warnings remain
on stderr so stdout stays valid JSON.
Setup, security model, and migration steps: [Chrome extension](/tools/chrome-extension).
## Tabs
```bash
+14 -9
View File
@@ -426,27 +426,25 @@ See [Plugins](/tools/plugin).
},
tabCleanup: {
enabled: true,
idleMinutes: 120,
maxTabsPerSession: 8,
sweepMinutes: 5,
},
extensionRelay: {
allowLegacyAuth: true,
},
profiles: {
openclaw: { cdpPort: 18800, color: "#FF4500" },
openclaw: { cdpPort: 18800 },
work: {
cdpPort: 18801,
color: "#0066CC",
executablePath: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
},
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
chrome: { driver: "extension" },
user: { driver: "existing-session", attachOnly: true },
brave: {
driver: "existing-session",
attachOnly: true,
userDataDir: "~/Library/Application Support/BraveSoftware/Brave-Browser",
color: "#FB542B",
},
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" },
remote: { cdpUrl: "http://10.0.0.42:9222" },
},
color: "#FF4500",
// headless: false,
// noSandbox: false,
// extraArgs: [],
@@ -457,6 +455,10 @@ See [Plugins](/tools/plugin).
```
- `evaluateEnabled: false` disables `act:evaluate` and `wait --fn`.
- `extensionRelay.allowLegacyAuth` defaults to `true` for one Browser Relay
Authentication migration window. It permits old extension and external CDP
Bearer, Basic, and token-subprotocol clients. Set it to `false`
after all relay clients use auth v2; v2 clients never downgrade.
- `tabCleanup` controls best-effort periodic cleanup for tracked primary-agent
tabs after idle time or when a session exceeds its cap. Tracking applies only
to tabs created by browser tool `action: "open"`; tabs opened by the user or
@@ -487,6 +489,9 @@ See [Plugins](/tools/plugin).
local managed browser profile and may report local port ownership errors.
- `existing-session` profiles use Chrome MCP instead of CDP and can attach on
the selected host or through a connected browser node.
- `extension` profiles use the authenticated OpenClaw Chrome extension relay.
The relay owns its loopback endpoint, so these profiles do not accept
`cdpUrl`. See [Chrome extension](/tools/chrome-extension).
- `existing-session` profiles can set `userDataDir` to target a specific
Chromium-based browser profile such as Brave or Edge.
- `existing-session` profiles can set `cdpUrl` when Chrome is already running
+4 -2
View File
@@ -287,7 +287,7 @@ That stages grounded durable candidates into the short-term dreaming store while
| `plugins.entries.voice-call.config.streaming.openaiApiKey`/`sttModel`/`silenceDurationMs`/`vadThreshold` | `plugins.entries.voice-call.config.streaming.providers.openai.*` |
| `models.providers.*.api: "openai"` | `"openai-completions"` (gateway startup also skips providers whose `api` is a future/unknown enum value rather than failing closed) |
| `browser.ssrfPolicy.allowPrivateNetwork` | `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` |
| `browser.profiles.*.driver: "extension"` | `"existing-session"` |
| `browser.profiles.*.driver: "extension"` with a stale `cdpUrl` | driver preserved; stale relay URL removed |
| `browser.relayBindHost` | removed (legacy Chrome extension relay setting) |
| `mcp.servers.*.type` (CLI-native aliases) | `mcp.servers.*.transport` |
| `mcp.servers.*.disabled` | inverse `mcp.servers.*.enabled` |
@@ -355,7 +355,9 @@ That stages grounded durable candidates into the short-term dreaming store while
If you have added `models.providers.opencode`, `opencode-zen`, or `opencode-go` manually while the matching official external plugin is installed and enabled, it overrides that plugin-provided catalog. That can force models onto the wrong API or zero out costs. Doctor warns so you can remove the override and restore per-model API routing + costs. Without the matching plugin, the entry remains a valid standalone custom provider.
</Accordion>
<Accordion title="2c. Browser migration and Chrome MCP readiness">
If your browser config still points at the removed Chrome extension path, doctor normalizes it to the current host-local Chrome MCP attach model (`browser.profiles.*.driver: "extension"` → `"existing-session"`; `browser.relayBindHost` removed).
If an extension-driver profile still carries a retired relay `cdpUrl`, doctor removes that URL while preserving `driver: "extension"`; the current extension relay owns its endpoint. Doctor also removes the retired `browser.relayBindHost` setting.
Doctor warns while `browser.extensionRelay.allowLegacyAuth` is enabled. Upgrade paired Chrome extensions and external CDP clients to Browser Relay Authentication v2, then set the flag to `false`. V2 clients do not downgrade to legacy authentication.
Doctor also audits the host-local Chrome MCP path when you use `defaultProfile: "user"` or a configured `existing-session` profile:
+1
View File
@@ -84,6 +84,7 @@ exhaustive):
| `browser.control_no_auth` | critical | Browser control exposed without token/password auth | `gateway.auth.*` | no |
| `browser.remote_cdp_http` | warn | Remote CDP over plain HTTP lacks transport encryption | browser profile `cdpUrl` | no |
| `browser.remote_cdp_private_host` | warn | Remote CDP targets a private/internal host | browser profile `cdpUrl`, `browser.ssrfPolicy.*` | no |
| `browser.extension_relay_legacy_auth` | warn | Legacy extension relay bearer/Basic/token authentication remains enabled | `browser.extensionRelay.allowLegacyAuth` | no |
| `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no |
| `sandbox.bind_mount_non_absolute` | warn | Relative bind mounts can resolve unpredictably | `agents.*.sandbox.docker.binds[]` | no |
| `sandbox.dangerous_bind_mount` | critical | Sandbox bind mount targets blocked system, credential, or Docker socket paths | `agents.*.sandbox.docker.binds[]` | no |
+9
View File
@@ -483,6 +483,15 @@ Enabling browser control gives the model a real browser. If that profile already
- Keep Gateway and node hosts tailnet-only; avoid exposing browser control ports to LAN or public internet.
- Disable browser proxy routing when not needed (`gateway.nodes.browser.mode="off"`).
- Chrome MCP existing-session mode is not "safer" - it can act as you in whatever that host Chrome profile can reach.
- Browser Relay Authentication v2 never sends the persistent extension relay
key. The extension and external CDP clients verify a signed server challenge
before returning a short-lived, one-time, connection-bound HMAC proof. Proofs
bind the protocol version, role, transport, method, resource, flow, profile,
and relay instance; replay on the same or another socket fails.
- `browser.extensionRelay.allowLegacyAuth` defaults to `true` for one migration
window. This temporarily accepts old Bearer, Basic, and token-subprotocol
relay clients. Update every relay client, then set it to `false`. V2 clients
never downgrade after a failed proof or unsupported response.
- Run a **node host** on the browser machine and let the Gateway proxy browser actions when the Gateway is remote from the browser (see [Browser tool](/tools/browser)); treat node pairing like admin access, keep Gateway and node host on the same tailnet, and avoid exposing relay/control ports over LAN, public internet, or Tailscale Funnel.
### Browser SSRF policy (strict by default)
+77 -34
View File
@@ -63,16 +63,18 @@ has stale tab state.
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 **per-host secret** created on first use and stored
The pairing key is a **per-host 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
key, so no credential has to travel between machines. To rotate it, delete the
`browser-extension-relay.secret` file and pair again.
The secret stays in the pairing string fragment rather than the WebSocket URL
sent to the server. During connection, the extension presents it as a
WebSocket subprotocol credential. This keeps it out of normal proxy request
URLs and access logs; still treat the complete pairing string as a password.
The key stays in the pairing string fragment rather than the WebSocket URL sent
to the server. Browser Relay Authentication v2 uses it only as an HMAC key: the
extension first verifies the relay's signed, connection-bound challenge, then
sends a one-time proof. The key is never sent in a URL, header, WebSocket
subprotocol, or application frame. Still treat the complete pairing string as a
password.
## Use it
@@ -101,38 +103,71 @@ openclaw config set browser.defaultProfile chrome
### Authenticated external CDP clients
The relay supports authenticated external CDP clients such as mcporter,
chrome-devtools-mcp, and Puppeteer. They use the same paired Chrome and the same
tab-group consent boundary, without Chrome's "Allow remote debugging?" prompt.
Print the endpoint and bearer-auth header:
The relay supports Browser Relay Authentication v2 clients such as mcporter.
They use the same paired Chrome and the same tab-group consent boundary,
without Chrome's "Allow remote debugging?" prompt. Print the non-secret v2
endpoint metadata:
```bash
openclaw browser extension cdp
```
For example, Google's [chrome-devtools-mcp](https://github.com/ChromeDevTools/chrome-devtools-mcp)
connects with:
`openclaw browser extension cdp --json` emits the loopback endpoint, protocol
version, key ID, and fixed challenge/complete resource metadata. It never emits
the relay key or an authorization header. A v2 client must keep one raw
loopback TCP connection from challenge through `/json/version` and the `/cdp`
WebSocket upgrade; redirects, reconnects, and a second upstream socket are not
valid relay authentication.
During the migration window, an old external client can request the legacy
Bearer header explicitly:
```bash
npx chrome-devtools-mcp --wsEndpoint ws://127.0.0.1:18799/cdp \
--wsHeaders '{"Authorization":"Bearer <token>"}'
openclaw browser extension cdp --legacy-bearer
```
`openclaw browser extension cdp --json` emits `{ browserUrl, wsEndpoint,
headers }` for scripting. External CDP clients authenticate to the local CDP
endpoint with this header; the extension authenticates its separate relay
WebSocket with the subprotocol credential described above. Both derive from
the same per-host relay secret. Treat it as private, and rotate it by deleting
`credentials/browser-extension-relay.secret` and pairing again.
This command warns because it reveals the relay key in a credential header. It
works only while `browser.extensionRelay.allowLegacyAuth` is `true`; when legacy
auth is disabled, the command fails without printing a credential.
[mcporter](https://github.com/openclaw/mcporter) is a supported external CDP
client and needs no extension-side wiring: when a paired relay answers on this
host, it transparently rewrites
[mcporter](https://github.com/openclaw/mcporter) is the supported external CDP
adapter. Use a release that supports Browser Relay Authentication v2; the
OpenClaw-side upgrade does not update mcporter. When a paired relay answers on
this host, a compatible mcporter release transparently rewrites
`chrome-devtools-mcp --autoConnect` server commands to the relay endpoint, so
agents calling Chrome DevTools through mcporter skip the remote-debugging
prompt automatically (set `MCPORTER_DISABLE_CHROME_DEVTOOLS_RELAY=1` there to
opt out).
## Migrate relay authentication
New OpenClaw extensions use Browser Relay Authentication v2 and never retry
legacy authentication after a bad proof, timeout, unsupported response, or
connection failure.
- Existing valid pairing strings migrate locally to `authVersion: 2`; you do
not need to pair again for the protocol upgrade.
- Stored direct-Gateway pairings behind a path-prefix proxy are cleared during
migration. Re-run `openclaw browser extension pair` with a Gateway URL that
has no path prefix; v2 supports the exact `/browser/extension` route only.
- Upgrade OpenClaw before upgrading the extension. A v2 extension reports an
old server as needing an upgrade instead of sending the old token.
- Old extensions and external CDP clients continue to work for one migration
window while `browser.extensionRelay.allowLegacyAuth` keeps its default value
of `true`.
- After every extension and external CDP client uses v2, set
`browser.extensionRelay.allowLegacyAuth` to `false` and restart the Gateway or
browser node host.
- Rotating `credentials/browser-extension-relay.secret` changes the key ID,
closes authenticated relay sessions, clears pending and replay state, and
requires extension re-pairing.
V2 external CDP access requires a client that implements the same-socket HTTP
challenge, completion, discovery, and WebSocket-upgrade sequence. Generic
Puppeteer or chrome-devtools-mcp clients do not implement that sequence by
themselves; use a v2-capable adapter, or the explicitly warned legacy escape
hatch only during the migration window.
### Tab copilot side panel
After pairing the extension, click **Open tab copilot** in its toolbar popup.
@@ -221,18 +256,20 @@ Chrome does not have to run on the Gateway host. Three topologies work:
It prints a `wss://…/browser/extension#<secret>` string; load and pair the
extension on the laptop. The extension connects **straight to the Gateway**
over `wss://` — no OpenClaw install, Node, CLI, or open inbound port on the
laptop. This is the managed-hosting path.
laptop. This is the managed-hosting path. The Gateway URL must expose
`/browser/extension` without a path-rewriting proxy prefix because v2 binds
the exact request path into every proof.
- **Via a browser node host** (Chrome on a machine already running an OpenClaw
node): run `pair` on the node and pair locally; the Gateway proxies browser
actions to the node over its existing authenticated node link.
The pairing secret is per host (the Gateway's, in the direct case), validated by
the Gateway's `/browser/extension` route. For the direct path, serve the Gateway
over TLS (`wss://`) so the pairing secret and CDP traffic are encrypted.
The secret remains in the pairing string's URL fragment and is presented during
the WebSocket handshake as a subprotocol credential, so normal proxy access
logs do not receive it in the request URL. Ensure any reverse proxy preserves
the standard `Sec-WebSocket-Protocol` header.
over TLS (`wss://`) so the proof exchange and CDP traffic are encrypted. The
secret remains in the pairing string's URL fragment and is never presented to
the server. The extension offers only the non-secret
`openclaw-extension-relay.v2` WebSocket subprotocol. Ensure any reverse proxy
preserves the standard `Sec-WebSocket-Protocol` header.
## Diagnostics
@@ -242,16 +279,22 @@ openclaw browser doctor --browser-profile chrome
```
`doctor` reports the **Chrome extension relay** check as failing until the
extension popup shows **Connected**.
extension popup shows **Connected**. `openclaw doctor` also warns while legacy
relay authentication remains enabled and tells you when to set
`browser.extensionRelay.allowLegacyAuth=false`.
## Security model
- Same-host and browser-node relays bind loopback; direct remote pairing uses
the Gateway's `wss://` route. Both WebSocket sides authenticate with the
per-host secret, and the extension side is origin-checked to
the Gateway's `wss://` route. Both use connection-bound HMAC proofs derived
from the per-host key, and the extension side is origin-checked to
`chrome-extension://`.
- Direct Gateway pairing does not accept the relay token in the request URL;
the bundled extension carries it in the WebSocket subprotocol list instead.
- Before verifying the relay's server proof, the client sends only the
non-secret key ID and a fresh nonce; it never sends an HMAC proof. Client
proofs are short-lived, one-time, and bound to the exact socket, protocol
version, role, transport, method, resource, flow, profile, and relay instance.
- In v2, the per-host key is never transmitted. Failed proof validation does
not fall back to legacy Bearer, Basic, or token-subprotocol auth.
- The relay exposes only tabs in the **OpenClaw tab group**, and the extension
independently rechecks group membership before each authority-bearing
existing-tab command. Your other tabs stay private.
@@ -0,0 +1,30 @@
import { describe, expect, it, vi } from "vitest";
const getBrowserControlState = vi.hoisted(() => vi.fn());
const closeTrackedBrowserTabs = vi.hoisted(() =>
vi.fn(async (_params: { getResolvedBrowserConfig: () => unknown; sessionKeys: string[] }) => 0),
);
vi.mock("./src/browser-control-state.js", () => ({ getBrowserControlState }));
vi.mock("./src/browser/session-tab-registry.js", () => ({
closeTrackedBrowserTabsForSessions: closeTrackedBrowserTabs,
}));
import { closeTrackedBrowserTabsForSessions } from "./browser-maintenance.js";
describe("browser maintenance cleanup ownership", () => {
it("injects the current live Browser runtime config without retaining a stale snapshot", async () => {
const firstResolved = { marker: "first" };
const secondResolved = { marker: "second" };
getBrowserControlState.mockReturnValue({ resolved: firstResolved });
await closeTrackedBrowserTabsForSessions({ sessionKeys: ["agent:main:main"] });
const cleanupParams = closeTrackedBrowserTabs.mock.calls[0]?.[0];
expect(cleanupParams?.getResolvedBrowserConfig()).toBe(firstResolved);
getBrowserControlState.mockReturnValue({ resolved: secondResolved });
expect(cleanupParams?.getResolvedBrowserConfig()).toBe(secondResolved);
getBrowserControlState.mockReturnValue(null);
expect(cleanupParams?.getResolvedBrowserConfig()).toBeNull();
});
});
+15 -1
View File
@@ -2,5 +2,19 @@
* Browser maintenance API barrel. It exposes tab cleanup and trash helpers for
* runtime and doctor flows.
*/
export { closeTrackedBrowserTabsForSessions } from "./src/browser/session-tab-registry.js";
import { closeTrackedBrowserTabsForSessions as closeTrackedBrowserTabs } from "./src/browser/session-tab-registry.js";
type CloseTrackedBrowserTabsParams = Parameters<typeof closeTrackedBrowserTabs>[0];
/** Route lifecycle cleanup through the currently running Browser runtime when available. */
export async function closeTrackedBrowserTabsForSessions(
params: CloseTrackedBrowserTabsParams,
): Promise<number> {
const { getBrowserControlState } = await import("./src/browser-control-state.js");
return await closeTrackedBrowserTabs({
...params,
getResolvedBrowserConfig: () => getBrowserControlState()?.resolved ?? null,
});
}
export { movePathToTrash } from "./src/browser/trash.js";
+104 -112
View File
@@ -5,6 +5,8 @@ import {
waitForCondition,
} from "./modules/page-share-core.js";
import { createPageShareRelay } from "./modules/page-share-relay.js";
import { createRelayCommandHandler } from "./modules/relay-command-handler.js";
import { openAuthenticatedRelaySocket } from "./modules/relay-connection.js";
// OpenClaw extension service worker.
//
// Thin transport between the OpenClaw extension relay (loopback WebSocket) and
@@ -14,7 +16,6 @@ import { createPageShareRelay } from "./modules/page-share-relay.js";
// consent boundary: only grouped tabs are reported to (and driven by) OpenClaw.
import {
OPENCLAW_TAB_GROUP_TITLE,
buildRelayWsProtocols,
createPairingConfigStore,
nearestGroupColor,
parsePairingString,
@@ -42,7 +43,7 @@ const COPILOT_RELAY_LABEL = {
};
const RELAY_WATCHDOG_ALARM = "openclaw-relay-watchdog";
const RELAY_OPENING_DEADLINE_ALARM = "openclaw-relay-opening-deadline";
const RELAY_OPENING_TIMEOUT_MS = 30_000;
const RELAY_AUTH_TIMEOUT_MS = 10_000;
/** @type {WebSocket|null} */
let relayWs = null;
@@ -51,6 +52,9 @@ let copilot = null;
let reconnectAttempt = 0;
let reconnectTimer = null;
let relayOpeningDeadlineAt = 0;
let relayOpeningDeadlineTimer = null;
let relayAuthenticatedSocket = null;
let relayStatusHint = "";
let reconciledPairingInvalidationRevision = 0;
/** Tab ids with an active chrome.debugger attachment. */
const attachedTabs = new Set();
@@ -74,6 +78,9 @@ function closeRelaySocket() {
return;
}
relayWs = null;
if (relayAuthenticatedSocket === socket) {
relayAuthenticatedSocket = null;
}
// Chrome completes close asynchronously; fail pending requests before the
// handshake so pairing and unpairing never leave a popup stuck on Sending.
pageShareRelay.rejectSocket(socket);
@@ -118,7 +125,11 @@ function flashPageShareBadge(ok) {
}
async function getConfig() {
return await pairingConfigStore.read();
const config = await pairingConfigStore.read();
if (config.pairingStatusHint) {
relayStatusHint = config.pairingStatusHint;
}
return config;
}
// ---------------------------------------------------------------------------
@@ -171,7 +182,7 @@ function scheduleTabsSync() {
}
async function syncTabsToRelay() {
if (!relayWs || relayWs.readyState !== WebSocket.OPEN) {
if (!relayWs || relayWs.readyState !== WebSocket.OPEN || relayAuthenticatedSocket !== relayWs) {
return;
}
const shared = await listSharedTabs();
@@ -332,87 +343,51 @@ chrome.debugger.onDetach.addListener((source, reason) => {
// ---------------------------------------------------------------------------
function send(message) {
if (relayWs && relayWs.readyState === WebSocket.OPEN) {
if (relayWs && relayWs.readyState === WebSocket.OPEN && relayAuthenticatedSocket === relayWs) {
relayWs.send(JSON.stringify(message));
}
}
function clearRelayOpeningDeadline() {
relayOpeningDeadlineAt = 0;
if (relayOpeningDeadlineTimer) {
clearTimeout(relayOpeningDeadlineTimer);
relayOpeningDeadlineTimer = null;
}
void chrome.alarms.clear(RELAY_OPENING_DEADLINE_ALARM);
}
function armRelayOpeningDeadline() {
relayOpeningDeadlineAt = Date.now() + RELAY_OPENING_TIMEOUT_MS;
clearRelayOpeningDeadline();
relayOpeningDeadlineAt = Date.now() + RELAY_AUTH_TIMEOUT_MS;
relayOpeningDeadlineTimer = setTimeout(handleRelayOpeningDeadline, RELAY_AUTH_TIMEOUT_MS);
chrome.alarms.create(RELAY_OPENING_DEADLINE_ALARM, { when: relayOpeningDeadlineAt });
}
async function handleRelayCommand(msg) {
const { seq } = msg;
function failRelayAuthentication(ws, error) {
if (relayWs !== ws) {
return;
}
relayStatusHint =
"Relay authentication v2 failed. Update OpenClaw, or re-pair after a relay key rotation.";
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": {
// Detach is the cleanup primitive after consent is revoked, so it must
// remain available when the tab is no longer shared.
await detachDebugger(msg.tabId);
send({ type: "result", seq, result: {} });
return;
}
case "cdp": {
await requireSharedTab(msg.tabId);
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);
if (msg.focus === true) {
await focusWindowForTab(tab);
}
scheduleTabsSync();
send({ type: "result", seq, result: { tabId: tab.id } });
return;
}
case "closeTab": {
await requireSharedTab(msg.tabId);
await detachDebugger(msg.tabId);
await requireSharedTab(msg.tabId);
await chrome.tabs.remove(msg.tabId);
send({ type: "result", seq, result: {} });
return;
}
case "activateTab": {
const tab = await requireSharedTab(msg.tabId);
await chrome.tabs.update(msg.tabId, { active: true });
await requireSharedTab(msg.tabId);
await focusWindowForTab(tab);
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) });
}
ws.close(4001, error instanceof Error ? error.message.slice(0, 120) : "authentication failed");
} catch {
closeRelaySocket();
setBadge("error");
scheduleReconnect();
}
}
const handleRelayCommand = createRelayCommandHandler({
send,
attachDebugger,
detachDebugger,
addTabToOpenClawGroup,
focusWindowForTab,
scheduleTabsSync,
});
async function sendHello() {
const shared = await listSharedTabs();
const uaMatch = /Chrom(?:e|ium)\/[\d.]+/.exec(navigator.userAgent);
@@ -442,52 +417,57 @@ async function connectRelay() {
setBadge("connecting");
let ws;
try {
ws = new WebSocket(relayUrl, buildRelayWsProtocols(token));
ws = openAuthenticatedRelaySocket({
relayUrl,
token,
isCurrent: (socket) => relayWs === socket,
onAuthenticated: async (socket) => {
relayAuthenticatedSocket = socket;
relayStatusHint = "";
clearRelayOpeningDeadline();
reconnectAttempt = 0;
setBadge("on");
await sendHello();
},
onApplicationMessage: (socket, msg) => {
if (msg?.type === "pageShareResult") {
pageShareRelay.settle(socket, msg);
return;
}
void handleRelayCommand(msg);
},
onAuthenticationFailure: (socket, error) => failRelayAuthentication(socket, error),
onClose: (socket, authenticated) => {
pageShareRelay.rejectSocket(socket);
if (relayWs !== socket) {
return;
}
clearRelayOpeningDeadline();
relayWs = null;
if (authenticated) {
relayAuthenticatedSocket = null;
} else if (!relayStatusHint) {
relayStatusHint =
"Relay authentication v2 failed. Update OpenClaw, or re-pair after a relay key rotation.";
}
setBadge("error");
scheduleReconnect();
},
});
} catch {
setBadge("error");
scheduleReconnect();
return;
}
relayWs = ws;
relayAuthenticatedSocket = null;
armRelayOpeningDeadline();
ws.addEventListener("open", () => {
if (relayWs !== ws) {
ws.close();
return;
}
clearRelayOpeningDeadline();
reconnectAttempt = 0;
setBadge("on");
void sendHello();
});
ws.addEventListener("message", (event) => {
let msg;
try {
msg = JSON.parse(String(event.data));
} catch {
return;
}
if (msg?.type === "pageShareResult") {
pageShareRelay.settle(ws, msg);
return;
}
void handleRelayCommand(msg);
});
ws.addEventListener("close", () => {
pageShareRelay.rejectSocket(ws);
if (relayWs === ws) {
clearRelayOpeningDeadline();
relayWs = null;
setBadge("error");
scheduleReconnect();
}
});
// onclose follows onerror and drives the reconnect, so no error handler needed.
}
async function sendPageShareRequest(payload) {
const socket = relayWs;
if (!socket || socket.readyState !== WebSocket.OPEN) {
if (!socket || socket.readyState !== WebSocket.OPEN || relayAuthenticatedSocket !== socket) {
throw new Error("Relay not connected.");
}
await pageShareRelay.send(socket, payload);
@@ -499,9 +479,14 @@ async function ensureRelayReady() {
if (!config.relayUrl || !config.token) {
throw new Error("Pair the extension first.");
}
if (!relayWs || relayWs.readyState !== WebSocket.OPEN) {
if (!relayWs || relayWs.readyState !== WebSocket.OPEN || relayAuthenticatedSocket !== relayWs) {
await connectRelay();
if (!(await waitForCondition(() => relayWs?.readyState === WebSocket.OPEN, 3_000))) {
if (
!(await waitForCondition(
() => relayWs?.readyState === WebSocket.OPEN && relayAuthenticatedSocket === relayWs,
RELAY_AUTH_TIMEOUT_MS,
))
) {
throw new Error("Relay not connected.");
}
}
@@ -567,34 +552,38 @@ const copilotCustodyReady = copilot.initializeCustody();
const copilotReady = copilot.initialize();
function handleRelayOpeningDeadline() {
// Unit-test module isolation can outlive the mocked Chrome global. The real
// MV3 worker always has chrome; a detached test timer has no owner to mutate.
if (typeof chrome === "undefined") {
relayOpeningDeadlineAt = 0;
relayOpeningDeadlineTimer = null;
return;
}
const ws = relayWs;
if (!ws) {
clearRelayOpeningDeadline();
void connectRelay();
return;
}
if (ws.readyState === WebSocket.OPEN) {
if (relayAuthenticatedSocket === ws) {
clearRelayOpeningDeadline();
return;
}
if (
ws.readyState !== WebSocket.CONNECTING ||
relayOpeningDeadlineAt === 0 ||
Date.now() < relayOpeningDeadlineAt
) {
if (relayOpeningDeadlineAt === 0 || Date.now() < relayOpeningDeadlineAt) {
return;
}
// Clear ownership before close so a delayed close/open event from this
// socket cannot mutate the replacement connection's badge or deadline.
relayWs = null;
relayAuthenticatedSocket = null;
clearRelayOpeningDeadline();
try {
ws.close();
ws.close(4001, "relay authentication timed out");
} catch {
// The socket may have changed state while the alarm event was queued.
}
setBadge("error");
relayStatusHint = "Relay authentication v2 timed out. Make sure OpenClaw is up to date.";
scheduleReconnect();
}
@@ -638,6 +627,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, reply) => {
state: relayState,
sharedTabCount: shared.length,
relayUrl: relayUrl ?? "",
...(relayStatusHint ? { hint: relayStatusHint } : {}),
});
return;
}
@@ -648,6 +638,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, reply) => {
return;
}
await pairingConfigStore.save(parsed, nearestGroupColor(msg.groupColor));
relayStatusHint = "";
reconnectAttempt = 0;
clearRelayOpeningDeadline();
closeRelaySocket();
@@ -658,6 +649,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, reply) => {
}
case "unpair": {
await pairingConfigStore.clear();
relayStatusHint = "";
clearRelayOpeningDeadline();
closeRelaySocket();
setBadge("off");
@@ -0,0 +1,86 @@
import { vi } from "vitest";
export const AUTH_INSTANCE_ID = "ICEiIyQlJicoKSorLC0uLw";
export const AUTH_SESSION_ID = "MDEyMzQ1Njc4OTo7PD0-Pw";
export const AUTH_SERVER_NONCE = "YGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn8";
type SocketEvent = { data?: unknown };
type SocketListener = (event: SocketEvent) => void;
export type RuntimeMessageListener = (
message: { type: string; tabId?: number; note?: string; pairingString?: string },
sender: unknown,
sendResponse: (response: unknown) => void,
) => boolean;
export type PageCaptureResult = {
content: string;
selection: string;
title: string;
url: string;
};
let configuredSockets: FakeWebSocket[] = [];
let configuredDeferredClose = false;
let configuredProtocol: string | undefined;
export function configureFakeWebSockets(options: {
sockets: FakeWebSocket[];
deferSocketClose: boolean;
relayNegotiatedProtocol?: string;
}): void {
configuredSockets = options.sockets;
configuredDeferredClose = options.deferSocketClose;
configuredProtocol = options.relayNegotiatedProtocol;
}
export class FakeWebSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
readyState = FakeWebSocket.CONNECTING;
readonly protocol: string;
readonly send = vi.fn();
readonly close = vi.fn(() => {
if (configuredDeferredClose) {
this.readyState = FakeWebSocket.CLOSING;
return;
}
this.readyState = FakeWebSocket.CLOSED;
this.emit("close");
});
private readonly listeners = new Map<string, SocketListener[]>();
constructor(
readonly url: string,
readonly protocols: string[] = [],
) {
this.protocol = protocols.includes("openclaw-extension-relay.v2")
? (configuredProtocol ?? "openclaw-extension-relay.v2")
: (protocols[0] ?? "");
configuredSockets.push(this);
}
addEventListener(type: string, listener: SocketListener): void {
const listeners = this.listeners.get(type) ?? [];
listeners.push(listener);
this.listeners.set(type, listeners);
}
open(): void {
this.readyState = FakeWebSocket.OPEN;
this.emit("open");
}
receive(message: unknown): void {
this.emit("message", { data: JSON.stringify(message) });
}
private emit(type: string, event: SocketEvent = {}): void {
for (const listener of this.listeners.get(type) ?? []) {
listener(event);
}
}
}
@@ -1,35 +1,32 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
AUTH_INSTANCE_ID,
AUTH_SERVER_NONCE,
AUTH_SESSION_ID,
configureFakeWebSockets,
FakeWebSocket,
} from "./background.test-support.js";
import type { PageCaptureResult, RuntimeMessageListener } from "./background.test-support.js";
import { computeRelayAuthProof } from "./modules/relay-auth-v2-crypto.js";
const RELAY_WATCHDOG_ALARM = "openclaw-relay-watchdog";
const RELAY_OPENING_DEADLINE_ALARM = "openclaw-relay-opening-deadline";
const START_TIME_MS = Date.parse("2026-07-16T08:00:00.000Z");
const RELAY_SECRET = "a".repeat(64);
const REPLACEMENT_RELAY_SECRET = "b".repeat(64);
const PAIRING_CONFIG_KEYS = ["relayUrl", "gatewayUrl", "token", "groupColor"];
type SocketEvent = { data?: unknown };
type SocketListener = (event: SocketEvent) => void;
type RuntimeMessageListener = (
message: { type: string; tabId?: number; note?: string; pairingString?: string },
sender: unknown,
sendResponse: (response: unknown) => void,
) => boolean;
type PageCaptureResult = {
content: string;
selection: string;
title: string;
url: string;
};
const PAIRING_CONFIG_KEYS = ["relayUrl", "token", "pairingStatus"];
async function loadBackground({
deferSocketClose = false,
onConsentChanged,
rejectStorageRemove = false,
relayNegotiatedProtocol,
storedConfig,
}: {
deferSocketClose?: boolean;
onConsentChanged?: () => Promise<void>;
rejectStorageRemove?: boolean;
relayNegotiatedProtocol?: string;
storedConfig?: Record<string, unknown>;
} = {}) {
const sockets: FakeWebSocket[] = [];
@@ -42,56 +39,11 @@ async function loadBackground({
...(storedConfig ?? {
relayUrl: "ws://127.0.0.1:18797/extension",
token: RELAY_SECRET,
authVersion: 2,
groupColor: "orange",
}),
};
class FakeWebSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
readyState = FakeWebSocket.CONNECTING;
readonly send = vi.fn();
readonly close = vi.fn(() => {
if (deferSocketClose) {
this.readyState = FakeWebSocket.CLOSING;
return;
}
this.readyState = FakeWebSocket.CLOSED;
this.emit("close");
});
private readonly listeners = new Map<string, SocketListener[]>();
constructor(
readonly url: string,
readonly protocols: string[] = [],
) {
sockets.push(this);
}
addEventListener(type: string, listener: SocketListener) {
const listeners = this.listeners.get(type) ?? [];
listeners.push(listener);
this.listeners.set(type, listeners);
}
open() {
this.readyState = FakeWebSocket.OPEN;
this.emit("open");
}
receive(message: unknown) {
this.emit("message", { data: JSON.stringify(message) });
}
private emit(type: string, event: SocketEvent = {}) {
for (const listener of this.listeners.get(type) ?? []) {
listener(event);
}
}
}
configureFakeWebSockets({ sockets, deferSocketClose, relayNegotiatedProtocol });
const addListener = vi.fn();
const createAlarm = vi.fn();
@@ -230,10 +182,8 @@ async function loadBackground({
const backgroundModulePath = "./background.js";
await import(backgroundModulePath);
await vi.waitFor(() => {
const pairingReads = storageGet.mock.calls.filter(
([keys]) =>
keys.length === PAIRING_CONFIG_KEYS.length &&
PAIRING_CONFIG_KEYS.every((key) => keys.includes(key)),
const pairingReads = storageGet.mock.calls.filter(([keys]) =>
PAIRING_CONFIG_KEYS.every((key) => keys.includes(key)),
);
expect(pairingReads.length).toBeGreaterThanOrEqual(2);
});
@@ -263,11 +213,73 @@ async function loadBackground({
return release;
},
get gatewaySockets() {
return sockets.filter((socket) => !socket.protocols.includes("openclaw-extension-relay"));
return sockets.filter((socket) => !socket.protocols.includes("openclaw-extension-relay.v2"));
},
messageListener,
get relaySockets() {
return sockets.filter((socket) => socket.protocols.includes("openclaw-extension-relay"));
return sockets.filter((socket) => socket.protocols.includes("openclaw-extension-relay.v2"));
},
authenticate: async (socket: FakeWebSocket) => {
if (socket.readyState !== FakeWebSocket.OPEN) {
socket.open();
}
await vi.waitFor(() => {
expect(socket.send).toHaveBeenCalled();
});
const helloRaw = socket.send.mock.calls.find(
([raw]) => JSON.parse(raw).type === "auth.hello",
)?.[0];
if (typeof helloRaw !== "string") {
throw new Error("expected auth.hello");
}
const hello = JSON.parse(helloRaw) as { keyId: string; clientNonce: string };
const issuedAtMs = Date.now();
const fields = {
keyId: hello.keyId,
instanceId: AUTH_INSTANCE_ID,
sessionId: AUTH_SESSION_ID,
clientNonce: hello.clientNonce,
serverNonce: AUTH_SERVER_NONCE,
issuedAtMs,
expiresAtMs: issuedAtMs + 10_000,
role: "extension",
transport: "websocket",
method: "GET",
resource: new URL(socket.url).pathname + new URL(socket.url).search,
flow: "extension",
};
socket.receive({
type: "auth.challenge",
v: 2,
...fields,
serverProof: await computeRelayAuthProof(String(storageValues.token), "server", fields),
});
await vi.waitFor(() => {
expect(
socket.send.mock.calls.some(([raw]) => JSON.parse(raw).type === "auth.response"),
).toBe(true);
});
const responseRaw = socket.send.mock.calls.find(
([raw]) => JSON.parse(raw).type === "auth.response",
)?.[0];
if (typeof responseRaw !== "string") {
throw new Error("expected auth.response");
}
const response = JSON.parse(responseRaw) as { clientProof: string };
socket.receive({
type: "auth.ok",
v: 2,
sessionId: AUTH_SESSION_ID,
acceptProof: await computeRelayAuthProof(
String(storageValues.token),
"accept",
fields,
response.clientProof,
),
});
await vi.waitFor(() => {
expect(socket.send.mock.calls.some(([raw]) => JSON.parse(raw).type === "hello")).toBe(true);
});
},
setBadgeText,
sockets,
@@ -303,9 +315,10 @@ describe("persisted relay pairing validation", () => {
it("opens the canonical persisted pairing on startup", async () => {
const harness = await loadBackground({
storedConfig: {
relayUrl: "wss://gateway.example.com/base/browser/extension",
relayUrl: "wss://gateway.example.com/browser/extension",
token: RELAY_SECRET,
gatewayUrl: "wss://gateway.example.com/base",
authVersion: 2,
gatewayUrl: "wss://gateway.example.com",
groupColor: "blue",
},
});
@@ -315,12 +328,26 @@ describe("persisted relay pairing validation", () => {
expect(harness.gatewaySockets).toHaveLength(1);
});
expect(harness.relaySockets[0]).toMatchObject({
url: "wss://gateway.example.com/base/browser/extension",
protocols: ["openclaw-extension-relay", `openclaw-extension-token.${RELAY_SECRET}`],
url: "wss://gateway.example.com/browser/extension",
protocols: ["openclaw-extension-relay.v2"],
});
expect(harness.storageRemove).not.toHaveBeenCalled();
});
it("migrates a canonical existing pairing to authVersion 2 before connecting", async () => {
const harness = await loadBackground({
storedConfig: {
relayUrl: "ws://127.0.0.1:18797/extension",
token: RELAY_SECRET,
gatewayUrl: "",
groupColor: "orange",
},
});
await vi.waitFor(() => expect(harness.relaySockets).toHaveLength(1));
expect(harness.storageSet).toHaveBeenCalledWith({ authVersion: 2 });
expect(harness.storageValues.authVersion).toBe(2);
});
it.each([
["an invalid token", { relayUrl: "ws://127.0.0.1:18797/extension", token: "short" }],
[
@@ -369,12 +396,16 @@ describe("persisted relay pairing validation", () => {
{ relayUrl: "ws://127.0.0.1:18797/extension?unknown=1", token: RELAY_SECRET },
],
["partial state", { relayUrl: "ws://127.0.0.1:18797/extension", groupColor: "orange" }],
[
"a proxy-prefixed direct pairing",
{ relayUrl: "wss://gateway.example.com/proxy/browser/extension", token: RELAY_SECRET },
],
[
"mismatched direct state",
{
relayUrl: "wss://gateway.example.com/base/browser/extension",
relayUrl: "wss://gateway.example.com/browser/extension",
token: RELAY_SECRET,
gatewayUrl: "wss://other.example.com/base",
gatewayUrl: "wss://other.example.com",
},
],
])("clears %s before startup can open a socket", async (_label, storedConfig) => {
@@ -382,16 +413,23 @@ describe("persisted relay pairing validation", () => {
expect(harness.relaySockets).toHaveLength(0);
expect(harness.gatewaySockets).toHaveLength(0);
expect(harness.storageRemove).toHaveBeenCalledWith(["relayUrl", "gatewayUrl", "token"]);
expect(harness.storageRemove).toHaveBeenCalledWith([
"relayUrl",
"gatewayUrl",
"token",
"authVersion",
]);
const response = vi.fn();
harness.messageListener({ type: "getStatus" }, {}, response);
await vi.waitFor(() => {
expect(response).toHaveBeenCalledWith({
paired: false,
state: "off",
sharedTabCount: 0,
relayUrl: "",
});
expect(response).toHaveBeenCalledWith(
expect.objectContaining({
paired: false,
state: "off",
sharedTabCount: 0,
relayUrl: "",
}),
);
});
});
@@ -430,7 +468,12 @@ describe("persisted relay pairing validation", () => {
await vi.advanceTimersByTimeAsync(1_000);
expect(harness.sockets).toHaveLength(1);
expect(harness.storageRemove).toHaveBeenCalledWith(["relayUrl", "gatewayUrl", "token"]);
expect(harness.storageRemove).toHaveBeenCalledWith([
"relayUrl",
"gatewayUrl",
"token",
"authVersion",
]);
expect(harness.setBadgeText).toHaveBeenLastCalledWith({ text: "" });
});
@@ -491,6 +534,81 @@ describe("persisted relay pairing validation", () => {
});
});
describe("relay authentication v2 transport", () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("offers only the non-secret v2 protocol", async () => {
const harness = await loadBackground();
const socket = harness.relaySockets[0];
expect(socket?.protocols).toEqual(["openclaw-extension-relay.v2"]);
expect(JSON.stringify(socket?.protocols)).not.toContain(RELAY_SECRET);
});
it("rejects a mismatched negotiated protocol before sending any frame", async () => {
const harness = await loadBackground({ relayNegotiatedProtocol: "" });
const socket = harness.relaySockets[0];
if (!socket) {
throw new Error("expected relay socket");
}
socket.open();
await vi.waitFor(() => expect(socket.close).toHaveBeenCalled());
expect(socket.send).not.toHaveBeenCalled();
});
it("sends no client proof or application hello after a bad server proof", async () => {
const harness = await loadBackground();
const socket = harness.relaySockets[0];
if (!socket) {
throw new Error("expected relay socket");
}
socket.open();
await vi.waitFor(() => expect(socket.send).toHaveBeenCalled());
const helloRaw = socket.send.mock.calls[0]?.[0];
const hello = JSON.parse(helloRaw) as { keyId: string; clientNonce: string };
const issuedAtMs = Date.now();
socket.receive({
type: "auth.challenge",
v: 2,
keyId: hello.keyId,
instanceId: AUTH_INSTANCE_ID,
sessionId: AUTH_SESSION_ID,
clientNonce: hello.clientNonce,
serverNonce: AUTH_SERVER_NONCE,
issuedAtMs,
expiresAtMs: issuedAtMs + 10_000,
role: "extension",
transport: "websocket",
method: "GET",
resource: "/extension",
flow: "extension",
serverProof: "A".repeat(43),
});
await vi.waitFor(() => expect(socket.close).toHaveBeenCalled());
const types = socket.send.mock.calls.map(([raw]) => JSON.parse(raw).type);
expect(types).toEqual(["auth.hello"]);
expect(harness.setBadgeText).not.toHaveBeenLastCalledWith({ text: "ON" });
});
it("rejects application commands before authentication", async () => {
const harness = await loadBackground();
const socket = harness.relaySockets[0];
if (!socket) {
throw new Error("expected relay socket");
}
socket.open();
await vi.waitFor(() => expect(socket.send).toHaveBeenCalled());
socket.receive({ type: "attach", seq: 1, tabId: 1 });
await vi.waitFor(() => expect(socket.close).toHaveBeenCalled());
expect(harness.debuggerAttach).not.toHaveBeenCalled();
});
});
async function startPendingPageShare(
harness: Awaited<ReturnType<typeof loadBackground>>,
socket = harness.sockets.at(-1),
@@ -499,7 +617,7 @@ async function startPendingPageShare(
throw new Error("expected the page-share relay socket");
}
if (socket.readyState !== 1) {
socket.open();
await harness.authenticate(socket);
}
harness.executeScript.mockResolvedValueOnce([
{
@@ -544,10 +662,10 @@ describe("relay opening deadline", () => {
periodInMinutes: 0.5,
});
expect(harness.createAlarm).toHaveBeenCalledWith(RELAY_OPENING_DEADLINE_ALARM, {
when: START_TIME_MS + 30_000,
when: START_TIME_MS + 10_000,
});
vi.setSystemTime(START_TIME_MS + 30_000);
vi.setSystemTime(START_TIME_MS + 10_000);
harness.alarmListener({ name: RELAY_OPENING_DEADLINE_ALARM });
expect(harness.sockets[0]?.close).toHaveBeenCalledOnce();
@@ -557,16 +675,23 @@ describe("relay opening deadline", () => {
await vi.advanceTimersByTimeAsync(1_000);
expect(harness.sockets).toHaveLength(2);
expect(harness.createAlarm).toHaveBeenLastCalledWith(RELAY_OPENING_DEADLINE_ALARM, {
when: START_TIME_MS + 61_000,
when: START_TIME_MS + 21_000,
});
});
it("clears the deadline after the socket opens", async () => {
it("clears the deadline only after relay authentication completes", async () => {
const harness = await loadBackground();
const socket = harness.sockets[0];
expect(socket).toBeDefined();
const clearsBeforeOpen = harness.clearAlarm.mock.calls.length;
socket?.open();
expect(harness.clearAlarm).toHaveBeenCalledTimes(clearsBeforeOpen);
expect(harness.setBadgeText).toHaveBeenLastCalledWith({ text: "…" });
if (socket) {
await harness.authenticate(socket);
}
expect(harness.clearAlarm).toHaveBeenCalledWith(RELAY_OPENING_DEADLINE_ALARM);
expect(harness.setBadgeText).toHaveBeenLastCalledWith({ text: "ON" });
@@ -875,7 +1000,7 @@ describe("relay command authorization", () => {
if (!socket) {
throw new Error("expected relay socket");
}
socket.open();
await harness.authenticate(socket);
harness.shareTab(41);
harness.unshareTab(41);
@@ -906,7 +1031,7 @@ describe("relay command authorization", () => {
if (!socket) {
throw new Error("expected relay socket");
}
socket.open();
await harness.authenticate(socket);
harness.unshareTab(41);
socket.receive({ type: "detach", seq: 5, tabId: 41 });
@@ -924,7 +1049,7 @@ describe("relay command authorization", () => {
if (!socket) {
throw new Error("expected relay socket");
}
socket.open();
await harness.authenticate(socket);
harness.tabsCreate.mockResolvedValueOnce({ id: 42 });
socket.receive({ type: "createTab", seq: 6, url: "https://example.com" });
@@ -942,7 +1067,7 @@ describe("relay command authorization", () => {
if (!socket) {
throw new Error("expected relay socket");
}
socket.open();
await harness.authenticate(socket);
harness.shareTab(43);
let releaseAttach = () => {};
harness.debuggerAttach.mockImplementationOnce(
@@ -0,0 +1,37 @@
export const RELAY_AUTH_VERSION: 2;
export function requireRelayCrypto(cryptoApi: Crypto): Crypto;
export function relayBytesFromBase64Url(
value: unknown,
expectedLength: number,
field: string,
): Uint8Array;
export function randomRelayBase64Url(cryptoApi: Crypto, byteLength: number): string;
export function extensionRelayAuthResource(relayUrl: string): string;
export type RelayAuthProofFields = {
keyId: string;
instanceId: string;
sessionId: string;
clientNonce: string;
serverNonce: string;
issuedAtMs: number;
expiresAtMs: number;
role: string;
transport: string;
method: string;
resource: string;
flow: string;
};
export function canonicalRelayAuthProofBytes(
proofKind: "server" | "client" | "accept",
fields: RelayAuthProofFields,
clientProof?: string,
): Uint8Array;
export function importRelayHmacKey(token: string, cryptoApi: Crypto): Promise<CryptoKey>;
export function deriveRelayAuthKeyId(token: string, cryptoApi?: Crypto): Promise<string>;
export function computeRelayAuthProof(
token: string,
proofKind: "server" | "client" | "accept",
fields: RelayAuthProofFields,
clientProof?: string,
cryptoApi?: Crypto,
): Promise<string>;
@@ -0,0 +1,134 @@
// Browser-native proof primitives shared by the extension auth client and its vectors.
const RELAY_AUTH_LABEL = "openclaw.browser-relay.auth";
export const RELAY_AUTH_VERSION = 2;
const RELAY_KEY_PATTERN = /^[0-9a-f]{64}$/;
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
const textEncoder = new TextEncoder();
export function requireRelayCrypto(cryptoApi) {
if (!cryptoApi?.subtle || typeof cryptoApi.getRandomValues !== "function") {
throw new Error("WebCrypto is unavailable");
}
return cryptoApi;
}
function relayHexToBytes(value) {
if (!RELAY_KEY_PATTERN.test(value)) {
throw new Error("relay key must be 32 lowercase-hex bytes");
}
const bytes = new Uint8Array(32);
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
}
return bytes;
}
function relayBytesToBase64Url(bytes) {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export function relayBytesFromBase64Url(value, expectedLength, field) {
if (typeof value !== "string" || !BASE64URL_PATTERN.test(value)) {
throw new Error(`${field} must be base64url`);
}
const padded = value
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(Math.ceil(value.length / 4) * 4, "=");
let bytes;
try {
bytes = Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
} catch {
throw new Error(`${field} must be base64url`);
}
if (bytes.length !== expectedLength || relayBytesToBase64Url(bytes) !== value) {
throw new Error(`${field} has an invalid length or encoding`);
}
return bytes;
}
export function randomRelayBase64Url(cryptoApi, byteLength) {
return relayBytesToBase64Url(cryptoApi.getRandomValues(new Uint8Array(byteLength)));
}
export function extensionRelayAuthResource(relayUrl) {
const url = new URL(relayUrl);
const entries = [...url.searchParams];
if (
entries.some(([key, value]) => key !== "profile" || !/^[a-z0-9-]+$/.test(value)) ||
entries.filter(([key]) => key === "profile").length > 1
) {
throw new Error("relay URL auth resource has unsupported query parameters");
}
url.searchParams.sort();
return `${url.pathname}${url.search}`;
}
export function canonicalRelayAuthProofBytes(proofKind, fields, clientProof) {
if (proofKind !== "server" && proofKind !== "client" && proofKind !== "accept") {
throw new Error("invalid relay auth proof kind");
}
const values = [
RELAY_AUTH_LABEL,
RELAY_AUTH_VERSION,
proofKind,
fields.keyId,
fields.instanceId,
fields.sessionId,
fields.clientNonce,
fields.serverNonce,
fields.issuedAtMs,
fields.expiresAtMs,
fields.role,
fields.transport,
fields.method,
fields.resource,
fields.flow,
];
if (proofKind === "accept") {
if (typeof clientProof !== "string") {
throw new Error("accept proof requires clientProof");
}
values.push(clientProof);
}
return textEncoder.encode(JSON.stringify(values));
}
export async function importRelayHmacKey(token, cryptoApi) {
return await cryptoApi.subtle.importKey(
"raw",
relayHexToBytes(token),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"],
);
}
export async function deriveRelayAuthKeyId(token, cryptoApi = globalThis.crypto) {
const runtime = requireRelayCrypto(cryptoApi);
const digest = await runtime.subtle.digest("SHA-256", relayHexToBytes(token));
return relayBytesToBase64Url(new Uint8Array(digest)).slice(0, 22);
}
export async function computeRelayAuthProof(
token,
proofKind,
fields,
clientProof,
cryptoApi = globalThis.crypto,
) {
const runtime = requireRelayCrypto(cryptoApi);
const key = await importRelayHmacKey(token, runtime);
const signature = await runtime.subtle.sign(
"HMAC",
key,
canonicalRelayAuthProofBytes(proofKind, fields, clientProof),
);
return relayBytesToBase64Url(new Uint8Array(signature));
}
@@ -0,0 +1,21 @@
export const EXTENSION_RELAY_V2_PROTOCOL: string;
export function parseRelayAuthJson(raw: string): Record<string, unknown> | null;
export function createExtensionRelayAuthClient(params: {
token: string;
relayUrl: string;
cryptoApi?: Crypto;
now?: () => number;
clientNonce?: string;
}): Promise<{
readonly keyId: string;
readonly clientNonce: string;
readonly authenticated: boolean;
start(): { type: "auth.hello"; v: 2; keyId: string; clientNonce: string };
acceptChallenge(message: unknown): Promise<{
type: "auth.response";
v: 2;
sessionId: string;
clientProof: string;
}>;
acceptOk(message: unknown): Promise<void>;
}>;
@@ -0,0 +1,282 @@
// Browser Relay Authentication v2 for the unpacked MV3 extension.
// This module is intentionally browser-native ESM: no Node globals or bundling.
import {
RELAY_AUTH_VERSION,
canonicalRelayAuthProofBytes,
computeRelayAuthProof,
deriveRelayAuthKeyId,
extensionRelayAuthResource,
importRelayHmacKey,
randomRelayBase64Url,
relayBytesFromBase64Url,
requireRelayCrypto,
} from "./relay-auth-v2-crypto.js";
export const EXTENSION_RELAY_V2_PROTOCOL = "openclaw-extension-relay.v2";
const CHALLENGE_LIFETIME_MS = 10_000;
const MAX_CLOCK_SKEW_MS = 30_000;
const MAX_AUTH_JSON_BYTES = 16 * 1024;
const authTextEncoder = new TextEncoder();
const CHALLENGE_KEYS = [
"type",
"v",
"keyId",
"instanceId",
"sessionId",
"clientNonce",
"serverNonce",
"issuedAtMs",
"expiresAtMs",
"role",
"transport",
"method",
"resource",
"flow",
"serverProof",
];
const OK_KEYS = ["type", "v", "sessionId", "acceptProof"];
function hasExactKeys(value, expected) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const actual = Object.keys(value).toSorted((a, b) => a.localeCompare(b));
const wanted = [...expected].toSorted((a, b) => a.localeCompare(b));
return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
}
function hasDuplicateJsonObjectKeys(text) {
const stack = [];
let expectingKey = false;
let index = 0;
const skipWhitespace = () => {
while (/\s/.test(text[index] ?? "")) {
index += 1;
}
};
while (index < text.length) {
const char = text[index];
if (char === '"') {
const start = index;
index += 1;
let escaped = false;
while (index < text.length) {
const next = text[index++];
if (escaped) {
escaped = false;
} else if (next === "\\") {
escaped = true;
} else if (next === '"') {
break;
}
}
if (expectingKey && stack.at(-1)) {
let key;
try {
key = JSON.parse(text.slice(start, index));
} catch {
return false;
}
skipWhitespace();
if (text[index] === ":" && typeof key === "string") {
const keys = stack.at(-1);
if (keys.has(key)) {
return true;
}
keys.add(key);
expectingKey = false;
}
}
continue;
}
if (char === "{") {
stack.push(new Set());
expectingKey = true;
} else if (char === "[") {
stack.push(null);
expectingKey = false;
} else if (char === "}") {
stack.pop();
expectingKey = false;
} else if (char === "]") {
stack.pop();
expectingKey = false;
} else if (char === ",") {
expectingKey = stack.at(-1) instanceof Set;
}
index += 1;
}
return false;
}
/** Parse an authentication frame without allowing JSON duplicate-key shadowing. */
export function parseRelayAuthJson(raw) {
if (
typeof raw !== "string" ||
raw.length > MAX_AUTH_JSON_BYTES ||
authTextEncoder.encode(raw).byteLength > MAX_AUTH_JSON_BYTES ||
hasDuplicateJsonObjectKeys(raw)
) {
return null;
}
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}
function assertSafeTimestamp(value, field) {
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error(`${field} must be a non-negative safe integer`);
}
}
/**
* One connection-bound client state machine. Any invalid or out-of-order
* security frame permanently fails this instance; callers must close the socket.
*/
export async function createExtensionRelayAuthClient({
token,
relayUrl,
cryptoApi = globalThis.crypto,
now = () => Date.now(),
clientNonce,
}) {
const runtime = requireRelayCrypto(cryptoApi);
const keyId = await deriveRelayAuthKeyId(token, runtime);
const key = await importRelayHmacKey(token, runtime);
const nonce = clientNonce ?? randomRelayBase64Url(runtime, 32);
relayBytesFromBase64Url(nonce, 32, "clientNonce");
const resource = extensionRelayAuthResource(relayUrl);
let state = "new";
let challenge = null;
let clientProof = null;
const fail = (error) => {
state = "failed";
throw error instanceof Error ? error : new Error(String(error));
};
return {
keyId,
clientNonce: nonce,
get authenticated() {
return state === "authenticated";
},
start() {
if (state !== "new") {
return fail(new Error("relay auth hello is out of sequence"));
}
state = "waiting-challenge";
return { type: "auth.hello", v: RELAY_AUTH_VERSION, keyId, clientNonce: nonce };
},
async acceptChallenge(message) {
if (state !== "waiting-challenge") {
return fail(new Error("relay auth challenge is out of sequence"));
}
state = "verifying-challenge";
try {
if (!hasExactKeys(message, CHALLENGE_KEYS) || message.type !== "auth.challenge") {
throw new Error("invalid relay auth challenge shape");
}
if (
message.v !== RELAY_AUTH_VERSION ||
message.keyId !== keyId ||
message.clientNonce !== nonce ||
message.role !== "extension" ||
message.transport !== "websocket" ||
message.method !== "GET" ||
message.resource !== resource ||
message.flow !== "extension"
) {
throw new Error("relay auth challenge binding mismatch");
}
relayBytesFromBase64Url(message.instanceId, 16, "instanceId");
relayBytesFromBase64Url(message.sessionId, 16, "sessionId");
relayBytesFromBase64Url(message.serverNonce, 32, "serverNonce");
const serverProofBytes = relayBytesFromBase64Url(message.serverProof, 32, "serverProof");
assertSafeTimestamp(message.issuedAtMs, "issuedAtMs");
assertSafeTimestamp(message.expiresAtMs, "expiresAtMs");
const currentTime = now();
assertSafeTimestamp(currentTime, "current time");
if (
message.expiresAtMs <= message.issuedAtMs ||
message.expiresAtMs - message.issuedAtMs > CHALLENGE_LIFETIME_MS ||
Math.abs(currentTime - message.issuedAtMs) > MAX_CLOCK_SKEW_MS ||
currentTime > message.expiresAtMs
) {
throw new Error("relay auth challenge is expired or outside the allowed clock skew");
}
const fields = {
keyId: message.keyId,
instanceId: message.instanceId,
sessionId: message.sessionId,
clientNonce: message.clientNonce,
serverNonce: message.serverNonce,
issuedAtMs: message.issuedAtMs,
expiresAtMs: message.expiresAtMs,
role: message.role,
transport: message.transport,
method: message.method,
resource: message.resource,
flow: message.flow,
};
const valid = await runtime.subtle.verify(
"HMAC",
key,
serverProofBytes,
canonicalRelayAuthProofBytes("server", fields),
);
if (!valid) {
throw new Error("relay auth server proof is invalid");
}
clientProof = await computeRelayAuthProof(token, "client", fields, undefined, runtime);
challenge = fields;
state = "waiting-ok";
return {
type: "auth.response",
v: RELAY_AUTH_VERSION,
sessionId: fields.sessionId,
clientProof,
};
} catch (error) {
return fail(error);
}
},
async acceptOk(message) {
if (state !== "waiting-ok" || !challenge || !clientProof) {
return fail(new Error("relay auth ok is out of sequence"));
}
state = "verifying-ok";
try {
if (
!hasExactKeys(message, OK_KEYS) ||
message.type !== "auth.ok" ||
message.v !== RELAY_AUTH_VERSION ||
message.sessionId !== challenge.sessionId
) {
throw new Error("invalid relay auth ok binding");
}
const acceptProofBytes = relayBytesFromBase64Url(message.acceptProof, 32, "acceptProof");
const valid = await runtime.subtle.verify(
"HMAC",
key,
acceptProofBytes,
canonicalRelayAuthProofBytes("accept", challenge, clientProof),
);
if (!valid) {
throw new Error("relay auth accept proof is invalid");
}
state = "authenticated";
return undefined;
} catch (error) {
return fail(error);
}
},
};
}
@@ -0,0 +1,189 @@
import { describe, expect, it } from "vitest";
import {
computeRelayAuthProof,
deriveRelayAuthKeyId,
extensionRelayAuthResource,
type RelayAuthProofFields,
} from "./relay-auth-v2-crypto.js";
import { createExtensionRelayAuthClient, parseRelayAuthJson } from "./relay-auth-v2.js";
const VECTOR = {
token: Array.from({ length: 32 }, (_, index) => index.toString(16).padStart(2, "0")).join(""),
fields: {
keyId: "Yw3NKWbEM2aRElRIu7JbT_",
instanceId: "EREREREREREREREREREREQ",
sessionId: "IiIiIiIiIiIiIiIiIiIiIg",
clientNonce: "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM",
serverNonce: "REREREREREREREREREREREREREREREREREREREREREQ",
issuedAtMs: 1_786_123_456_000,
expiresAtMs: 1_786_123_466_000,
role: "extension",
transport: "websocket",
method: "GET",
resource: "/extension?profile=chrome",
flow: "extension",
} satisfies RelayAuthProofFields,
serverProof: "ynhaAA_l2HkOGXQ8DvIWfzWwwGjDcV93aumHNe_NM-Q",
clientProof: "Rl8TStMYlPLxJPDYwSe__mtEjgMf1C4TM-ZN6sUipZ4",
acceptProof: "1R5MpHs6qnAdc0_X6vKBwj91tlRoWfNuGXaNfSD7VnI",
};
function challenge(overrides: Record<string, unknown> = {}) {
return {
type: "auth.challenge",
v: 2,
...VECTOR.fields,
serverProof: VECTOR.serverProof,
...overrides,
};
}
async function client() {
return await createExtensionRelayAuthClient({
token: VECTOR.token,
relayUrl: "ws://127.0.0.1:18797/extension?profile=chrome",
clientNonce: VECTOR.fields.clientNonce,
now: () => VECTOR.fields.issuedAtMs + 1,
});
}
describe("Browser Relay Authentication v2 WebCrypto vectors", () => {
it("matches the fixed Node HMAC vector", async () => {
await expect(deriveRelayAuthKeyId(VECTOR.token)).resolves.toBe(VECTOR.fields.keyId);
await expect(computeRelayAuthProof(VECTOR.token, "server", VECTOR.fields)).resolves.toBe(
VECTOR.serverProof,
);
await expect(computeRelayAuthProof(VECTOR.token, "client", VECTOR.fields)).resolves.toBe(
VECTOR.clientProof,
);
await expect(
computeRelayAuthProof(VECTOR.token, "accept", VECTOR.fields, VECTOR.clientProof),
).resolves.toBe(VECTOR.acceptProof);
});
it("verifies server proof before producing client proof and verifies accept proof", async () => {
const auth = await client();
expect(auth.start()).toEqual({
type: "auth.hello",
v: 2,
keyId: VECTOR.fields.keyId,
clientNonce: VECTOR.fields.clientNonce,
});
await expect(auth.acceptChallenge(challenge())).resolves.toEqual({
type: "auth.response",
v: 2,
sessionId: VECTOR.fields.sessionId,
clientProof: VECTOR.clientProof,
});
expect(auth.authenticated).toBe(false);
await expect(
auth.acceptOk({
type: "auth.ok",
v: 2,
sessionId: VECTOR.fields.sessionId,
acceptProof: VECTOR.acceptProof,
}),
).resolves.toBeUndefined();
expect(auth.authenticated).toBe(true);
});
});
describe("Browser Relay Authentication v2 client validation", () => {
it("rejects a bad server proof without producing a client proof", async () => {
const auth = await client();
auth.start();
await expect(auth.acceptChallenge(challenge({ serverProof: "A".repeat(43) }))).rejects.toThrow(
"server proof is invalid",
);
expect(auth.authenticated).toBe(false);
await expect(auth.acceptChallenge(challenge())).rejects.toThrow("out of sequence");
});
it("rejects every substituted challenge binding", async () => {
const substitutions: Array<[string, unknown]> = [
["v", 1],
["keyId", "A".repeat(22)],
["instanceId", "A".repeat(22)],
["sessionId", "A".repeat(22)],
["clientNonce", "A".repeat(43)],
["serverNonce", "A".repeat(43)],
["issuedAtMs", VECTOR.fields.issuedAtMs - 1],
["expiresAtMs", VECTOR.fields.expiresAtMs - 1],
["role", "cdp"],
["transport", "connection"],
["method", "POST"],
["resource", "/other"],
["flow", "json-list"],
];
for (const [field, value] of substitutions) {
const auth = await client();
auth.start();
await expect(auth.acceptChallenge(challenge({ [field]: value }))).rejects.toThrow();
}
});
it("rejects expired, overlong, and extra-field challenges", async () => {
for (const mutation of [
{
issuedAtMs: VECTOR.fields.issuedAtMs - 20_000,
expiresAtMs: VECTOR.fields.issuedAtMs - 10_000,
},
{ expiresAtMs: VECTOR.fields.issuedAtMs + 10_001 },
{ unexpected: true },
]) {
const auth = await client();
auth.start();
await expect(auth.acceptChallenge(challenge(mutation))).rejects.toThrow();
}
});
it("rejects bad accept proof and exact-sequence violations", async () => {
const early = await client();
await expect(
early.acceptOk({
type: "auth.ok",
v: 2,
sessionId: VECTOR.fields.sessionId,
acceptProof: VECTOR.acceptProof,
}),
).rejects.toThrow("out of sequence");
const auth = await client();
auth.start();
await auth.acceptChallenge(challenge());
await expect(
auth.acceptOk({
type: "auth.ok",
v: 2,
sessionId: VECTOR.fields.sessionId,
acceptProof: "A".repeat(43),
}),
).rejects.toThrow("accept proof is invalid");
expect(auth.authenticated).toBe(false);
});
it("canonicalizes only the allowed profile query", () => {
expect(
extensionRelayAuthResource("wss://gateway.example/base/browser/extension?profile=work"),
).toBe("/base/browser/extension?profile=work");
expect(() =>
extensionRelayAuthResource("ws://127.0.0.1/extension?profile=a&profile=b"),
).toThrow("unsupported query");
expect(() => extensionRelayAuthResource("ws://127.0.0.1/extension?token=nope")).toThrow(
"unsupported query",
);
});
it("rejects duplicate security fields before JSON parsing", () => {
expect(parseRelayAuthJson('{"type":"auth.ok","v":2,"v":1}')).toBeNull();
expect(parseRelayAuthJson('{"type":"auth.ok","v":2}')).toEqual({
type: "auth.ok",
v: 2,
});
});
it("rejects oversized authentication frames before JSON parsing", () => {
expect(parseRelayAuthJson(`{"padding":"${"a".repeat(16 * 1024)}"}`)).toBeNull();
expect(parseRelayAuthJson(`{"padding":"${"é".repeat(9 * 1024)}"}`)).toBeNull();
});
});
@@ -0,0 +1,8 @@
export function createRelayCommandHandler(params: {
send: (message: Record<string, unknown>) => void;
attachDebugger: (tabId: number) => Promise<unknown>;
detachDebugger: (tabId: number) => Promise<void>;
addTabToOpenClawGroup: (tabId: number) => Promise<void>;
focusWindowForTab: (tab: chrome.tabs.Tab) => Promise<void>;
scheduleTabsSync: () => void;
}): (message: Record<string, unknown>) => Promise<void>;
@@ -0,0 +1,82 @@
import { requireSharedTab } from "./relay-tab-groups.js";
/** Build the authenticated application-command dispatcher for the relay socket. */
export function createRelayCommandHandler({
send,
attachDebugger,
detachDebugger,
addTabToOpenClawGroup,
focusWindowForTab,
scheduleTabsSync,
}) {
return async (message) => {
const { seq } = message;
try {
switch (message.type) {
case "ping":
send({ type: "pong" });
return;
case "attach":
send({ type: "result", seq, result: await attachDebugger(message.tabId) });
return;
case "detach":
await detachDebugger(message.tabId);
send({ type: "result", seq, result: {} });
return;
case "cdp": {
await requireSharedTab(message.tabId);
const target = message.sessionId
? { tabId: message.tabId, sessionId: message.sessionId }
: { tabId: message.tabId };
const result = await chrome.debugger.sendCommand(
target,
message.method,
message.params ?? {},
);
send({ type: "result", seq, result: result ?? {} });
return;
}
case "createTab": {
const tab = await chrome.tabs.create({
url: message.url,
active: message.background !== true,
});
await addTabToOpenClawGroup(tab.id);
if (message.focus === true) {
await focusWindowForTab(tab);
}
scheduleTabsSync();
send({ type: "result", seq, result: { tabId: tab.id } });
return;
}
case "closeTab":
await requireSharedTab(message.tabId);
await detachDebugger(message.tabId);
await requireSharedTab(message.tabId);
await chrome.tabs.remove(message.tabId);
send({ type: "result", seq, result: {} });
return;
case "activateTab": {
const tab = await requireSharedTab(message.tabId);
await chrome.tabs.update(message.tabId, { active: true });
await requireSharedTab(message.tabId);
await focusWindowForTab(tab);
send({ type: "result", seq, result: {} });
return;
}
default:
if (typeof seq === "number") {
send({ type: "error", seq, message: `unknown relay command: ${message.type}` });
}
}
} catch (error) {
if (typeof seq === "number") {
send({
type: "error",
seq,
message: error instanceof Error ? error.message : String(error),
});
}
}
};
}
@@ -0,0 +1,9 @@
export function openAuthenticatedRelaySocket(params: {
relayUrl: string;
token: string;
isCurrent: (socket: WebSocket) => boolean;
onAuthenticated: (socket: WebSocket) => void | Promise<void>;
onApplicationMessage: (socket: WebSocket, message: Record<string, unknown>) => void;
onAuthenticationFailure: (socket: WebSocket, error: unknown) => void;
onClose: (socket: WebSocket, authenticated: boolean) => void;
}): WebSocket;
@@ -0,0 +1,91 @@
import {
createExtensionRelayAuthClient,
EXTENSION_RELAY_V2_PROTOCOL,
parseRelayAuthJson,
} from "./relay-auth-v2.js";
import { buildRelayWsProtocols } from "./relay-core.js";
/** Open one v2-only relay socket and expose application frames only after auth.ok. */
export function openAuthenticatedRelaySocket({
relayUrl,
token,
isCurrent,
onAuthenticated,
onApplicationMessage,
onAuthenticationFailure,
onClose,
}) {
const authClientPromise = createExtensionRelayAuthClient({ token, relayUrl });
const ws = new WebSocket(relayUrl, buildRelayWsProtocols());
let authenticated = false;
ws.addEventListener("open", () => {
void (async () => {
try {
if (!isCurrent(ws)) {
ws.close();
return;
}
if (ws.protocol !== EXTENSION_RELAY_V2_PROTOCOL) {
throw new Error("relay did not negotiate Browser Relay Authentication v2");
}
const authClient = await authClientPromise;
ws.send(JSON.stringify(authClient.start()));
} catch (error) {
onAuthenticationFailure(ws, error);
}
})();
});
let messageChain = Promise.resolve();
ws.addEventListener("message", (event) => {
messageChain = messageChain.then(async () => {
try {
if (!isCurrent(ws)) {
return;
}
const raw = String(event.data);
if (authenticated) {
let message;
try {
message = JSON.parse(raw);
} catch {
return;
}
if (typeof message?.type === "string" && message.type.startsWith("auth.")) {
throw new Error("relay sent an authentication frame after completion");
}
onApplicationMessage(ws, message);
return;
}
const message = parseRelayAuthJson(raw);
if (!message) {
throw new Error("relay sent malformed authentication JSON");
}
const authClient = await authClientPromise;
if (message.type === "auth.challenge") {
const response = await authClient.acceptChallenge(message);
if (isCurrent(ws) && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(response));
}
return;
}
if (message.type !== "auth.ok") {
throw new Error("relay authentication frame is out of sequence");
}
await authClient.acceptOk(message);
if (!isCurrent(ws) || ws.readyState !== WebSocket.OPEN) {
return;
}
authenticated = true;
await onAuthenticated(ws);
} catch (error) {
onAuthenticationFailure(ws, error);
}
});
});
ws.addEventListener("close", () => onClose(ws, authenticated));
return ws;
}
@@ -17,7 +17,9 @@ export function createPairingConfigStore(storage: {
relayUrl: string;
token: string;
gatewayUrl: string;
authVersion?: 2;
groupColor: string;
pairingStatusHint: string;
}>;
save(
pairing: { relayUrl: string; token: string; gatewayUrl?: string },
@@ -26,7 +28,7 @@ export function createPairingConfigStore(storage: {
clear(): Promise<void>;
};
export function buildRelayWsProtocols(token: string): string[];
export function buildRelayWsProtocols(): string[];
export function reconnectDelayMs(attempt: number): number;
@@ -4,10 +4,13 @@
/** Tab group shown to the user; membership == what the agent may touch. */
export const OPENCLAW_TAB_GROUP_TITLE = "OpenClaw";
const EXTENSION_RELAY_PROTOCOL = "openclaw-extension-relay";
const EXTENSION_RELAY_TOKEN_PROTOCOL_PREFIX = "openclaw-extension-token.";
const EXTENSION_RELAY_PROTOCOL = "openclaw-extension-relay.v2";
const RELAY_SECRET_PATTERN = /^[0-9a-f]{64}$/;
const PAIRING_STORAGE_KEYS = ["relayUrl", "gatewayUrl", "token"];
const PAIRING_STORAGE_KEYS = ["relayUrl", "gatewayUrl", "token", "authVersion"];
const PAIRING_STATUS_KEY = "pairingStatus";
const UNSUPPORTED_PROXY_PREFIX_STATUS = "proxy-prefix-unsupported";
const UNSUPPORTED_PROXY_PREFIX_HINT =
"Stored proxy-prefixed browser relay pairing is no longer supported. Re-run `openclaw browser extension pair` with a Gateway URL that has no path prefix.";
const CHROME_GROUP_COLORS = {
grey: [128, 128, 128],
@@ -66,15 +69,43 @@ function parseGatewayHint(raw) {
}
function directGatewayUrlFromRelay(relay) {
const suffix = "/browser/extension";
if (!relay.pathname.endsWith(suffix)) {
if (relay.pathname !== "/browser/extension") {
return null;
}
const gateway = new URL(relay.toString());
gateway.pathname = gateway.pathname.slice(0, -suffix.length) || "/";
gateway.pathname = "/";
gateway.search = "";
return gateway.toString();
}
function isUnsupportedProxyPrefix(raw) {
if (typeof raw !== "string") {
return false;
}
try {
const relay = new URL(raw);
return (
isAllowedWebSocketUrl(relay) &&
relay.pathname !== "/browser/extension" &&
relay.pathname.endsWith("/browser/extension")
);
} catch {
return false;
}
}
function normalizeRelayQuery(relay) {
const query = [...relay.searchParams];
if (
query.some(([key, value]) => key !== "profile" || !/^[a-z0-9-]+$/.test(value)) ||
query.filter(([key]) => key === "profile").length > 1
) {
return false;
}
relay.searchParams.sort();
return true;
}
function validatePairingFields(relayUrl, token, gatewayUrl) {
if (typeof relayUrl !== "string" || typeof token !== "string") {
return null;
@@ -88,10 +119,13 @@ function validatePairingFields(relayUrl, token, gatewayUrl) {
} catch {
return null;
}
const supportedPath =
(isLoopbackHost(relay.hostname) && relay.pathname === "/extension") ||
relay.pathname === "/browser/extension";
if (
!isAllowedWebSocketUrl(relay) ||
!relay.pathname.endsWith("/extension") ||
relay.search ||
!supportedPath ||
!normalizeRelayQuery(relay) ||
relay.hash
) {
return null;
@@ -137,14 +171,21 @@ export function parsePairingString(raw) {
return null;
}
const query = [...parsed.searchParams];
if (query.length > 1 || (query.length === 1 && query[0]?.[0] !== "gateway")) {
const gatewayEntries = query.filter(([key]) => key === "gateway");
const profileEntries = query.filter(([key]) => key === "profile");
if (
gatewayEntries.length > 1 ||
profileEntries.length > 1 ||
query.some(([key]) => key !== "gateway" && key !== "profile")
) {
return null;
}
const gatewayUrl = query.length === 1 ? query[0]?.[1] : undefined;
if (query.length === 1 && !gatewayUrl?.trim()) {
const gatewayUrl = gatewayEntries[0]?.[1];
if ((gatewayEntries.length === 1 && !gatewayUrl?.trim()) || profileEntries[0]?.[1] === "") {
return null;
}
parsed.search = "";
parsed.searchParams.delete("gateway");
parsed.searchParams.sort();
return validatePairingFields(parsed.toString(), token, gatewayUrl);
}
@@ -156,6 +197,7 @@ function parseStoredPairing(stored) {
const parsed = validatePairingFields(stored.relayUrl, stored.token, stored.gatewayUrl);
if (
!parsed ||
(stored.authVersion !== undefined && stored.authVersion !== 2) ||
parsed.relayUrl !== stored.relayUrl ||
parsed.token !== stored.token ||
(parsed.gatewayUrl ?? "") !== (stored.gatewayUrl ?? "")
@@ -181,41 +223,69 @@ export function createPairingConfigStore(storage) {
},
read: () =>
run(async () => {
const stored = await storage.get([...PAIRING_STORAGE_KEYS, "groupColor"]);
const stored = await storage.get([
...PAIRING_STORAGE_KEYS,
PAIRING_STATUS_KEY,
"groupColor",
]);
const hasPairing = PAIRING_STORAGE_KEYS.some((key) => Object.hasOwn(stored, key));
const pairing = hasPairing ? parseStoredPairing(stored) : null;
let pairingStatus =
stored[PAIRING_STATUS_KEY] === UNSUPPORTED_PROXY_PREFIX_STATUS
? UNSUPPORTED_PROXY_PREFIX_STATUS
: "";
if (hasPairing && !pairing) {
if (!invalidObserved) {
invalidationRevision += 1;
}
invalidObserved = true;
pairingStatus = isUnsupportedProxyPrefix(stored.relayUrl)
? UNSUPPORTED_PROXY_PREFIX_STATUS
: "";
await storage.remove(PAIRING_STORAGE_KEYS).catch(() => undefined);
if (pairingStatus) {
await storage.set({ [PAIRING_STATUS_KEY]: pairingStatus }).catch(() => undefined);
} else if (Object.hasOwn(stored, PAIRING_STATUS_KEY)) {
await storage.remove([PAIRING_STATUS_KEY]).catch(() => undefined);
}
} else {
invalidObserved = false;
if (pairing && stored.authVersion === undefined) {
await storage.set({ authVersion: 2 });
}
if (pairing && pairingStatus) {
pairingStatus = "";
await storage.remove([PAIRING_STATUS_KEY]).catch(() => undefined);
}
}
return {
relayUrl: pairing?.relayUrl ?? "",
token: pairing?.token ?? "",
gatewayUrl: pairing?.gatewayUrl ?? "",
authVersion: pairing ? 2 : undefined,
groupColor: typeof stored.groupColor === "string" ? stored.groupColor : "orange",
pairingStatusHint:
pairingStatus === UNSUPPORTED_PROXY_PREFIX_STATUS ? UNSUPPORTED_PROXY_PREFIX_HINT : "",
};
}),
save: (pairing, groupColor) =>
run(() =>
storage.set({
run(async () => {
await storage.set({
relayUrl: pairing.relayUrl,
token: pairing.token,
gatewayUrl: pairing.gatewayUrl ?? "",
authVersion: 2,
groupColor,
}),
),
clear: () => run(() => storage.remove(PAIRING_STORAGE_KEYS)),
});
await storage.remove([PAIRING_STATUS_KEY]);
}),
clear: () => run(() => storage.remove([...PAIRING_STORAGE_KEYS, PAIRING_STATUS_KEY])),
};
}
/** Build WebSocket subprotocols without putting the relay secret in the request URL. */
export function buildRelayWsProtocols(token) {
return [EXTENSION_RELAY_PROTOCOL, `${EXTENSION_RELAY_TOKEN_PROTOCOL_PREFIX}${token}`];
/** Build the v2 WebSocket subprotocol list; credentials stay in WebCrypto only. */
export function buildRelayWsProtocols() {
return [EXTENSION_RELAY_PROTOCOL];
}
/** Exponential reconnect backoff: 1s, 2s, 4s ... capped at 30s. */
@@ -1,6 +1,6 @@
// 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 { describe, expect, it, vi } from "vitest";
import {
buildRelayWsProtocols,
createPairingConfigStore,
@@ -29,10 +29,7 @@ describe("parsePairingString", () => {
throw new Error("expected pairing string to parse");
}
expect(parsed.relayUrl).toBe(`ws://127.0.0.1:${port}/extension`);
expect(buildRelayWsProtocols(parsed.token)).toEqual([
"openclaw-extension-relay",
`openclaw-extension-token.${token}`,
]);
expect(buildRelayWsProtocols()).toEqual(["openclaw-extension-relay.v2"]);
});
it("extracts the additive direct Gateway hint without passing it to the relay", () => {
@@ -45,12 +42,23 @@ describe("parsePairingString", () => {
});
});
it("retains and canonicalizes the profile auth binding while stripping the Gateway hint", () => {
const pairing = `ws://127.0.0.1:18797/extension?profile=work&gateway=${encodeURIComponent("wss://gateway.example.com")}#${RELAY_SECRET}`;
expect(parsePairingString(pairing)).toEqual({
relayUrl: "ws://127.0.0.1:18797/extension?profile=work",
token: RELAY_SECRET,
gatewayUrl: "wss://gateway.example.com",
});
});
it.each([
"ws://localhost.:18797/extension",
"ws://127.25.0.1:18797/extension",
"ws://[::1]:18797/extension",
"ws://[::ffff:127.0.0.1]:18797/extension",
"ws://127.0.0.1:18789/browser/extension",
"wss://gateway.example.com/browser/extension",
"wss://gateway.example.com/browser/extension?profile=work",
])("accepts the supported relay transport %s", (relayUrl) => {
expect(parsePairingString(`${relayUrl}#${RELAY_SECRET}`)?.token).toBe(RELAY_SECRET);
});
@@ -59,12 +67,26 @@ describe("parsePairingString", () => {
["an empty string", ""],
["an HTTP URL", `http://127.0.0.1/extension#${RELAY_SECRET}`],
["a non-loopback plaintext URL", `ws://gateway.example.com/extension#${RELAY_SECRET}`],
["a remote relay path", `wss://gateway.example.com/extension#${RELAY_SECRET}`],
[
"a proxy-prefixed remote relay path",
`wss://gateway.example.com/proxy/browser/extension#${RELAY_SECRET}`,
],
[
"a proxy-prefixed loopback direct-Gateway path",
`ws://127.0.0.1:18789/proxy/browser/extension#${RELAY_SECRET}`,
],
[
"a suffixed remote relay path",
`wss://gateway.example.com/browser/extension/extra#${RELAY_SECRET}`,
],
["relay credentials", `wss://user:pass@gateway.example.com/extension#${RELAY_SECRET}`],
["the wrong path", `ws://127.0.0.1/other#${RELAY_SECRET}`],
["a missing secret", "ws://127.0.0.1/extension#"],
["a short secret", "ws://127.0.0.1/extension#abc123"],
["an uppercase secret", `ws://127.0.0.1/extension#${"A".repeat(64)}`],
["an unknown query parameter", `ws://127.0.0.1/extension?token=nope#${RELAY_SECRET}`],
["duplicate profiles", `ws://127.0.0.1/extension?profile=one&profile=two#${RELAY_SECRET}`],
[
"duplicate Gateway hints",
`ws://127.0.0.1/extension?gateway=wss%3A%2F%2Fone.example&gateway=wss%3A%2F%2Ftwo.example#${RELAY_SECRET}`,
@@ -117,17 +139,98 @@ describe("persisted pairing storage", () => {
},
},
{
label: "a direct relay with its matching trailing-slash Gateway hint",
label: "an exact direct relay with its matching trailing-slash Gateway hint",
stored: {
relayUrl: "wss://gateway.example.com/base/browser/extension",
relayUrl: "wss://gateway.example.com/browser/extension",
token: RELAY_SECRET,
gatewayUrl: "wss://gateway.example.com/base/",
gatewayUrl: "wss://gateway.example.com/",
},
},
])("accepts $label", async ({ stored }) => {
expect(await readStoredPairing(stored)).toEqual(stored);
});
it("migrates a canonical existing pairing to authVersion 2 without re-pairing", async () => {
const stored = {
relayUrl: "ws://127.0.0.1:18797/extension",
token: RELAY_SECRET,
gatewayUrl: "",
groupColor: "orange",
};
const set = vi.fn(async (values: Record<string, unknown>) => {
Object.assign(stored, values);
});
const config = await createPairingConfigStore({
get: async () => stored,
set,
remove: async () => undefined,
}).read();
expect(set).toHaveBeenCalledWith({ authVersion: 2 });
expect(config).toMatchObject({
relayUrl: "ws://127.0.0.1:18797/extension",
token: RELAY_SECRET,
authVersion: 2,
});
});
it("rejects and clears an unsupported stored auth version", async () => {
const remove = vi.fn(async () => undefined);
const config = await createPairingConfigStore({
get: async () => ({
relayUrl: "ws://127.0.0.1:18797/extension",
token: RELAY_SECRET,
gatewayUrl: "",
authVersion: 1,
}),
set: async () => undefined,
remove,
}).read();
expect(config.relayUrl).toBe("");
expect(remove).toHaveBeenCalledWith(["relayUrl", "gatewayUrl", "token", "authVersion"]);
});
it.each([
{
relayUrl: "wss://gateway.example.com/proxy/browser/extension",
gatewayUrl: "wss://gateway.example.com/proxy",
},
{
relayUrl: "ws://127.0.0.1:18789/proxy/browser/extension",
gatewayUrl: "ws://127.0.0.1:18789/proxy",
},
])("clears stored proxy-prefixed direct pairing $relayUrl with safe guidance", async (route) => {
const stored: Record<string, unknown> = {
relayUrl: route.relayUrl,
token: RELAY_SECRET,
gatewayUrl: route.gatewayUrl,
authVersion: 2,
};
const set = vi.fn(async (values: Record<string, unknown>) => {
Object.assign(stored, values);
});
const remove = vi.fn(async (keys: string[]) => {
for (const key of keys) {
delete stored[key];
}
});
const store = createPairingConfigStore({ get: async () => stored, set, remove });
const config = await store.read();
expect(config).toMatchObject({ relayUrl: "", token: "", authVersion: undefined });
expect(config.pairingStatusHint).toContain("no path prefix");
expect(config.pairingStatusHint).not.toContain(RELAY_SECRET);
expect(remove).toHaveBeenCalledWith(["relayUrl", "gatewayUrl", "token", "authVersion"]);
expect(set).toHaveBeenCalledWith({ pairingStatus: "proxy-prefix-unsupported" });
const afterWorkerRestart = await createPairingConfigStore({
get: async () => stored,
set,
remove,
}).read();
expect(afterWorkerRestart.pairingStatusHint).toContain("openclaw browser extension pair");
});
it.each([
["an invalid token", { relayUrl: "ws://127.0.0.1:18797/extension", token: "short" }],
[
@@ -186,9 +289,9 @@ describe("persisted pairing storage", () => {
[
"a mismatched direct Gateway hint",
{
relayUrl: "wss://gateway.example.com/base/browser/extension",
relayUrl: "wss://gateway.example.com/browser/extension",
token: RELAY_SECRET,
gatewayUrl: "wss://other.example.com/base",
gatewayUrl: "wss://other.example.com",
},
],
])("rejects %s", async (_label, stored) => {
@@ -1,7 +1,12 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import { createServer, type Server } from "node:http";
import path from "node:path";
import { chromium, type CDPSession } from "playwright-core";
import { afterEach, describe, expect, it } from "vitest";
import { WebSocketServer } from "ws";
import {
EXTENSION_RELAY_MAX_PAYLOAD_BYTES,
startExtensionRelayServer,
type ExtensionRelayHandle,
} from "../src/browser/extension-relay/relay-server.js";
@@ -9,6 +14,7 @@ import { useAutoCleanupTempDirTracker } from "../test-support.js";
import {
copyCopilotSidepanelExtension,
createRelayHarness,
rawDataText,
waitForContextExtensionId,
waitForLoadedExtensionId,
} from "./sidepanel.e2e-support.js";
@@ -73,6 +79,24 @@ async function listen(server: Server): Promise<number> {
return address.port;
}
async function configureRelayCredential(token: string): Promise<void> {
const priorStateDir = process.env.OPENCLAW_STATE_DIR;
const stateDir = tempDirs.make("openclaw-extension-relay-state-");
const credentialsDir = path.join(stateDir, "credentials");
await fs.mkdir(credentialsDir, { recursive: true });
await fs.writeFile(path.join(credentialsDir, "browser-extension-relay.secret"), `${token}\n`, {
mode: 0o600,
});
process.env.OPENCLAW_STATE_DIR = stateDir;
cleanups.push(async () => {
if (priorStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = priorStateDir;
}
});
}
async function evaluateToolbarPopup<T>(
browserCdp: CDPSession,
sessionId: string,
@@ -125,8 +149,102 @@ async function evaluateToolbarPopup<T>(
}
describe.runIf(runE2E)("Chrome extension relay authorization", () => {
it("sends no client proof or raw key to a malicious loopback listener", async () => {
const server = createServer();
const port = await listen(server);
const wss = new WebSocketServer({
noServer: true,
maxPayload: EXTENSION_RELAY_MAX_PAYLOAD_BYTES,
handleProtocols: (protocols) =>
protocols.has("openclaw-extension-relay.v2") ? "openclaw-extension-relay.v2" : false,
});
const protocolHeaders: string[] = [];
const receivedTypes: string[] = [];
server.on("upgrade", (request, socket, head) => {
const protocolHeader = request.headers["sec-websocket-protocol"];
protocolHeaders.push(
Array.isArray(protocolHeader) ? protocolHeader.join(", ") : (protocolHeader ?? ""),
);
wss.handleUpgrade(request, socket, head, (client) => wss.emit("connection", client, request));
});
wss.on("connection", (socket) => {
socket.on("message", (data) => {
const message = JSON.parse(rawDataText(data)) as Record<string, unknown>;
receivedTypes.push(String(message.type));
if (message.type !== "auth.hello") {
return;
}
const issuedAtMs = Date.now();
socket.send(
JSON.stringify({
type: "auth.challenge",
v: 2,
keyId: createHash("sha256")
.update(Buffer.from(PAGE_SHARE_RELAY_SECRET, "hex"))
.digest("base64url")
.slice(0, 22),
instanceId: "ICEiIyQlJicoKSorLC0uLw",
sessionId: "MDEyMzQ1Njc4OTo7PD0-Pw",
clientNonce: message.clientNonce,
serverNonce: "YGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn8",
issuedAtMs,
expiresAtMs: issuedAtMs + 10_000,
role: "extension",
transport: "websocket",
method: "GET",
resource: "/extension",
flow: "extension",
serverProof: "A".repeat(43),
}),
);
});
});
cleanups.push(async () => {
for (const client of wss.clients) {
client.terminate();
}
await new Promise<void>((resolve) => {
wss.close(() => resolve());
});
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
});
const unpackedExtension = await copyCopilotSidepanelExtension(tempDirs);
const context = await chromium.launchPersistentContext(
tempDirs.make("openclaw-extension-malicious-relay-profile-"),
{
channel: "chromium",
headless: true,
ignoreDefaultArgs: ["--disable-extensions"],
args: [
"--enable-unsafe-extension-debugging",
`--disable-extensions-except=${unpackedExtension}`,
`--load-extension=${unpackedExtension}`,
],
},
);
cleanups.push(async () => await context.close());
const extensionId = await waitForContextExtensionId(context, unpackedExtension);
const launcher = context.pages()[0] ?? (await context.newPage());
await launcher.goto(`chrome-extension://${extensionId}/e2e-launcher.html`);
await launcher.evaluate(
async (pairingString) => await chrome.runtime.sendMessage({ type: "pair", pairingString }),
`ws://127.0.0.1:${port}/extension#${PAGE_SHARE_RELAY_SECRET}`,
);
await expect.poll(() => receivedTypes.length, { timeout: 10_000 }).toBeGreaterThan(0);
await new Promise<void>((resolve) => {
setTimeout(resolve, 750);
});
expect(new Set(receivedTypes)).toEqual(new Set(["auth.hello"]));
expect(new Set(protocolHeaders)).toEqual(new Set(["openclaw-extension-relay.v2"]));
expect(protocolHeaders.join("\n")).not.toContain(PAGE_SHARE_RELAY_SECRET);
}, 60_000);
it("clears an invalid persisted pairing before reconnecting after restart", async () => {
const relay = await createRelayHarness();
const relay = await createRelayHarness(PAGE_SHARE_RELAY_SECRET);
cleanups.push(relay.close);
const unpackedExtension = await copyCopilotSidepanelExtension(tempDirs);
const userDataDir = tempDirs.make("openclaw-extension-persisted-auth-profile-");
@@ -168,7 +286,8 @@ describe.runIf(runE2E)("Chrome extension relay authorization", () => {
.poll(
async () =>
await launcher.evaluate(
async () => await chrome.storage.local.get(["relayUrl", "gatewayUrl", "token"]),
async () =>
await chrome.storage.local.get(["relayUrl", "gatewayUrl", "token", "authVersion"]),
),
{ timeout: 10_000 },
)
@@ -183,7 +302,7 @@ describe.runIf(runE2E)("Chrome extension relay authorization", () => {
}, 60_000);
it("enforces pairing and current tab-group consent at the extension edge", async () => {
const relay = await createRelayHarness();
const relay = await createRelayHarness(PAGE_SHARE_RELAY_SECRET);
cleanups.push(relay.close);
const fixture = createServer((_request, response) => {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
@@ -276,6 +395,7 @@ describe.runIf(runE2E)("Chrome page sharing with a real Gateway extension relay"
const delivery = new Promise<void>((resolve) => {
releaseDelivery = resolve;
});
await configureRelayCredential(PAGE_SHARE_RELAY_SECRET);
const relay = await startExtensionRelayServer({
port: 0,
token: PAGE_SHARE_RELAY_SECRET,
@@ -500,6 +620,7 @@ describe.runIf(runE2E)("Chrome page sharing with a real Gateway extension relay"
});
it("keeps a real stale-tab sharing error visible across the popup status poll", async () => {
await configureRelayCredential(PAGE_SHARE_RELAY_SECRET);
const relay = await startExtensionRelayServer({
port: 0,
token: PAGE_SHARE_RELAY_SECRET,
@@ -13,6 +13,7 @@ type PopupMessage = {
type PopupState = {
paired?: boolean;
shared?: boolean;
statusHint?: string;
failures: Partial<Record<"getStatus" | "pair" | "unpair" | "toggleShareTab", string>>;
onFailure?: (message: PopupMessage) => void;
};
@@ -39,6 +40,7 @@ async function loadPopup(params: PopupState) {
state: "on",
sharedTabCount: params.shared ? 1 : 0,
relayUrl: "ws://127.0.0.1:18797/extension",
...(params.statusHint ? { hint: params.statusHint } : {}),
};
case "prepareCopilotPanel":
return { ok: true, path: "sidepanel.html?binding=fixture" };
@@ -106,6 +108,15 @@ describe("Chrome extension popup action errors", () => {
vi.useFakeTimers();
});
it("shows persisted re-pair guidance after an unsupported pairing is cleared", async () => {
const hint =
"Stored proxy-prefixed browser relay pairing is no longer supported. Re-run openclaw browser extension pair with a Gateway URL that has no path prefix.";
await loadPopup({ paired: false, statusHint: hint, failures: {} });
expect(popupElement("statusLine").textContent).toBe(hint);
expect(popupElement("pairSection").classList.contains("hidden")).toBe(false);
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
@@ -322,7 +322,7 @@
</section>
<section id="connectedSection" class="hidden">
<p id="statusHint" class="lede hidden" style="margin: 0 2px 4px">
Relay unreachable — is the OpenClaw gateway running?
Relay unreachable — is the OpenClaw gateway running and up to date?
</p>
<button id="copilotButton" class="action primary" type="button" disabled>
Open tab copilot
+3 -1
View File
@@ -61,13 +61,15 @@ async function refresh() {
unpairButton.classList.toggle("hidden", !status.paired);
unpairNote.classList.toggle("hidden", !status.paired);
if (!status.paired) {
statusLine.textContent = actionError ?? "Not paired with a gateway";
statusLine.textContent = actionError ?? status.hint ?? "Not paired with a gateway";
return;
}
const label = STATE_LABEL[status.state] ?? STATE_LABEL.off;
statusLine.textContent =
actionError ??
`${label} · ${status.sharedTabCount} tab${status.sharedTabCount === 1 ? "" : "s"} shared`;
statusHint.textContent =
status.hint || "Relay unreachable — is the OpenClaw gateway running and up to date?";
statusHint.classList.toggle("hidden", status.state !== "error");
const tab = await activeTab();
if (tab?.id === undefined) {
@@ -1,3 +1,4 @@
import { randomBytes } from "node:crypto";
import fs from "node:fs/promises";
import { createServer } from "node:http";
import path from "node:path";
@@ -5,6 +6,11 @@ import { fileURLToPath } from "node:url";
import type { BrowserContext, CDPSession, Page } from "playwright-core";
import type { expect as VitestExpect } from "vitest";
import { WebSocketServer, type RawData } from "ws";
import {
computeRelayAuthProof,
deriveRelayAuthKeyId,
type RelayAuthProofFields,
} from "./modules/relay-auth-v2-crypto.js";
type CopilotTurnIsolationGateway = {
chatSends: Array<Record<string, unknown>>;
@@ -67,7 +73,7 @@ type RelayHarness = {
setAvailable: (available: boolean) => void;
};
export async function createRelayHarness(): Promise<RelayHarness> {
export async function createRelayHarness(token = "a".repeat(64)): Promise<RelayHarness> {
const server = createServer();
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
@@ -80,7 +86,8 @@ export async function createRelayHarness(): Promise<RelayHarness> {
const wss = new WebSocketServer({
noServer: true,
maxPayload: 1_000_000,
handleProtocols: (protocols) => protocols.values().next().value ?? false,
handleProtocols: (protocols) =>
protocols.has("openclaw-extension-relay.v2") ? "openclaw-extension-relay.v2" : false,
});
const hellos: Array<Record<string, unknown>> = [];
const pendingCommands = new Map<
@@ -90,6 +97,7 @@ export async function createRelayHarness(): Promise<RelayHarness> {
let available = true;
let connectionCount = 0;
let nextCommandSeq = 0;
const authenticated = new Set<import("ws").WebSocket>();
server.on("upgrade", (request, socket, head) => {
if (!available) {
socket.destroy();
@@ -100,10 +108,87 @@ export async function createRelayHarness(): Promise<RelayHarness> {
});
});
wss.on("connection", (socket) => {
connectionCount += 1;
socket.on("message", (data) => {
let authState:
| { kind: "hello" }
| {
kind: "response";
fields: RelayAuthProofFields;
clientProof?: string;
}
| { kind: "authenticated" } = { kind: "hello" };
const handleMessage = async (data: RawData) => {
const message = JSON.parse(rawDataText(data)) as Record<string, unknown>;
if (authState.kind === "hello") {
if (
message.type !== "auth.hello" ||
message.v !== 2 ||
typeof message.keyId !== "string" ||
typeof message.clientNonce !== "string"
) {
socket.close(4001, "expected auth.hello");
return;
}
const keyId = await deriveRelayAuthKeyId(token);
if (message.keyId !== keyId) {
socket.close(4001, "keyId mismatch");
return;
}
const issuedAtMs = Date.now();
const fields: RelayAuthProofFields = {
keyId,
instanceId: randomBytes(16).toString("base64url"),
sessionId: randomBytes(16).toString("base64url"),
clientNonce: message.clientNonce,
serverNonce: randomBytes(32).toString("base64url"),
issuedAtMs,
expiresAtMs: issuedAtMs + 10_000,
role: "extension",
transport: "websocket",
method: "GET",
resource: "/extension",
flow: "extension",
};
authState = { kind: "response", fields };
socket.send(
JSON.stringify({
type: "auth.challenge",
v: 2,
...fields,
serverProof: await computeRelayAuthProof(token, "server", fields),
}),
);
return;
}
if (authState.kind === "response") {
if (
message.type !== "auth.response" ||
message.v !== 2 ||
message.sessionId !== authState.fields.sessionId ||
typeof message.clientProof !== "string"
) {
socket.close(4001, "expected auth.response");
return;
}
const fields = authState.fields;
const expectedClientProof = await computeRelayAuthProof(token, "client", fields);
if (message.clientProof !== expectedClientProof) {
socket.close(4001, "clientProof mismatch");
return;
}
authState = { kind: "authenticated" };
authenticated.add(socket);
socket.send(
JSON.stringify({
type: "auth.ok",
v: 2,
sessionId: fields.sessionId,
acceptProof: await computeRelayAuthProof(token, "accept", fields, message.clientProof),
}),
);
return;
}
if (message.type === "hello") {
connectionCount += 1;
hellos.push(message);
return;
}
@@ -121,7 +206,11 @@ export async function createRelayHarness(): Promise<RelayHarness> {
} else {
pending.resolve(message.result);
}
};
socket.on("message", (data) => {
void handleMessage(data);
});
socket.on("close", () => authenticated.delete(socket));
});
return {
get connectionCount() {
@@ -130,7 +219,7 @@ export async function createRelayHarness(): Promise<RelayHarness> {
hellos,
port: address.port,
command: async (body) => {
const client = [...wss.clients].find((candidate) => candidate.readyState === 1);
const client = [...authenticated].find((candidate) => candidate.readyState === 1);
if (!client) {
throw new Error("extension relay client is not connected");
}
+21 -6
View File
@@ -191,17 +191,32 @@ describe("browser config", () => {
expect(() => resolveBrowserConfig({ profiles })).toThrow(/extension.*relay.*port/i);
});
it("embeds the host-local relay secret as Basic auth in the extension cdpUrl", () => {
const token = "a".repeat(64);
writeRelaySecret(token);
it("keeps the host-local relay key out of the extension cdpUrl", () => {
const relayKey = "a".repeat(64);
writeRelaySecret(relayKey);
const resolved = resolveBrowserConfig(undefined);
expect(resolved.extensionRelayToken).toBe(token);
expect(resolved.extensionRelayToken).toBe(relayKey);
const chrome = resolveProfile(resolved, "chrome");
expect(chrome?.cdpUrl).toBe(
`http://openclaw:${token}@127.0.0.1:${resolved.extensionRelayDefaultPort}`,
expect(chrome?.cdpUrl).toBe(`http://127.0.0.1:${resolved.extensionRelayDefaultPort}`);
resolved.extensionRelayInternalTokens.chrome = "process-only-token";
expect(resolveProfile(resolved, "chrome")?.cdpUrl).toBe(
[
"http://openclaw-internal:",
"process-only-token",
`@127.0.0.1:${resolved.extensionRelayDefaultPort}`,
].join(""),
);
});
it("allows legacy extension relay auth for one migration window by default", () => {
expect(resolveBrowserConfig(undefined).extensionRelay.allowLegacyAuth).toBe(true);
expect(
resolveBrowserConfig({ extensionRelay: { allowLegacyAuth: false } }).extensionRelay
.allowLegacyAuth,
).toBe(false);
});
it("derives default ports from OPENCLAW_GATEWAY_PORT when unset", () => {
withEnv({ OPENCLAW_GATEWAY_PORT: "19001" }, () => {
const resolved = resolveBrowserConfig(undefined);
+17 -7
View File
@@ -91,7 +91,13 @@ export type ResolvedBrowserConfig = {
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). */
/** Extension relay authentication compatibility policy. */
extensionRelay: {
allowLegacyAuth: boolean;
};
/** Per-profile process-only Basic credentials for internal browser clients. */
extensionRelayInternalTokens: Record<string, string>;
/** Persistent relay HMAC key (absent until pairing or relay startup creates it). */
extensionRelayToken?: string;
};
@@ -138,8 +144,8 @@ const DEFAULT_BROWSER_REMOTE_CDP_HANDSHAKE_TIMEOUT_MS = 3_000;
* 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";
/** Username half of the process-only internal relay credential. */
const EXTENSION_RELAY_CDP_USER = "openclaw-internal";
/** Environment variable that overrides managed Chrome headless mode. */
const BROWSER_HEADLESS_ENV_KEY = "OPENCLAW_BROWSER_HEADLESS";
@@ -411,7 +417,7 @@ 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
// Host-local HMAC key (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;
@@ -478,6 +484,10 @@ export function resolveBrowserConfig(
profiles,
controlPort + EXTENSION_RELAY_PORT_OFFSET,
),
extensionRelay: {
allowLegacyAuth: cfg?.extensionRelay?.allowLegacyAuth ?? true,
},
extensionRelayInternalTokens: {},
...(extensionRelayToken ? { extensionRelayToken } : {}),
};
}
@@ -514,9 +524,9 @@ export function resolveProfile(
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 token = resolved.extensionRelayInternalTokens[profileName];
// Internal browser clients use a process-only credential. The persistent
// relay key is reserved for HMAC proofs and never enters a URL or header.
const relayCdpUrl = token
? `http://${EXTENSION_RELAY_CDP_USER}:${encodeURIComponent(token)}@127.0.0.1:${relayPort}`
: `http://127.0.0.1:${relayPort}`;
@@ -0,0 +1,141 @@
import crypto from "node:crypto";
export const BROWSER_RELAY_AUTH_LABEL = "openclaw.browser-relay.auth" as const;
export const BROWSER_RELAY_AUTH_VERSION = 2 as const;
const KEY_HEX_PATTERN = /^[0-9a-f]{64}$/u;
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/u;
type BrowserRelayProofKind = "server" | "client" | "accept";
type BrowserRelayRole = "extension" | "cdp";
type BrowserRelayTransport = "websocket" | "connection";
type BrowserRelayMethod = "GET" | "SEQUENCE";
type BrowserRelayFlow = "extension" | "cdp" | "json-list";
export type BrowserRelayProofFields = {
keyId: string;
instanceId: string;
sessionId: string;
clientNonce: string;
serverNonce: string;
issuedAtMs: number;
expiresAtMs: number;
role: BrowserRelayRole;
transport: BrowserRelayTransport;
method: BrowserRelayMethod;
resource: string;
flow: BrowserRelayFlow;
};
export type BrowserRelayAuthChallenge = BrowserRelayProofFields & {
type: "auth.challenge";
v: 2;
serverProof: string;
};
export type BrowserRelayAuthOk = {
type: "auth.ok";
v: 2;
sessionId: string;
acceptProof: string;
};
function decodeRelayKey(keyHex: string): Buffer {
if (!KEY_HEX_PATTERN.test(keyHex)) {
throw new Error("browser relay key must be 32 lowercase-hex bytes");
}
return Buffer.from(keyHex, "hex");
}
export function isCanonicalBase64UrlBytes(value: unknown, bytes: number): value is string {
if (typeof value !== "string" || !BASE64URL_PATTERN.test(value)) {
return false;
}
try {
const decoded = Buffer.from(value, "base64url");
return decoded.length === bytes && decoded.toString("base64url") === value;
} catch {
return false;
}
}
export function isBase64UrlText(value: string): boolean {
return BASE64URL_PATTERN.test(value);
}
export function relayKeyIdFromHex(keyHex: string): string {
return crypto
.createHash("sha256")
.update(decodeRelayKey(keyHex))
.digest("base64url")
.slice(0, 22);
}
export function randomRelayNonce(): string {
return crypto.randomBytes(32).toString("base64url");
}
export function randomRelayId(): string {
return crypto.randomBytes(16).toString("base64url");
}
function canonicalRelayProofBytes(
proofKind: BrowserRelayProofKind,
fields: BrowserRelayProofFields,
clientProof?: string,
): Buffer {
const values: unknown[] = [
BROWSER_RELAY_AUTH_LABEL,
BROWSER_RELAY_AUTH_VERSION,
proofKind,
fields.keyId,
fields.instanceId,
fields.sessionId,
fields.clientNonce,
fields.serverNonce,
fields.issuedAtMs,
fields.expiresAtMs,
fields.role,
fields.transport,
fields.method,
fields.resource,
fields.flow,
];
if (proofKind === "accept") {
if (!isCanonicalBase64UrlBytes(clientProof, 32)) {
throw new Error("accept proof requires a 32-byte client proof");
}
values.push(clientProof);
}
return Buffer.from(JSON.stringify(values), "utf8");
}
export function createRelayProof(
keyHex: string,
proofKind: BrowserRelayProofKind,
fields: BrowserRelayProofFields,
clientProof?: string,
): string {
return crypto
.createHmac("sha256", decodeRelayKey(keyHex))
.update(canonicalRelayProofBytes(proofKind, fields, clientProof))
.digest("base64url");
}
export function verifyRelayProof(
keyHex: string,
proofKind: BrowserRelayProofKind,
fields: BrowserRelayProofFields,
candidate: unknown,
clientProof?: string,
): boolean {
if (!isCanonicalBase64UrlBytes(candidate, 32)) {
return false;
}
const expected = Buffer.from(
createRelayProof(keyHex, proofKind, fields, clientProof),
"base64url",
);
const actual = Buffer.from(candidate, "base64url");
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
}
@@ -0,0 +1,341 @@
import { describe, expect, it, vi } from "vitest";
import {
createRelayProof,
relayKeyIdFromHex,
verifyRelayProof,
type BrowserRelayProofFields,
} from "./auth-v2-crypto.js";
import {
BrowserRelayAuthV2Authority,
getBrowserRelayAuthV2Authority,
invalidateBrowserRelayAuthV2Authority,
parseExtensionRelayResource,
parseRelayAuthHello,
parseRelayAuthResponse,
parseStrictJsonObject,
} from "./auth-v2.js";
const KEY = Array.from({ length: 32 }, (_, index) => index.toString(16).padStart(2, "0")).join("");
const VECTOR_FIELDS: BrowserRelayProofFields = {
keyId: "Yw3NKWbEM2aRElRIu7JbT_",
instanceId: "EREREREREREREREREREREQ",
sessionId: "IiIiIiIiIiIiIiIiIiIiIg",
clientNonce: "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM",
serverNonce: "REREREREREREREREREREREREREREREREREREREREREQ",
issuedAtMs: 1_786_123_456_000,
expiresAtMs: 1_786_123_466_000,
role: "extension",
transport: "websocket",
method: "GET",
resource: "/extension?profile=chrome",
flow: "extension",
};
const BINDING = {
role: "extension",
transport: "websocket",
method: "GET",
resource: "/extension",
flow: "extension",
} as const;
function hello(clientNonce = "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM") {
return {
type: "auth.hello" as const,
v: 2 as const,
keyId: relayKeyIdFromHex(KEY),
clientNonce,
};
}
describe("browser relay auth v2 proofs", () => {
it("matches the frozen Node/WebCrypto test vector", () => {
expect(relayKeyIdFromHex(KEY)).toBe(VECTOR_FIELDS.keyId);
expect(createRelayProof(KEY, "server", VECTOR_FIELDS)).toBe(
"ynhaAA_l2HkOGXQ8DvIWfzWwwGjDcV93aumHNe_NM-Q",
);
const clientProof = createRelayProof(KEY, "client", VECTOR_FIELDS);
expect(clientProof).toBe("Rl8TStMYlPLxJPDYwSe__mtEjgMf1C4TM-ZN6sUipZ4");
expect(createRelayProof(KEY, "accept", VECTOR_FIELDS, clientProof)).toBe(
"1R5MpHs6qnAdc0_X6vKBwj91tlRoWfNuGXaNfSD7VnI",
);
});
it.each([
["keyId", "EREREREREREREREREREREQ"],
["instanceId", "IiIiIiIiIiIiIiIiIiIiIg"],
["sessionId", "EREREREREREREREREREREQ"],
["clientNonce", "REREREREREREREREREREREREREREREREREREREREREQ"],
["serverNonce", "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM"],
["issuedAtMs", VECTOR_FIELDS.issuedAtMs + 1],
["expiresAtMs", VECTOR_FIELDS.expiresAtMs + 1],
["role", "cdp"],
["transport", "connection"],
["method", "SEQUENCE"],
["resource", "/extension"],
["flow", "cdp"],
] as const)("binds %s", (field, replacement) => {
const proof = createRelayProof(KEY, "server", VECTOR_FIELDS);
expect(verifyRelayProof(KEY, "server", { ...VECTOR_FIELDS, [field]: replacement }, proof)).toBe(
false,
);
});
it("rejects malformed or wrong-length proofs before constant-time comparison", () => {
expect(verifyRelayProof(KEY, "server", VECTOR_FIELDS, "short")).toBe(false);
expect(verifyRelayProof(KEY, "server", VECTOR_FIELDS, "!".repeat(43))).toBe(false);
});
});
describe("BrowserRelayAuthV2Authority", () => {
it("binds completion to the exact connection and consumes it atomically", () => {
const authority = new BrowserRelayAuthV2Authority(KEY);
const socket = {};
const otherSocket = {};
expect(authority.registerPendingConnection(socket, vi.fn())).toBe(true);
expect(authority.registerPendingConnection(otherSocket, vi.fn())).toBe(true);
const challenge = authority.issueChallenge(socket, hello(), BINDING, 1_000);
expect(challenge).not.toBeNull();
const fields = challenge as BrowserRelayProofFields;
const response = {
type: "auth.response",
v: 2,
sessionId: fields.sessionId,
clientProof: createRelayProof(KEY, "client", fields),
} as const;
expect(authority.completeChallenge(otherSocket, response, 1_001)).toBeNull();
expect(authority.completeChallenge(socket, response, 1_001)?.ok.type).toBe("auth.ok");
expect(authority.completeChallenge(socket, response, 1_001)).toBeNull();
expect(authority.issueChallenge(socket, hello(), BINDING, 1_002)).toBeNull();
});
it("rejects replayed hello across the same or another socket until expiry", () => {
const authority = new BrowserRelayAuthV2Authority(KEY);
const first = {};
const second = {};
authority.registerPendingConnection(first, vi.fn());
authority.registerPendingConnection(second, vi.fn());
expect(authority.issueChallenge(first, hello(), BINDING, 1_000)).not.toBeNull();
expect(authority.issueChallenge(first, hello(), BINDING, 1_001)).toBeNull();
expect(authority.issueChallenge(second, hello(), BINDING, 1_001)).toBeNull();
expect(authority.issueChallenge(second, hello(), BINDING, 11_001)).not.toBeNull();
});
it("rejects expired challenges and wrong client proofs", () => {
const authority = new BrowserRelayAuthV2Authority(KEY);
const first = {};
authority.registerPendingConnection(first, vi.fn());
const expired = authority.issueChallenge(first, hello(), BINDING, 1_000);
expect(expired).not.toBeNull();
expect(
authority.completeChallenge(
first,
{
type: "auth.response",
v: 2,
sessionId: expired!.sessionId,
clientProof: createRelayProof(KEY, "client", expired!),
},
11_001,
),
).toBeNull();
const second = {};
authority.registerPendingConnection(second, vi.fn());
const challenge = authority.issueChallenge(
second,
hello("REREREREREREREREREREREREREREREREREREREREREQ"),
BINDING,
20_000,
);
expect(challenge).not.toBeNull();
expect(
authority.completeChallenge(
second,
{
type: "auth.response",
v: 2,
sessionId: challenge!.sessionId,
clientProof: "A".repeat(43),
},
20_001,
),
).toBeNull();
});
it("invalidates pending and authenticated connections exactly once on rotation", () => {
invalidateBrowserRelayAuthV2Authority();
const pendingInvalidated = vi.fn();
const authenticatedInvalidated = vi.fn();
const pending = {};
const authenticated = {};
const first = getBrowserRelayAuthV2Authority(KEY);
first.registerPendingConnection(pending, pendingInvalidated);
first.registerAuthenticatedConnection(authenticated, authenticatedInvalidated);
expect(first.issueChallenge(pending, hello(), BINDING, 1_000)).not.toBeNull();
const rotated = getBrowserRelayAuthV2Authority("f".repeat(64));
expect(rotated).not.toBe(first);
expect(pendingInvalidated).toHaveBeenCalledOnce();
expect(authenticatedInvalidated).toHaveBeenCalledOnce();
expect(first.issueChallenge(pending, hello(), BINDING, 1_001)).toBeNull();
first.dispose();
expect(pendingInvalidated).toHaveBeenCalledOnce();
expect(authenticatedInvalidated).toHaveBeenCalledOnce();
invalidateBrowserRelayAuthV2Authority();
});
it("keeps pending admission independent from authenticated capacity", () => {
const authority = new BrowserRelayAuthV2Authority(KEY);
const pendingInvalidators = Array.from({ length: 128 }, () => vi.fn());
for (const invalidate of pendingInvalidators) {
expect(authority.registerPendingConnection({}, invalidate)).toBe(true);
}
expect(authority.registerPendingConnection({}, vi.fn())).toBe(false);
const active = {};
const activeInvalidated = vi.fn();
expect(authority.registerAuthenticatedConnection(active, activeInvalidated)).toBe(true);
expect(activeInvalidated).not.toHaveBeenCalled();
expect(pendingInvalidators.every((invalidate) => !invalidate.mock.calls.length)).toBe(true);
});
it("rejects promotion at active capacity without disturbing active connections", () => {
const authority = new BrowserRelayAuthV2Authority(KEY);
const pending = {};
const pendingInvalidated = vi.fn();
expect(authority.registerPendingConnection(pending, pendingInvalidated)).toBe(true);
const challenge = authority.issueChallenge(pending, hello(), BINDING, 1_000);
expect(challenge).not.toBeNull();
const activeInvalidators = Array.from({ length: 128 }, () => vi.fn());
const activeBindings = activeInvalidators.map(() => ({}));
for (const [index, binding] of activeBindings.entries()) {
expect(authority.registerAuthenticatedConnection(binding, activeInvalidators[index]!)).toBe(
true,
);
}
expect(
authority.completeChallenge(
pending,
{
type: "auth.response",
v: 2,
sessionId: challenge!.sessionId,
clientProof: createRelayProof(KEY, "client", challenge!),
},
1_001,
),
).toBeNull();
expect(pendingInvalidated).not.toHaveBeenCalled();
expect(activeInvalidators.every((invalidate) => !invalidate.mock.calls.length)).toBe(true);
authority.releaseConnection(activeBindings[0]!);
expect(authority.registerAuthenticatedConnection({}, vi.fn())).toBe(true);
authority.releaseConnection(pending);
expect(authority.issueChallenge(pending, hello(), BINDING, 1_002)).toBeNull();
});
it("cleans failed and expired pending proofs without affecting active connections", () => {
const authority = new BrowserRelayAuthV2Authority(KEY);
const active = {};
const activeInvalidated = vi.fn();
authority.registerAuthenticatedConnection(active, activeInvalidated);
const failed = {};
authority.registerPendingConnection(failed, vi.fn());
const failedChallenge = authority.issueChallenge(failed, hello(), BINDING, 1_000)!;
expect(
authority.completeChallenge(
failed,
{
type: "auth.response",
v: 2,
sessionId: failedChallenge.sessionId,
clientProof: "A".repeat(43),
},
1_001,
),
).toBeNull();
authority.releaseConnection(failed);
const expired = {};
authority.registerPendingConnection(expired, vi.fn());
const expiredChallenge = authority.issueChallenge(
expired,
hello("REREREREREREREREREREREREREREREREREREREREREQ"),
BINDING,
2_000,
)!;
expect(
authority.completeChallenge(
expired,
{
type: "auth.response",
v: 2,
sessionId: expiredChallenge.sessionId,
clientProof: createRelayProof(KEY, "client", expiredChallenge),
},
12_001,
),
).toBeNull();
authority.releaseConnection(expired);
expect(activeInvalidated).not.toHaveBeenCalled();
expect(authority.registerPendingConnection({}, vi.fn())).toBe(true);
});
it("fails closed at the bounded replay-cache limit", () => {
const authority = new BrowserRelayAuthV2Authority(KEY);
for (let index = 0; index < 1_024; index += 1) {
const connection = {};
authority.registerPendingConnection(connection, vi.fn());
const nonce = Buffer.alloc(32);
nonce.writeUInt32BE(index, 28);
expect(
authority.issueChallenge(connection, hello(nonce.toString("base64url")), BINDING, 1_000),
).not.toBeNull();
authority.releaseConnection(connection);
}
const overflow = {};
authority.registerPendingConnection(overflow, vi.fn());
expect(
authority.issueChallenge(
overflow,
hello(Buffer.alloc(32, 0xff).toString("base64url")),
BINDING,
1_000,
),
).toBeNull();
});
});
describe("browser relay auth v2 wire validation", () => {
it("accepts only exact hello and response shapes", () => {
expect(parseRelayAuthHello(hello())).toEqual(hello());
expect(parseRelayAuthHello({ ...hello(), extra: true })).toBeNull();
expect(parseRelayAuthHello({ ...hello(), v: 1 })).toBeNull();
expect(
parseRelayAuthResponse({
type: "auth.response",
v: 2,
sessionId: "EREREREREREREREREREREQ",
clientProof: "A".repeat(43),
}),
).not.toBeNull();
});
it("canonicalizes only the exact path and optional profile query", () => {
expect(parseExtensionRelayResource("/extension", "/extension")).toBe("/extension");
expect(parseExtensionRelayResource("/extension?profile=chrome", "/extension")).toBe(
"/extension?profile=chrome",
);
expect(parseExtensionRelayResource("/extension?x=1", "/extension")).toBeNull();
expect(parseExtensionRelayResource("/extension?profile=a&profile=b", "/extension")).toBeNull();
expect(parseExtensionRelayResource("/other", "/extension")).toBeNull();
});
it("detects duplicate security fields before JSON parsing", () => {
expect(parseStrictJsonObject('{"v":2,"v":1}')).toBeNull();
expect(parseStrictJsonObject('{"outer":{"v":2,"v":1}}')).toBeNull();
expect(parseStrictJsonObject('{"a":1,"nested":{"a":2}}')).not.toBeNull();
});
});
@@ -0,0 +1,446 @@
import {
BROWSER_RELAY_AUTH_VERSION,
createRelayProof,
isBase64UrlText,
isCanonicalBase64UrlBytes,
randomRelayId,
randomRelayNonce,
relayKeyIdFromHex,
verifyRelayProof,
type BrowserRelayAuthChallenge,
type BrowserRelayAuthOk,
type BrowserRelayProofFields,
} from "./auth-v2-crypto.js";
export const BROWSER_RELAY_EXTENSION_SUBPROTOCOL = "openclaw-extension-relay.v2";
export const BROWSER_RELAY_AUTH_CHALLENGE_PATH = "/_openclaw/relay/auth/v2/challenge";
export const BROWSER_RELAY_AUTH_COMPLETE_PATH = "/_openclaw/relay/auth/v2/complete";
export const BROWSER_RELAY_CHALLENGE_TTL_MS = 10_000;
const MAX_PENDING_AUTH_CONNECTIONS = 128;
const MAX_AUTHENTICATED_CONNECTIONS = 128;
const MAX_REPLAY_ENTRIES = 1_024;
type BrowserRelayAuthHello = {
type: "auth.hello";
v: 2;
keyId: string;
clientNonce: string;
};
type BrowserRelayAuthResponse = {
type: "auth.response";
v: 2;
sessionId: string;
clientProof: string;
};
type BrowserRelayHttpChallengeRequest = {
v: 2;
keyId: string;
clientNonce: string;
role: "cdp";
transport: "connection";
method: "SEQUENCE" | "GET";
resource: "/json/version -> /cdp" | "/json/list";
flow: "cdp" | "json-list";
};
type BrowserRelayHttpCompleteRequest = {
v: 2;
sessionId: string;
clientProof: string;
};
type BrowserRelayBinding = Pick<
BrowserRelayProofFields,
"role" | "transport" | "method" | "resource" | "flow"
>;
type ChallengeState = {
binding: object;
fields: BrowserRelayProofFields;
};
function hasExactKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
const actual = Object.keys(value).toSorted();
const expected = [...keys].toSorted();
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
}
export function parseRelayAuthHello(value: unknown): BrowserRelayAuthHello | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
if (
!hasExactKeys(record, ["type", "v", "keyId", "clientNonce"]) ||
record.type !== "auth.hello" ||
record.v !== BROWSER_RELAY_AUTH_VERSION ||
typeof record.keyId !== "string" ||
record.keyId.length !== 22 ||
!isBase64UrlText(record.keyId) ||
!isCanonicalBase64UrlBytes(record.clientNonce, 32)
) {
return null;
}
return record as BrowserRelayAuthHello;
}
export function parseRelayAuthResponse(value: unknown): BrowserRelayAuthResponse | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
if (
!hasExactKeys(record, ["type", "v", "sessionId", "clientProof"]) ||
record.type !== "auth.response" ||
record.v !== BROWSER_RELAY_AUTH_VERSION ||
!isCanonicalBase64UrlBytes(record.sessionId, 16) ||
!isCanonicalBase64UrlBytes(record.clientProof, 32)
) {
return null;
}
return record as BrowserRelayAuthResponse;
}
export function parseRelayHttpChallengeRequest(
value: unknown,
): BrowserRelayHttpChallengeRequest | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
if (
!hasExactKeys(record, [
"v",
"keyId",
"clientNonce",
"role",
"transport",
"method",
"resource",
"flow",
]) ||
record.v !== BROWSER_RELAY_AUTH_VERSION ||
typeof record.keyId !== "string" ||
record.keyId.length !== 22 ||
!isBase64UrlText(record.keyId) ||
!isCanonicalBase64UrlBytes(record.clientNonce, 32) ||
record.role !== "cdp" ||
record.transport !== "connection" ||
!(
(record.flow === "cdp" &&
record.method === "SEQUENCE" &&
record.resource === "/json/version -> /cdp") ||
(record.flow === "json-list" && record.method === "GET" && record.resource === "/json/list")
)
) {
return null;
}
return record as BrowserRelayHttpChallengeRequest;
}
export function parseRelayHttpCompleteRequest(
value: unknown,
): BrowserRelayHttpCompleteRequest | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
if (
!hasExactKeys(record, ["v", "sessionId", "clientProof"]) ||
record.v !== BROWSER_RELAY_AUTH_VERSION ||
!isCanonicalBase64UrlBytes(record.sessionId, 16) ||
!isCanonicalBase64UrlBytes(record.clientProof, 32)
) {
return null;
}
return record as BrowserRelayHttpCompleteRequest;
}
export function parseExtensionRelayResource(rawUrl: string, expectedPath: string): string | null {
let url: URL;
try {
url = new URL(rawUrl, "http://127.0.0.1");
} catch {
return null;
}
if (url.pathname !== expectedPath || url.hash) {
return null;
}
const entries = [...url.searchParams.entries()];
if (entries.some(([key]) => key !== "profile") || url.searchParams.getAll("profile").length > 1) {
return null;
}
const profile = url.searchParams.get("profile");
if (profile !== null && !/^[a-z0-9-]+$/u.test(profile)) {
return null;
}
return profile === null ? expectedPath : `${expectedPath}?profile=${encodeURIComponent(profile)}`;
}
/** Reject duplicate object keys before JSON.parse can silently keep the last value. */
function hasDuplicateJsonObjectKeys(text: string): boolean {
const stack: Array<Set<string> | null> = [];
let expectingKey = false;
let index = 0;
const skipWhitespace = () => {
while (/\s/u.test(text[index] ?? "")) {
index += 1;
}
};
while (index < text.length) {
const char = text[index];
if (char === '"') {
const start = index;
index += 1;
let escaped = false;
while (index < text.length) {
const next = text[index++];
if (escaped) {
escaped = false;
} else if (next === "\\") {
escaped = true;
} else if (next === '"') {
break;
}
}
if (expectingKey && stack.at(-1)) {
let key: unknown;
try {
key = JSON.parse(text.slice(start, index));
} catch {
return false;
}
skipWhitespace();
if (text[index] === ":" && typeof key === "string") {
const keys = stack.at(-1) as Set<string>;
if (keys.has(key)) {
return true;
}
keys.add(key);
expectingKey = false;
}
}
continue;
}
if (char === "{") {
stack.push(new Set());
expectingKey = true;
} else if (char === "[") {
stack.push(null);
expectingKey = false;
} else if (char === "}") {
stack.pop();
expectingKey = false;
} else if (char === "]") {
stack.pop();
expectingKey = false;
} else if (char === ",") {
expectingKey = stack.at(-1) instanceof Set;
}
index += 1;
}
return false;
}
export function parseStrictJsonObject(text: string): Record<string, unknown> | null {
if (hasDuplicateJsonObjectKeys(text)) {
return null;
}
try {
const parsed: unknown = JSON.parse(text);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
class BoundedReplayCache {
private readonly entries = new Map<string, number>();
reserve(key: string, expiresAtMs: number, nowMs: number): boolean {
for (const [candidate, expiry] of this.entries) {
if (expiry < nowMs) {
this.entries.delete(candidate);
}
}
if (this.entries.has(key) || this.entries.size >= MAX_REPLAY_ENTRIES) {
return false;
}
this.entries.set(key, expiresAtMs);
return true;
}
clear(): void {
this.entries.clear();
}
}
export class BrowserRelayAuthV2Authority {
readonly keyId: string;
readonly instanceId = randomRelayId();
private readonly challenges = new Map<string, ChallengeState>();
private readonly pendingConnections = new Map<object, () => void>();
private readonly authenticatedConnections = new Map<object, () => void>();
private readonly replay = new BoundedReplayCache();
private disposed = false;
constructor(private readonly keyHex: string) {
this.keyId = relayKeyIdFromHex(keyHex);
}
registerPendingConnection(binding: object, onInvalidate: () => void): boolean {
if (
this.disposed ||
this.pendingConnections.has(binding) ||
this.authenticatedConnections.has(binding) ||
this.pendingConnections.size >= MAX_PENDING_AUTH_CONNECTIONS
) {
return false;
}
this.pendingConnections.set(binding, onInvalidate);
return true;
}
registerAuthenticatedConnection(binding: object, onInvalidate: () => void): boolean {
if (
this.disposed ||
this.pendingConnections.has(binding) ||
this.authenticatedConnections.has(binding) ||
this.authenticatedConnections.size >= MAX_AUTHENTICATED_CONNECTIONS
) {
return false;
}
this.authenticatedConnections.set(binding, onInvalidate);
return true;
}
releaseConnection(binding: object): void {
this.pendingConnections.delete(binding);
this.authenticatedConnections.delete(binding);
for (const [sessionId, challenge] of this.challenges) {
if (challenge.binding === binding) {
this.challenges.delete(sessionId);
}
}
}
issueChallenge(
binding: object,
hello: BrowserRelayAuthHello,
expected: BrowserRelayBinding,
nowMs = Date.now(),
): BrowserRelayAuthChallenge | null {
if (
this.disposed ||
!this.pendingConnections.has(binding) ||
hello.keyId !== this.keyId ||
this.challenges.size >= MAX_PENDING_AUTH_CONNECTIONS
) {
return null;
}
const expiresAtMs = nowMs + BROWSER_RELAY_CHALLENGE_TTL_MS;
if (!this.replay.reserve(`${this.keyId}:${hello.clientNonce}`, expiresAtMs, nowMs)) {
return null;
}
const fields: BrowserRelayProofFields = {
keyId: this.keyId,
instanceId: this.instanceId,
sessionId: randomRelayId(),
clientNonce: hello.clientNonce,
serverNonce: randomRelayNonce(),
issuedAtMs: nowMs,
expiresAtMs,
...expected,
};
this.challenges.set(fields.sessionId, { binding, fields });
return {
type: "auth.challenge",
v: BROWSER_RELAY_AUTH_VERSION,
...fields,
serverProof: createRelayProof(this.keyHex, "server", fields),
};
}
completeChallenge(
binding: object,
response: BrowserRelayAuthResponse,
nowMs = Date.now(),
): { ok: BrowserRelayAuthOk; fields: BrowserRelayProofFields } | null {
const challenge = this.challenges.get(response.sessionId);
if (!challenge || challenge.binding !== binding || this.disposed) {
return null;
}
// The exact socket owns one attempt. Consume before proof verification so
// duplicate completion cannot race a successful verification.
this.challenges.delete(response.sessionId);
if (
nowMs > challenge.fields.expiresAtMs ||
!verifyRelayProof(this.keyHex, "client", challenge.fields, response.clientProof)
) {
return null;
}
const invalidate = this.pendingConnections.get(binding);
if (!invalidate || this.authenticatedConnections.size >= MAX_AUTHENTICATED_CONNECTIONS) {
return null;
}
// Promotion is synchronous and moves the exact binding between disjoint
// registries, so pending admission can never consume active capacity.
this.pendingConnections.delete(binding);
this.authenticatedConnections.set(binding, invalidate);
return {
fields: challenge.fields,
ok: {
type: "auth.ok",
v: BROWSER_RELAY_AUTH_VERSION,
sessionId: challenge.fields.sessionId,
acceptProof: createRelayProof(
this.keyHex,
"accept",
challenge.fields,
response.clientProof,
),
},
};
}
dispose(): void {
if (this.disposed) {
return;
}
this.disposed = true;
const invalidators = [
...this.pendingConnections.values(),
...this.authenticatedConnections.values(),
];
this.pendingConnections.clear();
this.authenticatedConnections.clear();
this.challenges.clear();
this.replay.clear();
for (const invalidate of invalidators) {
invalidate();
}
}
}
let activeAuthority: { keyHex: string; authority: BrowserRelayAuthV2Authority } | null = null;
/** One process-wide replay/session authority for the current host key. */
export function getBrowserRelayAuthV2Authority(keyHex: string): BrowserRelayAuthV2Authority {
if (activeAuthority?.keyHex === keyHex) {
return activeAuthority.authority;
}
activeAuthority?.authority.dispose();
const authority = new BrowserRelayAuthV2Authority(keyHex);
activeAuthority = { keyHex, authority };
return authority;
}
export function invalidateBrowserRelayAuthV2Authority(): void {
activeAuthority?.authority.dispose();
activeAuthority = null;
}
@@ -1,5 +1,7 @@
import { EventEmitter, once } from "node:events";
// Gateway extension relay upgrade handler: auth + routing decisions.
import type { IncomingMessage } from "node:http";
import http, { type IncomingMessage } from "node:http";
import net from "node:net";
import type { Duplex } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -21,12 +23,22 @@ vi.mock("../config.js", () => ({
resolveProfile: (...args: unknown[]) => resolveProfileMock(...args),
}));
const configState = vi.hoisted(() => ({ allowLegacyAuth: true }));
vi.mock("../../config/config.js", () => ({
getRuntimeConfig: () => ({
browser: { extensionRelay: { allowLegacyAuth: configState.allowLegacyAuth } },
}),
}));
const attachExtensionWebSocketMock = vi.fn();
const authenticateExtensionWebSocketMock = vi.fn();
vi.mock("./relay-server.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./relay-server.js")>();
return {
...actual,
attachExtensionWebSocket: (...args: unknown[]) => attachExtensionWebSocketMock(...args),
authenticateExtensionWebSocket: (...args: unknown[]) =>
authenticateExtensionWebSocketMock(...args),
};
});
@@ -39,7 +51,11 @@ vi.mock("./relay-auth.js", async (importOriginal) => {
};
});
import { handleGatewayExtensionUpgrade } from "./gateway-relay-route.js";
import { invalidateBrowserRelayAuthV2Authority } from "./auth-v2.js";
import {
disposeGatewayExtensionRelay,
handleGatewayExtensionUpgrade,
} from "./gateway-relay-route.js";
const TOKEN = "a".repeat(64);
const ROTATED_TOKEN = "b".repeat(64);
@@ -47,7 +63,7 @@ const ROTATED_TOKEN = "b".repeat(64);
function fakeSocket() {
const writes: string[] = [];
let destroyed = false;
const socket = {
const socket = Object.assign(new EventEmitter(), {
write: (chunk: string) => {
writes.push(chunk);
return true;
@@ -55,7 +71,7 @@ function fakeSocket() {
destroy: () => {
destroyed = true;
},
} as unknown as Duplex;
}) as unknown as Duplex;
return { socket, writes, isDestroyed: () => destroyed };
}
@@ -74,6 +90,10 @@ function relayReq(
});
}
function v2Req(url = "/browser/extension"): IncomingMessage {
return req(url, { "sec-websocket-protocol": "openclaw-extension-relay.v2" });
}
function stateWithExtensionProfile() {
return {
resolved: {
@@ -84,14 +104,31 @@ function stateWithExtensionProfile() {
}
beforeEach(() => {
configState.allowLegacyAuth = true;
readExtensionRelayTokenMock.mockReturnValue(TOKEN);
});
afterEach(() => {
disposeGatewayExtensionRelay();
invalidateBrowserRelayAuthV2Authority();
vi.restoreAllMocks();
vi.clearAllMocks();
});
function oversizedMaskedTextFrame(): Buffer {
const payload = Buffer.alloc(18 * 1024, 0x20);
const header = Buffer.alloc(8);
header[0] = 0x81;
header[1] = 0x80 | 126;
header.writeUInt16BE(payload.length, 2);
const mask = Buffer.from([0x12, 0x34, 0x56, 0x78]);
mask.copy(header, 4);
for (let index = 0; index < payload.length; index += 1) {
payload[index] = payload[index]! ^ mask[index % 4]!;
}
return Buffer.concat([header, payload]);
}
// Default: the requested profile resolves to a valid extension profile.
function primeProfile() {
resolveProfileMock.mockReturnValue({ name: "chrome", driver: "extension" });
@@ -99,11 +136,18 @@ function primeProfile() {
async function mockSuccessfulUpgrade() {
const wsMod = await import("ws");
const ws = Object.assign(new EventEmitter(), {
readyState: 1,
close: vi.fn(),
terminate: vi.fn(),
send: vi.fn(),
});
vi.spyOn(wsMod.WebSocketServer.prototype, "handleUpgrade").mockImplementation(
(_req, _socket, _head, cb) => {
(cb as (ws: unknown) => void)({ readyState: 1 });
(cb as (socket: unknown) => void)(ws);
},
);
return ws;
}
describe("handleGatewayExtensionUpgrade", () => {
@@ -173,7 +217,7 @@ describe("handleGatewayExtensionUpgrade", () => {
socket,
Buffer.alloc(0),
);
expect(writes.join("")).toContain("401");
expect(writes.join("")).toContain("400");
expect(ensureExtensionRelayForProfileMock).not.toHaveBeenCalled();
});
@@ -196,7 +240,135 @@ describe("handleGatewayExtensionUpgrade", () => {
expect(handled).toBe(true);
expect(readExtensionRelayTokenMock).toHaveBeenCalledOnce();
expect(startBrowserControlServiceFromConfigMock).toHaveBeenCalledOnce();
expect(attachExtensionWebSocketMock).toHaveBeenCalledWith(bridge, { readyState: 1 });
expect(attachExtensionWebSocketMock).toHaveBeenCalledWith(
bridge,
expect.objectContaining({ readyState: 1 }),
);
});
it("does not lazy-start or attach v2 before the in-band client proof succeeds", async () => {
getBrowserControlStateMock.mockReturnValue(null);
startBrowserControlServiceFromConfigMock.mockResolvedValue(stateWithExtensionProfile());
primeProfile();
const bridge = { id: "v2-bridge" };
ensureExtensionRelayForProfileMock.mockResolvedValue({ bridge });
await mockSuccessfulUpgrade();
const handled = await handleGatewayExtensionUpgrade(
v2Req(),
fakeSocket().socket,
Buffer.alloc(0),
);
expect(handled).toBe(true);
expect(authenticateExtensionWebSocketMock).toHaveBeenCalledOnce();
expect(startBrowserControlServiceFromConfigMock).not.toHaveBeenCalled();
expect(ensureExtensionRelayForProfileMock).not.toHaveBeenCalled();
expect(attachExtensionWebSocketMock).not.toHaveBeenCalled();
const authParams = authenticateExtensionWebSocketMock.mock.calls[0]?.[0] as {
prepareAuthenticated: () => Promise<() => void>;
resource: string;
};
expect(authParams.resource).toBe("/browser/extension");
const attach = await authParams.prepareAuthenticated();
expect(startBrowserControlServiceFromConfigMock).toHaveBeenCalledOnce();
expect(ensureExtensionRelayForProfileMock).toHaveBeenCalledOnce();
expect(attachExtensionWebSocketMock).not.toHaveBeenCalled();
attach();
expect(attachExtensionWebSocketMock).toHaveBeenCalledWith(
bridge,
expect.objectContaining({ readyState: 1 }),
);
});
it("rejects oversized direct-Gateway upgrade-head data before ws auth or lazy startup", async () => {
const server = http.createServer();
server.on("upgrade", (request, socket, head) => {
void handleGatewayExtensionUpgrade(request, socket, head);
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("expected direct-Gateway test port");
}
const socket = net.createConnection({ host: "127.0.0.1", port: address.port });
socket.on("error", () => {});
await once(socket, "connect");
const response: Buffer[] = [];
socket.on("data", (chunk) => response.push(Buffer.from(chunk)));
const closed = once(socket, "close");
const request = Buffer.from(
[
"GET /browser/extension HTTP/1.1",
"Host: 127.0.0.1",
"Connection: Upgrade",
"Upgrade: websocket",
"Sec-WebSocket-Version: 13",
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
"Sec-WebSocket-Protocol: openclaw-extension-relay.v2",
"Origin: chrome-extension://relay-auth-v2-test",
"",
"",
].join("\r\n"),
);
socket.write(Buffer.concat([request, oversizedMaskedTextFrame()]));
await closed;
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
const text = Buffer.concat(response).toString("utf8");
expect(text).not.toContain("auth.challenge");
expect(text).not.toContain("auth.ok");
expect(authenticateExtensionWebSocketMock).not.toHaveBeenCalled();
expect(startBrowserControlServiceFromConfigMock).not.toHaveBeenCalled();
expect(ensureExtensionRelayForProfileMock).not.toHaveBeenCalled();
expect(attachExtensionWebSocketMock).not.toHaveBeenCalled();
});
it("binds v2 to the exact profile resource and refuses mixed-protocol downgrade", async () => {
await mockSuccessfulUpgrade();
const valid = fakeSocket();
await handleGatewayExtensionUpgrade(
v2Req("/browser/extension?profile=chrome"),
valid.socket,
Buffer.alloc(0),
);
expect(authenticateExtensionWebSocketMock.mock.calls[0]?.[0]).toMatchObject({
resource: "/browser/extension?profile=chrome",
});
const duplicate = fakeSocket();
await handleGatewayExtensionUpgrade(
v2Req("/browser/extension?profile=chrome&profile=other"),
duplicate.socket,
Buffer.alloc(0),
);
expect(duplicate.writes.join("")).toContain("400");
const mixed = fakeSocket();
await handleGatewayExtensionUpgrade(
req("/browser/extension", {
"sec-websocket-protocol": `openclaw-extension-relay.v2, openclaw-extension-relay, openclaw-extension-token.${TOKEN}`,
}),
mixed.socket,
Buffer.alloc(0),
);
expect(mixed.writes.join("")).toContain("400");
});
it("accepts legacy only while the explicit migration gate is enabled", async () => {
configState.allowLegacyAuth = false;
const denied = fakeSocket();
await handleGatewayExtensionUpgrade(
relayReq("/browser/extension"),
denied.socket,
Buffer.alloc(0),
);
expect(denied.writes.join("")).toContain("401");
expect(getBrowserControlStateMock).not.toHaveBeenCalled();
});
it("attaches the socket to the bridge on a valid token", async () => {
@@ -214,7 +386,10 @@ describe("handleGatewayExtensionUpgrade", () => {
);
expect(handled).toBe(true);
expect(ensureExtensionRelayForProfileMock).toHaveBeenCalledOnce();
expect(attachExtensionWebSocketMock).toHaveBeenCalledWith(bridge, { readyState: 1 });
expect(attachExtensionWebSocketMock).toHaveBeenCalledWith(
bridge,
expect.objectContaining({ readyState: 1 }),
);
});
it("authenticates against the live relay secret when Browser state is stale", async () => {
@@ -243,6 +418,9 @@ describe("handleGatewayExtensionUpgrade", () => {
expect(handled).toBe(true);
expect(ensureExtensionRelayForProfileMock).toHaveBeenCalledOnce();
expect(attachExtensionWebSocketMock).toHaveBeenCalledWith(bridge, { readyState: 1 });
expect(attachExtensionWebSocketMock).toHaveBeenCalledWith(
bridge,
expect.objectContaining({ readyState: 1 }),
);
});
});
@@ -1,44 +1,39 @@
/**
* Gateway-hosted extension relay upgrade handler.
*
* Lets the OpenClaw Chrome extension connect DIRECTLY to a remote gateway over
* `wss://` no OpenClaw node host on the browser machine. This is the
* cross-machine path for #53599: a user installs only the extension and pastes
* a `wss://gateway/browser/extension#<secret>` pairing string.
*
* The gateway route is registered with `auth: "plugin"` and no nodeCapability,
* so the gateway does NOT pre-enforce gateway-token auth (browser WebSockets
* cannot send an Authorization header anyway). This handler self-validates the
* host-local relay secret from the WebSocket subprotocol list, then attaches
* the socket to the same ExtensionRelayBridge the loopback relay uses so all
* CDP synthesis, tab-group scoping, and the in-process Playwright /cdp client
* are unchanged.
*/
/** Direct Gateway extension relay with in-band Browser Relay Authentication v2. */
import type { IncomingMessage } from "node:http";
import type { Duplex } from "node:stream";
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
import { WebSocketServer } from "ws";
import { getRuntimeConfig } from "../../config/config.js";
import {
getBrowserControlState,
startBrowserControlServiceFromConfig,
} from "../../control-service.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { resolveProfile } from "../config.js";
import {
BROWSER_RELAY_EXTENSION_SUBPROTOCOL,
getBrowserRelayAuthV2Authority,
invalidateBrowserRelayAuthV2Authority,
parseExtensionRelayResource,
} from "./auth-v2.js";
import { handlePreAuthWebSocketUpgrade } from "./preauth-websocket-guard.js";
import { readExtensionRelayToken } from "./relay-auth.js";
import { ensureExtensionRelayForProfile } from "./relay-lifecycle.js";
import {
attachExtensionWebSocket,
EXTENSION_RELAY_MAX_PAYLOAD_BYTES,
isAllowedExtensionOrigin,
LEGACY_EXTENSION_RELAY_PROTOCOL,
requestExtensionProtocolToken,
requestProtocols,
} from "./relay-request.js";
import {
attachExtensionWebSocket,
authenticateExtensionWebSocket,
EXTENSION_RELAY_MAX_PAYLOAD_BYTES,
} from "./relay-server.js";
const log = createSubsystemLogger("browser").child("extension-relay-gateway");
/** Path the browser plugin registers on the gateway (ends in /extension so the pairing parser accepts it). */
const GATEWAY_EXTENSION_RELAY_PATH = "/browser/extension";
// Single noServer WebSocketServer for all gateway-hosted extension upgrades.
let wss: WebSocketServer | null = null;
function getWss(): WebSocketServer {
wss ??= new WebSocketServer({ noServer: true, maxPayload: EXTENSION_RELAY_MAX_PAYLOAD_BYTES });
@@ -48,22 +43,15 @@ function getWss(): WebSocketServer {
function destroy(socket: Duplex, statusLine: string): void {
try {
socket.write(`HTTP/1.1 ${statusLine}\r\nConnection: close\r\n\r\n`);
} finally {
socket.destroy();
} catch {
// socket already gone
}
}
function requestedProfileName(req: IncomingMessage, fallback: string): string {
try {
const value = new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("profile");
return value?.trim() || fallback;
} catch {
return fallback;
}
function requestedProfileName(resource: string, fallback: string): string {
return new URL(resource, "http://127.0.0.1").searchParams.get("profile") ?? fallback;
}
/** First extension-driver profile name, defaulting to the built-in `chrome`. */
function defaultExtensionProfileName(profiles: Record<string, { driver?: string }>): string {
for (const [name, profile] of Object.entries(profiles)) {
if (profile.driver === "extension") {
@@ -73,77 +61,138 @@ function defaultExtensionProfileName(profiles: Record<string, { driver?: string
return "chrome";
}
/**
* Handle a gateway upgrade for the extension relay path. Returns true when the
* request was claimed (handled or rejected), false to let the gateway continue.
*/
async function resolveGatewayBridge(resource: string) {
let state = getBrowserControlState();
if (!state) {
state = await startBrowserControlServiceFromConfig();
if (!state) {
throw new Error("Browser control is disabled");
}
}
const profileName = requestedProfileName(
resource,
defaultExtensionProfileName(state.resolved.profiles),
);
const resolved = resolveProfile(state.resolved, profileName);
if (!resolved || resolved.driver !== "extension") {
throw new Error(`Extension browser profile "${profileName}" was not found`);
}
return {
bridge: (await ensureExtensionRelayForProfile(state, resolved)).bridge,
profileName,
};
}
/** Handle the plugin-owned Gateway upgrade path. */
export async function handleGatewayExtensionUpgrade(
req: IncomingMessage,
socket: Duplex,
head: Buffer,
): Promise<boolean> {
const path = (req.url ?? "/").split("?")[0];
if (path !== GATEWAY_EXTENSION_RELAY_PATH) {
return false;
const resource = parseExtensionRelayResource(req.url ?? "/", GATEWAY_EXTENSION_RELAY_PATH);
if (!resource) {
return (req.url ?? "/").split("?")[0] === GATEWAY_EXTENSION_RELAY_PATH
? (destroy(socket, "400 Bad Request"), true)
: false;
}
// chrome-extension:// origin hygiene (not a security boundary on its own —
// the relay secret is the gate — but rejects obvious cross-site sockets).
if (!isAllowedExtensionOrigin(req)) {
destroy(socket, "403 Forbidden");
return true;
}
// Authenticate before lazy-starting Browser control. A valid pairing secret
// may start the service; an arbitrary public WebSocket request may not.
let state = getBrowserControlState();
const expectedToken = readExtensionRelayToken();
const candidate = requestExtensionProtocolToken(req);
if (!expectedToken || candidate.length === 0 || !safeEqualSecret(expectedToken, candidate)) {
const protocols = requestProtocols(req);
const token = readExtensionRelayToken();
if (!token) {
invalidateBrowserRelayAuthV2Authority();
destroy(socket, "401 Unauthorized");
return true;
}
if (!state) {
try {
state = await startBrowserControlServiceFromConfig();
} catch (err) {
log.warn(`failed to start Browser control for extension relay: ${String(err)}`);
if (protocols.length === 1 && protocols[0] === BROWSER_RELAY_EXTENSION_SUBPROTOCOL) {
const authority = getBrowserRelayAuthV2Authority(token);
if (
!handlePreAuthWebSocketUpgrade({
wss: getWss(),
req,
socket,
head,
onUpgrade: (ws, removePreAuthGuard) => {
authenticateExtensionWebSocket({
ws,
authority,
resource,
removePreAuthGuard,
prepareAuthenticated: async () => {
// The proof may finish while an operator rotates the host key. Never
// let an old authenticated socket lazy-start or claim a new bridge.
if (readExtensionRelayToken() !== token) {
throw new Error("browser relay key rotated during authentication");
}
const { bridge, profileName } = await resolveGatewayBridge(resource);
return () => {
attachExtensionWebSocket(bridge, ws);
log.info(`extension authenticated over gateway for profile "${profileName}"`);
};
},
});
},
})
) {
destroy(socket, "400 Bad Request");
}
if (!state) {
destroy(socket, "503 Service Unavailable");
return true;
}
}
const profileName = requestedProfileName(
req,
defaultExtensionProfileName(state.resolved.profiles),
);
const resolved = resolveProfile(state.resolved, profileName);
if (!resolved || resolved.driver !== "extension") {
destroy(socket, "404 Not Found");
return true;
}
let bridge;
if (protocols.includes(BROWSER_RELAY_EXTENSION_SUBPROTOCOL)) {
destroy(socket, "400 Bad Request");
return true;
}
const config = getRuntimeConfig();
const allowLegacyAuth = config.browser?.extensionRelay?.allowLegacyAuth !== false;
const legacyToken = requestExtensionProtocolToken(req);
if (
!allowLegacyAuth ||
!protocols.includes(LEGACY_EXTENSION_RELAY_PROTOCOL) ||
legacyToken.length === 0 ||
!safeEqualSecret(token, legacyToken)
) {
destroy(socket, "401 Unauthorized");
return true;
}
let resolved;
try {
bridge = (await ensureExtensionRelayForProfile(state, resolved)).bridge;
resolved = await resolveGatewayBridge(resource);
} catch (err) {
log.warn(`failed to start relay for profile "${profileName}": ${String(err)}`);
log.warn(`failed to start Browser control for legacy extension relay: ${String(err)}`);
destroy(socket, "503 Service Unavailable");
return true;
}
const authority = getBrowserRelayAuthV2Authority(token);
getWss().handleUpgrade(req, socket, head, (ws) => {
attachExtensionWebSocket(bridge, ws);
log.info(`extension connected over gateway for profile "${profileName}"`);
if (
!authority.registerAuthenticatedConnection(ws, () =>
ws.close(4003, "browser relay key rotated"),
)
) {
ws.terminate();
return;
}
ws.once("close", () => authority.releaseConnection(ws));
attachExtensionWebSocket(resolved.bridge, ws);
log.warn(`legacy extension authentication accepted for profile "${resolved.profileName}"`);
});
return true;
}
/** Release the shared WebSocketServer (runtime shutdown / tests). */
export function disposeGatewayExtensionRelay(): void {
wss?.close();
if (!wss) {
return;
}
for (const client of wss.clients) {
client.terminate();
}
wss.close();
wss = null;
}
@@ -0,0 +1,110 @@
import type { IncomingMessage } from "node:http";
import { Duplex } from "node:stream";
import type { RawData, WebSocket, WebSocketServer } from "ws";
export const MAX_WEBSOCKET_AUTH_MESSAGE_BYTES = 16 * 1024;
// A masked 16 KiB frame needs at most 8 bytes of framing. Keep a small bounded
// allowance for a few fragments without approaching the 64 MiB app limit.
const MAX_WEBSOCKET_PREAUTH_WIRE_BYTES = MAX_WEBSOCKET_AUTH_MESSAGE_BYTES + 1024;
export function boundedRawDataByteLength(data: RawData, limit: number): number {
if (!Array.isArray(data)) {
return data.byteLength;
}
let length = 0;
for (const chunk of data) {
length += chunk.byteLength;
if (length > limit) {
break;
}
}
return length;
}
class PreAuthWebSocketTransport extends Duplex {
private guardActive = true;
private wireBytes: number;
constructor(
private readonly rawSocket: Duplex,
headBytes: number,
) {
super();
this.wireBytes = headBytes;
rawSocket.on("data", this.onRawData);
rawSocket.once("end", this.onRawEnd);
rawSocket.once("close", this.onRawClose);
rawSocket.once("error", this.onRawError);
}
removeGuard = () => {
this.guardActive = false;
};
override _read(): void {
this.rawSocket.resume();
}
override _write(
chunk: Buffer,
encoding: BufferEncoding,
callback: (error?: Error | null) => void,
): void {
this.rawSocket.write(chunk, encoding, callback);
}
override _final(callback: (error?: Error | null) => void): void {
this.rawSocket.end(callback);
}
override _destroy(error: Error | null, callback: (error?: Error | null) => void): void {
this.rawSocket.off("data", this.onRawData);
this.rawSocket.off("end", this.onRawEnd);
this.rawSocket.off("close", this.onRawClose);
this.rawSocket.off("error", this.onRawError);
if (!this.rawSocket.destroyed) {
this.rawSocket.destroy();
}
callback(error);
}
private readonly onRawData = (chunk: Buffer) => {
if (this.guardActive) {
this.wireBytes += chunk.byteLength;
if (this.wireBytes > MAX_WEBSOCKET_PREAUTH_WIRE_BYTES) {
this.destroy();
return;
}
}
if (!this.push(chunk)) {
this.rawSocket.pause();
}
};
private readonly onRawEnd = () => this.push(null);
private readonly onRawClose = () => this.destroy();
private readonly onRawError = (error: Error) => this.destroy(error);
}
/** Withhold pre-auth bytes from `ws`; successful proof makes the transport transparent. */
export function handlePreAuthWebSocketUpgrade(params: {
wss: WebSocketServer;
req: IncomingMessage;
socket: Duplex;
head: Buffer;
onUpgrade: (ws: WebSocket, removePreAuthGuard: () => void) => void;
}): boolean {
if (params.head.byteLength > MAX_WEBSOCKET_PREAUTH_WIRE_BYTES) {
return false;
}
const transport = new PreAuthWebSocketTransport(params.socket, params.head.byteLength);
try {
params.wss.handleUpgrade(params.req, transport, params.head, (ws) => {
params.onUpgrade(ws, transport.removeGuard);
});
} catch (err) {
transport.destroy();
throw err;
}
return true;
}
@@ -8,12 +8,14 @@ class FakeSocket {
readonly sent: unknown[] = [];
closed = false;
closeCode?: number;
closeReason?: string;
send(data: string): void {
this.sent.push(JSON.parse(data));
}
close(code?: number): void {
close(code?: number, reason?: string): void {
this.closed = true;
this.closeCode = code;
this.closeReason = reason;
}
/** Frames of a given method (client CDP responses/events). */
frames(): Array<Record<string, unknown>> {
@@ -574,6 +576,113 @@ describe("ExtensionRelayBridge", () => {
expect(bridge.extensionConnected).toBe(false);
});
it("keeps the active extension while a candidate is pending, malformed, or closed", () => {
const bridge = new ExtensionRelayBridge();
const active = wireExtension(bridge);
sendHello(active.handlers);
const pendingSocket = new FakeSocket();
const pending = bridge.attachExtensionSocket(pendingSocket);
expect(active.socket.closed).toBe(false);
expect(bridge.identity?.browserVersion).toBe("Chrome/144.0.0.0");
pending.onClose();
sendHello(pending);
expect(bridge.extensionConnected).toBe(true);
expect(active.socket.closed).toBe(false);
const malformedSocket = new FakeSocket();
const malformed = bridge.attachExtensionSocket(malformedSocket);
malformed.onMessage(
JSON.stringify({
type: "hello",
userAgent: "candidate",
browserVersion: "Chrome/145.0.0.0",
extensionVersion: "2.0.0",
}),
);
expect(malformedSocket).toMatchObject({
closed: true,
closeCode: 4001,
closeReason: "expected valid hello",
});
expect(bridge.identity?.browserVersion).toBe("Chrome/144.0.0.0");
expect(active.socket.closed).toBe(false);
});
it("replaces the active extension only after the candidate sends a valid hello", () => {
const bridge = new ExtensionRelayBridge();
const active = wireExtension(bridge);
sendHello(active.handlers);
const candidateSocket = new FakeSocket();
const candidate = bridge.attachExtensionSocket(candidateSocket);
sendHello(candidate, [
{ tabId: 2, url: "https://candidate.example", title: "Candidate", active: true },
]);
expect(active.socket).toMatchObject({
closed: true,
closeCode: 4000,
closeReason: "replaced by newer extension connection",
});
expect(bridge.identity?.browserVersion).toBe("Chrome/144.0.0.0");
expect(bridge.sharedTabs()).toEqual([
{ tabId: 2, url: "https://candidate.example", title: "Candidate", active: true },
]);
active.handlers.onClose();
expect(bridge.extensionConnected).toBe(true);
expect(bridge.sharedTabs()).toHaveLength(1);
});
it("rejects an older candidate when a newer candidate promotes first", () => {
const bridge = new ExtensionRelayBridge();
const active = wireExtension(bridge);
sendHello(active.handlers);
const firstSocket = new FakeSocket();
const first = bridge.attachExtensionSocket(firstSocket);
const secondSocket = new FakeSocket();
const second = bridge.attachExtensionSocket(secondSocket);
expect(active.socket.closed).toBe(false);
second.onMessage(
JSON.stringify({
type: "hello",
userAgent: "second",
browserVersion: "Chrome/146.0.0.0",
extensionVersion: "2.0.0",
tabs: [],
}),
);
expect(active.socket.closed).toBe(true);
expect(firstSocket.closed).toBe(false);
expect(secondSocket.closed).toBe(false);
expect(bridge.identity?.browserVersion).toBe("Chrome/146.0.0.0");
first.onMessage(
JSON.stringify({
type: "hello",
userAgent: "first",
browserVersion: "Chrome/145.0.0.0",
extensionVersion: "2.0.0",
tabs: [],
}),
);
expect(firstSocket).toMatchObject({
closed: true,
closeCode: 4000,
closeReason: "superseded by newer extension connection",
});
expect(bridge.identity?.browserVersion).toBe("Chrome/146.0.0.0");
first.onClose();
active.handlers.onClose();
expect(bridge.extensionConnected).toBe(true);
expect(secondSocket.closed).toBe(false);
});
it("answers the Puppeteer connect bootstrap without protocol errors", async () => {
// The exact browser-scoped sequence puppeteer.connect() issues before any
// page work (chrome-devtools-mcp --browserUrl/--wsEndpoint rides this).
@@ -98,6 +98,7 @@ function toErrorPayload(
*/
export class ExtensionRelayBridge {
private extension: { socket: BridgeSocket; identity: ExtensionIdentity } | null = null;
private readonly extensionCandidates = new Set<BridgeSocket>();
private readonly clients = new Set<CdpClientState>();
private readonly tabs = new Map<number, TabState>();
/** Browser-level sessions created by Playwright for page-scoped CDP access. */
@@ -109,6 +110,8 @@ export class ExtensionRelayBridge {
private readonly pendingExtension = new Map<number, PendingExtensionCommand>();
private nextSeq = 1;
private nextSessionOrdinal = 1;
private nextExtensionCandidateOrdinal = 1;
private latestPromotedCandidateOrdinal = 0;
private pingTimer: NodeJS.Timeout | null = null;
private readonly onStateChange?: () => void;
private readonly onPageShare?: (payload: PageSharePayload) => Promise<void>;
@@ -171,26 +174,41 @@ export class ExtensionRelayBridge {
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 candidateOrdinal = this.nextExtensionCandidateOrdinal++;
let candidateState: "awaiting-hello" | "active" | "rejected" = "awaiting-hello";
this.extensionCandidates.add(socket);
const rejectCandidate = (code: number, reason: string) => {
candidateState = "rejected";
this.extensionCandidates.delete(socket);
socket.close(code, reason);
};
const onMessage = (raw: string) => {
const msg = parseExtensionMessage(raw);
if (!msg) {
log.warn("dropping malformed extension relay frame");
if (candidateState === "rejected") {
return;
}
if (!helloSeen) {
if (msg.type !== "hello") {
socket.close(4001, "expected hello");
const msg = parseExtensionMessage(raw);
if (candidateState === "awaiting-hello") {
if (msg?.type !== "hello") {
rejectCandidate(4001, "expected valid hello");
return;
}
helloSeen = true;
if (candidateOrdinal < this.latestPromotedCandidateOrdinal) {
rejectCandidate(4000, "superseded by newer extension connection");
return;
}
candidateState = "active";
this.extensionCandidates.delete(socket);
this.latestPromotedCandidateOrdinal = candidateOrdinal;
if (this.extension) {
// Authentication happens before bridge attachment. Keep the active
// socket until its replacement also proves it can speak the relay protocol.
log.info("extension reconnected; replacing previous relay connection");
const previous = this.extension;
previous.socket.close(4000, "replaced by newer extension connection");
if (this.extension === previous) {
this.handleExtensionGone();
}
}
this.extension = {
socket,
identity: {
@@ -204,9 +222,18 @@ export class ExtensionRelayBridge {
this.onStateChange?.();
return;
}
if (this.extension?.socket !== socket) {
return;
}
if (!msg) {
log.warn("dropping malformed extension relay frame");
return;
}
this.handleExtensionMessage(msg);
};
const onClose = () => {
candidateState = "rejected";
this.extensionCandidates.delete(socket);
if (this.extension?.socket === socket) {
this.handleExtensionGone();
this.onStateChange?.();
@@ -977,6 +1004,10 @@ export class ExtensionRelayBridge {
pending.reject(new Error("extension relay stopped"));
}
this.pendingExtension.clear();
for (const candidate of this.extensionCandidates) {
candidate.close(1001, "relay stopped");
}
this.extensionCandidates.clear();
this.extension?.socket.close(1001, "relay stopped");
this.extension = null;
for (const client of this.clients) {
@@ -30,6 +30,8 @@ function createState(token: string, existing?: ExtensionRelayHandle) {
extensionRelayToken: token,
extensionRelayDefaultPort: 18_799,
extensionRelayPorts: { [PROFILE_NAME]: RELAY_PORT },
extensionRelay: { allowLegacyAuth: true },
extensionRelayInternalTokens: existing ? { [PROFILE_NAME]: existing.internalToken } : {},
profiles: {
[PROFILE_NAME]: {
cdpPort: RELAY_PORT,
@@ -56,6 +58,8 @@ function createHandle(token: string, port = RELAY_PORT): ExtensionRelayHandle {
return {
port,
token,
allowLegacyAuth: true,
internalToken: `${token.slice(0, 8)}-internal`,
bridge: {} as ExtensionRelayHandle["bridge"],
close: vi.fn(async () => {}),
};
@@ -74,9 +78,11 @@ describe("extension relay lifecycle", () => {
vi.clearAllMocks();
readExtensionRelayTokenMock.mockReturnValue(ROTATED_TOKEN);
ensureExtensionRelayTokenMock.mockReturnValue(ROTATED_TOKEN);
startExtensionRelayServerMock.mockImplementation(async ({ port, token }) => ({
startExtensionRelayServerMock.mockImplementation(async ({ port, token, allowLegacyAuth }) => ({
port,
token,
allowLegacyAuth,
internalToken: "replacement-internal",
bridge: {},
close: vi.fn(async () => {}),
}));
@@ -85,7 +91,8 @@ describe("extension relay lifecycle", () => {
it("rebounds an existing relay when the host-local token rotates", async () => {
const oldRelay = createHandle(OLD_TOKEN);
const { profile, state } = createState(OLD_TOKEN, oldRelay);
expect(profile.cdpUrl).toContain(OLD_TOKEN);
expect(profile.cdpUrl).toContain(encodeURIComponent(oldRelay.internalToken));
expect(profile.cdpUrl).not.toContain(OLD_TOKEN);
const handle = await ensureExtensionRelayForProfile(state, profile);
@@ -93,12 +100,14 @@ describe("extension relay lifecycle", () => {
expect(startExtensionRelayServerMock).toHaveBeenCalledWith({
port: RELAY_PORT,
token: ROTATED_TOKEN,
allowLegacyAuth: true,
onPageShare: expect.any(Function),
});
expect(handle.token).toBe(ROTATED_TOKEN);
expect(state.resolved.extensionRelayToken).toBe(ROTATED_TOKEN);
expect(profile.cdpUrl).toContain(ROTATED_TOKEN);
expect(resolveProfile(state.resolved, PROFILE_NAME)?.cdpUrl).toContain(ROTATED_TOKEN);
expect(profile.cdpUrl).toContain("replacement-internal");
expect(profile.cdpUrl).not.toContain(ROTATED_TOKEN);
expect(resolveProfile(state.resolved, PROFILE_NAME)?.cdpUrl).toContain("replacement-internal");
expect(state.extensionRelays?.get(PROFILE_NAME)).toBe(handle);
});
@@ -19,6 +19,7 @@ const log = createSubsystemLogger("browser").child("extension-relay");
type PendingRelayEnsure = {
port: number;
token: string;
allowLegacyAuth: boolean;
promise: Promise<ExtensionRelayHandle>;
};
@@ -35,6 +36,26 @@ function relays(state: BrowserServerState): Map<string, ExtensionRelayHandle> {
return state.extensionRelays;
}
function applyInternalRelayToken(
state: BrowserServerState,
profileName: string,
internalToken: string | null,
): ResolvedBrowserProfile | null {
const tokens = { ...state.resolved.extensionRelayInternalTokens };
if (internalToken) {
tokens[profileName] = internalToken;
} else {
delete tokens[profileName];
}
state.resolved = { ...state.resolved, extensionRelayInternalTokens: tokens };
const resolved = resolveProfile(state.resolved, profileName);
const runtime = state.profiles.get(profileName);
if (resolved?.driver === "extension" && runtime?.profile.driver === "extension") {
Object.assign(runtime.profile, resolved);
}
return resolved;
}
/**
* Start the relay server for one extension-driver profile, reconciling any
* existing one. Idempotency is keyed on profile name, but the desired (port,
@@ -51,9 +72,8 @@ export async function ensureExtensionRelayForProfile(
if (!isBrowserRuntimeRunning(state)) {
throw new Error("Browser runtime is stopping");
}
// The host-local relay secret can rotate while Browser control stays up.
// Resolve one canonical desired profile after applying that token so the
// intentional auth-derived cdpUrl change is not mistaken for config drift.
// The host-local HMAC key can rotate while Browser control stays up.
// Resolve one canonical desired profile after adopting the live key.
const { ensureExtensionRelayToken, readExtensionRelayToken } = await import("./relay-auth.js");
const token = readExtensionRelayToken() ?? (await ensureExtensionRelayToken());
if (state.resolved.extensionRelayToken !== token) {
@@ -67,8 +87,7 @@ export async function ensureExtensionRelayForProfile(
) {
throw new Error(`Extension relay profile "${profile.name}" changed during startup.`);
}
// Token rotation changes only the auth-derived CDP URL. Keep the active
// request's shared profile object aligned with the relay it will use.
// Keep the active request's shared profile object aligned with the relay.
Object.assign(profile, desiredProfile);
const runtime = getOrCreateProfileRuntime(state, desiredProfile);
@@ -81,8 +100,17 @@ export async function ensureExtensionRelayForProfile(
}
const pending = pendingRelayEnsures.get(runtime);
if (pending) {
if (pending.port === desiredProfile.cdpPort && pending.token === token) {
return await pending.promise;
if (
pending.port === desiredProfile.cdpPort &&
pending.token === token &&
pending.allowLegacyAuth === state.resolved.extensionRelay.allowLegacyAuth
) {
const handle = await pending.promise;
const current = resolveProfile(state.resolved, profile.name);
if (current) {
Object.assign(profile, current);
}
return handle;
}
try {
await pending.promise;
@@ -95,10 +123,20 @@ export async function ensureExtensionRelayForProfile(
}
const promise = ensureDesiredRelay({ state, runtime, profile: desiredProfile, token });
const owned = { port: desiredProfile.cdpPort, token, promise };
const owned = {
port: desiredProfile.cdpPort,
token,
allowLegacyAuth: state.resolved.extensionRelay.allowLegacyAuth,
promise,
};
pendingRelayEnsures.set(runtime, owned);
try {
return await promise;
const handle = await promise;
const current = resolveProfile(state.resolved, profile.name);
if (current) {
Object.assign(profile, current);
}
return handle;
} finally {
if (pendingRelayEnsures.get(runtime) === owned) {
pendingRelayEnsures.delete(runtime);
@@ -123,7 +161,15 @@ async function ensureDesiredRelay(params: {
const actor = getProfileLifecycle(runtime);
const existing = map.get(profile.name);
if (existing) {
if (existing.port === profile.cdpPort && existing.token === token) {
if (
existing.port === profile.cdpPort &&
existing.token === token &&
existing.allowLegacyAuth === state.resolved.extensionRelay.allowLegacyAuth
) {
const current = applyInternalRelayToken(state, profile.name, existing.internalToken);
if (current) {
Object.assign(profile, current);
}
return existing;
}
// Never drop the exact old handle until close succeeds; shutdown can retry it.
@@ -133,12 +179,14 @@ async function ensureDesiredRelay(params: {
if (map.get(profile.name) === existing) {
map.delete(profile.name);
}
applyInternalRelayToken(state, profile.name, null);
}
let handle: ExtensionRelayHandle | undefined;
try {
handle = await startExtensionRelayServer({
port: profile.cdpPort,
token,
allowLegacyAuth: state.resolved.extensionRelay.allowLegacyAuth,
onPageShare: (payload) => deliverPageShare(payload),
});
actor.cleanupRelays.add(handle);
@@ -147,12 +195,21 @@ async function ensureDesiredRelay(params: {
if (
state.profiles.get(profile.name) !== runtime ||
currentProfile?.driver !== "extension" ||
currentProfile.cdpUrl !== profile.cdpUrl ||
currentProfile.cdpPort !== profile.cdpPort ||
state.resolved.extensionRelayToken !== token
) {
throw new Error(`Extension relay profile "${profile.name}" changed during startup.`);
}
map.set(profile.name, handle);
const currentWithInternalAuth = applyInternalRelayToken(
state,
profile.name,
handle.internalToken,
);
if (!currentWithInternalAuth) {
throw new Error(`Extension relay profile "${profile.name}" disappeared during startup.`);
}
Object.assign(profile, currentWithInternalAuth);
actor.cleanupRelays.delete(handle);
log.info(
`extension relay for profile "${profile.name}" listening on 127.0.0.1:${handle.port}`,
@@ -209,6 +266,7 @@ export async function stopExtensionRelays(state: BrowserServerState): Promise<vo
if (map.get(name) === handle) {
map.delete(name);
}
applyInternalRelayToken(state, name, null);
} catch (err) {
log.warn(`extension relay for profile "${name}" failed to stop: ${String(err)}`);
firstError ??=
@@ -3,7 +3,16 @@ import { describe, expect, it } from "vitest";
import { parseExtensionMessage } from "./relay-protocol.js";
describe("parseExtensionMessage", () => {
const validHello = {
type: "hello",
userAgent: "Mozilla/5.0 Chrome/144.0.0.0",
browserVersion: "Chrome/144.0.0.0",
extensionVersion: "2.0.0",
tabs: [{ tabId: 1, url: "https://example.com", title: "Example", active: true }],
};
it("accepts known frame types", () => {
expect(parseExtensionMessage(JSON.stringify(validHello))).toEqual(validHello);
expect(parseExtensionMessage(JSON.stringify({ type: "pong" }))).toEqual({ type: "pong" });
expect(
parseExtensionMessage(JSON.stringify({ type: "result", seq: 3, result: { ok: true } })),
@@ -24,6 +33,37 @@ describe("parseExtensionMessage", () => {
});
});
it.each([
["missing identity", { ...validHello, userAgent: undefined }],
["empty browser version", { ...validHello, browserVersion: "" }],
["oversized user agent", { ...validHello, userAgent: "x".repeat(2_049) }],
["an extra hello field", { ...validHello, extra: true }],
["non-array tabs", { ...validHello, tabs: {} }],
[
"a fractional tab id",
{ ...validHello, tabs: [{ tabId: 1.5, url: "", title: "", active: true }] },
],
[
"an extra tab field",
{
...validHello,
tabs: [{ tabId: 1, url: "", title: "", active: true, incognito: false }],
},
],
[
"duplicate tab ids",
{
...validHello,
tabs: [
{ tabId: 1, url: "https://one.example", title: "One", active: true },
{ tabId: 1, url: "https://two.example", title: "Two", active: false },
],
},
],
])("rejects a hello with %s", (_label, hello) => {
expect(parseExtensionMessage(JSON.stringify(hello))).toBeNull();
});
it("rejects malformed or unknown frames", () => {
expect(parseExtensionMessage("not json")).toBeNull();
expect(parseExtensionMessage(JSON.stringify({ type: "evil" }))).toBeNull();
@@ -131,6 +131,58 @@ export type RelayToExtensionMessage =
| RelayPingMessage
| RelayPageShareResultMessage;
function hasExactOwnKeys(value: object, keys: readonly string[]): boolean {
const actual = Object.keys(value);
return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
}
function isRelayTabInfo(value: unknown): value is RelayTabInfo {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
if (!hasExactOwnKeys(value, ["tabId", "url", "title", "active"])) {
return false;
}
const tab = value as Record<string, unknown>;
return (
Number.isSafeInteger(tab.tabId) &&
(tab.tabId as number) >= 0 &&
typeof tab.url === "string" &&
tab.url.length <= 16_384 &&
typeof tab.title === "string" &&
tab.title.length <= 4_096 &&
typeof tab.active === "boolean"
);
}
function isExtensionHelloMessage(value: object): value is ExtensionHelloMessage {
if (
!hasExactOwnKeys(value, ["type", "userAgent", "browserVersion", "extensionVersion", "tabs"])
) {
return false;
}
const hello = value as Record<string, unknown>;
if (
hello.type !== "hello" ||
typeof hello.userAgent !== "string" ||
hello.userAgent.length === 0 ||
hello.userAgent.length > 2_048 ||
typeof hello.browserVersion !== "string" ||
hello.browserVersion.length === 0 ||
hello.browserVersion.length > 512 ||
typeof hello.extensionVersion !== "string" ||
hello.extensionVersion.length === 0 ||
hello.extensionVersion.length > 128 ||
!Array.isArray(hello.tabs) ||
hello.tabs.length > 1_000 ||
!hello.tabs.every(isRelayTabInfo)
) {
return false;
}
const tabIds = new Set(hello.tabs.map((tab) => tab.tabId));
return tabIds.size === hello.tabs.length;
}
/** Parse one extension frame; returns null for malformed input. */
export function parseExtensionMessage(raw: string): ExtensionToRelayMessage | null {
let parsed: unknown;
@@ -148,6 +200,7 @@ export function parseExtensionMessage(raw: string): ExtensionToRelayMessage | nu
}
switch (type) {
case "hello":
return isExtensionHelloMessage(parsed) ? parsed : null;
case "tabs":
case "cdpEvent":
case "result":
@@ -0,0 +1,31 @@
import type { IncomingMessage } from "node:http";
export const LEGACY_EXTENSION_RELAY_PROTOCOL = "openclaw-extension-relay";
const LEGACY_EXTENSION_RELAY_TOKEN_PROTOCOL_PREFIX = "openclaw-extension-token.";
export function firstHeader(value: string | string[] | undefined): string {
return Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
}
export function requestProtocols(req: IncomingMessage): string[] {
return firstHeader(req.headers["sec-websocket-protocol"])
.split(",")
.map((value) => value.trim())
.filter(Boolean);
}
export function requestExtensionProtocolToken(req: IncomingMessage): string {
const protocols = requestProtocols(req);
if (!protocols.includes(LEGACY_EXTENSION_RELAY_PROTOCOL)) {
return "";
}
const tokenProtocol = protocols.find((value) =>
value.startsWith(LEGACY_EXTENSION_RELAY_TOKEN_PROTOCOL_PREFIX),
);
return tokenProtocol?.slice(LEGACY_EXTENSION_RELAY_TOKEN_PROTOCOL_PREFIX.length) ?? "";
}
export function isAllowedExtensionOrigin(req: IncomingMessage): boolean {
const origin = firstHeader(req.headers.origin);
return origin === "" || origin.startsWith("chrome-extension://");
}
@@ -0,0 +1,747 @@
import { EventEmitter, once } from "node:events";
import fs from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WebSocket, type RawData } from "ws";
import {
createRelayProof,
randomRelayNonce,
relayKeyIdFromHex,
type BrowserRelayAuthChallenge,
} from "./auth-v2-crypto.js";
import {
BROWSER_RELAY_AUTH_CHALLENGE_PATH,
BROWSER_RELAY_AUTH_COMPLETE_PATH,
BROWSER_RELAY_CHALLENGE_TTL_MS,
BROWSER_RELAY_EXTENSION_SUBPROTOCOL,
BrowserRelayAuthV2Authority,
getBrowserRelayAuthV2Authority,
invalidateBrowserRelayAuthV2Authority,
} from "./auth-v2.js";
import {
authenticateExtensionWebSocket,
startExtensionRelayServer,
type ExtensionRelayHandle,
} from "./relay-server.js";
const KEY = "0123456789abcdef".repeat(4);
type RawResponse = {
status: number;
headers: Record<string, string>;
body: string;
};
class RawHttpConnection {
private buffer = Buffer.alloc(0);
private readonly waiters: Array<() => void> = [];
private constructor(readonly socket: net.Socket) {
socket.on("data", (chunk) => {
this.buffer = Buffer.concat([
this.buffer,
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk),
]);
this.waiters.splice(0).forEach((resolve) => resolve());
});
}
static async connect(port: number): Promise<RawHttpConnection> {
const socket = net.createConnection({ host: "127.0.0.1", port });
await new Promise<void>((resolve, reject) => {
socket.once("connect", resolve);
socket.once("error", reject);
});
return new RawHttpConnection(socket);
}
async request(
method: string,
requestPath: string,
body = "",
headers: Record<string, string> = {},
): Promise<RawResponse> {
this.socket.write(
[
`${method} ${requestPath} HTTP/1.1`,
"Host: 127.0.0.1",
"Connection: keep-alive",
`Content-Length: ${Buffer.byteLength(body)}`,
...Object.entries(headers).map(([key, value]) => `${key}: ${value}`),
"",
body,
].join("\r\n"),
);
return await this.readResponse();
}
async upgrade(requestPath: string): Promise<RawResponse> {
this.socket.write(
[
`GET ${requestPath} HTTP/1.1`,
"Host: 127.0.0.1",
"Connection: Upgrade",
"Upgrade: websocket",
"Sec-WebSocket-Version: 13",
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
"",
"",
].join("\r\n"),
);
return await this.readResponse({ headersOnly: true });
}
private async waitForData(): Promise<void> {
if (this.socket.destroyed) {
throw new Error("socket closed before response completed");
}
await new Promise<void>((resolve) => {
this.waiters.push(resolve);
});
}
private async readResponse(options: { headersOnly?: boolean } = {}): Promise<RawResponse> {
let headerEnd = this.buffer.indexOf("\r\n\r\n");
while (headerEnd < 0) {
await this.waitForData();
headerEnd = this.buffer.indexOf("\r\n\r\n");
}
const headerText = this.buffer.subarray(0, headerEnd).toString("utf8");
const [statusLine, ...headerLines] = headerText.split("\r\n");
const headers = Object.fromEntries(
headerLines.map((line) => {
const separator = line.indexOf(":");
return [line.slice(0, separator).toLowerCase(), line.slice(separator + 1).trim()];
}),
);
const contentLength = options.headersOnly ? 0 : Number(headers["content-length"] ?? 0);
const responseLength = headerEnd + 4 + contentLength;
while (this.buffer.length < responseLength) {
await this.waitForData();
}
const body = this.buffer.subarray(headerEnd + 4, responseLength).toString("utf8");
this.buffer = this.buffer.subarray(responseLength);
return {
status: Number(/^HTTP\/1\.1 (\d+)/u.exec(statusLine ?? "")?.[1] ?? 0),
headers,
body,
};
}
close(): void {
this.socket.destroy();
}
}
async function authenticate(
connection: RawHttpConnection,
flow: "cdp" | "json-list",
clientNonce = randomRelayNonce(),
): Promise<BrowserRelayAuthChallenge> {
const binding =
flow === "cdp"
? { method: "SEQUENCE", resource: "/json/version -> /cdp" }
: { method: "GET", resource: "/json/list" };
const challengeResponse = await connection.request(
"POST",
BROWSER_RELAY_AUTH_CHALLENGE_PATH,
JSON.stringify({
v: 2,
keyId: relayKeyIdFromHex(KEY),
clientNonce,
role: "cdp",
transport: "connection",
...binding,
flow,
}),
{ "Content-Type": "application/json" },
);
expect(challengeResponse.status).toBe(200);
const challenge = JSON.parse(challengeResponse.body) as BrowserRelayAuthChallenge;
const completeResponse = await connection.request(
"POST",
BROWSER_RELAY_AUTH_COMPLETE_PATH,
JSON.stringify({
v: 2,
sessionId: challenge.sessionId,
clientProof: createRelayProof(KEY, "client", challenge),
}),
{ "Content-Type": "application/json" },
);
expect(completeResponse.status).toBe(200);
expect(JSON.parse(completeResponse.body)).toMatchObject({
type: "auth.ok",
v: 2,
sessionId: challenge.sessionId,
});
return challenge;
}
function attachTestExtension(handle: ExtensionRelayHandle): void {
const handlers = handle.bridge.attachExtensionSocket({ send: () => {}, close: () => {} });
handlers.onMessage(
JSON.stringify({
type: "hello",
userAgent: "test",
browserVersion: "Chrome/test",
extensionVersion: "2",
tabs: [],
}),
);
}
function rawDataText(data: RawData): string {
if (Array.isArray(data)) {
return Buffer.concat(data).toString("utf8");
}
if (data instanceof ArrayBuffer) {
return Buffer.from(data).toString("utf8");
}
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8");
}
async function openExtensionSocket(
handle: ExtensionRelayHandle,
protocols: string | string[],
): Promise<WebSocket> {
const ws = new WebSocket(`ws://127.0.0.1:${handle.port}/extension`, protocols, {
origin: "chrome-extension://relay-auth-v2-test",
});
ws.on("error", () => {});
await once(ws, "open");
return ws;
}
async function authenticateV2Extension(handle: ExtensionRelayHandle): Promise<WebSocket> {
const ws = await openExtensionSocket(handle, BROWSER_RELAY_EXTENSION_SUBPROTOCOL);
const challengeMessage = once(ws, "message");
ws.send(
JSON.stringify({
type: "auth.hello",
v: 2,
keyId: relayKeyIdFromHex(KEY),
clientNonce: randomRelayNonce(),
}),
);
const [challengeData] = (await challengeMessage) as [RawData];
const challenge = JSON.parse(rawDataText(challengeData)) as BrowserRelayAuthChallenge;
const okMessage = once(ws, "message");
ws.send(
JSON.stringify({
type: "auth.response",
v: 2,
sessionId: challenge.sessionId,
clientProof: createRelayProof(KEY, "client", challenge),
}),
);
const [okData] = (await okMessage) as [RawData];
expect(JSON.parse(rawDataText(okData))).toMatchObject({
type: "auth.ok",
v: 2,
sessionId: challenge.sessionId,
});
return ws;
}
function createWebSocketAuthHarness(
options: {
prepareAuthenticated?: () => Promise<() => void>;
removePreAuthGuard?: () => void;
} = {},
) {
const close = vi.fn();
const send = vi.fn();
const socket = Object.assign(new EventEmitter(), {
close,
readyState: 1,
send,
terminate: vi.fn(),
}) as unknown as WebSocket;
const authority = new BrowserRelayAuthV2Authority(KEY);
const issueChallenge = vi.spyOn(authority, "issueChallenge");
const prepareAuthenticated = vi.fn(options.prepareAuthenticated ?? (async () => vi.fn()));
authenticateExtensionWebSocket({
ws: socket,
authority,
resource: "/extension",
prepareAuthenticated,
removePreAuthGuard: options.removePreAuthGuard,
});
return { authority, close, issueChallenge, prepareAuthenticated, send, socket };
}
function maskedFrame(payload: Buffer, options: { fin: boolean; opcode: number }): Buffer {
const lengthBytes = payload.length < 126 ? 0 : 2;
const header = Buffer.alloc(2 + lengthBytes + 4);
header[0] = (options.fin ? 0x80 : 0) | options.opcode;
header[1] = 0x80 | (lengthBytes === 0 ? payload.length : 126);
if (lengthBytes === 2) {
header.writeUInt16BE(payload.length, 2);
}
const maskOffset = 2 + lengthBytes;
const mask = Buffer.from([0x12, 0x34, 0x56, 0x78]);
mask.copy(header, maskOffset);
const masked = Buffer.allocUnsafe(payload.length);
for (let index = 0; index < payload.length; index += 1) {
masked[index] = payload[index]! ^ mask[index % 4]!;
}
return Buffer.concat([header, masked]);
}
async function sendRawV2Frames(params: {
port: number;
initialFrames?: Buffer[];
subsequentFrames?: Buffer[];
}): Promise<string> {
const socket = net.createConnection({ host: "127.0.0.1", port: params.port });
socket.on("error", () => {});
await once(socket, "connect");
const received: Buffer[] = [];
socket.on("data", (chunk) => received.push(Buffer.from(chunk)));
const closed = once(socket, "close");
const request = Buffer.from(
[
"GET /extension HTTP/1.1",
"Host: 127.0.0.1",
"Connection: Upgrade",
"Upgrade: websocket",
"Sec-WebSocket-Version: 13",
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
`Sec-WebSocket-Protocol: ${BROWSER_RELAY_EXTENSION_SUBPROTOCOL}`,
"Origin: chrome-extension://relay-auth-v2-test",
"",
"",
].join("\r\n"),
);
socket.write(Buffer.concat([request, ...(params.initialFrames ?? [])]));
if (params.subsequentFrames?.length) {
await vi.waitFor(() => {
expect(Buffer.concat(received).toString("utf8")).toContain("101 Switching Protocols");
});
socket.write(Buffer.concat(params.subsequentFrames));
}
await closed;
return Buffer.concat(received).toString("utf8");
}
describe("extension relay WebSocket auth v2 frame boundary", () => {
it.each([
["Buffer", Buffer.alloc(16 * 1024 + 1, 0x20)],
["ArrayBuffer", new Uint8Array(16 * 1024 + 1).buffer],
["Buffer[]", [Buffer.alloc(8 * 1024), Buffer.alloc(8 * 1024 + 1)]],
] satisfies Array<[string, RawData]>)(
"rejects an oversized text auth frame backed by %s before issuing a challenge",
(_kind, data) => {
const harness = createWebSocketAuthHarness();
harness.socket.emit("message", data, false);
expect(harness.close).toHaveBeenCalledWith(4003, "browser relay auth frame is too large");
expect(harness.issueChallenge).not.toHaveBeenCalled();
expect(harness.send).not.toHaveBeenCalled();
expect(harness.prepareAuthenticated).not.toHaveBeenCalled();
harness.socket.emit("close");
},
);
it("rejects a binary auth frame without issuing a challenge or promoting the bridge", () => {
const harness = createWebSocketAuthHarness();
harness.socket.emit("message", Buffer.from("{}"), true);
expect(harness.close).toHaveBeenCalledWith(
4003,
"binary browser relay auth frames are not allowed",
);
expect(harness.issueChallenge).not.toHaveBeenCalled();
expect(harness.send).not.toHaveBeenCalled();
expect(harness.prepareAuthenticated).not.toHaveBeenCalled();
harness.socket.emit("close");
});
it("releases a timed-out pending socket without disturbing active capacity", async () => {
vi.useFakeTimers();
try {
const removePreAuthGuard = vi.fn();
const harness = createWebSocketAuthHarness({ removePreAuthGuard });
const activeInvalidated = vi.fn();
expect(harness.authority.registerAuthenticatedConnection({}, activeInvalidated)).toBe(true);
for (let index = 0; index < 127; index += 1) {
expect(harness.authority.registerPendingConnection({}, vi.fn())).toBe(true);
}
expect(harness.authority.registerPendingConnection({}, vi.fn())).toBe(false);
await vi.advanceTimersByTimeAsync(10_000);
expect(harness.close).toHaveBeenCalledWith(4008, "browser relay auth timeout");
expect(removePreAuthGuard).not.toHaveBeenCalled();
harness.socket.emit("close");
expect(removePreAuthGuard).toHaveBeenCalledOnce();
expect(harness.authority.registerPendingConnection({}, vi.fn())).toBe(true);
expect(activeInvalidated).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("ends the proof deadline at promotion while authenticated preparation remains pending", async () => {
vi.useFakeTimers();
try {
let finishPreparation = (_attach: () => void) => {};
const attach = vi.fn();
const preparation = new Promise<() => void>((resolve) => {
finishPreparation = resolve;
});
const removePreAuthGuard = vi.fn();
const harness = createWebSocketAuthHarness({
prepareAuthenticated: async () => await preparation,
removePreAuthGuard,
});
const clientNonce = randomRelayNonce();
harness.socket.emit(
"message",
Buffer.from(
JSON.stringify({
type: "auth.hello",
v: 2,
keyId: relayKeyIdFromHex(KEY),
clientNonce,
}),
),
false,
);
const challenge = JSON.parse(harness.send.mock.calls[0]?.[0]) as BrowserRelayAuthChallenge;
harness.socket.emit(
"message",
Buffer.from(
JSON.stringify({
type: "auth.response",
v: 2,
sessionId: challenge.sessionId,
clientProof: createRelayProof(KEY, "client", challenge),
}),
),
false,
);
expect(removePreAuthGuard).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(BROWSER_RELAY_CHALLENGE_TTL_MS + 1);
expect(harness.close).not.toHaveBeenCalled();
expect(harness.socket.readyState).toBe(WebSocket.OPEN);
finishPreparation(attach);
await vi.waitFor(() => expect(attach).toHaveBeenCalledOnce());
expect(harness.send.mock.calls.some(([raw]) => JSON.parse(raw).type === "auth.ok")).toBe(
true,
);
expect(harness.close).not.toHaveBeenCalled();
harness.socket.emit("close");
} finally {
vi.useRealTimers();
}
});
});
describe.sequential("extension relay HTTP auth v2", () => {
let stateDir: string;
let previousStateDir: string | undefined;
let handle: ExtensionRelayHandle | null = null;
beforeEach(async () => {
previousStateDir = process.env.OPENCLAW_STATE_DIR;
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-relay-auth-v2-"));
process.env.OPENCLAW_STATE_DIR = stateDir;
await fs.mkdir(path.join(stateDir, "credentials"), { recursive: true });
await fs.writeFile(
path.join(stateDir, "credentials", "browser-extension-relay.secret"),
`${KEY}\n`,
{
mode: 0o600,
},
);
invalidateBrowserRelayAuthV2Authority();
});
afterEach(async () => {
await handle?.close();
handle = null;
invalidateBrowserRelayAuthV2Authority();
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
await fs.rm(stateDir, { recursive: true, force: true });
});
it("keeps the same-socket CDP upgrade active and rotation-bound", async () => {
handle = await startExtensionRelayServer({ port: 0, token: KEY, allowLegacyAuth: false });
attachTestExtension(handle);
const connection = await RawHttpConnection.connect(handle.port);
await authenticate(connection, "cdp");
const version = await connection.request("GET", "/json/version");
expect(version.status).toBe(200);
expect(JSON.parse(version.body).webSocketDebuggerUrl).toBe(`ws://127.0.0.1:${handle.port}/cdp`);
const upgraded = await connection.upgrade("/cdp");
expect(upgraded.status).toBe(101);
expect(handle.bridge.cdpClientCount).toBe(1);
const closed = once(connection.socket, "close");
getBrowserRelayAuthV2Authority("f".repeat(64));
await closed;
await vi.waitFor(() => expect(handle?.bridge.cdpClientCount).toBe(0));
connection.close();
});
it("rejects oversized upgrade-head auth data before challenge or bridge promotion", async () => {
handle = await startExtensionRelayServer({ port: 0, token: KEY, allowLegacyAuth: false });
const authority = getBrowserRelayAuthV2Authority(KEY);
const issueChallenge = vi.spyOn(authority, "issueChallenge");
const validHello = maskedFrame(
Buffer.from(
JSON.stringify({
type: "auth.hello",
v: 2,
keyId: relayKeyIdFromHex(KEY),
clientNonce: randomRelayNonce(),
}),
),
{ fin: true, opcode: 0x1 },
);
const response = await sendRawV2Frames({
port: handle.port,
initialFrames: [
validHello,
maskedFrame(Buffer.alloc(18 * 1024, 0x20), { fin: true, opcode: 0x1 }),
],
});
expect(response).not.toContain("auth.challenge");
expect(response).not.toContain("auth.ok");
expect(issueChallenge).not.toHaveBeenCalled();
expect(handle.bridge.extensionConnected).toBe(false);
});
it("rejects fragmented masked pre-auth wire overhead before challenge or bridge promotion", async () => {
handle = await startExtensionRelayServer({ port: 0, token: KEY, allowLegacyAuth: false });
const authority = getBrowserRelayAuthV2Authority(KEY);
const issueChallenge = vi.spyOn(authority, "issueChallenge");
const payload = Buffer.alloc(16 * 1024, 0x20);
const fragments: Buffer[] = [];
for (let offset = 0; offset < payload.length; offset += 91) {
const end = Math.min(offset + 91, payload.length);
fragments.push(
maskedFrame(payload.subarray(offset, end), {
fin: end === payload.length,
opcode: offset === 0 ? 0x1 : 0x0,
}),
);
}
const response = await sendRawV2Frames({ port: handle.port, subsequentFrames: fragments });
expect(response).not.toContain("auth.challenge");
expect(response).not.toContain("auth.ok");
expect(issueChallenge).not.toHaveBeenCalled();
expect(handle.bridge.extensionConnected).toBe(false);
});
it("keeps the 64 MiB application receiver after v2 authentication", async () => {
handle = await startExtensionRelayServer({ port: 0, token: KEY, allowLegacyAuth: false });
const socket = await authenticateV2Extension(handle);
socket.send(
JSON.stringify({
type: "hello",
userAgent: "test",
browserVersion: "Chrome/test",
extensionVersion: "2",
tabs: [
{
tabId: 1,
url: `https://example.test/${"a".repeat(16_000)}`,
title: "one",
active: true,
},
{
tabId: 2,
url: `https://example.test/${"b".repeat(16_000)}`,
title: "two",
active: false,
},
],
}),
);
await vi.waitFor(() => expect(handle?.bridge.extensionConnected).toBe(true));
expect(socket.readyState).toBe(WebSocket.OPEN);
socket.close();
});
it("keeps an active extension while pending admission is full and recovers after release", async () => {
handle = await startExtensionRelayServer({ port: 0, token: KEY, allowLegacyAuth: true });
const active = await openExtensionSocket(handle, [
"openclaw-extension-relay",
`openclaw-extension-token.${KEY}`,
]);
active.send(
JSON.stringify({
type: "hello",
userAgent: "test",
browserVersion: "Chrome/test",
extensionVersion: "2",
tabs: [],
}),
);
await vi.waitFor(() => expect(handle?.bridge.extensionConnected).toBe(true));
const pending = await Promise.all(
Array.from({ length: 128 }, () =>
openExtensionSocket(handle!, BROWSER_RELAY_EXTENSION_SUBPROTOCOL),
),
);
expect(active.readyState).toBe(WebSocket.OPEN);
expect(handle.bridge.extensionConnected).toBe(true);
const overflow = new WebSocket(
`ws://127.0.0.1:${handle.port}/extension`,
BROWSER_RELAY_EXTENSION_SUBPROTOCOL,
{ origin: "chrome-extension://relay-auth-v2-test" },
);
overflow.on("error", () => {});
const overflowClosed = once(overflow, "close");
await once(overflow, "open");
const [overflowCode] = (await overflowClosed) as [number, Buffer];
expect(overflowCode).toBe(4013);
expect(active.readyState).toBe(WebSocket.OPEN);
expect(handle.bridge.extensionConnected).toBe(true);
const released = once(pending[0]!, "close");
pending[0]!.close();
await released;
const promoted = await authenticateV2Extension(handle);
promoted.send(
JSON.stringify({
type: "hello",
userAgent: "test-v2",
browserVersion: "Chrome/test-v2",
extensionVersion: "2",
tabs: [],
}),
);
await vi.waitFor(() => expect(handle?.bridge.extensionConnected).toBe(true));
promoted.close();
active.close();
for (const socket of pending.slice(1)) {
socket.close();
}
}, 30_000);
it("rejects completion on another socket without consuming the original challenge", async () => {
handle = await startExtensionRelayServer({ port: 0, token: KEY });
const original = await RawHttpConnection.connect(handle.port);
const other = await RawHttpConnection.connect(handle.port);
const challengeResponse = await original.request(
"POST",
BROWSER_RELAY_AUTH_CHALLENGE_PATH,
JSON.stringify({
v: 2,
keyId: relayKeyIdFromHex(KEY),
clientNonce: randomRelayNonce(),
role: "cdp",
transport: "connection",
method: "GET",
resource: "/json/list",
flow: "json-list",
}),
);
const challenge = JSON.parse(challengeResponse.body) as BrowserRelayAuthChallenge;
const completion = JSON.stringify({
v: 2,
sessionId: challenge.sessionId,
clientProof: createRelayProof(KEY, "client", challenge),
});
expect((await other.request("POST", BROWSER_RELAY_AUTH_COMPLETE_PATH, completion)).status).toBe(
409,
);
expect(
(await original.request("POST", BROWSER_RELAY_AUTH_COMPLETE_PATH, completion)).status,
).toBe(200);
original.close();
other.close();
});
it("rejects replayed client nonces across sockets", async () => {
handle = await startExtensionRelayServer({ port: 0, token: KEY });
const first = await RawHttpConnection.connect(handle.port);
const second = await RawHttpConnection.connect(handle.port);
const nonce = randomRelayNonce();
const body = JSON.stringify({
v: 2,
keyId: relayKeyIdFromHex(KEY),
clientNonce: nonce,
role: "cdp",
transport: "connection",
method: "GET",
resource: "/json/list",
flow: "json-list",
});
expect((await first.request("POST", BROWSER_RELAY_AUTH_CHALLENGE_PATH, body)).status).toBe(200);
expect((await second.request("POST", BROWSER_RELAY_AUTH_CHALLENGE_PATH, body)).status).toBe(
401,
);
first.close();
second.close();
});
it("uses a separate one-GET json-list flow and closes it", async () => {
handle = await startExtensionRelayServer({ port: 0, token: KEY, allowLegacyAuth: false });
attachTestExtension(handle);
const connection = await RawHttpConnection.connect(handle.port);
await authenticate(connection, "json-list");
const list = await connection.request("GET", "/json/list");
expect(list.status).toBe(200);
expect(JSON.parse(list.body)).toEqual([]);
expect(list.headers.connection).toBe("close");
connection.close();
});
it("gates K-bearing legacy auth but preserves process-ephemeral internal Basic auth", async () => {
handle = await startExtensionRelayServer({ port: 0, token: KEY, allowLegacyAuth: false });
attachTestExtension(handle);
const bearer = await RawHttpConnection.connect(handle.port);
expect(
(await bearer.request("GET", "/json/version", "", { Authorization: `Bearer ${KEY}` })).status,
).toBe(401);
bearer.close();
const query = await RawHttpConnection.connect(handle.port);
expect((await query.request("GET", `/json/version?token=${KEY}`)).status).toBe(401);
query.close();
const internal = await RawHttpConnection.connect(handle.port);
const credential = Buffer.from(`openclaw-internal:${handle.internalToken}`).toString("base64");
expect(
(await internal.request("GET", "/json/version", "", { Authorization: `Basic ${credential}` }))
.status,
).toBe(200);
internal.close();
});
it("rejects query substitutions and duplicate security fields", async () => {
handle = await startExtensionRelayServer({ port: 0, token: KEY });
const query = await RawHttpConnection.connect(handle.port);
expect(
(await query.request("POST", `${BROWSER_RELAY_AUTH_CHALLENGE_PATH}?x=1`, JSON.stringify({})))
.status,
).toBe(400);
query.close();
const duplicate = await RawHttpConnection.connect(handle.port);
const nonce = randomRelayNonce();
const body = `{"v":2,"v":1,"keyId":"${relayKeyIdFromHex(KEY)}","clientNonce":"${nonce}","role":"cdp","transport":"connection","method":"GET","resource":"/json/list","flow":"json-list"}`;
expect((await duplicate.request("POST", BROWSER_RELAY_AUTH_CHALLENGE_PATH, body)).status).toBe(
400,
);
duplicate.close();
});
});
@@ -1,104 +1,121 @@
/**
* 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 the token in its WebSocket subprotocol list.
*/
import http, { type IncomingMessage, type Server } from "node:http";
/** Loopback extension relay with connection-bound Browser Relay Authentication v2. */
import crypto from "node:crypto";
import http, { type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { Duplex } from "node:stream";
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
import { WebSocketServer, type WebSocket } from "ws";
import { WebSocketServer, type RawData, type WebSocket } from "ws";
import { isLoopbackHost } from "../../gateway/net.js";
import { rawDataToString } from "../../infra/ws.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import {
BROWSER_RELAY_AUTH_CHALLENGE_PATH,
BROWSER_RELAY_AUTH_COMPLETE_PATH,
BROWSER_RELAY_CHALLENGE_TTL_MS,
BROWSER_RELAY_EXTENSION_SUBPROTOCOL,
getBrowserRelayAuthV2Authority,
invalidateBrowserRelayAuthV2Authority,
parseExtensionRelayResource,
parseRelayAuthHello,
parseRelayAuthResponse,
parseRelayHttpChallengeRequest,
parseRelayHttpCompleteRequest,
parseStrictJsonObject,
type BrowserRelayAuthV2Authority,
} from "./auth-v2.js";
import {
boundedRawDataByteLength,
handlePreAuthWebSocketUpgrade,
MAX_WEBSOCKET_AUTH_MESSAGE_BYTES,
} from "./preauth-websocket-guard.js";
import { readExtensionRelayToken } from "./relay-auth.js";
import { ExtensionRelayBridge } from "./relay-bridge.js";
import type { PageSharePayload } from "./relay-protocol.js";
import { parseExtensionMessage, type PageSharePayload } from "./relay-protocol.js";
import {
firstHeader,
isAllowedExtensionOrigin,
requestExtensionProtocolToken,
requestProtocols,
} from "./relay-request.js";
const log = createSubsystemLogger("browser").child("extension-relay");
const EXTENSION_RELAY_PROTOCOL = "openclaw-extension-relay";
const EXTENSION_RELAY_TOKEN_PROTOCOL_PREFIX = "openclaw-extension-token.";
const INTERNAL_CDP_USERNAME = "openclaw-internal";
const MAX_AUTH_BODY_BYTES = 8 * 1024;
/**
* Cap relay frame size to bound memory from a hostile/buggy peer while leaving
* headroom for CDP payloads (base64 screenshots, DOM snapshots, network bodies).
*/
export const EXTENSION_RELAY_MAX_PAYLOAD_BYTES = 64 * 1024 * 1024;
/** Wire an accepted extension WebSocket to a bridge (shared by loopback + gateway paths). */
export function attachExtensionWebSocket(bridge: ExtensionRelayBridge, ws: WebSocket): void {
bindSocket(ws, bridge.attachExtensionSocket(ws));
}
type HttpAuthState =
| { stage: "busy" }
| {
stage: "challenged";
flow: "cdp" | "json-list";
authority: BrowserRelayAuthV2Authority;
timer: NodeJS.Timeout;
}
| {
stage: "authenticated";
flow: "cdp" | "json-list";
authority: BrowserRelayAuthV2Authority;
timer: NodeJS.Timeout;
}
| {
stage: "awaiting-upgrade";
authority: BrowserRelayAuthV2Authority;
timer: NodeJS.Timeout;
};
/** 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;
allowLegacyAuth: boolean;
/** Process-only Basic credential for OpenClaw's own CDP client. Never persisted or printed. */
internalToken: string;
bridge: ExtensionRelayBridge;
close: () => Promise<void>;
};
function firstHeader(value: string | string[] | undefined): string {
return Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
}
/** Extract a relay token carried by the extension's WebSocket subprotocol list. */
export function requestExtensionProtocolToken(req: IncomingMessage): string {
const protocols = firstHeader(req.headers["sec-websocket-protocol"])
.split(",")
.map((value) => value.trim());
if (!protocols.includes(EXTENSION_RELAY_PROTOCOL)) {
return "";
}
const tokenProtocol = protocols.find((value) =>
value.startsWith(EXTENSION_RELAY_TOKEN_PROTOCOL_PREFIX),
);
return tokenProtocol?.slice(EXTENSION_RELAY_TOKEN_PROTOCOL_PREFIX.length) ?? "";
}
/** Extract relay auth from a CDP header, extension subprotocol, or legacy query. */
function requestToken(req: IncomingMessage): string {
function decodeBasic(req: IncomingMessage): { username: string; password: string } | null {
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;
}
const protocolToken = requestExtensionProtocolToken(req);
if (protocolToken) {
return protocolToken;
if (!auth.startsWith("Basic ")) {
return null;
}
try {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
return url.searchParams.get("token") ?? "";
const decoded = Buffer.from(auth.slice("Basic ".length), "base64").toString("utf8");
const separator = decoded.indexOf(":");
return separator < 0
? { username: "", password: decoded }
: { username: decoded.slice(0, separator), password: decoded.slice(separator + 1) };
} catch {
return "";
return null;
}
}
function isAuthorized(req: IncomingMessage, token: string): boolean {
const candidate = requestToken(req);
return candidate.length > 0 && safeEqualSecret(token, candidate);
function isAuthorizedInternal(req: IncomingMessage, internalToken: string): boolean {
const basic = decodeBasic(req);
return (
basic?.username === INTERNAL_CDP_USERNAME && safeEqualSecret(internalToken, basic.password)
);
}
/** Reject cross-origin websocket upgrades; the extension side must come from Chrome. */
export 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://");
function isAuthorizedLegacy(
req: IncomingMessage,
token: string,
allowLegacyAuth: boolean,
): boolean {
if (!allowLegacyAuth) {
return false;
}
const auth = firstHeader(req.headers.authorization);
if (auth.startsWith("Bearer ") && safeEqualSecret(token, auth.slice("Bearer ".length).trim())) {
return true;
}
const basic = decodeBasic(req);
if (basic && safeEqualSecret(token, basic.password)) {
return true;
}
const protocolToken = requestExtensionProtocolToken(req);
return protocolToken.length > 0 && safeEqualSecret(token, protocolToken);
}
/** 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) {
@@ -112,17 +129,228 @@ function hasLoopbackHostHeader(req: IncomingMessage): boolean {
}
function destroySocket(socket: Duplex, response: string): void {
socket.write(response);
socket.destroy();
try {
socket.write(response);
} finally {
socket.destroy();
}
}
function writeJson(
res: ServerResponse,
status: number,
value: unknown,
headers: Record<string, string> = {},
): void {
const body = JSON.stringify(value);
res.writeHead(status, {
"Content-Type": "application/json",
"Content-Length": String(Buffer.byteLength(body)),
...headers,
});
res.end(body);
}
function rejectHttp(res: ServerResponse, status: number, message: string): void {
res.once("finish", () => res.socket?.destroy());
writeJson(res, status, { error: message }, { Connection: "close" });
}
async function readAuthBody(req: IncomingMessage): Promise<string | null> {
let body = "";
for await (const chunk of req) {
body += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
if (Buffer.byteLength(body) > MAX_AUTH_BODY_BYTES) {
return null;
}
}
return body;
}
function bindSocket(
ws: WebSocket,
handlers: { onMessage: (raw: string) => void; onClose: () => void },
): void {
ws.on("message", (data) => handlers.onMessage(rawDataToString(data)));
ws.on("close", handlers.onClose);
ws.on("error", (err) => log.warn(`relay socket error: ${String(err)}`));
}
function trackAuthenticatedSocket(authority: BrowserRelayAuthV2Authority, ws: WebSocket): boolean {
if (
!authority.registerAuthenticatedConnection(ws, () =>
ws.close(4003, "browser relay key rotated"),
)
) {
ws.terminate();
return false;
}
ws.once("close", () => authority.releaseConnection(ws));
return true;
}
/** Wire an already-v2-authenticated extension socket to the bridge. */
export function attachExtensionWebSocket(bridge: ExtensionRelayBridge, ws: WebSocket): void {
const handlers = bridge.attachExtensionSocket(ws);
let helloSeen = false;
const helloTimer = setTimeout(() => {
ws.close(4008, "extension hello timeout");
ws.terminate();
}, BROWSER_RELAY_CHALLENGE_TTL_MS);
helloTimer.unref?.();
bindSocket(ws, {
onMessage: (raw) => {
if (!helloSeen && parseExtensionMessage(raw)?.type === "hello") {
helloSeen = true;
clearTimeout(helloTimer);
}
handlers.onMessage(raw);
},
onClose: () => {
clearTimeout(helloTimer);
handlers.onClose();
},
});
}
export function authenticateExtensionWebSocket(params: {
ws: WebSocket;
authority: BrowserRelayAuthV2Authority;
resource: string;
prepareAuthenticated: () => Promise<() => void>;
removePreAuthGuard?: () => void;
}): void {
const { ws, authority } = params;
let stage: "hello" | "response" | "authenticated" | "failed" = "hello";
let preAuthGuardActive = true;
const removePreAuthGuard = () => {
if (!preAuthGuardActive) {
return;
}
preAuthGuardActive = false;
params.removePreAuthGuard?.();
};
const timer = setTimeout(() => {
stage = "failed";
ws.off("message", onMessage);
ws.close(4008, "browser relay auth timeout");
ws.terminate();
}, BROWSER_RELAY_CHALLENGE_TTL_MS);
timer.unref?.();
const release = () => {
clearTimeout(timer);
removePreAuthGuard();
authority.releaseConnection(ws);
};
if (
!authority.registerPendingConnection(ws, () => {
ws.close(4003, "browser relay key rotated");
})
) {
clearTimeout(timer);
ws.close(4013, "browser relay auth capacity reached");
return;
}
ws.once("close", release);
const fail = (code: number, reason: string) => {
if (stage === "failed") {
return;
}
stage = "failed";
clearTimeout(timer);
ws.off("message", onMessage);
ws.close(code, reason);
const terminateTimer = setTimeout(() => ws.terminate(), 100);
terminateTimer.unref?.();
};
const onMessage = (data: RawData, isBinary: boolean) => {
if (isBinary) {
fail(4003, "binary browser relay auth frames are not allowed");
return;
}
if (
boundedRawDataByteLength(data, MAX_WEBSOCKET_AUTH_MESSAGE_BYTES) >
MAX_WEBSOCKET_AUTH_MESSAGE_BYTES
) {
fail(4003, "browser relay auth frame is too large");
return;
}
const raw = rawDataToString(data);
const parsed = parseStrictJsonObject(raw);
if (stage === "hello") {
const hello = parseRelayAuthHello(parsed);
if (!hello) {
fail(4003, "invalid browser relay auth hello");
return;
}
const challenge = authority.issueChallenge(ws, hello, {
role: "extension",
transport: "websocket",
method: "GET",
resource: params.resource,
flow: "extension",
});
if (!challenge) {
fail(4003, "browser relay auth rejected");
return;
}
stage = "response";
ws.send(JSON.stringify(challenge));
return;
}
if (stage === "response") {
const response = parseRelayAuthResponse(parsed);
if (!response) {
fail(4003, "invalid browser relay auth response");
return;
}
const completed = authority.completeChallenge(ws, response);
if (!completed) {
fail(4003, "browser relay auth proof failed");
return;
}
stage = "authenticated";
// The proof deadline owns only challenge completion. Promotion is now
// authoritative, so cold Browser/Gateway preparation must not race it.
clearTimeout(timer);
removePreAuthGuard();
void params
.prepareAuthenticated()
.then((attach) => {
if (ws.readyState !== 1) {
return;
}
ws.off("message", onMessage);
attach();
ws.send(JSON.stringify(completed.ok), (err) => {
if (err) {
ws.close(1011, "browser relay auth acknowledgement failed");
}
});
})
.catch((err: unknown) => {
log.warn(`browser relay post-auth preparation failed: ${String(err)}`);
fail(1011, "browser relay unavailable after authentication");
});
return;
}
fail(4003, "unexpected browser relay auth frame");
};
ws.on("message", onMessage);
}
/** Start the relay server for one extension-driver profile. */
export async function startExtensionRelayServer(params: {
port: number;
token: string;
allowLegacyAuth?: boolean;
onStateChange?: () => void;
onPageShare?: (payload: PageSharePayload) => Promise<void>;
}): Promise<ExtensionRelayHandle> {
const allowLegacyAuth = params.allowLegacyAuth ?? true;
const internalToken = crypto.randomBytes(32).toString("base64url");
if (readExtensionRelayToken() === params.token) {
getBrowserRelayAuthV2Authority(params.token);
}
const bridge = new ExtensionRelayBridge({
onStateChange: params.onStateChange,
onPageShare: params.onPageShare,
@@ -131,77 +359,330 @@ export async function startExtensionRelayServer(params: {
noServer: true,
maxPayload: EXTENSION_RELAY_MAX_PAYLOAD_BYTES,
});
const httpStates = new WeakMap<Duplex, HttpAuthState>();
const socketAuthorities = new WeakMap<Duplex, BrowserRelayAuthV2Authority>();
const authSockets = new Set<Duplex>();
const currentAuthority = (): BrowserRelayAuthV2Authority | null => {
const liveToken = readExtensionRelayToken();
if (!liveToken) {
invalidateBrowserRelayAuthV2Authority();
return null;
}
return getBrowserRelayAuthV2Authority(liveToken);
};
const clearSocketState = (socket: Duplex) => {
const state = httpStates.get(socket);
if (state && "timer" in state) {
clearTimeout(state.timer);
}
httpStates.delete(socket);
authSockets.delete(socket);
const authority = socketAuthorities.get(socket);
socketAuthorities.delete(socket);
authority?.releaseConnection(socket);
};
const armSocketTimer = (socket: Duplex): NodeJS.Timeout => {
const timer = setTimeout(() => socket.destroy(), BROWSER_RELAY_CHALLENGE_TTL_MS);
timer.unref?.();
return timer;
};
const registerHttpSocket = (socket: Duplex, authority: BrowserRelayAuthV2Authority): boolean => {
if (authSockets.has(socket)) {
return true;
}
if (!authority.registerPendingConnection(socket, () => socket.destroy())) {
return false;
}
authSockets.add(socket);
socketAuthorities.set(socket, authority);
socket.once("close", () => clearSocketState(socket));
return true;
};
const versionPayload = () => ({
Browser: bridge.identity?.browserVersion ?? "Chrome/unknown",
"Protocol-Version": "1.3",
"User-Agent": bridge.identity?.userAgent ?? "unknown",
webSocketDebuggerUrl: `ws://127.0.0.1:${resolvedPort()}/cdp`,
});
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`.",
}),
);
void (async () => {
if (!hasLoopbackHostHeader(req)) {
rejectHttp(res, 403, "Forbidden");
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.devtoolsTargetDescriptors()));
return;
}
res.writeHead(404).end("Not found");
const path = (req.url ?? "/").split("?")[0];
const socket = req.socket;
const existingState = httpStates.get(socket);
const authority = currentAuthority();
if (path === BROWSER_RELAY_AUTH_CHALLENGE_PATH) {
if (
req.url !== BROWSER_RELAY_AUTH_CHALLENGE_PATH ||
req.method !== "POST" ||
existingState ||
!authority ||
!registerHttpSocket(socket, authority)
) {
rejectHttp(res, existingState ? 409 : 400, "Invalid relay auth sequence");
return;
}
const pending: HttpAuthState = { stage: "busy" };
httpStates.set(socket, pending);
const raw = await readAuthBody(req);
const request =
raw === null ? null : parseRelayHttpChallengeRequest(parseStrictJsonObject(raw));
if (!request || request.keyId !== authority.keyId) {
clearSocketState(socket);
rejectHttp(res, 400, "Invalid relay auth challenge request");
return;
}
const challenge = authority.issueChallenge(
socket,
{ type: "auth.hello", v: 2, keyId: request.keyId, clientNonce: request.clientNonce },
{
role: request.role,
transport: request.transport,
method: request.method,
resource: request.resource,
flow: request.flow,
},
);
if (!challenge) {
clearSocketState(socket);
rejectHttp(res, 401, "Relay auth challenge rejected");
return;
}
res.once("finish", () => {
if (!socket.destroyed && httpStates.get(socket) === pending) {
httpStates.set(socket, {
stage: "challenged",
flow: request.flow,
authority,
timer: armSocketTimer(socket),
});
}
});
writeJson(res, 200, challenge);
return;
}
if (path === BROWSER_RELAY_AUTH_COMPLETE_PATH) {
if (
req.url !== BROWSER_RELAY_AUTH_COMPLETE_PATH ||
req.method !== "POST" ||
existingState?.stage !== "challenged"
) {
rejectHttp(res, 409, "Invalid relay auth sequence");
return;
}
clearTimeout(existingState.timer);
const pending: HttpAuthState = { stage: "busy" };
httpStates.set(socket, pending);
const raw = await readAuthBody(req);
const request =
raw === null ? null : parseRelayHttpCompleteRequest(parseStrictJsonObject(raw));
const completed = request
? existingState.authority.completeChallenge(socket, {
type: "auth.response",
...request,
})
: null;
if (!completed) {
clearSocketState(socket);
rejectHttp(res, 401, "Relay auth proof failed");
return;
}
res.once("finish", () => {
if (!socket.destroyed && httpStates.get(socket) === pending) {
httpStates.set(socket, {
stage: "authenticated",
flow: existingState.flow,
authority: existingState.authority,
timer: armSocketTimer(socket),
});
}
});
writeJson(res, 200, completed.ok);
return;
}
if (existingState?.stage === "authenticated") {
clearTimeout(existingState.timer);
const pending: HttpAuthState = { stage: "busy" };
httpStates.set(socket, pending);
if (existingState.flow === "cdp" && req.method === "GET" && req.url === "/json/version") {
if (!bridge.extensionConnected) {
clearSocketState(socket);
rejectHttp(res, 503, "OpenClaw Chrome extension is not connected");
return;
}
res.once("finish", () => {
if (!socket.destroyed && httpStates.get(socket) === pending) {
httpStates.set(socket, {
stage: "awaiting-upgrade",
authority: existingState.authority,
timer: armSocketTimer(socket),
});
}
});
writeJson(res, 200, versionPayload());
return;
}
if (
existingState.flow === "json-list" &&
req.method === "GET" &&
req.url === "/json/list"
) {
clearSocketState(socket);
res.once("finish", () => socket.destroy());
writeJson(res, 200, bridge.devtoolsTargetDescriptors(), { Connection: "close" });
return;
}
clearSocketState(socket);
rejectHttp(res, 409, "Invalid relay auth sequence");
return;
}
if (existingState) {
clearSocketState(socket);
rejectHttp(res, 409, "Invalid relay auth sequence");
return;
}
const legacyOrInternal =
isAuthorizedInternal(req, internalToken) ||
(authority !== null &&
isAuthorizedLegacy(req, readExtensionRelayToken() ?? "", allowLegacyAuth));
if (!legacyOrInternal) {
rejectHttp(res, 401, "Unauthorized");
return;
}
if (req.method === "GET" && (path === "/json/version" || path === "/json/version/")) {
if (!bridge.extensionConnected) {
writeJson(res, 503, {
error:
"OpenClaw Chrome extension is not connected. Install the extension and pair it with `openclaw browser extension pair`.",
});
return;
}
writeJson(res, 200, versionPayload());
return;
}
if (req.method === "GET" && (path === "/json" || path === "/json/list")) {
writeJson(res, 200, bridge.devtoolsTargetDescriptors());
return;
}
rejectHttp(res, 404, "Not found");
})().catch((err: unknown) => {
log.warn(`relay HTTP request failed: ${String(err)}`);
if (!res.headersSent) {
rejectHttp(res, 500, "Relay request failed");
} else {
res.destroy();
}
});
});
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");
destroySocket(socket, "HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
return;
}
if (path === "/extension") {
if (!isAllowedExtensionOrigin(req)) {
destroySocket(socket, "HTTP/1.1 403 Forbidden\r\n\r\n");
destroySocket(socket, "HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
return;
}
const protocols = requestProtocols(req);
const resource = parseExtensionRelayResource(req.url ?? "/", "/extension");
if (
protocols.length === 1 &&
protocols[0] === BROWSER_RELAY_EXTENSION_SUBPROTOCOL &&
resource
) {
const authority = currentAuthority();
if (!authority) {
destroySocket(socket, "HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
return;
}
if (
!handlePreAuthWebSocketUpgrade({
wss,
req,
socket,
head,
onUpgrade: (ws, removePreAuthGuard) => {
authenticateExtensionWebSocket({
ws,
authority,
resource,
removePreAuthGuard,
prepareAuthenticated: async () => () => {
attachExtensionWebSocket(bridge, ws);
log.info("extension authenticated and connected to relay");
},
});
},
})
) {
destroySocket(socket, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
}
return;
}
if (protocols.includes(BROWSER_RELAY_EXTENSION_SUBPROTOCOL)) {
destroySocket(socket, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
return;
}
const liveToken = readExtensionRelayToken();
if (!liveToken || !isAuthorizedLegacy(req, liveToken, allowLegacyAuth)) {
destroySocket(socket, "HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
return;
}
const authority = getBrowserRelayAuthV2Authority(liveToken);
wss.handleUpgrade(req, socket, head, (ws) => {
if (!trackAuthenticatedSocket(authority, ws)) {
return;
}
attachExtensionWebSocket(bridge, ws);
log.info("extension connected to relay");
log.warn("legacy extension relay authentication accepted");
});
return;
}
if (path === "/cdp") {
wss.handleUpgrade(req, socket, head, (ws) => {
bindSocket(ws, bridge.attachCdpClientSocket(ws));
});
const state = httpStates.get(socket);
if (req.url === "/cdp" && state?.stage === "awaiting-upgrade") {
clearTimeout(state.timer);
httpStates.delete(socket);
wss.handleUpgrade(req, socket, head, (ws) =>
bindSocket(ws, bridge.attachCdpClientSocket(ws)),
);
return;
}
if (
!isAuthorizedInternal(req, internalToken) &&
!isAuthorizedLegacy(req, readExtensionRelayToken() ?? "", allowLegacyAuth)
) {
destroySocket(socket, "HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
return;
}
const authority = currentAuthority();
if (!authority) {
destroySocket(socket, "HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
return;
}
wss.handleUpgrade(req, socket, head, (ws) =>
trackAuthenticatedSocket(authority, ws)
? bindSocket(ws, bridge.attachCdpClientSocket(ws))
: undefined,
);
return;
}
destroySocket(socket, "HTTP/1.1 404 Not Found\r\n\r\n");
destroySocket(socket, "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
});
await new Promise<void>((resolve, reject) => {
@@ -217,8 +698,17 @@ export async function startExtensionRelayServer(params: {
return {
port: resolvedPort(),
token: params.token,
allowLegacyAuth,
internalToken,
bridge,
close: async () => {
for (const socket of authSockets) {
clearSocketState(socket);
socket.destroy();
}
for (const client of wss.clients) {
client.terminate();
}
bridge.dispose();
wss.close();
await new Promise<void>((resolve) => {
@@ -227,16 +717,3 @@ export async function startExtensionRelayServer(params: {
},
};
}
function bindSocket(
ws: WebSocket,
handlers: { onMessage: (raw: string) => void; onClose: () => void },
): void {
ws.on("message", (data) => {
handlers.onMessage(rawDataToString(data));
});
ws.on("close", handlers.onClose);
ws.on("error", (err) => {
log.warn(`relay socket error: ${String(err)}`);
});
}
@@ -29,6 +29,7 @@ export async function createBrowserRuntimeState(
profiles: new Map(),
};
const stopTrackedTabCleanup = startTrackedBrowserTabCleanupTimer({
getResolvedBrowserConfig: () => state.resolved,
onWarn: params.onWarn,
});
trackedTabCleanupDisposers.set(state, stopTrackedTabCleanup);
@@ -25,7 +25,10 @@ const {
} = vi.hoisted(() => {
const trackedTabCleanupMockLocal = vi.fn();
return {
startTrackedBrowserTabCleanupTimerMock: vi.fn(() => trackedTabCleanupMockLocal),
startTrackedBrowserTabCleanupTimerMock: vi.fn(
(_params: { getResolvedBrowserConfig?: () => unknown; onWarn: (message: string) => void }) =>
trackedTabCleanupMockLocal,
),
stopKnownBrowserProfilesMock: vi.fn(async () => {}),
trackedTabCleanupMock: trackedTabCleanupMockLocal,
};
@@ -54,6 +57,26 @@ beforeEach(() => {
});
describe("browser unhandled rejection lifecycle", () => {
it("binds periodic cleanup to the current runtime config", async () => {
const firstResolved = { profiles: {}, marker: "first" } as never;
const secondResolved = { profiles: {}, marker: "second" } as never;
const state = await createBrowserRuntimeState({
resolved: firstResolved,
port: 18791,
onWarn: vi.fn(),
});
const cleanupParams = startTrackedBrowserTabCleanupTimerMock.mock.calls[0]?.[0];
expect(cleanupParams?.getResolvedBrowserConfig?.()).toBe(firstResolved);
state.resolved = secondResolved;
expect(cleanupParams?.getResolvedBrowserConfig?.()).toBe(secondResolved);
await stopBrowserRuntime({
current: state,
getState: () => state,
clearState: vi.fn(),
onWarn: vi.fn(),
});
});
it("matches direct and nested Playwright dialog-race protocol errors", async () => {
const state = await createBrowserRuntimeState({
resolved: { profiles: {} } as never,
@@ -61,6 +61,8 @@ function makeState(): BrowserServerState {
cdpPortRangeEnd: 18899,
extensionRelayDefaultPort: 18799,
extensionRelayPorts: {},
extensionRelay: { allowLegacyAuth: true },
extensionRelayInternalTokens: {},
cdpProtocol: "http",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
@@ -27,6 +27,8 @@ export function makeState(
cdpPortRangeEnd: 18899,
extensionRelayDefaultPort: 18799,
extensionRelayPorts: {},
extensionRelay: { allowLegacyAuth: true },
extensionRelayInternalTokens: {},
cdpProtocol: profile === "remote" ? "https" : "http",
cdpHost: profile === "remote" ? "1.1.1.1" : "127.0.0.1",
cdpIsLoopback: profile !== "remote",
@@ -41,6 +41,8 @@ export function makeBrowserServerState(params?: {
cdpPortRangeEnd: 18810,
extensionRelayDefaultPort: 18808,
extensionRelayPorts: {},
extensionRelay: { allowLegacyAuth: true },
extensionRelayInternalTokens: {},
evaluateEnabled: false,
remoteCdpTimeoutMs: 1500,
remoteCdpHandshakeTimeoutMs: 3000,
@@ -54,4 +54,21 @@ describe("session tab cleanup timer", () => {
await stop();
});
it("forwards the live runtime config resolver to every periodic sweep", async () => {
registryMocks.sweepTrackedBrowserTabs.mockResolvedValue(0);
const resolved = { profiles: {} } as never;
const getResolvedBrowserConfig = vi.fn(() => resolved);
const stop = startTrackedBrowserTabCleanupTimer({
getResolvedBrowserConfig,
onWarn: vi.fn(),
});
await vi.advanceTimersByTimeAsync(300_000);
await vi.waitFor(() => expect(registryMocks.sweepTrackedBrowserTabs).toHaveBeenCalledOnce());
expect(registryMocks.sweepTrackedBrowserTabs).toHaveBeenCalledWith(
expect.objectContaining({ getResolvedBrowserConfig }),
);
await stop();
});
});
@@ -7,7 +7,11 @@ import {
isSubagentSessionKey,
} from "openclaw/plugin-sdk/routing";
import { getRuntimeConfig } from "../config/config.js";
import { resolveBrowserConfig, type ResolvedBrowserTabCleanupConfig } from "./config.js";
import {
resolveBrowserConfig,
type ResolvedBrowserConfig,
type ResolvedBrowserTabCleanupConfig,
} from "./config.js";
import { sweepTrackedBrowserTabs } from "./session-tab-registry.js";
const MIN_SWEEP_INTERVAL_MS = 60_000;
@@ -35,6 +39,7 @@ async function runTrackedBrowserTabCleanupOnce(params?: {
now?: number;
cleanup?: ResolvedBrowserTabCleanupConfig;
closeTab?: (tab: { targetId: string; baseUrl?: string; profile?: string }) => Promise<void>;
getResolvedBrowserConfig?: () => ResolvedBrowserConfig | null;
onWarn?: (message: string) => void;
}): Promise<number> {
const cleanup = params?.cleanup ?? resolveBrowserTabCleanupRuntimeConfig();
@@ -47,12 +52,14 @@ async function runTrackedBrowserTabCleanupOnce(params?: {
maxTabsPerSession: cleanup.maxTabsPerSession,
sessionFilter: isPrimaryTrackedBrowserSessionKey,
closeTab: params?.closeTab,
getResolvedBrowserConfig: params?.getResolvedBrowserConfig,
onWarn: params?.onWarn,
});
}
/** Starts the recurring Browser tab cleanup timer and returns its disposer. */
export function startTrackedBrowserTabCleanupTimer(params: {
getResolvedBrowserConfig?: () => ResolvedBrowserConfig | null;
onWarn: (message: string) => void;
}): () => Promise<void> {
let stopped = false;
@@ -78,7 +85,10 @@ export function startTrackedBrowserTabCleanupTimer(params: {
return;
}
if (!running) {
running = runTrackedBrowserTabCleanupOnce({ onWarn: params.onWarn })
running = runTrackedBrowserTabCleanupOnce({
getResolvedBrowserConfig: params.getResolvedBrowserConfig,
onWarn: params.onWarn,
})
.catch((error: unknown) => {
params.onWarn(`failed to sweep tracked browser tabs: ${String(error)}`);
})
@@ -0,0 +1,203 @@
// Browser tests cover extension-tab cleanup through live runtime-owned credentials.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type {
OpenKeyedStoreOptions,
PluginStateSyncKeyedStore,
} from "openclaw/plugin-sdk/plugin-state-runtime";
import {
createPluginStateKeyedStoreForTests,
createPluginStateSyncKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
} from "openclaw/plugin-sdk/runtime-config-snapshot";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerBrowserPlugin } from "../../plugin-registration.js";
import type { OpenClawPluginApi } from "../../runtime-api.js";
import { useAutoCleanupTempDirTracker } from "../../test-support.js";
import type { CloseTrackedCdpTargetResult } from "./cdp.helpers.js";
import { resolveBrowserConfig, type ResolvedBrowserConfig } from "./config.js";
import { BROWSER_TAB_UNREACHABLE_RETIRE_MS } from "./constants.js";
import { durableOwnership } from "./session-tab-registry.sqlite.test-helpers.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const cdpMocks = vi.hoisted(() => ({
closeTrackedCdpTarget: vi.fn<() => Promise<CloseTrackedCdpTargetResult>>(),
}));
vi.mock("./cdp.helpers.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./cdp.helpers.js")>()),
closeTrackedCdpTarget: cdpMocks.closeTrackedCdpTarget,
}));
import {
closeTrackedBrowserTabsForSessions,
sweepTrackedBrowserTabs,
trackSessionBrowserTab,
} from "./session-tab-registry.js";
const config = {
browser: {
defaultProfile: "chrome",
profiles: {
chrome: {
driver: "extension",
cdpPort: 18_799,
color: "#123456",
},
},
},
} satisfies OpenClawConfig;
function clearProcessLocalTabState(): void {
const state = globalThis as Record<symbol, unknown>;
for (const name of [
"openclaw.browser.session-tabs.volatile",
"openclaw.browser.session-tabs.active-durable-keys",
"openclaw.browser.session-tabs.cold-native-activity",
"openclaw.browser.session-tabs.interaction-storage-keys",
"openclaw.browser.session-tabs.exact-interaction-storage-keys",
"openclaw.browser.session-tabs.volatile-aliases",
"openclaw.browser.session-tabs.exact-volatile-aliases",
]) {
delete state[Symbol.for(name)];
}
}
function installRuntime(): void {
registerBrowserPlugin(
createTestPluginApi({
id: "browser",
name: "Browser",
source: "test",
rootDir: "/plugins/browser",
config: {},
runtime: {
state: {
openKeyedStore: (options: OpenKeyedStoreOptions) =>
createPluginStateKeyedStoreForTests("browser", options),
openSyncKeyedStore: (options: OpenKeyedStoreOptions) =>
createPluginStateSyncKeyedStoreForTests("browser", options),
},
} as unknown as OpenClawPluginApi["runtime"],
}),
);
}
function openStore(): PluginStateSyncKeyedStore<unknown> {
return createPluginStateSyncKeyedStoreForTests("browser", {
namespace: "browser.session-tabs",
maxEntries: 5_000,
overflowPolicy: "reject-new",
});
}
describe("durable extension session tab cleanup", () => {
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
let resolved: ResolvedBrowserConfig;
beforeEach(() => {
clearRuntimeConfigSnapshot();
clearProcessLocalTabState();
process.env.OPENCLAW_STATE_DIR = tempDirs.make("openclaw-browser-extension-tabs-");
resetPluginStateStoreForTests();
installRuntime();
openStore().clear();
cdpMocks.closeTrackedCdpTarget.mockReset().mockResolvedValue({ status: "closed" });
setRuntimeConfigSnapshot(config, config);
resolved = resolveBrowserConfig(config.browser, config);
});
afterEach(() => {
clearRuntimeConfigSnapshot();
clearProcessLocalTabState();
resetPluginStateStoreForTests();
if (originalStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = originalStateDir;
}
});
it("uses the live process-only extension credential for lifecycle cleanup", async () => {
expect(resolved.extensionRelayInternalTokens).toEqual({});
trackSessionBrowserTab({
sessionKey: "agent:main:main",
targetId: "extension-tab",
profile: "chrome",
ownership: durableOwnership("NATIVE-EXTENSION"),
now: 1_000,
});
const internalToken = "process-only-test-credential";
const liveResolved: ResolvedBrowserConfig = {
...resolved,
extensionRelayInternalTokens: { chrome: internalToken },
};
expect(JSON.stringify(openStore().entries())).not.toContain(internalToken);
await expect(
closeTrackedBrowserTabsForSessions({
sessionKeys: ["agent:main:main"],
getResolvedBrowserConfig: () => liveResolved,
}),
).resolves.toBe(1);
expect(cdpMocks.closeTrackedCdpTarget).toHaveBeenCalledWith(
expect.objectContaining({
profileName: "chrome",
cdpUrl: `http://openclaw-internal:${internalToken}@127.0.0.1:18799`,
nativeTargetId: "NATIVE-EXTENSION",
}),
);
expect(openStore().entries()).toEqual([]);
});
it("retains cleanup without a runtime and closes it after reconnect", async () => {
trackSessionBrowserTab({
sessionKey: "agent:main:main",
targetId: "extension-tab",
profile: "chrome",
ownership: durableOwnership("NATIVE-EXTENSION"),
now: 1_000,
});
let liveResolved: ResolvedBrowserConfig | null = null;
const warnings: string[] = [];
const getResolvedBrowserConfig = () => liveResolved;
const afterRetireAge = 1_000 + BROWSER_TAB_UNREACHABLE_RETIRE_MS;
await expect(
sweepTrackedBrowserTabs({
now: afterRetireAge,
idleMs: 1,
getResolvedBrowserConfig,
onWarn: (message) => warnings.push(message),
}),
).resolves.toBe(0);
expect(cdpMocks.closeTrackedCdpTarget).not.toHaveBeenCalled();
expect(openStore().entries()).toHaveLength(1);
expect(warnings).toContain(
"deferred tracked browser tab NATIVE-EXTENSION: extension relay runtime unavailable",
);
const internalToken = "reconnected-process-only-credential";
liveResolved = {
...resolved,
extensionRelayInternalTokens: { chrome: internalToken },
};
await expect(
sweepTrackedBrowserTabs({
now: afterRetireAge + 1,
idleMs: 1,
getResolvedBrowserConfig,
}),
).resolves.toBe(1);
expect(cdpMocks.closeTrackedCdpTarget).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: `http://openclaw-internal:${internalToken}@127.0.0.1:18799`,
}),
);
expect(openStore().entries()).toEqual([]);
});
});
@@ -3,6 +3,7 @@
// exported from production code.
import type { CloseTrackedCdpTargetResult } from "./cdp.helpers.js";
import type { BrowserTabOwnership } from "./client.types.js";
import type { ResolvedBrowserConfig } from "./config.js";
type TabIdentity = {
sessionKey?: string;
@@ -45,6 +46,7 @@ type CleanupParams = {
tab: DurableTab,
options: { shouldClose: () => boolean },
) => Promise<CloseTrackedCdpTargetResult>;
getResolvedBrowserConfig?: () => ResolvedBrowserConfig | null;
onWarn?: (message: string) => void;
};
@@ -8,7 +8,7 @@ import { resolveCdpControlPolicy } from "./cdp-reachability-policy.js";
import { closeTrackedCdpTarget, type CloseTrackedCdpTargetResult } from "./cdp.helpers.js";
import { browserCloseTabByRawTargetId } from "./client.js";
import type { BrowserTabOwnership } from "./client.types.js";
import { resolveBrowserConfig, resolveProfile } from "./config.js";
import { resolveBrowserConfig, resolveProfile, type ResolvedBrowserConfig } from "./config.js";
import { BROWSER_TAB_UNREACHABLE_RETIRE_MS } from "./constants.js";
import {
type CleanupKind,
@@ -78,6 +78,9 @@ type DurableTab = DurableRecord & {
type TrackedTab = VolatileTab | DurableTab;
type DurableOwnership = Extract<BrowserTabOwnership, { status: "durable" }>;
type DurableCleanupResult =
| CloseTrackedCdpTargetResult
| { status: "unavailable"; reason: "extension-relay-unavailable" };
type CloseTab = (tab: {
targetId: string;
nativeTargetId?: string;
@@ -90,6 +93,7 @@ type CloseParams = {
tab: DurableTab,
options: { shouldClose: () => boolean },
) => Promise<CloseTrackedCdpTargetResult>;
getResolvedBrowserConfig?: () => ResolvedBrowserConfig | null;
onWarn?: (message: string) => void;
};
@@ -473,13 +477,20 @@ export function untrackSessionBrowserTab(params: SessionTabParams): void {
async function closeCurrentDurableTab(
tab: DurableTab,
shouldClose: () => boolean,
): Promise<CloseTrackedCdpTargetResult> {
const cfg = getRuntimeConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
getResolvedBrowserConfig?: () => ResolvedBrowserConfig | null,
): Promise<DurableCleanupResult> {
let resolved = getResolvedBrowserConfig?.();
if (!resolved) {
const cfg = getRuntimeConfig();
resolved = resolveBrowserConfig(cfg.browser, cfg);
}
const profile = resolveProfile(resolved, tab.profile);
if (!profile?.cdpUrl) {
return { status: "ownership-mismatch" };
}
if (profile.driver === "extension" && !resolved.extensionRelayInternalTokens[profile.name]) {
return { status: "unavailable", reason: "extension-relay-unavailable" };
}
const cdpControlPolicy = resolveCdpControlPolicy(profile, resolved.ssrfPolicy);
return await closeTrackedCdpTarget({
profileName: profile.name,
@@ -504,7 +515,7 @@ async function performDurableCleanup(
return 0;
}
const shouldClose = () => ownsCleanupAttempt(tab);
let outcome: CloseTrackedCdpTargetResult;
let outcome: DurableCleanupResult;
try {
if (params.closeDurableTab) {
outcome = await params.closeDurableTab(tab, { shouldClose });
@@ -519,7 +530,7 @@ async function performDurableCleanup(
});
outcome = { status: "closed" };
} else {
outcome = await closeCurrentDurableTab(tab, shouldClose);
outcome = await closeCurrentDurableTab(tab, shouldClose, params.getResolvedBrowserConfig);
}
} catch (error) {
if (isIgnorableTabCloseError(error)) {
@@ -533,6 +544,12 @@ async function performDurableCleanup(
return 0;
}
if (outcome.status === "unavailable") {
if (outcome.reason === "extension-relay-unavailable") {
params.onWarn?.(
`deferred tracked browser tab ${tab.nativeTargetId}: extension relay runtime unavailable`,
);
return 0;
}
// A browser that never comes back leaves its rows unreachable forever: the
// sweep re-claims them, fails ownership lookup, and defers again. Without an
// age bound the namespace fills to its reject-new cap and every later
@@ -1,6 +1,7 @@
import { Command } from "commander";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createCliRuntimeCapture } from "../../test-support.js";
import { relayKeyIdFromHex } from "../browser/extension-relay/auth-v2-crypto.js";
import { resolveLocalPairingGatewayUrl } from "./browser-cli-extension-pairing.js";
import * as cliCoreApiModule from "./core-api.js";
@@ -38,6 +39,27 @@ describe("browser extension pairing Gateway URL", () => {
).toBe("wss://gateway.example");
});
it("rejects path-rewriting proxy prefixes for strict v2 resource binding", async () => {
vi.spyOn(cliCoreApiModule, "getRuntimeConfig").mockReturnValue({});
const errorSpy = vi
.spyOn(cliCoreApiModule.defaultRuntime, "error")
.mockImplementation(runtime.error);
vi.spyOn(cliCoreApiModule.defaultRuntime, "exit").mockImplementation(runtime.exit);
const { registerBrowserExtensionCommands } = await import("./browser-cli-extension.js");
const program = new Command();
registerBrowserExtensionCommands(program.command("browser"), () => ({}));
await expect(
program.parseAsync(
["browser", "extension", "pair", "--gateway-url", "wss://gateway.example/proxy-prefix"],
{ from: "user" },
),
).rejects.toThrow("__exit__:1");
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("must not include a path prefix"),
);
});
it("writes explicit JSON output through the raw machine-output sink", async () => {
vi.spyOn(cliCoreApiModule, "getRuntimeConfig").mockReturnValue({});
const logSpy = vi.spyOn(cliCoreApiModule.defaultRuntime, "log").mockImplementation(runtime.log);
@@ -84,7 +106,7 @@ describe("browser extension pairing Gateway URL", () => {
});
});
it("prints the relay CDP endpoint for external clients via cdp --json", async () => {
it("prints only safe v2 relay metadata via cdp --json", async () => {
vi.spyOn(cliCoreApiModule, "getRuntimeConfig").mockReturnValue({});
const logSpy = vi.spyOn(cliCoreApiModule.defaultRuntime, "log").mockImplementation(runtime.log);
const writeJsonSpy = vi
@@ -100,8 +122,75 @@ describe("browser extension pairing Gateway URL", () => {
expect(writeJsonSpy).toHaveBeenCalledWith({
browserUrl: "http://127.0.0.1:18799",
wsEndpoint: "ws://127.0.0.1:18799/cdp",
headers: { Authorization: `Bearer ${"a".repeat(64)}` },
auth: {
label: "openclaw.browser-relay.auth",
version: 2,
keyId: relayKeyIdFromHex("a".repeat(64)),
challengeUrl: "http://127.0.0.1:18799/_openclaw/relay/auth/v2/challenge",
completeUrl: "http://127.0.0.1:18799/_openclaw/relay/auth/v2/complete",
role: "cdp",
transport: "connection",
method: "SEQUENCE",
resource: "/json/version -> /cdp",
flow: "cdp",
},
});
expect(JSON.stringify(writeJsonSpy.mock.calls[0]?.[0])).not.toContain("Bearer");
expect(JSON.stringify(writeJsonSpy.mock.calls[0]?.[0])).not.toContain("a".repeat(64));
expect(logSpy).not.toHaveBeenCalled();
});
it("prints an explicit warned legacy bearer only while legacy auth is enabled", async () => {
vi.spyOn(cliCoreApiModule, "getRuntimeConfig").mockReturnValue({});
const errorSpy = vi
.spyOn(cliCoreApiModule.defaultRuntime, "error")
.mockImplementation(runtime.error);
const writeJsonSpy = vi
.spyOn(cliCoreApiModule.defaultRuntime, "writeJson")
.mockImplementation(runtime.writeJson);
const { registerBrowserExtensionCommands } = await import("./browser-cli-extension.js");
const program = new Command();
const browser = program.command("browser");
registerBrowserExtensionCommands(browser, () => ({}));
await program.parseAsync(["browser", "extension", "cdp", "--legacy-bearer", "--json"], {
from: "user",
});
expect(writeJsonSpy).toHaveBeenCalledWith(
expect.objectContaining({
headers: { Authorization: `Bearer ${"a".repeat(64)}` },
}),
);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("reveals the relay key"));
});
it("refuses --legacy-bearer when legacy auth is disabled", async () => {
vi.spyOn(cliCoreApiModule, "getRuntimeConfig").mockReturnValue({
browser: { extensionRelay: { allowLegacyAuth: false } },
});
const errorSpy = vi
.spyOn(cliCoreApiModule.defaultRuntime, "error")
.mockImplementation(runtime.error);
vi.spyOn(cliCoreApiModule.defaultRuntime, "exit").mockImplementation(runtime.exit);
const writeJsonSpy = vi
.spyOn(cliCoreApiModule.defaultRuntime, "writeJson")
.mockImplementation(runtime.writeJson);
const { registerBrowserExtensionCommands } = await import("./browser-cli-extension.js");
const program = new Command();
const browser = program.command("browser");
registerBrowserExtensionCommands(browser, () => ({}));
await expect(
program.parseAsync(["browser", "extension", "cdp", "--legacy-bearer", "--json"], {
from: "user",
}),
).rejects.toThrow("__exit__:1");
expect(writeJsonSpy).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("Legacy browser relay auth is disabled"),
);
expect(errorSpy.mock.calls.flat().join("\n")).not.toContain("a".repeat(64));
});
});
@@ -5,6 +5,15 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { Command } from "commander";
import {
BROWSER_RELAY_AUTH_LABEL,
BROWSER_RELAY_AUTH_VERSION,
relayKeyIdFromHex,
} from "../browser/extension-relay/auth-v2-crypto.js";
import {
BROWSER_RELAY_AUTH_CHALLENGE_PATH,
BROWSER_RELAY_AUTH_COMPLETE_PATH,
} from "../browser/extension-relay/auth-v2.js";
import { ensureExtensionRelayToken } from "../browser/extension-relay/relay-auth.js";
import { isLoopbackHost } from "../gateway/net.js";
import { resolveGatewayPort } from "../sdk-config.js";
@@ -50,7 +59,7 @@ function firstExtensionProfile(
/** Gateway route path for the remote extension relay (see gateway-relay-route.ts). */
const GATEWAY_EXTENSION_RELAY_PATH = "/browser/extension";
/** Resolve a safe direct-Gateway relay URL, preserving an optional proxy base path. */
/** Resolve a safe direct-Gateway relay URL with an exact v2-bound route path. */
function buildRemoteGatewayRelayUrl(raw: string): string {
let url: URL;
try {
@@ -66,8 +75,12 @@ function buildRemoteGatewayRelayUrl(raw: string): string {
if (url.username || url.password || url.search || url.hash) {
throw new Error("--gateway-url must not include credentials, a query, or a fragment");
}
const basePath = url.pathname.replace(/\/+$/, "");
url.pathname = `${basePath}${GATEWAY_EXTENSION_RELAY_PATH}`;
if (url.pathname !== "/") {
throw new Error(
"--gateway-url must not include a path prefix; Browser Relay Authentication v2 binds the exact /browser/extension path",
);
}
url.pathname = GATEWAY_EXTENSION_RELAY_PATH;
return url.toString();
}
@@ -113,24 +126,60 @@ async function buildPairingString(gatewayUrl?: string): Promise<{
};
}
/**
* Resolve the local relay CDP endpoint for third-party CDP clients
* (Puppeteer, chrome-devtools-mcp, raw WebSocket). Creates the host-local
* relay secret on first use, mirroring `pair`.
*/
async function buildCdpEndpoint(): Promise<{
type BrowserRelayCdpEndpoint = {
browserUrl: string;
wsEndpoint: string;
headers: { Authorization: string };
}> {
auth: {
label: typeof BROWSER_RELAY_AUTH_LABEL;
version: typeof BROWSER_RELAY_AUTH_VERSION;
keyId: string;
challengeUrl: string;
completeUrl: string;
role: "cdp";
transport: "connection";
method: "SEQUENCE";
resource: "/json/version -> /cdp";
flow: "cdp";
};
headers?: { Authorization: string };
};
/** Resolve safe v2 metadata, with an explicit gated legacy credential escape hatch. */
async function buildCdpEndpoint(options: {
legacyBearer: boolean;
}): Promise<BrowserRelayCdpEndpoint> {
const cfg = getRuntimeConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const token = await ensureExtensionRelayToken();
const profile = firstExtensionProfile(resolved);
const relayPort = profile?.relayPort ?? resolved.extensionRelayDefaultPort;
return {
browserUrl: `http://127.0.0.1:${relayPort}`,
const browserUrl = `http://127.0.0.1:${relayPort}`;
const metadata = {
browserUrl,
wsEndpoint: `ws://127.0.0.1:${relayPort}/cdp`,
auth: {
label: BROWSER_RELAY_AUTH_LABEL,
version: BROWSER_RELAY_AUTH_VERSION,
keyId: relayKeyIdFromHex(token),
challengeUrl: new URL(BROWSER_RELAY_AUTH_CHALLENGE_PATH, browserUrl).toString(),
completeUrl: new URL(BROWSER_RELAY_AUTH_COMPLETE_PATH, browserUrl).toString(),
role: "cdp" as const,
transport: "connection" as const,
method: "SEQUENCE" as const,
resource: "/json/version -> /cdp" as const,
flow: "cdp" as const,
},
};
if (!options.legacyBearer) {
return metadata;
}
if (!resolved.extensionRelay.allowLegacyAuth) {
throw new Error(
"Legacy browser relay auth is disabled; remove --legacy-bearer and use Browser Relay Authentication v2.",
);
}
return {
...metadata,
headers: { Authorization: `Bearer ${token}` },
};
}
@@ -189,7 +238,7 @@ export function registerBrowserExtensionCommands(
"",
theme.heading(result.pairing),
"",
info("The token is a host-local secret; keep it private."),
info("The relay key is a host-local secret; keep it private."),
].join("\n"),
);
},
@@ -202,31 +251,45 @@ export function registerBrowserExtensionCommands(
extension
.command("cdp")
.description("Print the relay CDP endpoint and auth header for external CDP clients")
.description("Print non-secret Browser Relay Authentication v2 CDP metadata")
.option("--json", "Print the endpoint as JSON")
.option(
"--legacy-bearer",
"Print the legacy Bearer header while browser.extensionRelay.allowLegacyAuth is enabled",
)
.action(async (opts) => {
await runCommandWithRuntime(
defaultRuntime,
async () => {
const endpoint = await buildCdpEndpoint();
const legacyBearer = opts.legacyBearer === true;
const endpoint = await buildCdpEndpoint({ legacyBearer });
if (legacyBearer) {
defaultRuntime.error(
theme.warn(
"Warning: --legacy-bearer reveals the relay key in an authorization header. Migrate this client to Browser Relay Authentication v2.",
),
);
}
if (opts.json === true) {
defaultRuntime.writeJson(endpoint);
return;
}
defaultRuntime.log(
[
info("Relay CDP endpoint (pair the extension first):"),
`browserUrl: ${endpoint.browserUrl}`,
`wsEndpoint: ${endpoint.wsEndpoint}`,
`header: Authorization: ${endpoint.headers.Authorization}`,
"",
info("Example (chrome-devtools-mcp):"),
` npx chrome-devtools-mcp --wsEndpoint ${endpoint.wsEndpoint} \\`,
` --wsHeaders '${JSON.stringify(endpoint.headers)}'`,
"",
info("The token is a host-local secret; keep it private."),
].join("\n"),
);
const lines = [
info("Relay CDP endpoint (pair the extension first):"),
`browserUrl: ${endpoint.browserUrl}`,
`wsEndpoint: ${endpoint.wsEndpoint}`,
`auth: ${endpoint.auth.label} v${endpoint.auth.version}`,
`keyId: ${endpoint.auth.keyId}`,
`challenge: POST ${endpoint.auth.challengeUrl}`,
`complete: POST ${endpoint.auth.completeUrl}`,
`sequence: ${endpoint.auth.resource}`,
];
if (endpoint.headers) {
lines.push(`legacy: Authorization: ${endpoint.headers.Authorization}`);
} else {
lines.push("", info("No relay key or authorization header is printed."));
}
defaultRuntime.log(lines.join("\n"));
},
(err: unknown) => {
defaultRuntime.error(danger(String(err)));
+14 -7
View File
@@ -44,9 +44,8 @@ async function startBrowserControlServiceUnlocked(): Promise<BrowserServerState
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.
// Ensure the host-local HMAC key exists before relay startup. Gateway hosts
// and browser node hosts each own an independent key.
const hasExtensionProfiles = Object.values(resolved.profiles).some(
(profile) => profile.driver === "extension",
);
@@ -89,10 +88,18 @@ export async function startBrowserControlServiceFromConfig(): Promise<BrowserSer
/** Stops the in-process Browser control service runtime. */
export async function stopBrowserControlService(): Promise<void> {
await stopBrowserControlRuntime({
requestedBy: "service",
onWarn: (message) => logService.warn(message),
});
try {
await stopBrowserControlRuntime({
requestedBy: "service",
onWarn: (message) => logService.warn(message),
});
} finally {
// Direct Gateway auth sockets can exist before Browser control lazy-starts,
// so plugin shutdown must close them even when there is no runtime state.
const { disposeGatewayExtensionRelay } =
await import("./browser/extension-relay/gateway-relay-route.js");
disposeGatewayExtensionRelay();
}
}
/** Re-export Browser control context accessors for gateway-local dispatch. */
@@ -28,6 +28,7 @@ describe("browser doctor readiness", () => {
await noteChromeMcpBrowserReadiness(
{
browser: {
extensionRelay: { allowLegacyAuth: false },
profiles: {
openclaw: { color: "#FF4500" },
},
@@ -44,11 +45,38 @@ describe("browser doctor readiness", () => {
expect(noteFn).not.toHaveBeenCalled();
});
it("warns while legacy Browser Relay Authentication remains enabled", async () => {
const noteFn = vi.fn();
await noteChromeMcpBrowserReadiness(
{
browser: {
extensionRelay: { allowLegacyAuth: true },
profiles: {
openclaw: { color: "#FF4500" },
},
},
},
{
noteFn,
platform: "linux",
env: { DISPLAY: ":99" },
getUid: () => 1000,
resolveManagedExecutable: () => ({ kind: "chrome", path: "/usr/bin/google-chrome" }),
},
);
expect(noteFn).toHaveBeenCalledWith(
expect.stringContaining("browser.extensionRelay.allowLegacyAuth=true"),
"Browser relay authentication",
);
});
it("warns when managed browser profiles have no local executable", async () => {
const noteFn = vi.fn();
await noteChromeMcpBrowserReadiness(
{
browser: {
extensionRelay: { allowLegacyAuth: false },
profiles: {
openclaw: { color: "#FF4500" },
},
@@ -78,6 +106,7 @@ describe("browser doctor readiness", () => {
await noteChromeMcpBrowserReadiness(
{
browser: {
extensionRelay: { allowLegacyAuth: false },
headless: false,
noSandbox: false,
profiles: {
@@ -111,6 +140,7 @@ describe("browser doctor readiness", () => {
await noteChromeMcpBrowserReadiness(
{
browser: {
extensionRelay: { allowLegacyAuth: false },
profiles: {
openclaw: { color: "#FF4500" },
},
@@ -141,6 +171,7 @@ describe("browser doctor readiness", () => {
await noteChromeMcpBrowserReadiness(
{
browser: {
extensionRelay: { allowLegacyAuth: false },
profiles: {
clawd: { color: "#FF4500" },
openclaw: { color: "#00AA00" },
@@ -166,6 +197,7 @@ describe("browser doctor readiness", () => {
await noteChromeMcpBrowserReadiness(
{
browser: {
extensionRelay: { allowLegacyAuth: false },
defaultProfile: "user",
},
},
@@ -189,6 +221,7 @@ describe("browser doctor readiness", () => {
await noteChromeMcpBrowserReadiness(
{
browser: {
extensionRelay: { allowLegacyAuth: false },
profiles: {
chromeLive: {
driver: "existing-session",
@@ -216,6 +249,7 @@ describe("browser doctor readiness", () => {
await noteChromeMcpBrowserReadiness(
{
browser: {
extensionRelay: { allowLegacyAuth: false },
profiles: {
chromeLive: {
driver: "existing-session",
@@ -243,6 +277,7 @@ describe("browser doctor readiness", () => {
await noteChromeMcpBrowserReadiness(
{
browser: {
extensionRelay: { allowLegacyAuth: false },
profiles: {
braveLive: {
driver: "existing-session",
+10
View File
@@ -233,6 +233,16 @@ export async function noteChromeMcpBrowserReadiness(
const managedProfiles = collectManagedProfiles(cfg);
const managedProfileLabel = managedProfiles.map((profile) => profile.name).join(", ");
const resolved = resolveBrowserConfig(cfg.browser, cfg);
if (resolved.enabled && resolved.extensionRelay.allowLegacyAuth) {
noteFn(
[
"- Legacy Browser Relay Authentication is enabled (browser.extensionRelay.allowLegacyAuth=true).",
"- Update paired Chrome extensions and external CDP clients to Browser Relay Authentication v2, then set browser.extensionRelay.allowLegacyAuth=false.",
"- V2 clients never downgrade to legacy authentication.",
].join("\n"),
"Browser relay authentication",
);
}
const legacyClawdResidue = detectLegacyClawdBrowserProfileResidue(cfg, {
configDir: deps?.configDir,
pathExists: deps?.pathExists,
@@ -26,6 +26,30 @@ function findingByCheckId(
}
describe("browser security audit collector", () => {
it("warns while legacy extension relay auth remains enabled", () => {
const findings = collectFindings({
browser: {
extensionRelay: { allowLegacyAuth: true },
},
});
const finding = findingByCheckId(findings, "browser.extension_relay_legacy_auth");
expect(finding.severity).toBe("warn");
expect(finding.remediation).toContain("allowLegacyAuth=false");
});
it("does not warn when legacy extension relay auth is disabled", () => {
const findings = collectFindings({
browser: {
extensionRelay: { allowLegacyAuth: false },
},
});
expect(
findings.some((finding) => finding.checkId === "browser.extension_relay_legacy_auth"),
).toBe(false);
});
it("flags browser control without auth", () => {
const findings = collectFindings({
gateway: {
+12
View File
@@ -49,6 +49,18 @@ export function collectBrowserSecurityAuditFindings(ctx: OpenClawPluginSecurityA
return findings;
}
if (resolved.extensionRelay.allowLegacyAuth) {
findings.push({
checkId: "browser.extension_relay_legacy_auth",
severity: "warn" as const,
title: "Legacy browser extension relay authentication is enabled",
detail:
"browser.extensionRelay.allowLegacyAuth defaults to true for one migration window, so old relay Bearer, Basic, and token-subprotocol clients can still authenticate.",
remediation:
"Update paired Chrome extensions and external CDP clients to Browser Relay Authentication v2, then set browser.extensionRelay.allowLegacyAuth=false.",
});
}
const browserAuth = resolveBrowserControlAuth(ctx.config, ctx.env);
const explicitAuthMode = ctx.config.gateway?.auth?.mode;
const tokenConfigured =
@@ -395,6 +395,31 @@ describe("config schema regressions", () => {
expect(res.ok).toBe(false);
});
it("accepts the browser extension relay legacy-auth migration gate", () => {
const res = validateConfigObject({
browser: {
extensionRelay: {
allowLegacyAuth: false,
},
},
});
expect(res.ok).toBe(true);
});
it("rejects unknown keys under browser.extensionRelay", () => {
const res = validateConfigObject({
browser: {
extensionRelay: {
allowLegacyAuth: true,
unknownKey: true as unknown,
},
},
});
expect(res.ok).toBe(false);
});
it("accepts discovery.wideArea.domain for unicast DNS-SD", () => {
const res = validateConfigObject({
discovery: {
@@ -250,6 +250,7 @@ export const TARGET_KEYS = [
"browser.profiles.*.userDataDir",
"browser.profiles.*.driver",
"browser.profiles.*.attachOnly",
"browser.extensionRelay.allowLegacyAuth",
"tools",
"tools.allow",
"tools.deny",
@@ -377,7 +378,7 @@ export const ENUM_EXPECTATIONS: Record<string, string[]> = {
"gateway.bind": ['"auto"', '"lan"', '"loopback"', '"custom"', '"tailnet"'],
"gateway.auth.mode": ['"none"', '"token"', '"password"', '"trusted-proxy"'],
"gateway.tailscale.mode": ['"off"', '"serve"', '"funnel"'],
"browser.profiles.*.driver": ['"openclaw"', '"clawd"', '"existing-session"'],
"browser.profiles.*.driver": ['"openclaw"', '"clawd"', '"existing-session"', '"extension"'],
"discovery.mdns.mode": ['"off"', '"minimal"', '"full"'],
"diagnostics.otel.protocol": ['"http/protobuf"'],
"diagnostics.otel.logsExporter": ['"otlp"', '"stdout"', '"both"'],
+5 -1
View File
@@ -34,7 +34,7 @@ export const RUNTIME_FIELD_HELP: Record<string, string> = {
"browser.profiles.*.mcpArgs":
"Extra per-profile Chrome DevTools MCP arguments for existing-session attachment, such as --no-usage-statistics. Endpoint arguments here override the built-in auto-connect or browser URL selection.",
"browser.profiles.*.driver":
'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, or use "existing-session" for Chrome DevTools MCP attachment on the selected host or browser node.',
'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, "existing-session" for Chrome DevTools MCP attachment, or "extension" for the authenticated Chrome extension relay.',
"browser.profiles.*.executablePath":
"Per-profile browser executable path for locally launched managed browser profiles. Overrides browser.executablePath and accepts paths starting with ~ for the OS home directory.",
"browser.profiles.*.headless":
@@ -51,6 +51,10 @@ export const RUNTIME_FIELD_HELP: Record<string, string> = {
"Best-effort cleanup policy for browser tabs opened by primary-agent sessions. Keep enabled to avoid stale sandbox or managed-browser tabs accumulating across long-lived gateways.",
"browser.tabCleanup.enabled":
"Enables cleanup of idle tracked browser tabs for primary-agent sessions. Disable only when external tooling owns tab lifecycle completely.",
"browser.extensionRelay":
"Chrome extension relay authentication compatibility settings. Keep the legacy window only while older paired extensions or external CDP clients still need it.",
"browser.extensionRelay.allowLegacyAuth":
"Temporarily accepts legacy Bearer, Basic, and token-subprotocol relay authentication. Default: true for one migration window. Set false after every extension and external CDP client uses Browser Relay Authentication v2.",
"browser.ssrfPolicy":
"Server-side request forgery guardrail settings for browser/network fetch paths that could reach internal hosts. Keep restrictive defaults in production and open only explicitly approved targets.",
"browser.ssrfPolicy.dangerouslyAllowPrivateNetwork":
+2
View File
@@ -173,6 +173,8 @@ export const FIELD_LABELS: Record<string, string> = {
"browser.profiles.*.executablePath": "Browser Profile Executable Path",
"browser.profiles.*.headless": "Browser Profile Headless Mode",
"browser.profiles.*.attachOnly": "Browser Profile Attach-only Mode",
"browser.extensionRelay": "Browser Extension Relay",
"browser.extensionRelay.allowLegacyAuth": "Allow Legacy Browser Relay Auth",
tools: "Tools",
"tools.allow": "Tool Allowlist",
"tools.deny": "Tool Denylist",
+7 -1
View File
@@ -33,6 +33,10 @@ export type BrowserTabCleanupConfig = {
/** Enable best-effort cleanup for tracked primary-agent browser tabs. Default: true */
enabled?: boolean;
};
export type BrowserExtensionRelayConfig = {
/** Temporarily accept legacy relay bearer/basic/subprotocol auth. Default: true. */
allowLegacyAuth?: boolean;
};
export type BrowserSsrFPolicyConfig = SsrFPolicyConfig;
export type BrowserConfig = {
/** @deprecated Doctor-only legacy input; canonical schema rejects this field. */
@@ -52,7 +56,7 @@ export type BrowserConfig = {
noSandbox?: boolean;
/** If true: never launch; only attach to an existing browser. Default: false */
attachOnly?: boolean;
/** Default profile to use when profile param is omitted. Default: "chrome" */
/** Default profile to use when profile param is omitted. Default: "openclaw" */
defaultProfile?: string;
/** Named browser profiles with explicit CDP ports or URLs. */
profiles?: Record<string, BrowserProfileConfig>;
@@ -60,6 +64,8 @@ export type BrowserConfig = {
snapshotDefaults?: BrowserSnapshotDefaults;
/** Best-effort cleanup policy for tabs opened by primary-agent browser sessions. */
tabCleanup?: BrowserTabCleanupConfig;
/** Chrome extension relay authentication compatibility settings. */
extensionRelay?: BrowserExtensionRelayConfig;
/** SSRF policy for browser navigation/open-tab operations. */
ssrfPolicy?: BrowserSsrFPolicyConfig;
/**
+5
View File
@@ -201,6 +201,11 @@ export const OpenClawSchemaShape = {
enabled: z.boolean().optional(),
})
.optional(),
extensionRelay: z
.strictObject({
allowLegacyAuth: z.boolean().optional(),
})
.optional(),
})
.optional(),
ui: z