mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 16:51:40 +00:00
* 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
165 lines
5.8 KiB
TypeScript
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);
|
|
},
|
|
);
|
|
});
|
|
}
|