ScalebrowserDOCS

Extensions

Bring your own Chrome extensions: what the daemon accepts and refuses at upload, how a package reaches a profile, why every profile gets its own copy, and the three ways this quietly does nothing.

A profile without the extensions a real user would have stands out at exactly the point where it is meant to blend in. Upload a .crx once, assign it to profiles, and each profile starts with it.

Uploading a package

The body is the raw .crx file, not JSON:

bash
$ curl -X POST http://127.0.0.1:8787/v1/extensions \
    -H "Authorization: Bearer $SCALEBROWSER_BEARER_TOKEN" \
    -H "Content-Type: application/octet-stream" \
    --data-binary @ublock-lite.crx

Uploading the same version again replaces the entry and its unpacked files, which is what "I fixed that package" means. Profiles that already copied the old files pick up the replacement on their next start.

What is refused, and why

Three gates, and each of them exists because the alternative fails silently.

RefusedWhy
A package with no readable keyChromium derives an extension's id from the public key in its manifest. Without one it falls back to a hash of the install path, and then your whole fleet shares an invented id belonging to no real extension. That is worse than having no extension at all.
Manifest V2The shipped engine disables MV2 with no exception for a command-line load. Accepting one would store a package, assign it, name it on the command line, and the browser would carry nothing.
A version Chrome would not acceptThe version names a directory, twice. The gate is Chromium's own rule: one to four dot-separated numbers, such as 1, 1.0 or 2026.825.1619.

The upload also caps at 128 MiB, and the unpacked tree at 512 MiB. The compressed size measures nothing about what lands on disk.

The id is shared on purpose

Every profile carrying the same extension reports the same id, and it is the canonical Web Store id that millions of real installations report.

That is camouflage, not linkage. A per-profile id would be an id belonging to no extension anyone has ever installed, which is a far louder signal than sharing one with everybody else.

The files are not shared. Each profile gets its own copy, so timestamps and stored state belong to the profile rather than to the machine.

Assigning to profiles

CallWhat it does
GET /v1/extensionsThe library, newest first.
GET · DELETE /v1/extensions/:idFetch, or remove the package, its files and every profile's assignment of it.
DELETE /v1/extensions/:id/:versionRemove one stored version.
GET /v1/profiles/:id/extensionsThe profile's assigned set, plus the exact switches its next launch will emit.
POST /v1/profiles/:id/extensions{ ext_ref }: assign. Idempotent.
DELETE /v1/profiles/:id/extensions{ ext_ref }: unassign. Idempotent.

ext_ref is a library id, never a path. An unknown id is refused where it is typed: on the profile, in a bulk call, and in a preset.

For many profiles at once there is POST /v1/bulk/assign-extensions, which replaces each profile's set rather than adding to it. See Groups, presets & bulk.

Every profile draws its own version

The library keeps several versions of one package side by side, and a profile picks one from its own seed rather than everybody taking the newest.

A fleet standing uniformly on one version is the unusual thing: real Chrome staggers extension updates, and a package's web-accessible resources change between versions, which a page can read. With only one stored version everybody gets that one; nothing is invented.

The three ways this quietly does nothing

Each of these is a real case with a green-looking outcome.

An assignment does not reach a running browser. The copy is made at start. The management app says so on screen; the API response does not.

A missing library tree starts the profile without the extension. One log line, and 200 on the call. The only place it surfaces is the advice field of the extensions response, so read that rather than assuming.

A service worker that went to sleep looks like an extension that is not there. MV3 workers are stopped aggressively. If you are checking whether a package is active, wake it before you judge.

What a copy costs

Measured, not estimated: eight extensions totalling 226 MB cost a profile 19.1 seconds on its first start. Five profiles with two extensions each cost 2.3 seconds.

Disk multiplies with the number of profiles, because the copy is the point. That is the price paid for the previous section.

From an agent

One tool, in the management set: extension.list, which is read-only. Assigning is an operator action, and profile.batch with action: "assign_extensions" is the fleet form of it.

Next