Skip to content

Add --extension flag to load Chrome extensions in headless mode - #52

Open
goeric wants to merge 2 commits into
simonw:mainfrom
goeric:load-extensions
Open

Add --extension flag to load Chrome extensions in headless mode#52
goeric wants to merge 2 commits into
simonw:mainfrom
goeric:load-extensions

Conversation

@goeric

@goeric goeric commented Jul 19, 2026

Copy link
Copy Markdown

Extensions currently only work with rodney start --show. This adds --extension so they work headless too.

rodney start --extension ./my-extension          # unpacked directory
rodney start --extension ./packed.crx            # or a .crx / .zip
rodney start --extension ./one --extension ./two # repeat for several

Why headless didn't work

Chrome's old headless mode cannot run extensions at all. rod's Headless() sets a bare --headless, which selects old headless on the Chromium build rodney downloads, so --load-extension was silently ignored. Passing --extension now switches to new headless, where extensions work fully — I verified an MV3 extension's content scripts and its background service worker both run (the service worker fired on install and opened its onboarding tab). Launches without --extension are unchanged.

Two other things were needed:

  • Chrome only accepts unpacked extensions on the command line, so .crx/.zip archives are unpacked into the session directory first. Both CRX2 and CRX3 headers are handled.
  • Chrome 137+ ignores --load-extension unless DisableLoadExtensionCommandLineSwitch is disabled. That's appended to --disable-features rather than set, so rod's existing defaults survive.

rodney extensions

You need the extension ID to reach chrome-extension:// URLs, and nothing else in the CLI surfaces it:

$ rodney extensions
ldmakemplfmadpiihagnajidjbhnjlcm  My Extension  1.0.0  /path/to/my-extension

$ rodney open chrome-extension://ldmakemplfmadpiihagnajidjbhnjlcm/popup.html
$ rodney screenshot popup.png

IDs are computed the way Chrome computes them for unpacked extensions (first 16 bytes of the SHA-256 of the path, hex digits mapped onto a–p). Two subtleties I hit while testing:

  • Chrome resolves symlinks first, so /tmp/x on macOS hashes as /private/tmp/x. Without EvalSymlinks the reported ID is wrong for anything under /tmp.
  • On Windows, Chrome hashes the UTF-16 bytes of the path and upper-cases the drive letter (crx_file::id_util::GenerateIdForPath). I implemented this from the Chromium source but could not test it on a Windows host — worth a check by someone who can, or I'm happy to drop the Windows branch if you'd rather not carry untested code. It's isolated in extensionIDPathBytesFor and unit-tested for the encoding itself.

Extensions can't run under --single-process

rodney sets --single-process unconditionally (for screenshots in gVisor/container
environments). That's survivable under old headless, which is a stripped-down shell,
but new headless brings up the full browser stack — renderer, GPU, viz compositor,
media, and the extension's service worker. Putting all of that in one OS process
removes every fault boundary, so a CHECK failure anywhere kills the whole browser
rather than one renderer. Measured with an MV3 extension loaded:

config child processes threads
--headless=new --single-process 0 205
--headless=new 10 59

Chromium crash reports on my machine match the first row exactly: one process holding
Chrome_InProcGpuThread, Chrome_InProcRendererThread, Chrome_InProcUtilityThread,
a ServiceWorker thread and 23 DedicatedWorker threads, aborting on a CHECK on the
in-process utility thread.

So --single-process is now dropped on the extension path only — launches without
--extension keep it and are byte-identical to before. Whether rodney should set it
unconditionally at all feels like a separate question, happy to open an issue.

Notes

  • New code lives in extensions.go / extensions_test.go to keep main.go reviewable; happy to inline it if you'd prefer to keep everything in one file.
  • parseStartArgs now returns a startOptions struct rather than a growing tuple. The existing tests are updated accordingly.
  • Archive extraction rejects entries that would escape the destination, and the unpack directory is derived from a hash of the archive path rather than its basename — an archive named ...zip has stem .., which would otherwise resolve to the session directory and get deleted.
  • 30 tests added, including an end-to-end one that launches headless Chrome with a fixture extension and asserts its content script actually ran.

🤖 Generated with Claude Code

goeric and others added 2 commits July 18, 2026 21:06
Extensions previously only worked with "rodney start --show". The cause was
that Chrome's old headless mode cannot run extensions at all, and rod's
Headless() sets a bare --headless, which selects old headless on the Chromium
build rodney downloads. Passing --extension now switches to the new headless
mode, where extensions work fully, service workers included.

  rodney start --extension ./my-extension
  rodney start --extension ./packed.crx --extension ./other.zip

Chrome only accepts unpacked extensions on the command line, so .crx and .zip
archives are unpacked into the session directory first (CRX2 and CRX3 headers
are both handled). Chrome 137+ also ignores --load-extension unless
DisableLoadExtensionCommandLineSwitch is disabled, so that is appended to the
existing --disable-features list.

The new "rodney extensions" command lists the loaded extensions along with the
ID Chrome assigned to each, which is needed to reach chrome-extension:// URLs.
IDs are derived the same way Chrome derives them for unpacked extensions, from
the symlink-resolved path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
rodney sets --single-process unconditionally, for screenshots in
gVisor/container environments. That is survivable under old headless,
which is a stripped-down shell, but --extension switches Chrome to new
headless, where the full browser stack comes up: renderer, GPU, viz
compositor, media, and the extension's own service worker. Collapsing
all of that into one OS process removes every fault boundary, so a CHECK
failure or bad access anywhere takes down the whole browser instead of
one renderer.

Measured with an MV3 extension loaded:

  --headless=new + --single-process   0 child processes, 205 threads
  --headless=new                     10 child processes,  59 threads

Local Chromium crash reports match the first row: a single process
holding Chrome_InProcGpuThread, Chrome_InProcRendererThread,
Chrome_InProcUtilityThread, a ServiceWorker thread and 23 DedicatedWorker
threads, aborting on a CHECK on the in-process utility thread.

Drop --single-process only on the extension path; launches without
--extension keep it and are unchanged.

Also extract configureExtensions() so cmdStart and the end-to-end test
build the launcher the same way — the test previously duplicated the
flags inline, so it could not have caught this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@goeric

goeric commented Jul 25, 2026

Copy link
Copy Markdown
Author

Pushed 920b808 — extensions no longer run under --single-process.

I hit real instability using this branch locally: Chromium crashing repeatedly with EXC_BREAKPOINT on Chrome_InProcUtilityThread. The new-headless switch is what surfaces it — old headless is a stripped-down shell, but new headless brings up renderer + GPU + utility services + the extension service worker, and --single-process collapses all of them into one process with no fault isolation (0 child processes / 205 threads, vs 10 / 59 without it).

Scoped the change to the extension path only, so launches without --extension are unchanged. Also extracted configureExtensions() so cmdStart and the end-to-end test build the launcher the same way — the test had the flags duplicated inline, which is why it couldn't have caught this. Six unit tests cover the resulting flag set.

Worth noting separately: whether rodney should set --single-process unconditionally at all is a broader question I didn't touch here — happy to open an issue if you'd like.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant