Skip to content

Browser Extension

Implementation spec for the Chrome Manifest V3 extension. Built with WXT.

Decisions that constrain this: ADR-001 (programmatic injection only), ADR-002 (WXT), ADR-006 (jittered polling), ADR-007 (three write methods), ADR-013 (MAIN world on demand), ADR-014 (shadow-DOM targeting), ADR-015 (MAIN-world hygiene), ADR-016 (WAR default-deny).


extension/
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── wxt.config.ts
├── README.md
├── scripts/
│ ├── assert-build.ts
│ └── smoke/
│ ├── command.ts
│ ├── daemon.ts
│ ├── fixture-server.ts
│ ├── fixture.html
│ └── workflow.ts
└── src/
├── entrypoints/
│ ├── background.ts
│ ├── content.ts
│ └── popup/
│ ├── index.html
│ ├── main.ts
│ └── pairing.ts
├── background/
│ ├── browser-action-interstitials.ts
│ ├── browser-action-support.ts
│ ├── browser-actions.ts
│ ├── dedupe.ts
│ ├── dispatcher.ts
│ ├── forwarded-actions.ts
│ ├── forwarded-params.ts
│ ├── forwarded-request.ts
│ ├── injection.ts
│ ├── main-world*.ts
│ ├── responses.ts
│ ├── storage*.ts
│ ├── tabs*.ts
│ ├── trace.ts
│ └── ws-client.ts
├── content/
│ ├── actions/
│ │ ├── fill.ts
│ │ ├── interactions.ts
│ │ ├── reads.ts
│ │ ├── scroll-wait.ts
│ │ └── select.ts
│ ├── discovery.ts
│ ├── dom-helpers.ts
│ ├── events.ts
│ ├── page-state.ts
│ ├── polling.ts
│ ├── read-tree.ts
│ ├── rpc.ts
│ └── targeting.ts
└── test/
├── fakes/
├── fixtures/
└── setup-chrome-storage.ts
  • srcDir: "src" is enabled so dependency-cruiser and knip scan the real extension sources under extension/src/**.
  • The popup uses a directory entrypoint: src/entrypoints/popup/index.html + main.ts. WXT 0.20 rejects flat popup.html + popup.ts siblings with the same basename, so the source layout differs from the original phase sketch even though the emitted manifest still points at popup.html.
  • The runtime content script is bundled by WXT but not declared in the manifest. The background service worker injects it programmatically on first use.

Output: extension/.output/chrome-mv3/.


extension/wxt.config.ts is part of the security contract, not just build plumbing.

import { defineConfig } from "wxt";
export default defineConfig({
srcDir: "src",
vite: () => ({
build: {
sourcemap: true,
modulePreload: false,
},
}),
manifest: {
name: "bproxy",
description: "Browser proxy companion extension for bproxy daemon.",
permissions: ["tabs", "scripting", "webNavigation", "alarms", "storage"],
host_permissions: ["<all_urls>"],
action: {
default_title: "bproxy",
default_popup: "popup.html",
},
},
hooks: {
"build:manifestGenerated": (_wxt, manifest) => {
if (Array.isArray(manifest.content_scripts) && manifest.content_scripts.length === 0) {
delete manifest.content_scripts;
}
if (
Array.isArray(manifest.web_accessible_resources) &&
manifest.web_accessible_resources.length === 0
) {
delete manifest.web_accessible_resources;
}
},
},
});

Locked implications:

  • no declarative content_scripts (ADR-001);
  • no web_accessible_resources by default (ADR-016);
  • no debugger permission in the shipped manifest; debugger-backed screenshots remain future opt-in only;
  • source maps are preserved in production output to keep service-worker and content-script failures diagnosable;
  • Vite’s modulepreload polyfill is disabled because it injects MutationObserver, which would violate ADR-006.

Entrypoint: src/entrypoints/background.ts

Owns:

  1. bootstrap storage lookup and validation;
  2. daemon WebSocket connection, reconnect, heartbeat, badge state, and top-level navigation push messages;
  3. forwarded-request parsing (BproxyForwardedRequest);
  4. exactly-once execution via dedupe + replay-safe responses;
  5. extension trace ring buffer for debug.log;
  6. browser-API actions (navigate, screenshot, require-human, tab.*);
  7. programmatic content-script injection and DOM-action RPC;
  8. one-shot MAIN-world execution for fill(method="runtime-api").

Authentication uses WebSocket subprotocols:

  • bproxy.v1
  • auth.{base64url(extensionToken)}

Connection state is surfaced via the action badge:

  • empty / transparent = disconnected or connected idle;
  • = connecting;
  • ! = startup or transport error.

Entrypoint: src/entrypoints/popup/index.html + main.ts

Flow:

  1. user enters the one-time pairing code;
  2. popup POSTs { code } to http://127.0.0.1:9615/pair/claim;
  3. popup validates the bootstrap payload shape:
    • extensionToken non-empty string
    • wsUrl loopback ws://
    • protocolVersion === 2
    • expiresAt > Date.now()
    • nonce present
  4. popup stores the bootstrap payload as one atomic record in chrome.storage.local;
  5. popup sends pair.complete to the background worker so reconnect happens immediately.

Validation failures surface distinct popup-side error codes (INVALID_PAYLOAD_SHAPE, INVALID_WS_URL, UNSUPPORTED_PROTOCOL_VERSION, BOOTSTRAP_EXPIRED, MISSING_NONCE, PAIR_TRANSPORT_ERROR, PAIR_NOTIFY_FAILED) in addition to daemon pass-throughs (PAIRING_CODE_INVALID, PAIRING_CODE_EXPIRED, PAIRING_CODE_CONSUMED, PAIRING_RATE_LIMITED).

Entrypoint: src/entrypoints/content.ts

The content script is registered with:

export default defineContentScript({
registration: "runtime",
matches: ["<all_urls>"],
runAt: "document_idle",
world: "ISOLATED",
});

The service worker injects it with chrome.scripting.executeScript on first command per tab. The content side keeps a single chrome.runtime.onMessage listener and returns typed success/error envelopes plus page-state snapshots.


src/background/storage.ts defines the typed storage items.

KeyScopePurpose
local:bootstraplocalPairing bootstrap payload { extensionToken, wsUrl, protocolVersion, issuedAt, expiresAt, nonce }
local:configFlagslocalFuture opt-in flags such as debuggerScreenshot
session:pinssessionReserved tab-pin map storage seam
session:dedupesessionRequest-id → cached response + timestamp
session:injectedTabssessionTabs already injected with the runtime content script
session:tracesessionBounded extension trace ring buffer

Important contract: bootstrapItem is written and read as one record, never as separate token/url/version keys.


The extension parses forwarded daemon messages:

type BproxyForwardedRequest<A extends Action = Action> = BproxyRequest<A> & {
target: { tabId: number };
};

Implications:

  • the daemon remains the source of truth for session → tabId;
  • the extension does not re-resolve session state;
  • session.*, debug.last, and debug.status stay daemon-local and must not have extension handlers;
  • debug.log is forwarded and served from the extension ring buffer.

Responses are the normal shared BproxyResponse envelope; successful responses include page state and replay. In the other direction, the background worker may push { type: "navigation", tabId, url, cause: "committed" | "history_state" } over the existing WS connection whenever a top-level navigation event is observed.


Handled through src/content/** and routed via background/content RPC.

ActionNotes
text, links, images, elements, outline, domRead-only DOM extraction; links returns structured URLs, traverses open shadow roots, can filter to visible/in-viewport anchors, can filter absolute hrefs by substring, and supports offset pagination
inspectComputed-style and layout inspection for specific selectors (rect, display, descendants, scroll info)
snapshotAccessible DOM tree serialization (text-based, depth-limited, optional interactive-only mode)
scroll, waitJittered polling only; no MutationObserver. scroll targets only the viewport/document by default or an explicit agent-supplied ElementTarget; it never infers scroll containers.
clickExplicit target-only activation. Focuses when possible, dispatches honest click-shaped activation, and reports disappearance/stability.
hoverExplicit target-only hover primitive. Dispatches honest hover-shaped events at the element center and reports completion/stability.
fill(method="direct")Native DOM state write, no events
fill(method="paste")Dispatches beforeinput/input with inputType: "insertFromPaste" plus change; no synthetic key events
fill-formMulti-field isolated-world writes with hidden-field guard and read-back verification
selectTrigger + poll + option click + verification

links collection semantics:

  • normalize each anchor href to an absolute URL before filtering;
  • apply hrefContains as a case-sensitive substring check on the normalized href;
  • apply visibleOnly before counting;
  • collect matching links up to MAX_COLLECTION_CAP (2000), set capped:true if hit;
  • return total as the count of collected matching links before offset/limit slicing;
  • slice with offset then limit (returned limit is capped at 500).

The browser action handler receives separate seams for tab and window APIs (BrowserTabsSeam and BrowserWindowsSeam) so tests can fake activation/focus behavior without mixing concerns.

Handled in src/background/main-world*.ts.

ActionNotes
fill(method="runtime-api", world="main")Exactly one chrome.scripting.executeScript({ world: "MAIN" }) call per request

MAIN-world injected functions must:

  • resolve only the provided target/route;
  • catch and normalize errors inside the injected function;
  • return plain data only;
  • contain no identifying literals such as extension ids, chrome-extension, package names, or bproxy branding;
  • install no persistent listeners or globals.

Handled in src/background/browser-actions.ts.

ActionNotes
navigatechrome.tabs.update + wait for top-level load + interstitial detection → HUMAN_REQUIRED
screenshotchrome.tabs.captureVisibleTab normal path
screenshot(debugger=true)currently returns DEBUGGER_DISABLED unless a future explicit opt-in ships with permission + flag wiring
tab.listnot forwarded — daemon resolves from session tab registry without extension involvement
tab.open, tab.close, tab.pin, tab.unpinChrome tabs API only; does not take ownership of daemon session state
tab.activateChrome tabs/windows API; activates the tab and focuses its window
require-humanreturns structured HUMAN_REQUIRED for daemon pause handling

src/content/targeting.ts and src/content/discovery.ts implement the route-based targeting contract from ADR-014.

interface ElementRoute {
hosts: Array<{ selector: string; index?: number }>;
target: string;
}
type ElementTarget =
| { selector: string; route?: never }
| { selector?: never; route: ElementRoute };

Discovery rules:

  • open shadow roots are supported;
  • closed shadow roots are out of scope and return honest target errors;
  • probing is intent-scoped (active element chain, dialogs/popovers, viewport/hit-test roots, scoped subtree);
  • runtime editor handles are probed only inside the candidate root, never via whole-page recursive scans.

src/content/polling.ts provides the shared wait/stability primitive.

Rules:

  • jittered intervals, not fixed cadence;
  • bounded timeout;
  • visibility-aware bail-out for destructive actions (TAB_NOT_VISIBLE when hidden);
  • no MutationObserver in source or built output.

src/content/page-state.ts normalizes page snapshots into the shared PageState envelope:

interface PageState {
url: string;
title: string;
state: "loading" | "ready" | "error";
busy: boolean;
}

The busy heuristic checks for [aria-busy="true"], active <progress>, and pending navigations — but requires that matched elements are visible (via checkVisibility()). Hidden or off-screen busy indicators (common on Google SERPs) do not trigger false-positive busy: true.


src/background/dedupe.ts caches prior responses by request id:

interface DedupeEntry {
response: BproxyResponse;
ts: number;
}

The store is bounded by size and TTL so daemon replay-on-reconnect does not re-run destructive requests.

src/background/trace.ts stores bounded trace entries for debug.log.

interface TraceEntry {
id: string;
action: string;
tab: number;
timestamp: number;
elapsed: number;
result: "ok" | "error";
errorCode?: string;
replay: boolean;
extensionVersion: string;
}

The extensionVersion stamp makes stale-build traces visible after extension reloads.


  • Programmatic injection only. No default content script presence.
  • ISOLATED world by default. Reads plus scroll/click/hover and direct/paste writes stay out of MAIN world.
  • MAIN world is one-shot. runtime-api fill executes through a single chrome.scripting.executeScript({ world: "MAIN" }) call.
  • Default-deny WAR. No web_accessible_resources are shipped.
  • No default debugger surface. The manifest omits the debugger permission.
  • No MutationObserver. The extension uses jittered polling instead.
  • Bootstrap secrecy. Long-lived auth material is kept in chrome.storage.local; per-session caches live in chrome.storage.session.

Automated coverage lives under:

  • src/background/__tests__
  • src/content/__tests__
  • src/entrypoints/popup/__tests__

Locked design assertions include:

  • no MutationObserver in the production bundle;
  • manifest contains no declarative content_scripts or default web_accessible_resources;
  • paste writes dispatch the expected paste-flavored events and no key events;
  • MAIN-world actions use executeScript({ world: "MAIN" }) only on demand;
  • duplicate request ids reply from cache rather than executing twice;
  • production artifacts preserve source maps and useful startup crash labels.

Local smoke helpers live under scripts/smoke/ and exercise the real daemon + real Chrome pairing flow on localhost.


Terminal window
pnpm --filter @bproxy/extension dev
pnpm --filter @bproxy/extension build
pnpm --filter @bproxy/extension typecheck
pnpm --filter @bproxy/extension test

See extension/README.md for the end-to-end smoke workflow and local loading instructions.