[Examples & benchmarks](https://cdpfleet.com/docs/cases) / #46 · 2026-10-14 · Example · Easy · for [testing](https://cdpfleet.com/cross-browser-testing), [ai agents](https://cdpfleet.com/ai-agents)

# Browser extensions aren't supported — what to use instead

An honest "doesn't work" case: a launch with extension flags or an extension list is refused (\`400 unsupported\_option\`). Content scripts, request blocking and header rewriting — what most automation extensions are for — work with Playwright instead, on Chrome and Firefox alike.

[Google Chrome](https://cdpfleet.com/docs/browsers/chrome) [Firefox](https://cdpfleet.com/docs/browsers/firefox)

Run on production on 2026-10-14: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go

## The problem

Locally you might load an ad blocker, a header-rewriting extension or your own content script with `--load-extension`. cdpfleet launches browsers for you, so there is no way to install an extension, and launch fields that would change the browser process are refused. What do you lose, and how do you get the same effect?

## What we used, and why

| What | Why |
| --- | --- |
| `args: ["--load-extension=…"]`, `extensions: […]` in the launch | What people try first. Both are browser-process fields, which the fleet refuses with `400 unsupported_option`, as it does `executablePath`, `ignoreDefaultArgs`, `env`, `userDataDir`, `chromiumSandbox`, `devtools`, `downloadsPath` and `tracesDir`. |
| `chrome.runtime` / `browser.runtime` in the page | Present only when an extension exposes itself to the page. |
| `context.addInitScript()` | The content-script equivalent: runs in every page and frame before the page's own scripts, after every navigation. |
| `context.route()` + `route.abort()` | The request-blocking equivalent (ad and image blockers). |
| `context.setExtraHTTPHeaders()` | The header-rewriting equivalent, for every request the browser makes. |
| httpbin.org/headers | Echoes the request headers, to prove the header went out. |

## How it works

1.  Ask for Chrome and Firefox with an extension flag, a user-agent flag and an `extensions` list, and record the answer.
2.  Launch both normally and check the page for an extension API.
3.  In a new context, add a content script that marks the page, block images, and add a request header.
4.  Open two pages of books.toscrape.com: count the pages the script marked and the images blocked; then read the headers httpbin received.

## The code

The same program in five languages (also [on GitHub](https://github.com/cdpfleet/cdpfleet/tree/main/cases/46-no-extensions), with the raw output). Set these environment variables first:

-   `CDPFLEET_API_KEY` — your API key (dashboard → API keys)
-   `PROXY_URL` — your proxy, e.g. `http://user:pass@proxy.example.com:8000`

 

```node
// npm install playwright@1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
import { chromium, firefox } from 'playwright';

const KEY = process.env.CDPFLEET_API_KEY;
const SETUPS = [
  { label: 'Chrome', engine: 'chrome', type: chromium, api: 'typeof chrome !== "undefined" && !!chrome.runtime' },
  { label: 'Firefox', engine: 'firefox', type: firefox, api: 'typeof browser !== "undefined" && !!browser.runtime' },
];
const SITE = 'https://books.toscrape.com/';

// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async function launch(engine, body) {
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(`https://starter.cdpfleet.com/${engine}/session`, {
      method: 'POST',
      headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
      body: JSON.stringify(body),
    });
    if (res.ok) return { status: res.status, ...(await res.json()) };
    if (res.status !== 503 || attempt === 5) throw new Error(`launch ${engine} ${res.status} ${await res.text()}`);
    await new Promise((r) => setTimeout(r, (Number(res.headers.get('retry-after')) || 5) * 1000));
  }
}

// What an extension's content script does: runs in every page before the page's own scripts.
const CONTENT_SCRIPT = `
  window.__myExtension = true;
  document.addEventListener('DOMContentLoaded', () => {
    const b = document.createElement('div');
    b.id = 'my-extension-banner';
    b.textContent = 'Injected on ' + location.pathname;
    document.body.prepend(b);
  });`;

// Try to load an extension the way a local launch would: Chromium flags and an "extensions" list.
// The fleet refuses browser-process fields with 400 unsupported_option, so nothing is started.
async function tryExtensions(engine) {
  const res = await fetch(`https://starter.cdpfleet.com/${engine}/session`, {
    method: 'POST',
    headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
    body: JSON.stringify({
      proxy: process.env.PROXY_URL,
      args: ['--load-extension=/tmp/my-extension', '--user-agent=ARGS-WERE-APPLIED'],
      extensions: ['/tmp/my-extension'],
    }),
  });
  const body = await res.json().catch(() => ({}));
  return { status: res.status, error: body.error ?? null };
}

async function one(s) {
  const refused = await tryExtensions(s.engine);
  const out = { label: s.label, launch_status: refused.status, launch_error: refused.error };
  // A normal launch, for the Playwright equivalents.
  const session = await launch(s.engine, { proxy: process.env.PROXY_URL });
  const browser = await s.type.connect(session.wsUrl, { headers: { 'x-api-key': KEY } });
  try {
    const page = await browser.newPage();
    // chrome.runtime / browser.runtime exist in a page only when an extension talks to it.
    out.extension_api = await page.evaluate(s.api);
    await page.close();

    // The Playwright equivalents, in one context.
    const context = await browser.newContext();
    await context.addInitScript(CONTENT_SCRIPT); // content script
    await context.setExtraHTTPHeaders({ 'x-my-extension': 'on' }); // header rewriting
    let blocked = 0; // request blocking, like an ad or image blocker
    await context.route(/\.(png|jpe?g|gif|webp)(\?|$)/, (route) => { blocked++; return route.abort(); });
    const p = await context.newPage();
    let injected = 0;
    for (const url of [SITE, `${SITE}catalogue/page-2.html`]) {
      await p.goto(url, { timeout: 60000 });
      if (await p.locator('#my-extension-banner').count()) injected++;
    }
    out.content_script_pages = injected;
    out.blocked_images = blocked;
    // httpbin echoes the request headers; read the response body, not the rendered page.
    const { headers } = await (await p.goto('https://httpbin.org/headers', { timeout: 60000 })).json();
    out.header_added = headers['X-My-Extension'] === 'on';
  } finally {
    await browser.close();
  }
  return out;
}

const results = [];
for (const s of SETUPS) results.push(await one(s));
console.log(JSON.stringify({ results }, null, 2));
```

```python
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import json
import os
import re
import time

import requests
from playwright.sync_api import sync_playwright

KEY = os.environ["CDPFLEET_API_KEY"]
SETUPS = [
    {"label": "Chrome", "engine": "chrome", "family": "chromium", "api": "typeof chrome !== 'undefined' && !!chrome.runtime"},
    {"label": "Firefox", "engine": "firefox", "family": "firefox", "api": "typeof browser !== 'undefined' && !!browser.runtime"},
]
SITE = "https://books.toscrape.com/"

# What an extension's content script does: runs in every page before the page's own scripts.
CONTENT_SCRIPT = """
  window.__myExtension = true;
  document.addEventListener('DOMContentLoaded', () => {
    const b = document.createElement('div');
    b.id = 'my-extension-banner';
    b.textContent = 'Injected on ' + location.pathname;
    document.body.prepend(b);
  });"""

def launch(engine, body):
    # 503 means the fleet is momentarily busy: wait Retry-After and try again.
    for attempt in range(1, 6):
        res = requests.post(f"https://starter.cdpfleet.com/{engine}/session", headers={"x-api-key": KEY}, json=body, timeout=60)
        if res.ok:
            return res.status_code, res.json()
        if res.status_code != 503 or attempt == 5:
            raise RuntimeError(f"launch {engine} {res.status_code} {res.text}")
        time.sleep(int(res.headers.get("retry-after", 5)))

def try_extensions(engine):
    # Try to load an extension the way a local launch would: Chromium flags and an "extensions" list.
    # The fleet refuses browser-process fields with 400 unsupported_option, so nothing is started.
    res = requests.post(f"https://starter.cdpfleet.com/{engine}/session", headers={"x-api-key": KEY}, json={
        "proxy": os.environ["PROXY_URL"],
        "args": ["--load-extension=/tmp/my-extension", "--user-agent=ARGS-WERE-APPLIED"],
        "extensions": ["/tmp/my-extension"],
    }, timeout=60)
    try:
        error = res.json().get("error")
    except ValueError:
        error = None
    return res.status_code, error

def one(p, s):
    status, error = try_extensions(s["engine"])
    out = {"label": s["label"], "launch_status": status, "launch_error": error}
    # A normal launch, for the Playwright equivalents.
    _, session = launch(s["engine"], {"proxy": os.environ["PROXY_URL"]})
    browser = getattr(p, s["family"]).connect(session["wsUrl"], headers={"x-api-key": KEY})
    try:
        page = browser.new_page()
        # chrome.runtime / browser.runtime exist in a page only when an extension talks to it.
        out["extension_api"] = page.evaluate(s["api"])
        page.close()

        # The Playwright equivalents, in one context.
        context = browser.new_context()
        context.add_init_script(CONTENT_SCRIPT)  # content script
        context.set_extra_http_headers({"x-my-extension": "on"})  # header rewriting
        blocked = [0]  # request blocking, like an ad or image blocker

        def block(route):
            blocked[0] += 1
            route.abort()

        context.route(re.compile(r"\.(png|jpe?g|gif|webp)(\?|$)"), block)
        pg = context.new_page()
        injected = 0
        for url in [SITE, f"{SITE}catalogue/page-2.html"]:
            pg.goto(url, timeout=60000)
            if pg.locator("#my-extension-banner").count():
                injected += 1
        out["content_script_pages"] = injected
        out["blocked_images"] = blocked[0]
        # httpbin echoes the request headers; read the response body, not the rendered page.
        headers = pg.goto("https://httpbin.org/headers", timeout=60000).json()["headers"]
        out["header_added"] = headers.get("X-My-Extension") == "on"
    finally:
        browser.close()
    return out

with sync_playwright() as p:
    results = [one(p, s) for s in SETUPS]
print(json.dumps({"results": results}, indent=2))
```

```java
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1.
// env: CDPFLEET_API_KEY, PROXY_URL
import com.google.gson.*;
import com.microsoft.playwright.*;
import java.net.URI;
import java.net.http.*;
import java.util.*;
import java.util.regex.Pattern;

public class Main {
  static final String KEY = System.getenv("CDPFLEET_API_KEY");
  static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
  static final String SITE = "https://books.toscrape.com/";

  record Setup(String label, String engine, String family, String api) {}
  static final Setup[] SETUPS = {
    new Setup("Chrome", "chrome", "chromium", "typeof chrome !== 'undefined' && !!chrome.runtime"),
    new Setup("Firefox", "firefox", "firefox", "typeof browser !== 'undefined' && !!browser.runtime"),
  };

  // What an extension's content script does: runs in every page before the page's own scripts.
  static final String CONTENT_SCRIPT = """
      window.__myExtension = true;
      document.addEventListener('DOMContentLoaded', () => {
        const b = document.createElement('div');
        b.id = 'my-extension-banner';
        b.textContent = 'Injected on ' + location.pathname;
        document.body.prepend(b);
      });""";

  record Launched(int status, JsonObject session) {}

  // 503 means the fleet is momentarily busy: wait Retry-After and try again.
  static Launched launch(String engine, JsonObject body) throws Exception {
    for (int attempt = 1; ; attempt++) {
      HttpResponse<String> res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/" + engine + "/session"))
          .header("x-api-key", KEY).header("content-type", "application/json")
          .POST(HttpRequest.BodyPublishers.ofString(body.toString())).build(), HttpResponse.BodyHandlers.ofString());
      if (res.statusCode() == 200) return new Launched(res.statusCode(), JsonParser.parseString(res.body()).getAsJsonObject());
      if (res.statusCode() != 503 || attempt == 5) throw new RuntimeException("launch " + engine + " " + res.statusCode() + " " + res.body());
      Thread.sleep(Long.parseLong(res.headers().firstValue("retry-after").orElse("5")) * 1000);
    }
  }

  // Try to load an extension the way a local launch would: Chromium flags and an "extensions" list.
  // The fleet refuses browser-process fields with 400 unsupported_option, so nothing is started.
  static JsonObject tryExtensions(String engine) throws Exception {
    JsonObject body = new JsonObject();
    body.addProperty("proxy", System.getenv("PROXY_URL"));
    JsonArray args = new JsonArray();
    args.add("--load-extension=/tmp/my-extension");
    args.add("--user-agent=ARGS-WERE-APPLIED");
    body.add("args", args);
    JsonArray exts = new JsonArray();
    exts.add("/tmp/my-extension");
    body.add("extensions", exts);
    HttpResponse<String> res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/" + engine + "/session"))
        .header("x-api-key", KEY).header("content-type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(body.toString())).build(), HttpResponse.BodyHandlers.ofString());
    JsonObject out = new JsonObject();
    out.addProperty("status", res.statusCode());
    try {
      JsonElement e = JsonParser.parseString(res.body()).getAsJsonObject().get("error");
      out.add("error", e == null ? JsonNull.INSTANCE : e);
    } catch (Exception e) {
      out.add("error", JsonNull.INSTANCE);
    }
    return out;
  }

  static JsonObject one(Playwright pw, Setup s) throws Exception {
    JsonObject refused = tryExtensions(s.engine());
    JsonObject out = new JsonObject();
    out.addProperty("label", s.label());
    out.add("launch_status", refused.get("status"));
    out.add("launch_error", refused.get("error"));
    // A normal launch, for the Playwright equivalents.
    JsonObject body = new JsonObject();
    body.addProperty("proxy", System.getenv("PROXY_URL"));
    Launched l = launch(s.engine(), body);
    BrowserType type = s.family().equals("firefox") ? pw.firefox() : pw.chromium();
    Browser browser = type.connect(l.session().get("wsUrl").getAsString(), new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
    try {
      Page page = browser.newPage();
      // chrome.runtime / browser.runtime exist in a page only when an extension talks to it.
      out.addProperty("extension_api", (Boolean) page.evaluate(s.api()));
      page.close();

      // The Playwright equivalents, in one context.
      BrowserContext context = browser.newContext();
      context.addInitScript(CONTENT_SCRIPT); // content script
      context.setExtraHTTPHeaders(Map.of("x-my-extension", "on")); // header rewriting
      int[] blocked = {0}; // request blocking, like an ad or image blocker
      context.route(Pattern.compile("\\.(png|jpe?g|gif|webp)(\\?|$)"), route -> { blocked[0]++; route.abort(); });
      Page p = context.newPage();
      int injected = 0;
      for (String url : List.of(SITE, SITE + "catalogue/page-2.html")) {
        p.navigate(url, new Page.NavigateOptions().setTimeout(60000));
        if (p.locator("#my-extension-banner").count() > 0) injected++;
      }
      out.addProperty("content_script_pages", injected);
      out.addProperty("blocked_images", blocked[0]);
      // httpbin echoes the request headers; read the response body, not the rendered page.
      Response resp = p.navigate("https://httpbin.org/headers", new Page.NavigateOptions().setTimeout(60000));
      JsonObject headers = JsonParser.parseString(resp.text()).getAsJsonObject().getAsJsonObject("headers");
      out.addProperty("header_added", headers.has("X-My-Extension") && headers.get("X-My-Extension").getAsString().equals("on"));
    } finally {
      browser.close();
    }
    return out;
  }

  public static void main(String[] args) throws Exception {
    JsonArray results = new JsonArray();
    try (Playwright pw = Playwright.create()) {
      for (Setup s : SETUPS) results.add(one(pw, s));
    }
    JsonObject result = new JsonObject();
    result.add("results", results);
    System.out.println(GSON.toJson(result));
  }
}
```

```csharp
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Net.Http.Json;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Playwright;

var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
const string Site = "https://books.toscrape.com/";
var setups = new (string Label, string Engine, string Family, string Api)[]
{
    ("Chrome", "chrome", "chromium", "typeof chrome !== 'undefined' && !!chrome.runtime"),
    ("Firefox", "firefox", "firefox", "typeof browser !== 'undefined' && !!browser.runtime"),
};

// What an extension's content script does: runs in every page before the page's own scripts.
const string ContentScript = """
  window.__myExtension = true;
  document.addEventListener('DOMContentLoaded', () => {
    const b = document.createElement('div');
    b.id = 'my-extension-banner';
    b.textContent = 'Injected on ' + location.pathname;
    document.body.prepend(b);
  });
""";

using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();

// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async Task<(int Status, JsonElement Session)> Launch(string engine, JsonObject body)
{
    for (var attempt = 1; ; attempt++)
    {
        var res = await http.PostAsync($"https://starter.cdpfleet.com/{engine}/session",
            new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"));
        if (res.IsSuccessStatusCode) return ((int)res.StatusCode, await res.Content.ReadFromJsonAsync<JsonElement>());
        if ((int)res.StatusCode != 503 || attempt == 5) throw new Exception($"launch {engine} {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
        await Task.Delay((int)(res.Headers.RetryAfter?.Delta?.TotalMilliseconds ?? 5000));
    }
}

// Try to load an extension the way a local launch would: Chromium flags and an "extensions" list.
// The fleet refuses browser-process fields with 400 unsupported_option, so nothing is started.
async Task<(int Status, string? Error)> TryExtensions(string engine)
{
    var body = new JsonObject
    {
        ["proxy"] = Environment.GetEnvironmentVariable("PROXY_URL"),
        ["args"] = new JsonArray("--load-extension=/tmp/my-extension", "--user-agent=ARGS-WERE-APPLIED"),
        ["extensions"] = new JsonArray("/tmp/my-extension"),
    };
    var res = await http.PostAsync($"https://starter.cdpfleet.com/{engine}/session",
        new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"));
    string? error = null;
    try
    {
        var json = await res.Content.ReadFromJsonAsync<JsonElement>();
        if (json.ValueKind == JsonValueKind.Object && json.TryGetProperty("error", out var e) && e.ValueKind == JsonValueKind.String) error = e.GetString();
    }
    catch (JsonException) { }
    return ((int)res.StatusCode, error);
}

async Task<JsonObject> One(string label, string engine, string family, string api)
{
    var (refusedStatus, refusedError) = await TryExtensions(engine);
    var outp = new JsonObject { ["label"] = label, ["launch_status"] = refusedStatus, ["launch_error"] = refusedError };
    // A normal launch, for the Playwright equivalents.
    var (_, session) = await Launch(engine, new JsonObject { ["proxy"] = Environment.GetEnvironmentVariable("PROXY_URL") });
    var type = family == "firefox" ? playwright.Firefox : playwright.Chromium;
    var browser = await type.ConnectAsync(session.GetProperty("wsUrl").GetString()!, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
    try
    {
        var page = await browser.NewPageAsync();
        // chrome.runtime / browser.runtime exist in a page only when an extension talks to it.
        outp["extension_api"] = await page.EvaluateAsync<bool>(api);
        await page.CloseAsync();

        // The Playwright equivalents, in one context.
        var context = await browser.NewContextAsync();
        await context.AddInitScriptAsync(ContentScript); // content script
        await context.SetExtraHTTPHeadersAsync(new Dictionary<string, string> { ["x-my-extension"] = "on" }); // header rewriting
        var blocked = 0; // request blocking, like an ad or image blocker
        await context.RouteAsync(new Regex(@"\.(png|jpe?g|gif|webp)(\?|$)"), route => { blocked++; return route.AbortAsync(); });
        var p = await context.NewPageAsync();
        var injected = 0;
        foreach (var url in new[] { Site, $"{Site}catalogue/page-2.html" })
        {
            await p.GotoAsync(url, new() { Timeout = 60000 });
            if (await p.Locator("#my-extension-banner").CountAsync() > 0) injected++;
        }
        outp["content_script_pages"] = injected;
        outp["blocked_images"] = blocked;
        // httpbin echoes the request headers; read the response body, not the rendered page.
        var resp = await p.GotoAsync("https://httpbin.org/headers", new() { Timeout = 60000 });
        var headers = (await resp!.JsonAsync())!.Value.GetProperty("headers");
        outp["header_added"] = headers.TryGetProperty("X-My-Extension", out var v) && v.GetString() == "on";
    }
    finally
    {
        await browser.CloseAsync();
    }
    return outp;
}

var results = new JsonArray();
foreach (var s in setups) results.Add(await One(s.Label, s.Engine, s.Family, s.Api));
Console.WriteLine(new JsonObject { ["results"] = results }.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
```

```go
// go get github.com/playwright-community/playwright-go@v0.6000.0
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/playwright-community/playwright-go"
)

var key = os.Getenv("CDPFLEET_API_KEY")

const site = "https://books.toscrape.com/"

type setup struct{ label, engine, family, api string }

var setups = []setup{
	{"Chrome", "chrome", "chromium", "typeof chrome !== 'undefined' && !!chrome.runtime"},
	{"Firefox", "firefox", "firefox", "typeof browser !== 'undefined' && !!browser.runtime"},
}

// What an extension's content script does: runs in every page before the page's own scripts.
const contentScript = `
  window.__myExtension = true;
  document.addEventListener('DOMContentLoaded', () => {
    const b = document.createElement('div');
    b.id = 'my-extension-banner';
    b.textContent = 'Injected on ' + location.pathname;
    document.body.prepend(b);
  });`

// Field order matches the other languages' output.
type result struct {
	Label              string  `json:"label"`
	LaunchStatus       int     `json:"launch_status"`
	LaunchError        *string `json:"launch_error"`
	ExtensionAPI       bool    `json:"extension_api"`
	ContentScriptPages int     `json:"content_script_pages"`
	BlockedImages      int     `json:"blocked_images"`
	HeaderAdded        bool    `json:"header_added"`
}

// 503 means the fleet is momentarily busy: wait Retry-After and try again.
func launch(engine string, opts map[string]any) (int, map[string]any, error) {
	body, _ := json.Marshal(opts)
	for attempt := 1; ; attempt++ {
		req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+engine+"/session", bytes.NewReader(body))
		req.Header.Set("x-api-key", key)
		req.Header.Set("content-type", "application/json")
		res, err := http.DefaultClient.Do(req)
		if err != nil {
			return 0, nil, err
		}
		b, _ := io.ReadAll(res.Body)
		res.Body.Close()
		if res.StatusCode == 200 {
			var session map[string]any
			return res.StatusCode, session, json.Unmarshal(b, &session)
		}
		if res.StatusCode != 503 || attempt == 5 {
			return 0, nil, fmt.Errorf("launch %s %d %s", engine, res.StatusCode, strings.TrimSpace(string(b)))
		}
		wait, err := strconv.Atoi(res.Header.Get("retry-after"))
		if err != nil {
			wait = 5
		}
		time.Sleep(time.Duration(wait) * time.Second)
	}
}

// Try to load an extension the way a local launch would: Chromium flags and an "extensions" list.
// The fleet refuses browser-process fields with 400 unsupported_option, so nothing is started.
func tryExtensions(engine string) (int, *string, error) {
	body, _ := json.Marshal(map[string]any{
		"proxy":      os.Getenv("PROXY_URL"),
		"args":       []string{"--load-extension=/tmp/my-extension", "--user-agent=ARGS-WERE-APPLIED"},
		"extensions": []string{"/tmp/my-extension"},
	})
	req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+engine+"/session", bytes.NewReader(body))
	req.Header.Set("x-api-key", key)
	req.Header.Set("content-type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return 0, nil, err
	}
	defer res.Body.Close()
	var reply struct {
		Error *string `json:"error"`
	}
	b, _ := io.ReadAll(res.Body)
	_ = json.Unmarshal(b, &reply)
	return res.StatusCode, reply.Error, nil
}

func one(pw *playwright.Playwright, s setup) (result, error) {
	out := result{Label: s.label}
	status, refusal, err := tryExtensions(s.engine)
	if err != nil {
		return out, err
	}
	out.LaunchStatus, out.LaunchError = status, refusal
	// A normal launch, for the Playwright equivalents.
	_, session, err := launch(s.engine, map[string]any{"proxy": os.Getenv("PROXY_URL")})
	if err != nil {
		return out, err
	}
	browserType := pw.Chromium
	if s.family == "firefox" {
		browserType = pw.Firefox
	}
	browser, err := browserType.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
	if err != nil {
		return out, err
	}
	defer browser.Close()

	page, err := browser.NewPage()
	if err != nil {
		return out, err
	}
	// chrome.runtime / browser.runtime exist in a page only when an extension talks to it.
	api, err := page.Evaluate(s.api)
	if err != nil {
		return out, err
	}
	out.ExtensionAPI = api == true
	page.Close()

	// The Playwright equivalents, in one context.
	context, err := browser.NewContext()
	if err != nil {
		return out, err
	}
	if err := context.AddInitScript(playwright.Script{Content: playwright.String(contentScript)}); err != nil { // content script
		return out, err
	}
	if err := context.SetExtraHTTPHeaders(map[string]string{"x-my-extension": "on"}); err != nil { // header rewriting
		return out, err
	}
	blocked := 0 // request blocking, like an ad or image blocker
	if err := context.Route(regexp.MustCompile(`\.(png|jpe?g|gif|webp)(\?|$)`), func(route playwright.Route) {
		blocked++
		route.Abort()
	}); err != nil {
		return out, err
	}
	p, err := context.NewPage()
	if err != nil {
		return out, err
	}
	for _, url := range []string{site, site + "catalogue/page-2.html"} {
		if _, err := p.Goto(url, playwright.PageGotoOptions{Timeout: playwright.Float(60000)}); err != nil {
			return out, err
		}
		if n, err := p.Locator("#my-extension-banner").Count(); err == nil && n > 0 {
			out.ContentScriptPages++
		}
	}
	out.BlockedImages = blocked
	// httpbin echoes the request headers; read the response body, not the rendered page.
	resp, err := p.Goto("https://httpbin.org/headers", playwright.PageGotoOptions{Timeout: playwright.Float(60000)})
	if err != nil {
		return out, err
	}
	var echoed struct {
		Headers map[string]string `json:"headers"`
	}
	if err := resp.JSON(&echoed); err != nil {
		return out, err
	}
	out.HeaderAdded = echoed.Headers["X-My-Extension"] == "on"
	return out, nil
}

// run returns errors instead of exiting, so deferred Close and Stop calls still run.
func run() error {
	pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
	if err != nil {
		return err
	}
	defer pw.Stop()
	results := []result{}
	for _, s := range setups {
		r, err := one(pw, s)
		if err != nil {
			return fmt.Errorf("%s: %w", s.label, err)
		}
		results = append(results, r)
	}
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	enc.SetIndent("", "  ")
	if err := enc.Encode(map[string]any{"results": results}); err != nil {
		return err
	}
	fmt.Print(buf.String())
	return nil
}

func main() {
	if err := run(); err != nil {
		log.Fatal(err)
	}
}
```

## What we got

| Browser | Launch with args/extensions | Extension API in page | Content script (pages) | Images blocked | Header added |
| --- | --- | --- | --- | --- | --- |
| Chrome | 400 unsupported\_option | no | 2 / 2 | 44 | yes |
| Firefox | 400 unsupported\_option | no | 2 / 2 | 40 | yes |

From the Node.js run on 2026-10-14. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.

Raw output (Node.js)

```
{
  "results": [
    {
      "label": "Chrome",
      "launch_status": 400,
      "launch_error": "unsupported_option",
      "extension_api": false,
      "content_script_pages": 2,
      "blocked_images": 44,
      "header_added": true
    },
    {
      "label": "Firefox",
      "launch_status": 400,
      "launch_error": "unsupported_option",
      "extension_api": false,
      "content_script_pages": 2,
      "blocked_images": 40,
      "header_added": true
    }
  ]
}
```

## Takeaways

-   **Extensions can't be loaded, and you're told so:** the launch with `args` and `extensions` was refused with `400 {"error":"unsupported_option"}` on both browsers, so nothing starts and nothing is billed; a normal launch has no extension API in the page. The same goes for the other browser-process fields above — use the [launch options](https://cdpfleet.com/docs/browsers/chrome) instead.
-   **Content scripts → `addInitScript`:** the script ran on both pages, on Chrome and Firefox, and re-ran after each navigation — what a content script with `run_at: document_start` gives you.
-   **Blockers → `route()`:** aborting image requests blocked every image on the two pages (40 or more) on both browsers; the same pattern blocks ad and tracker hosts ([bandwidth diet](https://cdpfleet.com/docs/cases/bandwidth-diet)).
-   **Header rewriting → `setExtraHTTPHeaders`:** httpbin received the added header from both browsers.
-   **What has no equivalent:** an extension's own UI, popup and background service worker, and extensions that call browser-internal APIs (password managers, wallets). If a site requires one of those, it won't run here.

---
Source: https://cdpfleet.com/docs/cases/no-extensions · Markdown: https://cdpfleet.com/docs/cases/no-extensions.md · All docs for AI tools: https://cdpfleet.com/llms.txt
