Testing Browser Extensions with AI Agents in MicroVMs
A browser extension is one of the most over-privileged things a normal person installs without thinking about it. Grant `<all_urls>` and it gets a content script injected into every page you load — your bank, your email, your company's admin console — with DOM read/write access to all of it. Add the `cookies` permission and it can read the cookie jar for the hosts it's allowed on. Add a background service worker and it has a persistent execution context with `fetch` to anywhere, running whether or not you have the extension's UI open. That is a broader grant than most of the npm packages people agonize over. And yet the standard workflow for 'does this extension work?' is: install it into your own daily-driver Chrome profile, click around, and hope. If you're running an extension marketplace and reviewing third-party submissions, that workflow scales to 'install a stranger's arbitrary code into a machine that has your session cookies on it.' There is a better shape: hand the whole job to an AI agent working inside a disposable Firecracker microVM, let it install, exercise, and regression-test the extension against real pages, read back the artifacts, and delete the machine.
What an extension actually gets when you install it
Extensions are unusual because their threat model is inverted from most code you run. A CLI tool you `npx` runs once and exits. An extension is resident, event-driven, and positioned at exactly the layer where your authenticated sessions live. Manifest V3 was sold as a privilege reduction, and it did genuinely kill remotely-hosted code and long-lived background pages — but the interesting permissions survived intact. It promised less privilege and delivered a different shape of the same access.
- Content scripts on host permissions: a script running in the page's DOM on every matching site. It sees rendered account numbers, form fields as you type, anything in localStorage the page can reach, and it can rewrite the page — including swapping a wallet address or an IBAN in a payment form, the classic extension supply-chain attack.
- Cookie jar reads: the `cookies` permission plus a matching host permission returns session cookies as plain values, including HttpOnly ones that page JavaScript could never touch. A stolen session cookie is a logged-in session, no password required.
- A background service worker with network access: MV3 workers are ephemeral rather than persistent, but 'ephemeral' means they get torn down and revived by events, not that they lost `fetch`. An extension can wake on a tab update, scrape the page via a content script, and POST it somewhere — no UI, no user gesture, no visible tab.
- `webRequest` / `declarativeNetRequest`: the ability to observe or rewrite requests. MV3's declarative form is more constrained by design, but rule sets can still redirect and block, and the observational variants leak URL patterns which are themselves sensitive.
- `storage`, `tabs`, `history`, `downloads`, `scripting`: individually mundane, collectively a profile of everything you do in a browser plus the ability to write files to disk and inject code into pages on demand.
- Update-time privilege drift: an extension you audited at version 1.2 can ship 1.3 through the store's update channel. Permissions that don't require a new user prompt just take effect. Your one-time review has a shelf life measured in releases.
Why your laptop and your CI runner are both wrong
The obvious place to test an extension is the browser you already have, and it's the worst place available. Your daily profile holds live sessions for your email, your cloud console, your source host, and your password manager's web vault. An extension with host permissions installed into that profile is not being tested — it's being trusted, retroactively, with everything already in the jar. 'I'll use a separate Chrome profile' helps with cookies and nothing else: profiles share the same OS user, the same filesystem, the same keychain access, the same network position inside your VPN.
The shared CI runner is a different failure with the same root. Runners are long-lived and multi-tenant across your own jobs, and they hold things worth stealing: a registry token in the environment, a cloud credential from an OIDC exchange, a checkout of a private repo in the workspace. An extension's background worker is a general-purpose network client running on that box. It does not need to escape a sandbox to be a problem — it just needs to read `process.env`-adjacent files or reach an internal host that the runner's network position makes reachable. And because runners are reused, whatever the extension writes to disk, to the browser profile, or to a leftover process is inherited by the next job that lands there.
- Shared credentials: runner environments carry registry tokens, cloud creds, and repo checkouts. A background worker with `fetch` is a perfectly ordinary way to move any of that off the box.
- Shared network position: a CI runner usually sits somewhere useful — inside a VPC, able to resolve internal DNS, able to reach a metadata endpoint. Untrusted browser code inheriting that position is a lateral-movement primitive.
- Reuse across jobs: the browser profile, the extension's `storage` writes, downloaded files, and any process that outlived the job all persist to the next tenant of the runner. Extension persistence is the whole point of an extension.
- No egress control by default: most CI runners have wide-open outbound internet. You literally cannot tell whether the extension phoned home, because nothing was watching and nothing would have blocked it.
Why a container turns into a permission-flag scavenger hunt
The instinct is 'run it in a container,' and for extension testing specifically that instinct runs into a very concrete wall: Chromium ships its own multi-layer sandbox, and that sandbox wants kernel facilities a container is in the business of taking away. The browser's setuid/namespace sandbox needs user namespaces and clone flags; its seccomp-BPF layer needs `seccomp` and `PR_SET_NO_NEW_PRIVS` to behave in ways the container runtime's own seccomp profile may already have filtered. The two sandboxes are not composable so much as adjacent, and where they disagree, Chromium refuses to start.
So you go looking for the fix, and every answer on the internet is the same two words: `--no-sandbox`. Which works instantly, and which is why they are the two most load-bearing words in containerized Chrome. You have solved a startup error by disabling the renderer sandbox — the exact boundary that stops a compromised renderer, or in this case a malicious extension's content script, from being a plain unsandboxed process on the host kernel. For scraping a page you own, that's a shrug. For deliberately running attacker-supplied code, you've removed the one mitigation whose entire job was this scenario.
- Chromium's own sandbox vs. the container's: user namespaces, `clone` flags, and seccomp filters conflict between the browser's sandbox and the runtime's default profile — so you end up hand-tuning `--cap-add`, `--security-opt seccomp=...`, and `--security-opt apparmor=unconfined` until it launches, which is a slow way to disable security features one at a time.
- `/dev/shm` is the classic one: Docker defaults to a 64 MB `/dev/shm`, Chromium uses shared memory heavily for its renderer IPC and graphics buffers, and the result is tabs that crash with `SIGBUS` or 'Target closed' errors under load. The workaround is `--shm-size` or `--disable-dev-shm-usage`, i.e. more flags that trade the browser's assumptions for a container's defaults.
- One shared kernel: whatever the extension triggers — an fd exhaustion, a conntrack flood from a chatty background worker, a kernel-surface bug reached through the renderer — is reached against the host kernel that every other container on the box is also sitting on.
- The flag list is load-bearing and undocumented: the `--no-sandbox --disable-dev-shm-usage --disable-gpu --single-process` incantation people copy around is a set of security and reliability trade-offs made by strangers, inherited without review, for a workload where the whole point is that you don't trust the code.
A microVM sidesteps the argument instead of winning it. Each PandaStack sandbox is a full Firecracker guest with its own kernel, so Chromium gets the user namespaces, seccomp behavior, and `/dev/shm` sizing it expects, and you can run it with its own sandbox enabled — the way it was designed to run. The extension's content script is then inside a real renderer sandbox, inside a guest kernel, inside a hardware-virtualized VMM with a minimal device model. Escaping to your infrastructure means beating all three. And the outer boundary isn't shared with anyone: no other tenant's job is on that kernel, because the kernel came up when the VM did and dies when it does.
The workflow: an agent that installs, drives, and regresses
The reason this is an AI agent job and not a static test suite is that extension behavior is fiddly and site-specific. Somebody has to write a script that navigates to a page the extension claims to enhance, waits for the content script to do its thing, asserts on the DOM it produced, opens the popup, clicks through the options page, and checks that the background worker updated its storage. That's exactly the kind of iterative, look-at-the-result-and-adjust work an agent is good at and a human finds tedious. The catch is that an agent writing and running browser automation against untrusted code is precisely the combination you don't want on a machine you care about. Put both inside the same disposable VM and the problem dissolves.
- Provision a browser microVM per submission or per run. The template already has Chromium and its system libraries baked in, so create is a snapshot restore rather than a browser install: about 49ms for the restore step, 179ms p50 and roughly 203ms p99 end to end.
- Write the extension into the guest. Upload the unpacked directory (or the CRX, unzipped in-guest) through the filesystem API. It never touches your host filesystem, your host browser, or your host profile.
- Launch Chromium with `--load-extension` under Playwright or Puppeteer, using a persistent context so the extension actually loads. The agent then drives real pages and asserts on what the content script produced.
- Let the agent iterate. It writes a test script, runs it, reads the failure, and adjusts — inside the VM. A bad selector costs a re-run, not a compromised laptop.
- Capture evidence before teardown: the DOM assertions, screenshots, the HAR or request log showing every host the extension contacted, and the extension's own `chrome.storage` contents.
- Kill the VM. The profile, the background worker, the `storage` writes, the downloads, and anything the extension installed for persistence all go with it — because there is no 'it' outside the VM.
Driving an unpacked extension with Playwright
Loading an extension is the part people get wrong first, so here's the shape that works. Extensions only load in a persistent context — `launchPersistentContext` with a real user-data dir — not in the default `chromium.launch()` incognito-ish context. You pass `--disable-extensions-except` alongside `--load-extension` so the profile has exactly your extension and nothing else, then you fish the service worker out to get the extension ID, which you need to address `chrome-extension://` pages like the popup and options.
// extension-test.ts — runs INSIDE the microVM, not on your machine.
// Note what is NOT here: --no-sandbox. In a real guest kernel Chromium's
// own sandbox works, so the extension's content script stays inside it.
import { chromium, type BrowserContext } from "playwright";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const EXT_DIR = "/work/extension"; // unpacked manifest.json lives here
async function main() {
const profile = mkdtempSync(join(tmpdir(), "ext-profile-"));
// Extensions require a PERSISTENT context. launch() will silently
// ignore --load-extension and you'll debug that for an hour.
const ctx: BrowserContext = await chromium.launchPersistentContext(profile, {
headless: false, // use xvfb-run; headless MV3 support is version-dependent
args: [
`--disable-extensions-except=${EXT_DIR}`,
`--load-extension=${EXT_DIR}`,
],
});
// Every request the extension (or the page) makes, logged for review.
const contacted: string[] = [];
ctx.on("request", (req) => contacted.push(`${req.method()} ${req.url()}`));
// The MV3 background service worker gives us the extension ID.
let [sw] = ctx.serviceWorkers();
if (!sw) sw = await ctx.waitForEvent("serviceworker", { timeout: 10_000 });
const extId = new URL(sw.url()).host;
console.log("extension id:", extId);
// 1) Does the content script actually do its job on a real page?
const page = await ctx.newPage();
await page.goto("https://example.com/", { waitUntil: "domcontentloaded" });
const injected = await page
.locator("[data-acme-extension-badge]")
.waitFor({ timeout: 5_000 })
.then(() => true)
.catch(() => false);
// 2) Does the popup render and respond?
const popup = await ctx.newPage();
await popup.goto(`chrome-extension://${extId}/popup.html`);
await popup.getByRole("button", { name: "Enable" }).click();
const popupState = await popup.locator("#status").innerText();
// 3) What did the background worker persist?
const stored = await sw.evaluate(async () => {
return await chrome.storage.local.get(null);
});
await page.screenshot({ path: "/work/artifacts/content-script.png" });
writeFileSync(
"/work/artifacts/report.json",
JSON.stringify({ extId, injected, popupState, stored, contacted }, null, 2),
);
await ctx.close();
if (!injected) process.exit(1);
}
main();Orchestrating the run from your host
From outside, the whole thing is create, write, exec, read, kill. The extension bytes go in through the filesystem API, the test script runs with a hard timeout, artifacts come back out before teardown, and the VM dies on its TTL whether or not your orchestrator remembers to clean up. Set `PANDASTACK_API_KEY` in your environment first.
from pandastack import Sandbox
import json, pathlib
submission = pathlib.Path("./submissions/acme-helper-1.4.0")
# The `browser` template has Chromium + system libs baked into the snapshot,
# so this is a restore, not an install: ~49ms restore step,
# 179ms p50 / ~203ms p99 for the create end to end.
sbx = Sandbox.create(template="browser", ttl_seconds=900)
try:
# Push the unpacked extension into the guest. It never lands on your
# host filesystem and never sees your host browser profile.
for f in submission.rglob("*"):
if f.is_file():
rel = f.relative_to(submission)
sbx.filesystem.write(f"/work/extension/{rel}", f.read_bytes())
# The agent-authored Playwright script from the previous snippet.
sbx.filesystem.write(
"/work/extension-test.ts",
pathlib.Path("./extension-test.ts").read_text(),
)
sbx.exec("mkdir -p /work/artifacts && cd /work && npm i -D playwright tsx",
timeout_seconds=300)
# Hard timeout: a wedged service worker or a hung selector dies on
# schedule instead of holding the run open. The TTL is the backstop.
r = sbx.exec(
"cd /work && xvfb-run -a npx tsx extension-test.ts",
timeout_seconds=240,
)
# Read artifacts back BEFORE kill — after kill the bytes are gone.
report = json.loads(sbx.filesystem.read("/work/artifacts/report.json"))
shot = sbx.filesystem.read("/work/artifacts/content-script.png")
pathlib.Path("content-script.png").write_bytes(shot)
print("functional:", "PASS" if r.exit_code == 0 else "FAIL")
print("storage written:", report["stored"])
# The security half of the review: every host the extension talked to.
allowed = {"example.com", "api.acme-helper.dev"}
hosts = {u.split("/")[2] for u in
(line.split(" ", 1)[1] for line in report["contacted"])}
unexpected = sorted(h for h in hosts if h not in allowed)
if unexpected:
print("UNDECLARED EGRESS:", unexpected)
finally:
sbx.kill() # profile, worker, storage, downloads, persistence — all gone
The `finally` is the part that makes this reviewable at scale. Teardown isn't 'uninstall the extension, clear the profile, revoke its storage, check nothing scheduled itself' — a checklist that is only as correct as the last person who edited it, and that a determined extension is specifically trying to defeat. Teardown is `kill()`. Persistence mechanisms only mean anything if there's a machine left for them to persist on.
Catching an extension that exfiltrates
Functional testing tells you the extension works. Egress observation tells you what it does while working, and for third-party code that's the more valuable signal. The Playwright request log above is the in-browser view and it's a good first pass, but an extension can route around it — a service worker `fetch` in some configurations, or a request issued during a page load your listener wasn't attached for. The stronger view is at the network boundary, which in a microVM is a real boundary rather than a shared bridge.
Each PandaStack sandbox gets its own network namespace with a dedicated /30 out of a pre-allocated pool (16,384 per agent), so its traffic is separable from every other sandbox by construction rather than by tagging. That gives you two useful moves. First, run the test with a default-deny egress policy that allows only the hosts the extension declared in its manifest — anything else fails visibly, and a failed connection to an undeclared host is a much louder signal than a successful one you have to notice in a log. Second, capture inside the guest and read the capture back like any other artifact.
# Inside the guest, before driving the browser: capture everything the VM
# sends. The VM has its own netns, so this is only this extension's traffic.
tcpdump -i any -w /work/artifacts/egress.pcap -U 'not port 22' &
TCPDUMP_PID=$!
xvfb-run -a npx tsx /work/extension-test.ts
RESULT=$?
kill "$TCPDUMP_PID" 2>/dev/null || true
# A DNS-level summary is usually enough to spot a phone-home, and it is far
# easier to eyeball in a review queue than a full packet capture.
tcpdump -r /work/artifacts/egress.pcap -nn 2>/dev/null \
| grep -oE 'A\? [a-z0-9.-]+' | sort -u > /work/artifacts/dns-hosts.txt
exit "$RESULT"
# Then, from the host: sbx.filesystem.read("/work/artifacts/dns-hosts.txt")
# before sbx.kill(). Diff that list against the manifest's host_permissions.
# Names that appear here but not there are the whole reason you ran this.Regression runs: fork one warmed browser, don't boot ten
Once you have one working test, you want many: the extension against ten different sites, against three Chromium versions, before and after an update, with and without a logged-in fixture session. Booting a fresh browser environment for each of those would dominate the runtime. The snapshot model removes that cost the way it removes boot cost — get one VM into the exact state you want, freeze it, and stamp out copies.
- Bake the fixture once: a sandbox with Chromium, your test harness, `node_modules`, and any fixture state (a seeded test account's session, a local mock backend) already in place. Snapshot it.
- Fork per scenario: same-host forks run 400–750ms and share memory and rootfs copy-on-write, so the tenth fork doesn't recopy the browser. Cross-host is 1.2–3.5s when artifacts have to move first.
- Every fork starts from byte-identical state, which is what makes a regression comparison meaningful. If version 1.3 injects a badge and 1.4 doesn't, that's the extension changing, not the environment.
- Fan out as wide as memory allows. The forks share nothing mutable, so scenario N and scenario N+1 can't contaminate each other's profile, storage, or ports — every VM has its own full port space.
- Kill each fork when its scenario finishes. A regression suite that leaves no residue can be run on every submitted version, which is the only cadence that actually covers update-time privilege drift.
Local profile vs. shared CI container vs. microVM per run
- Exposure of real credentials — Local profile: the extension gets content-script and cookie access to every live session in that browser, which is your actual accounts. Shared CI container: no browser sessions, but registry tokens, cloud creds, and a private-repo checkout sit on the same box the background worker can `fetch` from. MicroVM per run: nothing in the guest but the extension and a fixture, and nothing outside it is reachable by default.
- Chromium's own sandbox — Local profile: fully enabled, as designed. Shared CI container: usually disabled via `--no-sandbox` to get past user-namespace and seccomp conflicts, removing the renderer boundary exactly when running untrusted code. MicroVM per run: real guest kernel, so the browser sandbox works and you keep the layer.
- `/dev/shm` and browser stability — Local profile: fine. Shared CI container: 64 MB default causes renderer crashes under load, worked around with `--shm-size` or `--disable-dev-shm-usage`. MicroVM per run: guest-sized like a normal machine, no flag archaeology.
- Kernel blast radius — Local profile: your kernel, your machine. Shared CI container: one host kernel shared with every other job on the runner, so fd exhaustion or a conntrack flood from a chatty worker is everyone's problem. MicroVM per run: a guest kernel that exists only for this run and dies with it.
- Egress visibility — Local profile: whatever your OS firewall happens to log, mixed in with all your other traffic. Shared CI container: typically wide-open outbound with no per-job attribution. MicroVM per run: dedicated network namespace and /30, so default-deny policy and per-run captures are straightforward.
- Persistence after the test — Local profile: extension `storage`, downloads, and any injected state survive until you manually remove them, and an update can revive behavior later. Shared CI container: profile and filesystem residue is inherited by the next job on that runner. MicroVM per run: `kill()` takes the profile, the worker, the storage, and the process tree with it.
- Cost of a clean start — Local profile: creating and scrubbing a fresh profile is manual and error-prone. Shared CI container: a fresh container is quick but re-installs or re-mounts browser deps and still shares the kernel. MicroVM per run: snapshot restore at ~49ms for the restore step (179ms p50, ~203ms p99 for a create), or a 400–750ms same-host fork from a warmed fixture.
- Fit for reviewing a marketplace queue — Local profile: doesn't scale past one human and one machine. Shared CI container: scales, but every submission runs unsandboxed browser code next to your CI secrets. MicroVM per run: parallel disposable VMs, per-submission egress evidence, and no cross-contamination between submissions.
What this doesn't solve
Being honest about the boundary matters more than the pitch. A disposable microVM contains the extension's execution and makes its behavior observable. It does not read the extension's mind.
- Dormant and conditional payloads: code that waits for a real user, a specific geography, a date, or the absence of automation signals will look clean in a short automated run. Combine dynamic testing with static review of the bundle — especially obfuscated blobs, dynamic `import()` of remote-ish URLs, and anything doing `eval`-shaped work.
- Server-side behavior: if the extension sends page content to a host it legitimately declared, your VM sees the request but has no idea what happens to the data afterward. That's a policy and contract question, not a sandboxing one.
- Real credentials in fixtures: if you log a fixture account into a real service to test a logged-in flow, that account's session is genuinely exposed to the extension inside the VM. Use throwaway accounts with no privileges you'd miss, and rotate them.
- Automation detection: extensions can notice Playwright, xvfb, or a suspiciously clean profile and behave differently. Fixtures with plausible history and storage help; nothing makes this go away entirely.
- The store update channel: testing version 1.4 says nothing about 1.5. The value comes from wiring this into the pipeline so every version gets a run — which is exactly the workflow a per-run disposable VM makes cheap enough to actually do.
The point isn't a guarantee, it's a change in what a bad outcome costs. Today, testing a sketchy extension risks your cookie jar or your CI runner's credentials; there's a strong incentive to skip the test, and skipped tests are how the ownership-transfer attacks land. In a disposable microVM the worst case is a VM that gets deleted anyway, so an agent can run the full battery on every submitted version without anybody making a trust decision first. For the general browser-in-a-VM case see /blog/browser-isolation-microvm, for running the same pattern across a fleet see /blog/browser-automation-farm-microvms, and for the egress-policy side see /blog/controlling-network-egress-untrusted-code.
Frequently asked questions
Why can't I just test a browser extension in a separate Chrome profile?
A second profile separates cookies and extension storage, and nothing else. Both profiles run as the same OS user, on the same filesystem, with the same keychain access and the same network position — inside your VPN, able to resolve internal hostnames. An extension's background service worker is a general-purpose network client with `fetch`; the browser profile boundary doesn't constrain what it can reach or what it can read from disk through other means. And a profile is persistent: extension storage, downloaded files, and injected state survive the test unless you remember to scrub them. A disposable microVM gives you a whole machine whose deletion is the cleanup step.
Why does Chrome need --no-sandbox in a container, and why does that matter here?
Chromium ships its own sandbox built on user namespaces, clone flags, and seccomp-BPF. Container runtimes restrict exactly those facilities with their own seccomp and AppArmor profiles, so the two collide and Chromium refuses to start. The universal internet fix is `--no-sandbox`, which works by disabling the renderer sandbox. For scraping a site you own that's a minor trade; for deliberately running attacker-supplied extension code it removes the specific mitigation that stops a malicious content script or compromised renderer from being an ordinary unsandboxed process on the shared host kernel. In a Firecracker microVM the guest has a real kernel, so Chromium's sandbox works normally and you keep that layer plus VM isolation underneath it.
How do I load an unpacked extension in Playwright or Puppeteer?
Use a persistent context, not the default launch. In Playwright that's `chromium.launchPersistentContext(userDataDir, { args: ['--disable-extensions-except=<dir>', '--load-extension=<dir>'] })` — a plain `chromium.launch()` will not load the extension and gives no obvious error. Manifest V3 extensions register a background service worker, so grab it via `context.serviceWorkers()` or by awaiting the `serviceworker` event; its URL host is the extension ID, which you need to navigate to `chrome-extension://<id>/popup.html` and the options page. Headless support for MV3 extensions varies by Chromium version, so running under `xvfb-run` with `headless: false` is the reliable path.
How do I tell whether an extension is exfiltrating page content?
Watch two levels. In-browser, attach a `request` listener to the Playwright context and log every method and URL, then diff the set of hosts against the `host_permissions` the manifest declared — names that appear at runtime but not in the manifest are the finding. At the network level, because each microVM has its own network namespace and a dedicated /30, you can run `tcpdump` inside the guest and read the capture (or a DNS-name summary) back through the filesystem API before teardown, and you can enforce default-deny egress so undeclared destinations fail loudly instead of succeeding quietly. Note the limit: a dormant or geofenced payload can pass a short automated run, so pair this with static review of the bundle.
Isn't a microVM per extension test too slow for a review queue?
It's a snapshot restore, not a boot. The `browser` template has Chromium and its system libraries baked into the snapshot, so create is about 49ms for the restore step and 179ms p50 / roughly 203ms p99 end to end; only the very first spawn of a template does a real cold boot at around 3 seconds. For regression matrices — the extension across ten sites, or before and after an update — bake a fixture VM once with your harness and `node_modules` installed, then fork it per scenario at 400–750ms same-host with copy-on-write memory and disk. The dominant cost stays the actual browser automation, not the environment.
49ms p50 cold start. Fork, snapshot, and scale to zero.