Skip to main content
Feature: plugins

A browser plugin is a Rust crate that changes how a browser starts and what every tab gets: block images, add a script to every page, set a preference, load an extension, route through a proxy. You add one to a PluginSet, give the set to the launch options, and its hooks run at fixed points. It is the idea of puppeteer-extra in Rust: plugins are ordinary crates, compiled into your program, and anyone can publish one.

The design is ADR 004. The hooks run in both engines: the Pure CDP engine (sb_cdp) and the WebDriver engine (BaseCase).

Note

Browser plugins are not Test Plugins, which run around a test (screenshots on failure, reports), and not the browser extension, which runs inside the browser. A browser plugin is Rust code in the program that drives the browser.

Info

What you will learn

  • Turn plugins on and use one, with either engine
  • Install a plugin from the plugins repository
  • Write a plugin, and test it without a browser
  • When each hook runs, and what happens when one fails
  • What each browser lets a plugin do on the WebDriver engine
  • What you trust when you add a plugin

Turning plugins on#

Plugins are behind the plugins feature, off by default. The feature is in release 0.3.0 and later (the WebDriver engine from 0.4.0):

Cargo.toml

The official plugins are features of their own (plugin-shield, plugin-block-resources, plugin-crx, and plugins-builtin for all of them), turned on by name in the settings file, in SB_PLUGINS, in a builder call, or on the command line (sbrs --plugin shield open https://example.com, with the plugins compiled into the prebuilt sbrs and sbrs-mcp): see Choosing plugins.

The plugin API is its own crate, seleniumbase-rs-plugin, in the seleniumbase-rs-plugins repository. This crate re-exports it as seleniumbase_rs::plugin_api, so a program needs no second dependency to use or write plugins.

Using a plugin#

Build a set, then give it to the engine you use:

Build a set, give it to LaunchOptionsBuilder::plugins, and launch as usual:

src/main.rs

To attach to a browser that is already running, use Browser::connect_with_plugins(host, port, plugins) instead of connect. There is nothing to launch, so before_launch does not run; the rest does.

PluginSet::with refuses a second plugin with the same name. Plugins run in the order they were added, except those that ask to run last.

Installing a plugin#

Plugins are crates named seleniumbase-rs-plugin-<name>. The official ones live in the plugins repository:

CrateWhat it does
seleniumbase-rs-plugin-block-resourcesStops pages from loading images, fonts or media (Network.setBlockedURLs)
seleniumbase-rs-plugin-crxLoads packed Chrome extensions (.crx files): unpacks them and adds the folders to the start-up settings, which the launch loads
seleniumbase-rs-plugin-wasmRuns plugins written in any language that compiles to WebAssembly, in a sandbox (no host imports, fuel and memory limits)

Add one with cargo add, from the same revision of the plugins repository that this crate pins (the rev of seleniumbase-rs-plugin in its Cargo.toml, currently 628991af0e6c5711298e3bd3692dc2974887401a):

Terminal

The official plugins also come with this crate, one cargo feature each, and are turned on by name without code; see Choosing plugins.

Warning

This crate takes seleniumbase-rs-plugin from that revision, so the plugin and this crate must resolve to the same source. Until the crates are on crates.io, Cargo counts one git repository under two references (a branch, a tag, a revision) as two different crates: a plugin added with another --rev, or with no reference (the default branch), implements a different Plugin trait. The compiler then says the trait Plugin is not implemented for the plugin, and mentions two versions of seleniumbase_rs_plugin. When this crate moves to a new revision, move the plugin crates with it.

Writing a plugin#

Implement Plugin. Every hook is optional; name is the only method you must write.

src/lib.rs

A PageHandle has four methods: send (a protocol command to the tab), add_init_script (a script for every document the tab loads from now on, run in the current one too), evaluate (a JavaScript value from the current document) and engine. A BrowserHandle has send, engine and family. Plugins never see sb_cdp::Browser, Page or BaseCase, so a plugin keeps working when those types grow, and the same plugin works with both engines.

A plugin crate depends on seleniumbase-rs-plugin (and serde_json), never on seleniumbase-rs. The plugins repository has a template/ to copy.

Packages#

packages returns JavaScript and CSS to load into every tab: Package::script(code), Package::style(css), Package::script_url(url) and Package::style_url(url). Each must say where it loads, .only_on(["example.com"]) or .everywhere(); a package that says neither is refused when the browser starts, so a plugin never loads a script into every site by accident. For a URL, pin the version and add .integrity("sha384-...") so the browser checks the file.

Requirements and dependencies#

requirements returns flags the set checks before anything starts:

FlagRefuses
Requirements::HEADFULa headless browser
Requirements::LAUNCH_ONLYa browser attached with connect_with_plugins, and a WebDriver session on a server other than the local default (with_webdriver_url, such as a Selenium Grid)
Requirements::CHROMIUM_ONLYbrowsers that are not Chromium-based: Firefox and Safari on the WebDriver engine
Requirements::RUN_LASTnothing; the plugin runs after the others

dependencies names other plugins that must be in the same set. The set is checked again after every before_launch has run, so one plugin cannot switch on headless mode under another that needs a window.

Testing a plugin#

Without a browser, the seleniumbase-rs-plugin-testing crate in the plugins repository has recording fakes (RecordingPage, RecordingBrowser) that remember every command and script a hook sends.

Through this crate, the test-util feature runs the real launch steps against a scripted mock: Browser::launch_mocked(options, &mock) checks the set, runs before_launch, loads the extensions, runs on_browser and prepares the first tab, with no Chrome. Script the mock’s answers first, then read back what the browser was sent. With the German plugin above:

When the hooks run#

HookPure CDP engine
before_launchIn Browser::launch, before the process starts. The changed settings become the launch options: arguments, preferences (written into the profile’s Default/Preferences), extensions (loaded over the protocol), the proxy and headless mode.
on_browserOnce the browser answers, before the first tab is prepared; after connect_with_plugins.
packages, then on_page_createdFor every tab the library attaches to (the first tab, new_page, new_window, pages, tabs in a BrowserContext), after the library’s own set-up, plugin by plugin.
on_closeOnce per launch: in Browser::close; or when the last handle is dropped without close, in the background; or, when the launch fails after before_launch ran, before launch returns the error. A first attempt that is retried does not count, so a plugin’s clean-up does not run under the retry.

The proxy reaches before_launch as its server only: a password in the launch options is not shown to plugins, and it stays if they leave the proxy as it is. A plugin that sets a new proxy may include a password in it.

The WebDriver engine#

BrowserConfig::with_plugins gives a set to BaseCase::new, and to BrowserSession::connect, which BaseCase::new calls. The hooks run at these points:

HookWebDriver engine
before_launchBefore the driver is asked for a browser. The settings go back into the BrowserConfig the session starts with, and the set is checked again.
on_browserOnce the session runs.
packages, then on_page_createdFor the first window; for each window opened with switch_to_new_window, open_new_window, open_new_tab or open_new_tab_url; and for a window you switched to yourself, such as a pop-up the page opened, when you call apply_plugins_to_window (on BaseCase or BrowserSession). Switching to a window runs no hook.
on_closeAt quit, while the browser still runs; in the background when the session is dropped without quit; and when the launch fails after before_launch ran.

reconnect checks the plugins for the new browser first, so a refusal leaves the session as it was; then the old browser’s plugins get on_close, and the new browser goes through every hook, before_launch starting again from the configuration. get_new_driver starts a browser with its own run of the hooks.

What reaches a plugin depends on the browser:

Chromium browsers (Chrome, Edge, Chromium, Brave, Opera, Comet, Chrome for Testing)FirefoxSafari
before_launch showsthe extra arguments, the prefs of goog:chromeOptions (ms:edgeOptions for Edge), the extension folders, the proxy (its server only), headlessFirefox’s own arguments and preferences, headlessheadless, always off
before_launch may not(no limit: every change reaches the browser)add extension folders (install add-ons with BaseCase::install_addon once the session runs) or set a proxychange anything: a change is refused
send, on the browser and on a pagechromedriver’s DevTools channel (goog/cdp/execute), which reaches the window the session is onrefused with an errorrefused with an error
add_init_script, and packagesPage.addScriptToEvaluateOnNewDocument for the window’s next documents, and run in the current onerun now as a WebDriver script, and again after each openas Firefox
evaluatea WebDriver script in the current framethe samethe same

On Firefox and Safari a script for every document runs after the page’s own scripts, not before them, and as the body of a function: its top-level declarations stay local, so assign to window to share a value. It runs again only after open (BrowserSession::goto), not after a click, a redirect, refresh, go_back or go_forward takes the window to another document. A plugin that must run before the page’s scripts, or that needs send, should say Requirements::CHROMIUM_ONLY, so it is refused on those browsers instead of half working.

The set is also checked against what the engine can give. A plugin that declares what it needs (metadata()) is refused before anything starts when the engine cannot provide it: WebDriver has no network observation or interception, and Firefox and Safari have no DevTools channel. A WebDriver server that answers browserName: tauri (tauri-plugin-sbrs-automation) is checked again once the session exists, as an embedded webview, which gives page scripts and evaluation only; a refusal quits the session. A plugin that declares nothing is not refused for lack of a DevTools channel on those browsers, as before: it fails when it calls send.

The launch settings that are Chromium switches (host_resolver_rules, disable_features, enable_features, webrtc_policy, disable_quic) become one --host-resolver-rules, --disable-features, --enable-features, --force-webrtc-ip-handling-policy and --disable-quic argument, joined with the ones the configuration already has. A plugin that sets one for Firefox or Safari is refused. On every engine a plugin’s Fetch.* command is refused (the host owns request interception), and Network.setBlockedURLs adds to the window’s blocklist, with the ad_block list, instead of replacing it.

A Firefox preference must be true or false, a whole number or text. Internet Explorer takes no plugins. Plugins cannot be combined with reuse_session: a browser kept for reuse would get the hooks a second time, or not at all, so the combination is refused. A session on a server other than the local default (with_webdriver_url) counts as attaching for LAUNCH_ONLY; before_launch still runs there, and its settings travel in the capabilities, so extension folders must exist on the machine that runs the browser.

When a plugin fails#

Plugins fail closed. A refusal from the checks, or an error from before_launch, on_browser, a package or on_page_created, stops the launch or the tab and comes back as SeleniumBaseError::Plugin, which names the plugin and the hook. A launch stopped by a plugin is not retried. A tab the library opened for new_page or new_window is closed again rather than handed out without what the plugin adds: a stealth or proxy plugin that did not run must not leave an unprotected browser.

Errors from on_close are logged (event cdp.plugin.close.failed) and do not stop the browser from closing.

On the WebDriver engine, a refusal, a launch setting the browser cannot take, or an error from before_launch, on_browser, a package or on_page_created for the first window stops BaseCase::new (or BrowserSession::connect) with SeleniumBaseError::Plugin, and a browser that had started is quit first. For a window opened later, the error is what the call that opened it returns; the window stays open, so close it or stop the test. A value the browser cannot be given (an extension folder whose path holds a comma, a Firefox preference that is a fraction) is SeleniumBaseError::InvalidConfig, as is plugins with reuse_session. Errors from on_close are logged as webdriver.plugin.close.failed.

Security#

Danger

A plugin is Rust code that runs with your program’s rights. It sees every page, cookie and password the browser handles, and it can send any protocol command. Adding one is the same trust decision as adding any dependency.

  • Read it, or trust whoever wrote it, before you add it.
  • Keep Cargo.lock in version control. It records the commit each git dependency resolved to, so a plugin cannot change under you until you run cargo update, and you can read the change before you commit it.
  • Run cargo deny check and cargo audit on the result; this repository’s deny.toml allows only the git sources it names.

Packages run inside the page, with the site’s origin. Pinned versions, Subresource Integrity and only_on keep a compromised CDN or a typo from reaching every site the browser visits.

What has and has not been verified#

Warning

On the Pure CDP engine, the hooks, their order, the commands they send, the fail-closed errors, closing a tab a plugin refused, and on_close on close and on drop are covered by tests against the mocked browser (tests/sb_cdp_plugins.rs); the conversion between the launch options and LaunchSettings, and the merging of preferences into a profile, by unit tests. None of it has been run against a real Chrome yet: loading an extension with Extensions.loadUnpacked follows the repository’s existing real-Chrome test of the same command, and the Default/Preferences file is the one Chrome reads at start-up, but neither was checked in a live browser for this feature. connect_with_plugins is tested only for its checks, not against a running browser. The same holds for the host’s part of the plugin platform: a fresh instance of a per-launch plugin for each browser, the host check, the merged launch switches, the one blocklist per tab and the refusal of Fetch.* are covered by tests against the mocked browser and the mock WebDriver server, and by unit tests that read the command line; no real Chrome has been shown to accept the merged switches or to apply the blocklist in iframes, workers and pop-ups.

On the WebDriver engine, the conversion between a BrowserConfig and LaunchSettings for each browser, and what is refused, are covered by unit tests; the hooks, their order, what reaches the driver, the DevTools channel on Chromium and its refusal on Firefox, the scripts run again after open, the windows a session and BaseCase open, reconnect, the fail-closed errors and on_close at quit, on drop and after a failed launch, by tests against a mock WebDriver server. tests/webdriver_plugins_chrome.rs runs one plugin through BaseCase and chromedriver against a real Chrome: a preference set in before_launch, Browser.getVersion through the DevTools channel, a time zone, a package and an init script in the first window, the time zone and the init script in a window open_new_window opens, evaluate, and on_close at quit. It passed in Chrome 155, headless, on macOS. Firefox and Safari have run only against the mock server, and the extension folders, the proxy and a remote server were not tried with a live browser.