Files
openclaw/extensions/browser/src/plugin-service.ts
Peter Steinberger 4efcea1fd2 feat(browser): send pages to OpenClaw from the Chrome extension (#111158)
* feat(browser): send pages to the main session from the Chrome extension

One-click page share in the OpenClaw Chrome extension: toolbar popup with an
optional note, page/selection context menu, and Alt+Shift+S. Capture is
selection-first with a readability heuristic, X/Twitter thread extraction, and
Google Docs plain-text export via the user's session cookies. Payloads ride the
existing paired relay WebSocket as a new pageShare message; the gateway-only
page-share sink wraps page text in the external-content safety boundary, then
enqueues a main-session system event and requests an immediate heartbeat
(hooks/wake semantics). Node-hosted relays report a clear unsupported error.

Capture heuristics adapted from Nat Eliason's MIT-licensed send-to-openclaw.

Co-authored-by: Codex <codex@openai.com>

* fix(browser): keep page-controlled metadata inside the share safety boundary

Review findings: move title/URL inside wrapExternalContent (a hostile <title>
must not become trusted header text), prefer the user's selection over the
full Google Docs export, and pass the context-menu selectionText through so
iframe selections and selections cleared during relay reconnect still win.

* fix(browser): bind context-menu shares to the click-time document

Selection shares from the context menu now send the click snapshot directly
(no recapture), so navigations during relay reconnect cannot mislabel the
source and iframe selections are preserved. The Google Docs selection probe
scans all accessible frames before falling back to the full-document export.

* test(browser): expect the page-share handler in relay server args

* fix(browser): probe only the main frame for Google Docs selections

All-frame injection rejects wholesale when one frame is inaccessible and
returns child frames in nondeterministic order, so the probe now reads the
main frame only. Child-frame selections still share correctly through the
context menu's click-time selectionText; toolbar/shortcut entry sends the
full page for that case (named tradeoff in the code comment).

* fix(browser): satisfy page-share CI gates

---------

Co-authored-by: Codex <codex@openai.com>
2026-07-19 01:07:28 -07:00

72 lines
2.6 KiB
TypeScript

/**
* Browser plugin service factory that lazily starts the control server.
*/
import {
startLazyPluginServiceModule,
type LazyPluginServiceHandle,
type OpenClawPluginService,
} from "./sdk-node-runtime.js";
type BrowserControlHandle = LazyPluginServiceHandle | null;
const EAGER_BROWSER_CONTROL_SERVICE_ENV = "OPENCLAW_EAGER_BROWSER_CONTROL_SERVER";
const UNSAFE_BROWSER_CONTROL_OVERRIDE_SPECIFIER = /^(?:data|http|https|node):/i;
function isTruthyEnvValue(value: string | undefined): boolean {
return /^(?:1|true|yes|on)$/iu.test(value?.trim() ?? "");
}
function validateBrowserControlOverrideSpecifier(specifier: string): string {
const trimmed = specifier.trim();
if (UNSAFE_BROWSER_CONTROL_OVERRIDE_SPECIFIER.test(trimmed)) {
throw new Error(`Refusing unsafe browser control override specifier: ${trimmed}`);
}
return trimmed;
}
/** Creates the Browser plugin service registered by the plugin entrypoint. */
export function createBrowserPluginService(): OpenClawPluginService {
let handle: BrowserControlHandle = null;
return {
id: "browser-control",
start: async () => {
const pageShare = await import("./browser/extension-relay/page-share.js");
// Plugin services start only in the Gateway process. The sink marks this
// process as able to deliver page shares to the main session.
pageShare.setPageShareSink(pageShare.createGatewayPageShareSink());
if (!isTruthyEnvValue(process.env[EAGER_BROWSER_CONTROL_SERVICE_ENV])) {
return;
}
if (handle) {
return;
}
handle = await startLazyPluginServiceModule({
skipEnvVar: "OPENCLAW_SKIP_BROWSER_CONTROL_SERVER",
overrideEnvVar: "OPENCLAW_BROWSER_CONTROL_MODULE",
validateOverrideSpecifier: validateBrowserControlOverrideSpecifier,
// Keep the default module import static so compiled builds still bundle it.
loadDefaultModule: async () => await import("./server.js"),
startExportNames: [
"startBrowserControlServiceFromConfig",
"startBrowserControlServerFromConfig",
],
stopExportNames: ["stopBrowserControlService", "stopBrowserControlServer"],
});
},
stop: async () => {
const { setPageShareSink } = await import("./browser/extension-relay/page-share.js");
setPageShareSink(null);
const current = handle;
if (current) {
await current.stop();
if (handle === current) {
handle = null;
}
return;
}
const { stopBrowserControlService } = await import("./control-service.js");
await stopBrowserControlService();
},
};
}