Files
openclaw/extensions/browser/src/cli/browser-cli-extension.ts
Peter Steinberger 654029892d feat(browser): pair the Chrome extension directly to a remote gateway (#101127)
* feat(browser): direct extension→gateway relay path for remote Chrome (#53599)

Let the OpenClaw Chrome extension pair directly to a remote gateway over
wss:// with no OpenClaw node host on the browser machine — the managed-hosting
path from #53599 (extension is the only thing installed on the laptop).

- Gateway route /browser/extension registered by the browser plugin with
  auth:"plugin" + no nodeCapability, so the gateway does not pre-enforce token
  auth (browser WebSockets cannot send an Authorization header). The upgrade
  handler self-validates the host-local relay secret from ?token=, origin-checks
  chrome-extension://, resolves the extension profile, then attaches the socket
  to the same ExtensionRelayBridge the loopback relay uses. All CDP synthesis,
  tab-group scoping, and the in-process Playwright /cdp client are unchanged.
- `openclaw browser extension pair --gateway-url wss://host` prints a
  wss://host/browser/extension#<secret> string; the path ends in /extension so
  the extension's existing pairing parser accepts it with zero extension code
  changes.
- relay-server: extract attachExtensionWebSocket + export requestToken /
  isAllowedExtensionOrigin / EXTENSION_RELAY_MAX_PAYLOAD_BYTES so loopback and
  gateway paths share one bind + one frame cap.
- runtime-lifecycle: dispose the shared gateway WebSocketServer on shutdown.
- docs: three remote topologies (same host / direct-to-gateway / via node host).

Coverage: 6 unit tests for the handler's path/503/403/404/401/attach branches.
The full extension→bridge→CDP→Chrome loop over /browser/extension was live-proven
with a real Chrome + the built extension. The real gateway upgrade→handleUpgrade
dispatch for an auth:"plugin" unprotected route is verified against core
(server-http.ts, plugins-http.ts, route-auth.ts).

* fix(browser): harden remote extension pairing
2026-07-07 04:31:52 +01:00

165 lines
5.8 KiB
TypeScript

/**
* `openclaw browser extension` CLI: locate the unpacked Chrome extension and
* print the pairing string that connects it to this install's relay.
*/
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { Command } from "commander";
import { ensureExtensionRelayToken } from "../browser/extension-relay/relay-auth.js";
import { isLoopbackHost } from "../gateway/net.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
import {
danger,
defaultRuntime,
getRuntimeConfig,
info,
resolveBrowserConfig,
runCommandWithRuntime,
theme,
} from "./core-api.js";
/** Absolute path to the bundled unpacked Chrome extension directory. */
function resolveChromeExtensionDir(pluginRoot?: string): string {
if (pluginRoot) {
return path.join(pluginRoot, "chrome-extension");
}
// extensions/browser/dist/cli/ -> extensions/browser/chrome-extension
const here = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(here, "..", "..", "chrome-extension");
}
function firstExtensionProfile(): { name: string; relayPort: number } | null {
const cfg = getRuntimeConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
for (const [name, profile] of Object.entries(resolved.profiles)) {
if (profile.driver === "extension") {
return { name, relayPort: profile.cdpPort ?? resolved.extensionRelayDefaultPort };
}
}
return null;
}
/** 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. */
export function buildRemoteGatewayRelayUrl(raw: string): string {
let url: URL;
try {
url = new URL(raw.trim());
} catch {
throw new Error("--gateway-url must be a valid ws:// or wss:// URL");
}
const secure = url.protocol === "wss:";
const localPlaintext = url.protocol === "ws:" && isLoopbackHost(url.hostname);
if (!secure && !localPlaintext) {
throw new Error("--gateway-url must use wss:// (ws:// is allowed only for loopback)");
}
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}`;
return url.toString();
}
function buildPairingString(gatewayUrl?: string): {
pairing: string;
relayPort: number;
remote: boolean;
} {
const cfg = getRuntimeConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
// Create the host-local relay secret if this host has not used the extension
// driver yet, so pairing works on a fresh gateway or node host before the
// relay has started. Pairing must run on the machine that hosts the browser.
const token = ensureExtensionRelayToken();
const profile = firstExtensionProfile();
const relayPort = profile?.relayPort ?? resolved.extensionRelayDefaultPort;
const gateway = gatewayUrl?.trim();
if (gateway) {
// Remote: the extension connects straight to this gateway over wss:// — no
// node host on the browser machine. The gateway route self-validates the
// same host-local secret.
return {
pairing: `${buildRemoteGatewayRelayUrl(gateway)}#${token}`,
relayPort,
remote: true,
};
}
return {
pairing: `ws://127.0.0.1:${relayPort}/extension#${token}`,
relayPort,
remote: false,
};
}
/** Register `openclaw browser extension {path,pair}`. */
export function registerBrowserExtensionCommands(
browser: Command,
_parentOpts: (cmd: Command) => BrowserParentOpts,
pluginRoot?: string,
) {
const extension = browser
.command("extension")
.description("Chrome extension: print the load path and pairing string");
extension
.command("path")
.description("Print the unpacked Chrome extension directory (Load unpacked)")
.action(() => {
defaultRuntime.log(resolveChromeExtensionDir(pluginRoot));
});
extension
.command("pair")
.description("Print the pairing string to paste into the OpenClaw extension popup")
.option("--json", "Print the pairing string as JSON")
.option(
"--gateway-url <url>",
"Print a remote pairing string for a Chrome on another machine (e.g. wss://gateway.example.com)",
)
.action(async (opts) => {
await runCommandWithRuntime(
defaultRuntime,
async () => {
const result = buildPairingString(opts.gatewayUrl);
if (opts.json === true) {
defaultRuntime.log(
JSON.stringify({
pairingString: result.pairing,
relayPort: result.relayPort,
remote: result.remote,
}),
);
return;
}
const setupLine = result.remote
? info(
"Remote pairing: load and pair the extension on the machine running Chrome; it connects to this gateway over wss://.",
)
: info(
"Run this on the machine that hosts the browser (gateway host or browser node).",
);
defaultRuntime.log(
[
setupLine,
info("1. Load the extension: chrome://extensions → Developer mode → Load unpacked →"),
` ${resolveChromeExtensionDir(pluginRoot)}`,
info("2. Open the OpenClaw popup and paste this pairing string:"),
"",
theme.heading(result.pairing),
"",
info("The token is a host-local secret; keep it private."),
].join("\n"),
);
},
(err: unknown) => {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
},
);
});
}