English | 한국어 | 日本語 | 中文 | Español
Add, modify and remove HTTP request and response headers, in Chrome, with no host access until you grant it.
| Light | Dark |
|---|---|
![]() |
![]() |
There is no Chrome Web Store listing. Take the zip attached to the latest release and unpack it, or build it yourself:
corepack enable # pnpm comes from package.json's packageManager field
pnpm install
pnpm build # → .output/chrome-mv3Then open chrome://extensions, turn on Developer mode, choose Load unpacked, and
select the unpacked directory. Chrome only — see Limitations.
npm i -g headerlabThat puts headerlab on your PATH, for driving the extension from a terminal — see
Agent bridge. It runs straight from a clone too, with no install step at
all, because the package has zero runtime dependencies:
node packages/headerlab/bin/headerlab.mjs. The line above is how a person uses this; the
clone is what a contributor does, and the two are ordered that way on purpose.
packages/plugin packages the CLI as a skill for Claude Code and for Codex, from one
skills/ tree under two manifests. Neither is published to a directory, so both install
from this repository:
# Claude Code
claude plugin marketplace add say8425/headerlab
claude plugin install headerlab@headerlab
# Codex
codex plugin marketplace add say8425/headerlabThe skill runs command -v headerlab before its own content reaches the model, so a
missing CLI arrives as a fact rather than as a surprise mid-task. It reports bridge-off
until the bridge is turned on. Installing the CLI globally is not a prerequisite: the
plugin carries its own shim to packages/headerlab. Running npm i -g headerlab as well
is not a conflict either — PATH resolves the global copy first.
Ask in your own words; the skill maps the request onto the CLI:
What is HeaderLab doing right now?
Add an X-Debug: on request header and scope it to staging.example.com
Stop sending the Referer header on api.example.com
Pause every rule, then turn them back on
Which sites am I actually allowed to modify?
The first and last are reads — status, site ls, rule ls and state get answer
without writing anything. The middle three write, and one detail is worth expecting:
adding a site scopes a rule to it but does not grant access to it. That site stays pending
until you press Grant in the popup, and the skill is told to say so rather than let you
read the write as if the site were already live.
-
Set, append or remove any header, on the request or the response side.
appendis limited by Chrome to a 21-header allowlist on requests, and HeaderLab names the rule that falls outside it — which matters more than it sounds, because Chrome rejects a ruleset as a whole rather than per rule, so one such rule stops every other one too. That is reported, not silent: the popup shows the registration failure. - Scope by site. Sites are matched by host: a port or a path is dropped when you add one, and the stored value is the value that operates, so what the rail shows is what goes on the wire.
-
Apply everywhere, as an explicit mode rather than an empty site list. It costs
<all_urls>, and the switch does not ask for it — the Grant button beside it does. -
Filter by request type — eight of Chrome's resource types, checkable individually.
main_frameis on by default, because DNR's own default silently excludes it. - Pause everything with one switch. The toolbar icon greys out to match, and is re-applied when the service worker wakes.
- Follows your OS theme, light or dark, before first paint.
Access is asked for per site, on the row that names the site — never as a side effect of typing a hostname or flipping a switch. Until you press Grant, the row is amber and says so — and the count beside Rules refuses to flatter it: a rule scoped only to hosts you have not granted is counted blocked, never live, and the hosts still waiting are named beside it, so the count stays honest on both ends ("3 of 4 live · 1 off · 1 site needs access"):
Anything that would stop a rule going out is said on that rule's own row, and counted
beside the Rules heading. Here the second rule asks Chrome to append a request header
it will not append — the row says which and what to do instead, the count reads
2 of 4 live · 1 off · 1 blocked, and nothing moves to make room for the message:
Captured from the real production build loaded in Chrome. Only the manifest was patched, to pre-grant the two example hosts so the granted state could be photographed without a native permission dialog.
-
No host permissions at install. The manifest's
permissionsis exactlystorageanddeclarativeNetRequestWithHostAccess. It also declaresoptional_host_permissions: ["<all_urls>"], which grants nothing on its own — Chrome refuses to let an extension request an origin it never declared, so that line is what makes the runtime Grant button legal, not what makes it unnecessary. Site access is granted by you, per host, at runtime, and can be revoked from Chrome at any time. -
No network calls. No analytics, telemetry, remote config or update pings. The shipped bundle never calls a network primitive, and you can check that yourself rather than believe it:
pnpm build grep -rE 'fetch\(|XMLHttpRequest|WebSocket|sendBeacon' .output/chrome-mv3That returns nothing. The pattern matches call and constructor forms on purpose: a bare case-insensitive search for those words does hit the bundle sixteen times, and every one is a string or an identifier rather than a call — React DOM's
prefetchDNS,fetchPriorityanddns-prefetch, and the literals"xmlhttprequest"and"websocket". Those last two are declarativeNetRequest resource-type names, and they arrive by different routes:xmlhttprequestis one of the eight the popup offers as checkboxes (labelledxhrthere), whilewebsocketis only ever a member of the fifteen-value resource-type enum the stored state is validated against. Said here so that finding them reads as expected rather than as a caught lie. -
No content scripts. Nothing is injected into any page. Headers are changed by Chrome's
declarativeNetRequestengine, which never hands request contents to the extension. -
No external resources. No CDN, no web fonts, no remote images.
-
No silent failures. Anything that stops a rule going out is stated on screen — a missing permission, an unusable hostname, a header name Chrome will reject. A rule that is not applying always says why.
An AI agent can drive HeaderLab from a terminal instead of a person clicking through the popup:
headerlab site add staging.example.com
headerlab rule add --target request --op set --name Authorization --value "Bearer $TOKEN"The bridge is off until a human turns on its switch in the popup, and the CLI can neither grant site access nor turn it on — Chrome takes both only from a user gesture. Nothing leaves the machine: CLI, host and extension meet on a unix domain socket in a per-user directory, never a network socket.
docs/agent-bridge.md is the whole of it — the protocol, the
commands, the exit codes, how to turn it on, and the five claims not to get wrong.
This is a Chrome MV3 build and nothing else. wxt.config.ts declares no other target,
and no build has been run on another browser. Edge is the same engine and should work, but
nobody has run the suite against it.
The table below is the platform ceiling a port would meet, not a support matrix — it is
MDN's browser-compat data for the APIs this
extension is built on, read at the versions each browser first shipped them. Edge's column
is ✓ rather than a number because BCD records it as mirror — it tracks Chrome's:
| Chrome | Edge | Firefox | Safari | |
|---|---|---|---|---|
Request headers (RuleAction.requestHeaders) |
86 | ✓ | 113 | 16.4 |
Response headers (RuleAction.responseHeaders) |
86 | ✓ | 113 | none |
Per-site runtime grant (optional_host_permissions) |
102 | ✓ | 128 | 15.5 |
Tab-scoped rules (RuleCondition.tabIds) |
92 | ✓ | 113 | none |
Native messaging (runtime.connectNative) |
29 | ✓ | 50 | 14 (containing app) |
Two of those are worth spelling out:
- Safari cannot modify response headers at all. That is half of what this extension does, so a Safari port would be a different, smaller product rather than the same one recompiled.
-
Safari's native messaging goes to a containing macOS app, per Apple's documented
model, rather than to a host manifest on disk.
headerlab bridge installwrites exactly such a manifest, so the agent bridge has nothing to install into there.
Features deliberately not built yet are tracked as issues: #30 one rule set · #31 JSON import/export · #32 tab lock UI · #33 regex scoping · #34 manual theme toggle.
lib/model/ types, zod schema, defaults, migrations pure
lib/compile/ AppState → DNR rules + diagnostics pure
lib/permissions/ origins.ts, audit.ts pure · probe.ts calls the browser
lib/view/ popup view models pure
lib/bridge/ protocol.ts (command schema), apply.ts (reducer),
query.ts (state → StatusPayload) pure
lib/storage/ state.ts, session.ts, useAppState.ts
lib/sync/ ruleSync.ts (reconcile), icon.ts
components/ popup UI
entrypoints/ background.ts, popup/
packages/ the agent bridge, outside the extension bundle — headerlab
(the CLI plus the native-messaging host, published to npm),
plugin. Zero deps, node:test, their own CI job
All correctness lives in a pure layer that never imports chrome.*. compile() turns
the whole application state into declarativeNetRequest rules plus a list of diagnostics,
and the popup runs that same function on that same state — so what the screen says and what
the browser was told cannot disagree.
One reconcile loop. Every trigger — a storage change, worker startup, a permission
granted or revoked — funnels into reconcile() in lib/sync/ruleSync.ts, which recompiles
from scratch and replaces the ruleset wholesale. It is idempotent, and there is no second
path by which state can drift down.
This shape is forced rather than chosen: @webext-core/fake-browser implements
declarativeNetRequest and permissions.* as throwing stubs, so browser-imitation testing
is unavailable. Making the browser irrelevant to the logic is the response.
Design documents live in docs/superpowers/specs/, and the measured platform constraints
behind them in docs/research/.
pnpm dev # WXT dev server → load .output/chrome-mv3-dev unpacked
pnpm check # four of CI's six jobs: typecheck · lint · format · unit tests
pnpm test # wxt build && vitest run — unit tests, no browser
pnpm test:packages # the agent-bridge packages, under node:test — vitest's
# glob does not reach them, so this is its own CI job
pnpm check:all # pnpm check && pnpm test:packages
pnpm test:e2e # builds both e2e modes, then playwright test — real Chrome
pnpm typecheck # wxt prepare && tsc --noEmit
pnpm lint # wxt prepare && oxlint --deny-warnings (lint:fix to fix)
pnpm format:check # oxfmt --check (pnpm format to write)
pnpm build # production build → .output/chrome-mv3
pnpm screenshots # rebuild the images in this README from the real popup
pnpm store:assets # rebuild the Chrome Web Store's 8 images → docs/store/assets/pnpm, not npm. package.json names the exact version under packageManager, so
corepack enable gives you that one and nothing else needs installing. There is no
package-lock.json; pnpm-lock.yaml is the lockfile CI installs from with
--frozen-lockfile.
Run pnpm test, not a bare pnpm exec vitest run. Several suites assert against
built output, and the bare tools do not build. A stale artifact has produced both a false
green that silently disabled a guard and a false red that cost an hour, so
tests/support/build.ts detects staleness and fails with the command to run.
pnpm test:e2e, pnpm screenshots and pnpm store:assets need a browser Playwright
does not install by default:
pnpm exec playwright install --with-deps --no-shell chromium--no-shell matters. Playwright's default headless download is chromium-headless-shell,
a stripped build that cannot load extensions — and both of those commands exist to load
one. Without the full binary they fail in a way that looks like a code problem rather than
a missing dependency.
pnpm screenshots and pnpm store:assets overwrite tracked PNGs — under
docs/screenshots/ and docs/store/assets/ respectively, and the second empties its
directory before rewriting all 8. That is their job, but it means a run leaves changes in
git status; commit them only when the UI actually changed.
The e2e build carries a host permission the shipped build does not, and that is worth
saying out loud given the first claim on this page. pnpm test:e2e builds into
.output/chrome-mv3-e2e and .output/chrome-mv3-bridge-e2e, beside the production
directory. The first of those declares http://127.0.0.1/* (wxt.config.ts) so the suite
can drive a local echo server without a runtime prompt Playwright cannot click, and the
second grants nativeMessaging outright. tests/unit/manifest.test.ts asserts neither
ever reaches production, and running the e2e suite does not touch .output/chrome-mv3 —
run pnpm build for a fresh production build.
CLAUDE.md carries the rest: why lint chains wxt prepare, why postinstall may never
run, what oxfmt does and does not format, and the platform traps that have already cost
someone time.
Three layers: pure logic with no browser, adapters driven by hand-planted spies, and
end-to-end against a genuinely loaded extension. Two of the e2e tests put a real request on
the wire through a local echo server and read the headers back off it — those are the
strongest evidence in the repo. The bridge has its own, including one that drives a real
headerlab site add through a real installed host, through the socket, and into real
storage.
packages/headerlab carries a suite of its own, run by Node's built-in test runner rather
than vitest, because the package has no dependency and should not acquire one.
vitest.config.ts's glob cannot reach them, which is why they get a CI job of their own:
for a while they were merging unexecuted, and a suite nothing runs is worse than one that
does not exist, because it reports success.
Apache-2.0. See LICENSE.



