Status#
Accepted, in progress. Proposed (2026-10-12), revised the same day: the
framework lives in its own repository, seleniumbase-rs-plugins, and this crate
keeps only the hook points that call it. Phase 1 (the plugins repository),
phase 2 (the hook points of the pure CDP engine) and phase 3 (the hook points
of the WebDriver engine), behind the plugins feature, are done, except the
test_plugin hook; phases 4 and 5 remain. The phases at the end give the
order, what each one holds and where the result differs from the plan.
Context#
Puppeteer and Playwright users extend their browsers with plugins:
puppeteer-extra and its
maintained fork Zorilla wrap launch()
and call every registered plugin at fixed points (beforeLaunch, afterLaunch,
onPageCreated, onClose, …). Stealth, ad blocking, captcha solving,
resource blocking and user preferences are all separate packages that anyone can
publish, and puppeteer.use(StealthPlugin()) is all a user writes. They are an
example of the ecosystem we want, not a design to copy: the design below is
shaped by Rust (no runtime loading, traits, Cargo) and by this crate’s two
engines.
seleniumbase-rs has three extension points today, and none of them lets a third-party crate change how a browser starts or what every page gets:
| Today | What it covers | What it cannot do |
|---|---|---|
plugins::observer::TestPlugin | Test started, failed (with the page), finished | Change launch options; see pages as they open |
stealth::providers::EvasionProvider | JavaScript added to every document by the stealth layer | Anything outside the stealth bootstrap script |
LaunchOptions flags (ad_block, shield_webrtc, identity) | Fixed built-in behaviours | New behaviours without changing this crate |
The Chrome extension in seleniumbase-rs-browser-extension is a different thing: it runs inside the browser. This ADR is about Rust code that runs in the program driving the browser.
Decision#
A small plugin API lives in its own repository and crate; seleniumbase-rs depends on it and calls it; community plugins depend only on it.
Where the code lives#
| Repository / crate | Holds | Depends on |
|---|---|---|
seleniumbase-rs-plugins / seleniumbase-rs-plugin | the Plugin trait, PluginSet (order, dependencies, requirements), LaunchSettings, the BrowserHandle and PageHandle traits, Package | async-trait, serde_json, thiserror only |
seleniumbase-rs-plugins / seleniumbase-rs-plugin-testing | recording fakes of BrowserHandle and PageHandle, so a plugin is tested without a browser or this crate | the API crate |
seleniumbase-rs-plugins / plugins/* | official plugins (block resources, JS packages, user preferences, …) and template/ for new ones | the API crate |
seleniumbase-rs | the hook points: it turns LaunchSettings into LaunchOptions or BrowserConfig, implements the handle traits for both engines, and calls the hooks | the API crate |
Plugins never name seleniumbase-rs types, so they do not break when this crate
changes, and the API crate changes rarely. Both engines take a PluginSet.
Community plugins are ordinary crates.
The trait#
Every hook is optional and does nothing by default, so a plugin implements only what it needs.
BrowserHandle and PageHandle are small types that send a CDP command
(send(method, params)), add a script to every new document, evaluate
JavaScript, and say which engine and browser they belong to. Plugins receive
them instead of sb_cdp::Browser or BaseCase, so the same plugin works with
both engines and the hook signatures stay stable when those types grow.
The existing TestPlugin stays as it is. A Plugin that also wants the test
hooks returns one from fn test_plugin(&self) -> Option<Box<dyn TestPlugin>>.
Using it#
On WebDriver the same set goes to BrowserConfig::with_plugins(plugins).
Where the hooks run#
| Hook | Pure CDP engine | WebDriver engine |
|---|---|---|
before_launch | Browser::launch, before launch(); the settings become LaunchOptions | BaseCase::new, before the driver starts; the settings become BrowserConfig |
on_browser | after launch and after Browser::connect | after the session starts |
on_page_created | in prepare_session, for every tab the library attaches to | Chromium only, through the DevTools bridge; other browsers skip it |
packages | added in prepare_session | added at session start on Chromium; on other browsers after each open |
on_close | Browser::close and drop | BaseCase::quit |
Rules the set enforces#
- Order. Plugins run in the order they were added, except those with
RUN_LAST, which run after the rest (Zorilla does the same). - Dependencies. A missing dependency stops the launch with
SeleniumBaseError::Plugin. Rust cannot install a crate at run time, so the error names the plugin and the crate to add. A plugin may also bring its own dependencies withfn bundled(&self) -> Vec<Arc<dyn Plugin>>; they are added when missing. - Requirements.
HEADFULrefuses a headless launch,LAUNCH_ONLYrefusesconnect,CHROMIUM_ONLYrefuses Firefox and Safari. Each refusal is an error, not a warning. - Errors fail closed. An error from
before_launch,on_browseroron_page_createdstops that launch or that page and is returned to the caller: a stealth or proxy plugin that did not run must not leave an unprotected browser. Errors fromon_closeand from the test hooks are logged, asTestPluginerrors are today. - Names are unique. Adding a second plugin with the same name is an error.
Packages (JavaScript and CSS)#
This is the JS package manager of Python SeleniumBase (add_js_link,
add_css_link), made declarative so a plugin can say what every page needs:
A package needs a pinned version in its URL and either only_on or
.everywhere(), so a plugin never loads a CDN script into every site by
accident. Code from a URL can be fetched by the program and injected inline
(.inline()), as add_js_code_from_link does, for pages whose content
security policy blocks the CDN.
The same work fills a gap: sb_cdp::Page gets add_script_tag,
add_style_tag and add_init_script, which BaseCase already has as
add_js_link, add_css_link and add_js_code.
Distribution#
- Crates are named
seleniumbase-rs-plugin-<name>and taggedseleniumbase-rs-pluginon GitHub, so a search finds them. The docs keep a list of community plugins. template/in the plugins repository has the crate layout, a test against the recording fakes, and CI. Copy it, or use it withcargo generate.- Plugin authors test with
seleniumbase-rs-plugin-testing: the fakes record each CDP command and script a hook sends, with no browser and no dependency on this crate. An end-to-end check can still useBrowser::new_mocked. - Until the crates are on crates.io, a plugin and the program must resolve to the
same
seleniumbase-rs-plugin; two git tags are two different crates to Cargo. The API crate changes rarely, and when it does the program fixes a mismatch with one[patch]entry, which the docs show. Publishing to crates.io later ends the problem through semver.
What is not part of this#
- Loading plugins at run time (
dlopen, a plugin folder). Rust has no stable ABI, so a plugin built with another compiler or crate version would crash. Plugins are compiled in. - WebAssembly plugins. Possible later, if people want plugins in other languages; not needed for Rust crates.
- A plugin hub or registry service. A list in the docs and a GitHub topic are enough until there are many plugins.
Security#
A plugin is Rust code that runs with the program’s rights and sees every page,
cookie and password the browser handles. Adding one is the same trust decision
as adding any dependency: review it, pin it, and run cargo deny. The docs say
this on the page that explains how to install a plugin.
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.
Consequences#
- Built-in behaviours can move into plugins over time (
ad_block,shield_webrtc, the stealth bootstrap as aStealthPluginover theEvasionRegistry). The existingLaunchOptionsflags keep working; they become shortcuts that add the built-in plugin. - Every launch and every new tab now walks the plugin set. With no plugins that is an empty loop.
- The hook list becomes public API. Adding a hook with a default body is not a breaking change; changing a signature is.
Phases#
-
The plugins repository. Create
seleniumbase-rs-plugins(public) with the API crate (Plugin,PluginSet,Requirements,Package,LaunchSettings, the handle traits), the testing crate, tests for order, dependencies, requirements and fail-closed errors, andtemplate/. -
Hook points, pure CDP engine. seleniumbase-rs depends on the API crate:
LaunchOptions::plugins, the hooks inBrowser::launch,connect,prepare_sessionandclose,PageHandleforsb_cdp::Page,Page::add_script_tag,add_style_tag,add_init_script,SeleniumBaseError::Plugin; one official plugin (block resources).Done (2026-10-15), behind the optional
pluginsfeature, with the API crate pinned to a git revision and re-exported asseleniumbase_rs::plugin_api.LaunchOptionsBuilder::plugins;Browser::launchchecks the set, runsbefore_launch(the settings becomeLaunchOptions: arguments, preferences written into the profile, extensions loaded withExtensions.loadUnpacked, proxy, headless), checks again, and callson_browser;Browser::connect_with_pluginschecks as attaching and callson_browser;prepare_sessionadds the packages and callson_page_createdthrough aPageHandleon the tab’s session;closeand the last drop callon_closeonce. Errors stop the launch or the tab asSeleniumBaseError::Plugin, and a tab opened bynew_pageis closed again. Block resources lives in the plugins repository. Left for phase 3, and done there: thePage::add_script_tag,add_style_tagandadd_init_scriptmethods, andPageHandleforsb_cdp::Pageitself (the hooks use a handle on the tab’s session). The tests run against the mock transport, not a real Chrome. Guide: Browser Plugins. -
Hook points, WebDriver engine.
BrowserConfig::with_plugins, the Chromium page hooks through the DevTools bridge,test_plugin.Done (2026-10-15), except
test_plugin, which the API crate does not have yet.BrowserConfig::with_plugins;BrowserSession::connect, and soBaseCase::new, checks the set against a WebDriverLaunchContext(a server other than the local default counts as attaching), runsbefore_launchon settings taken from the configuration and puts them back (Chromium: arguments, capability preferences, extension folders, proxy, headless; Firefox: its arguments and preferences, headless; Safari: nothing), checks again, then callson_browserand gives the first window the packages andon_page_created. Windows opened withswitch_to_new_windowandBaseCase::open_new_windowget them too, andapply_plugins_to_windowgives them to a window reached another way;reconnectruns the hooks again for the new browser;quitand drop callon_close. A refusal or a failing hook stops the session withSeleniumBaseError::Pluginand quits the browser. Unlike the table above,on_page_createdruns on every browser, Firefox and Safari refusingsend; their init scripts and packages run as WebDriver scripts at once and again after eachopen, after the page’s own scripts. Plugins together withreuse_sessionare refused.sb_cdp::Pagegotadd_script_tag,add_style_tag,add_init_scriptandremove_init_script, and implementsPageHandle. Tested against a mock WebDriver server and the mocked browser, and in Chrome 155 headless on macOS (tests/webdriver_plugins_chrome.rs,tests/sb_cdp_page_inject_chrome.rs); Firefox and Safari only against the mock server. -
Built-ins and community.
ad_blockandshield_webrtcas plugins, aStealthPlugin, the real Bootstrap Tour and Hopscotch libraries for the two tour themes that fall back to the built-in tour today, the template and a “Writing a plugin” tutorial. -
Later, if asked for. Plugins that add MCP tools (feature
mcp-server), and adata()hook that lets plugins share values, as puppeteer-extra’sgetDataFromPluginsdoes.