Skip to content

Reach a Chrome that serves only the browser WebSocket - #9

Closed
srizzo wants to merge 153 commits into
sblattj:mainfrom
srizzo:feat/browser-ws-transport
Closed

srizzo wants to merge 153 commits into
sblattj:mainfrom
srizzo:feat/browser-ws-transport

Conversation

@srizzo

@srizzo srizzo commented Sep 19, 2026

Copy link
Copy Markdown

cdp-toolkit discovers targets over HTTP (/json/version, /json/list) and then dials a per-target socket at /devtools/page/<id>. A Chrome whose remote debugging was enabled at runtime via the chrome://inspect/#remote-debugging toggle serves neither: /json/* returns 404 and /devtools/page/* returns 403. The only thing it serves is the single browser-level WebSocket at /devtools/browser/<uuid>. Against such a browser the toolkit cannot connect at all.

This PR adds that transport.

The endpoint shape this addresses

Request HTTP-discovery Chrome chrome://inspect toggle Chrome
/json/version 200 404
/json/list 200 404
/devtools/page/<id> 101 403
/devtools/browser/<uuid> 101 101

Against a real, heavily-loaded browser

186 pages listed in 0.29s (581 targets total: page 186, tab 188, iframe 126, worker 48, service_worker 11, browser_ui 18, background_page 2, shared_worker 2).

The headline number: a heavy dashboard tab that costs 59.7s to attach under an MCP that attaches to every target at connect answers here in 0.01s. take_snapshot on it: 0.04s. Five consecutive drives across different real tabs: 0.09s. Whole run: 8.7s wall clock, every check under a hard 60s budget.

No regression on the existing HTTP path

Against a disposable headless Chrome on --remote-debugging-port (/json/* = 200), the repo's own live smoke: 13/13 checks passed. Plus:

  • bun test → 1010 pass / 2 skip / 0 fail (1012 tests, 39 files)
  • tsc --noEmit → exit 0
  • bun run mcp:smoke → OK, 49 tools on both protocol eras

The wedge property, re-proven for a shared socket

This could not be inherited from the repo's existing bench:wedge: that benchmark gives every page its own socket, where a hung renderer can only block the one socket dialed into it. This transport puts every page on ONE socket — precisely the arrangement where a naive implementation head-of-line blocks. So bun run browser-ws:wedge re-proves it, 5s bound:

  (victim navigate returned in 5.01s — 'Page.navigate' timed out after 5000ms)
  ok   stuck tab rejects at the bound (no hang) — 5.00s
  ok   witness tab stays fast while the other is stuck — 0ms, value="witness"
  ok   list_pages stays fast with a stuck tab open — 0ms, 3 pages
  ok   recovery after closing the bricked tab — 0ms
PASS — one stuck tab does not wedge the shared browser socket.

The bound is enforced per command id inside CdpConnection.send, not per socket, which is why sharing the socket costs nothing.

Scale, on a fixture

TABS=200 bun run browser-ws:smoke against a proxy that serves the toggle-Chrome shape (404/403/101): 201 pages in 0.02s, 415 targets across 7 types.

How it works

  • Discovery via Target.getTargets over the browser WebSocket instead of HTTP /json/list. Target.getTargets returns metadata and attaches to nothing, which is what preserves the repo's headline property.
  • Per-target work via Target.attachToTarget with flatten: true, giving flat sessions multiplexed over that one socket by sessionId.
  • The one-target-per-call guarantee is unchanged: listing attaches to zero targets, and only the named target is ever attached.

Implementation points worth stating:

  • src/cdp/endpoint.ts — transport detection.
  • src/cdp/session.ts — a reference-counted shared browser socket with concurrent-first-borrow deduplication, plus openSession(targetId).
  • src/client.ts — listTargets() issues the HTTP request first and unconditionally, and only falls back to the browser socket after it fails; on fallback failure it rethrows the ORIGINAL HTTP error. This is why the existing HTTP path is byte-for-byte unchanged.
  • PageConnection is a structural interface over the two connection kinds, so the 7 tool modules work against either transport without narrowing.
  • Target.getTargets' default filter is NOT "everything" — measured on Chrome 153 the default returned 4 targets and filter: [{}] returned 6, the difference being exactly the iframe/worker types. The patch uses filter: [{}] so the documented frame: and worker: selector arms keep working.
  • Nothing is auto-discovered. The browser WS URL comes from CDP_BROWSER_WS, or from a DevToolsActivePort file in a profile dir the operator names via CDP_USER_DATA_DIR. No default profile is ever scanned, because merely connecting raises Chrome's "Allow remote debugging?" modal on the user's screen.

Two new test commands

  • bun run browser-ws:smoke — end-to-end over a proxy fixture that reproduces the 404/403/101 shape. TABS=N scales the tab count.
  • bun run browser-ws:wedge — the shared-socket wedge proof above.

Both spawn their own disposable headless Chrome with a mkdtemp profile and kill it on exit. Neither touches any existing browser.

Honestly

This is ~1000 unsolicited lines, and it changes the page-level helper type from CdpConnection to the structural PageConnection across 8 modules. Happy to split it into smaller PRs, drop parts, or adjust anything you'd prefer done differently. The measurements are the argument — I'm not claiming more than they show.

🤖 Generated with Claude Code

sblattj and others added 30 commits June 24, 2026 11:45
Single-target Chrome DevTools Protocol MCP server + Bun CLI: 33 tools over
one direct WebSocket, with a bounded per-command timeout, lazy domain
enabling, stateless element refs, and a built-in network-mocking fake
backend. Full chrome-devtools-mcp parity (29 tools) plus 4 superset tools
(performance_trace + mock_request/list_mocks/clear_mocks).
…r npx

- package.json: fix homepage/bugs/repository handle (was the wrong account);
  add main+exports so `import from "cdp-toolkit"` resolves; point bins at the
  built dist/*.js; add build + prepublishOnly; allowlist dist/.
- scripts/build.ts: bundle src -> Node ESM (MCP SDK external), node shebang on
  bins, so `npx -y cdp-toolkit` works as well as `bunx`.
- src/mcp.ts: server VERSION 0.1.0 -> 1.0.0 (matched package).
- README: show npx as the primary install (the dominant MCP idiom).
- gitignore dist/ (rebuilt at publish, never committed).
Provenance-signed releases, no NPM_TOKEN. Prereqs documented inline: repo must
be public, trusted publisher configured on npmjs.com, first publish manual for
a new package name.
Generated by rendering docs/social-preview.html headless at 1280x640@2x.
Upload via GitHub - Settings - Social preview at launch.
…ing + boost discoverability

- README: new hero + 'In plain terms' section explaining what it is (a lightweight
  drop-in alternative to chrome-devtools-mcp that won't wedge the agent / force a
  /mcp restart) in simple terms before any technical detail.
- package.json: discoverability-focused description + expanded keywords
  (chrome-devtools-mcp-alternative, web-automation, headless-chrome, windsurf, etc.).
…rkflow

- package.json: bin ./dist/*.js -> dist/*.js, repository.url -> git+https (clears
  the npm publish auto-correct warnings seen on the 1.0.0 publish).
- publish.yml: skip publishing a version already on npm, so a v1.0.0 release tag
  cut after the first manual publish keeps the workflow run green.
…BiDi port

Every tool is a stateless one-shot on withPage(), which is only correct because
a CDP Uid IS a backendDOMNodeId: a browser-global integer that outlives the
connection that minted it. BiDi breaks both halves. A session is bound to one
WebSocket, and a sharedId is document-scoped, dying on any navigation. A literal
port would break snapshot-then-click, the tool's core workflow.

Decision: Uid becomes an opaque tagged string. Chrome keeps cdp:<backendDOMNodeId>;
Firefox STAMPS data-cdp-uid onto snapshotted nodes and resolves via locateNodes,
because page state (unlike protocol state) survives a dropped socket, a new
session, and a fresh CLI process. Connection lifetime is declared rather than
implied. Anything living on exactly one browser stays out of the interface and is
named in Capability, so tools/list filters it instead of throwing at runtime.

Five alternatives are rejected in the ADR with the reason each fails, including a
computed css path, which resolves silently to the WRONG node after a sibling
insertion where a stamp resolves right-or-none.

Verified against Firefox 153.0.3: 57 of the spec's 66 commands implemented,
sharedId stable within a document and dead after any navigation, session not
resumable on a second socket.

Interface only, no implementations. tsc clean, 16/16 tests still pass.

Claude-Session-Id: 421b0104-b22d-48ad-bb94-d6a26306bf6b

- Claude Opus 5 via Claude Code
782 lines typing the 58 commands and 24 events in scope (session, browsingContext,
script, input, network, log, storage, emulation), read out of the CDDL blocks in
w3c/webdriver-bidi@6a2835c rather than recalled. Verified: the file carries exactly
82 unique method strings, matching the spec inventory for that scope.

The load-bearing pieces are the BidiCommands and BidiEvents map types, so a transport
can infer params and result from a method name alone, and the discriminated envelope
(command / success / error / event) it dispatches on.

The 9 commands Firefox 153 does not implement are typed anyway, since the types
describe the spec rather than one browser, each marked with a comment.

Four spots where the CDDL is genuinely unsettled are typed conservatively and
commented at the site, including network.continueWithAuth, whose own spec comment
flags the literal spelling as undecided.

Claude-Session-Id: 421b0104-b22d-48ad-bb94-d6a26306bf6b

- Claude Opus 5 via Claude Code
Firefox needs a launcher where Chrome needs none: --remote-debugging-port cannot
be added to an already-running Firefox, since relaunching hands off to the live
instance and exits silently without opening a port. So the BiDi side owns process
lifetime, while the CDP side only ever attaches. That asymmetry is documented at
the top of launch.ts so nobody tries to simplify it into an attach.

launch.ts resolves the binary (FIREFOX_BIN, then the .app path, then PATH), picks a
free ephemeral port by binding it, polls until the port genuinely accepts rather
than sleeping a fixed interval, and reaps on close() with SIGTERM then SIGKILL.
A throwaway profile is deleted on close; a caller-supplied profile never is.

Port selection is occupancy-detected, not deny-listed: an explicit port is attempted
and fails with "already in use" only when the bind fails. An earlier draft carried a
hardcoded refusal naming a port reserved on the author's own laptop, which has no
business in a published library.

Fixtures are static, dependency-free, and shared by both browsers' drivers, including
a contenteditable page, which is where the two diverge most: Chrome commits with an
atomic insert while Firefox synthesizes keystrokes.

Verified: real headless launch on an OS-picked port, the in-use error path, and zero
surviving browser processes afterward.

Claude-Session-Id: 421b0104-b22d-48ad-bb94-d6a26306bf6b

- Claude Opus 5 via Claude Code
Mirrors the CDP client's shape (send / on / waitFor / dispose, a typed error, an
env-overridable per-call timeout via BIDI_TIMEOUT_MS) so a reader who knows one
recognizes the other. send() and on() are generic over the method and event names,
so params, results, and event payloads all infer from protocol.ts's maps.

Two structural differences from CDP are handled here rather than pushed upward:

Events have no per-domain enable. Subscription is explicit and per event name, so
on() and waitFor() own the session.subscribe bookkeeping behind a refcount: the
first listener subscribes, the last one unsubscribes, and callers never subscribe
by hand.

A BiDi session is bound to one WebSocket and cannot be resumed on a second, so
release() is refcount bookkeeping only and dispose() is the sole thing that closes
the socket. There is deliberately no reconnect-and-resume path, because there is no
such thing to write.

Every in-flight call rejects rather than hangs on unexpected socket close or on
dispose, which is the failure this project exists to prevent.

Verified against headless Firefox 153: session.new, context create, navigate,
script.evaluate returning 42, a subscribed log.entryAdded observed firing, and a
1ms timeout rejecting in 1ms instead of hanging. Disposed clean, process reaped.

Claude-Session-Id: 421b0104-b22d-48ad-bb94-d6a26306bf6b

- Claude Opus 5 via Claude Code
Chrome gets its snapshot free from Accessibility.getFullAXTree. BiDi has no
equivalent, so Firefox reconstructs the tree in-page: infer implicit ARIA roles,
run a reduced accname algorithm, prune the invisible and the meaningless, and
stamp data-cdp-uid on every emitted node so uids survive a dropped socket and a
fresh process, per ADR-001. Stamps are reused rather than reminted, because the
common agent loop is snapshot, read, snapshot again, then click, and renumbering
between the two would silently repoint a uid the caller still holds.

Format matches the Chrome tool's, and the differences were MEASURED by running
both implementations against the same fixtures rather than reasoned about. That
measurement refuted one assumption and confirmed two gaps, both now fixed:

- Role-less status text was missing entirely. Chrome emits it; Firefox dropped it,
  which silently breaks snapshot-click-snapshot-diff on exactly the status text an
  agent checks after acting. Now emitted as a text leaf.
- Options of a closed select were pruned as zero-size. Chrome lists them regardless,
  and this tool exists to drive forms, so options are exempted from the size check
  specifically, with every other visibility rule untouched.

The second fix exposed a real accname bug: options inside a labelled select
inherited the select's label, so all three read "Color" instead of their own text.
Native-label lookup is now restricted to labelable elements.

One divergence is deliberate: a plain contenteditable is reported as textbox, where
Chrome's AX tree says generic. The snapshot describes affordances to an agent, and
generic does not tell it the element is typable. Commented at the branch so it is
not "fixed" toward parity later.

Verified against real Firefox 153 and real Chrome. 36/36 tests pass.

Claude-Session-Id: 421b0104-b22d-48ad-bb94-d6a26306bf6b

- Claude Opus 5 via Claude Code
A wrapper over code that already works, not a rewrite: today's CDP behavior is the
reference, so any behavioral change would be a regression. setValue's clearing
sequence is reproduced byte for byte from input.ts, deliberately including its
quirks, because the framework-commit-path work is separate and must not land here
by accident. The bare-numeric uid path is kept alongside cdp:<backendDOMNodeId>,
since published snapshots and the MCP schema emit bare numbers.

Two corrections during review, both from the brief rather than the implementation:

Network is enabled LAZILY, never eagerly at page acquisition. "No eager
Network.enable hang" is a documented property of this tool and the reason it is
preferred over heavier alternatives, but that fact lives in the author's global
policy rather than in this repo, so it was invisible to an implementer reading only
CONTRACT.md. Reverting it also exposed that emulate's networkConditions branch had
been silently relying on the eager enable, and it now arms the domain itself.

Consequently network.responseBodyPostHoc is NOT declared. With lazy enabling a body
is only readable if something armed capture first, which is arming by another name,
so declaring it would be true by construction rather than true in fact. A capability
model is worth nothing if an agent cannot rely on what it advertises.

locate.text and locate.xpath are implemented via DOM.performSearch, restored after
an earlier draft cut them to fit a line cap. A capability model is for real
asymmetries, not for ones a line budget invented.

tsc clean, 36/36 tests pass.

Claude-Session-Id: 421b0104-b22d-48ad-bb94-d6a26306bf6b

- Claude Opus 5 via Claude Code
Firefox is now drivable end to end. Verified live against Firefox 153, not reasoned
about: snapshot, fill by uid, select an option, click, re-snapshot showing the status
text changed, screenshot, plus typeText, pressKey, hover, drag, emulate, waitForText,
locate.xpath and network interception.

The two properties the uid design exists for are proven directly. Snapshotting twice
reuses all 13 stamps, so a uid taken before the second snapshot still resolves, which
is what makes the agent loop of snapshot, read, snapshot, click safe. And after
navigating away and back, a pre-navigation uid is reported STALE rather than silently
resolving to a different node: loud miss over quiet mis-hit, per ADR-001.

Three things were found only by running it, all corrections to assumptions in the brief:
- locate.text is NOT supported. Firefox 153 rejects the innerText locator outright, so
  the capability is not declared. locate.xpath is real and is.
- A select cannot be committed reliably by synthesized keystrokes, so setValue assigns
  value and selectedIndex directly for SELECT only, then fires input and change.
- Firefox defaults unhandledPromptBehavior to dismiss, so alerts auto-dismissed before
  handleDialog could ever see them. The session now opens with ignore.

network.responseBodyPostHoc is not declared, matching the Chrome driver's call: a
collector must arm before the request, so claiming post-hoc reads would be true only
by construction.

741 lines against a 700 budget. The overage is real functionality (interception,
screenshot clipping, snapshot-line parsing) and a budget does not outrank capability.

Claude-Session-Id: 421b0104-b22d-48ad-bb94-d6a26306bf6b

- Claude Opus 5 via Claude Code
Both were found by the implementer who had to build against them, and neither broke
anything today, which is exactly why they were worth closing now.

ADR-001's LifetimeModel promised that a "session" driver re-establishes itself
transparently if the transport dies. That is not merely unimplemented, it is
impossible: a BiDi session cannot be resumed on a second socket, re-dialing returns
invalid session id. The BiDi driver correctly refused to implement it and said so
rather than faking it. The doc now states the truth, that a dead transport surfaces
as an error on every subsequent call and the caller must dispose and re-acquire.

The uid stamp grammar disagreed with itself across three sites. THE UID CODEC
documents exactly 12 lowercase hex, the snapshot walker minted a counter-based value
of variable length, and the driver's single-element stamp path used unpadded random
hex that usually produced 12 characters and occasionally fewer. Nothing validates the
grammar yet, so all three passed every test; the day validation is added, every
snapshot uid breaks at once. All three now mint exactly 12 lowercase hex.

That third site is the interesting one: an intermittent short stamp would have failed
at a rate low enough to look like anything except what it was.

Stamp reuse and page-uniqueness are preserved, and points 4, 6 and 7 of the live
Firefox proof were re-run after the change, including the stamp-reuse property the
minting change could have broken.

Claude-Session-Id: 421b0104-b22d-48ad-bb94-d6a26306bf6b

- Claude Opus 5 via Claude Code
Backend selection is --browser, then CDP_BROWSER, then chrome. Chrome remains the
default and its path is untouched: same dispatch, same manifest, and the tool listing
was diffed byte-for-byte against a pre-change capture rather than assumed. This ships
to npm, so an existing user must see no change at all.

Under Firefox the listing is 27 tools instead of 33. The six absent ones are the four
performance tools, take_heapsnapshot and lighthouse_audit, filtered through
REQUIRED_CAPABILITIES against the driver's declared capabilities. They are ABSENT
rather than present-and-throwing, per ADR-001's rejected alternative E, and a new
--capabilities flag answers "why is this tool missing" in one command.

Lifetime differs by entry point because it has to: the CLI launches, uses and disposes
within one call, while the MCP server memoizes one session and tears it down on
shutdown.

Two honest limitations are documented rather than hidden. Firefox cannot be attached
to, only launched, so a multi-step CLI workflow across separate invocations does not
work against Firefox: that is an MCP-server use case. And the README states the real
capability gaps rather than implying parity.

KNOWN DEBT, deliberately taken to keep the Chrome path at zero risk: the 27 neutral
tool implementations in src/neutral.ts are a second implementation alongside
src/tools/, which stays CDP-direct and unmodified. Two implementations of the same
behavior will drift. The end state is migrating src/tools/ onto the Driver so the CDP
driver serves both, and that migration is deliberately not in this commit.

Claude-Session-Id: 421b0104-b22d-48ad-bb94-d6a26306bf6b

- Claude Opus 5 via Claude Code
test/firefox-smoke.ts owns the full lifecycle: it launches its own
headless Firefox via src/bidi/launch.ts (never attaches to a real
browser on 9333), serves the local fixtures over Bun.serve on an
ephemeral port, then walks through session establishment, navigation,
snapshot uid-scheme, uid stability, text-input fill, contenteditable
fill, click, a Chrome-only capability check, and clean process
teardown. It carries a 120s hard wall-clock cap and kills the browser
in a finally block so a mid-test throw can never leak a process.

The new `firefox` job in ci.yml installs its own pinned Firefox
(browser-actions/setup-firefox pinned to a full commit SHA, not the
floating v1 tag, since a floating third-party action ref is a
supply-chain hole in a public repo) and DOES gate the badge, unlike
the existing `smoke` job's `|| true`. That is deliberate: `smoke` is
non-gating because its live tools/call round-trip depends on a flaky
CI Chrome nobody controls, whereas this job installs its own Firefox
and serves its own fixtures, so a failure here is a real regression,
not CI flake.

Verified locally against /Applications/Firefox.app (153.0.3): 8 of 9
assertions pass. The contenteditable fill assertion fails for real —
input.performActions synthesizes keydown/keyup on the focused element
but no beforeinput/input event fires and no text lands, verified by
direct event-log inspection outside this script. That is a genuine
gap in the BiDi driver's setValue/typeText path for contenteditable
targets, not a test-authoring mistake, and is reported rather than
weakened or silently patched.

Claude-Session-Id: 421b0104-b22d-48ad-bb94-d6a26306bf6b

- Claude Sonnet 5 via Claude Code
…ools

Chrome and Firefox each carried their own copy of most tool bodies. Two editable
copies of one behavior is a bug generator, and this change removes it for every tool
where the two were genuinely doing the same thing: 20 tools now have exactly one
implementation, written against the Driver interface, used by both browsers.
src/neutral.ts is deleted, its Firefox-only remainder moved to bidi-tools.ts, and
firefox-tools.ts is now just composition.

Seven tools are deliberately NOT unified: the console, network and mock readers.
Chrome's are disk-persisted to rec-<targetId>.jsonl, cross-process, keyed by target,
with per-rule hit counts and request correlation. Firefox's are in-memory,
same-process, session-scoped. Unifying them means either dropping Chrome's
cross-process persistence, which is a real behavior change users would feel, or
teaching Firefox to write the same format, which is new feature work and not this
change. They stay as two implementations, untouched.

The audit found the two sides were not shape-compatible in about fifteen places, so
this is not a pure code move and the differences are worth recording. cdp/driver.ts
was eagerly sending Page.enable and Runtime.enable on every page acquisition while
the legacy tools sent none; domains are lazy again, per method, preserving which
enables are soft-caught and which are not. take_snapshot keeps bare-digit uids for
the cdp scheme, legacy per-field quoting, and a depth-elision quirk where a skipped
wrapper does not consume an indent level, none of which survives a generic renderer.
navigate_page translates driver-native vocabulary back to the legacy field names.

Four real emulate bugs came out of the diff and are fixed: applied[] reported
"mediaFeatures" where the legacy name is "emulatedMedia", width and height were
silently skipped unless required together, cpuThrottlingRate below 1 was never
validated, and networkConditions.connectionType was dropped. list_pages ignored
`all` and omitted `type`; close_page hardcoded success instead of reading the CDP
response. handle_dialog needed the interface to actually grow, since the driver
method supported neither autoMs nor the fuller return shape.

Verified against live Chrome and live Firefox by diffing every tool's output against
a baseline captured before any edit: zero shape or behavior differences beyond tab
ids and trace byte counts. typecheck clean, 36/36 unit tests, smoke 13/13, MCP and
mock smokes pass.

The doc comments claiming methods were copied verbatim from tools/*.ts had drifted
from the files they named, so every method was diffed line by line against its real
source instead. A comment is a claim, not evidence.

Claude-Session-Id: 421b0104-b22d-48ad-bb94-d6a26306bf6b

- Claude Opus 5 via Claude Code
… explicit selection

setValue and typeText silently did nothing on a contenteditable element under
Firefox. The old path cleared the node and then synthesized keystrokes through
input.performActions. Measured against Firefox 153.0.3, that raw key route
fires keydown and keyup on a contenteditable target and nothing else: no
beforeinput, no input, no DOM mutation. The text never landed.

Key synthesis is not fixable here and there is no protocol primitive to fall
back on. WebDriver BiDi has no input.insertText command at all; performActions
and setFiles are the entire input module. So contenteditable now takes a
separate branch that calls document.execCommand('insertText') from inside the
page, after installing an explicit Range and Selection on the element.

The explicit selection is the whole trick, not defensive coding. Both cases
were probed separately: with focus() alone and no selection anchor, execCommand
returns false and nothing happens. With the Range installed it returns true,
the DOM mutates, and a real input event fires. setValue selects the node
contents whole so the native call replaces them in one edit, which is why it
does not pre-clear via textContent first. A textContent write is not a native
edit, and it would also destroy the selection the execCommand call depends on.
typeText collapses the same Range to the end so it appends.

Known limit, stated plainly rather than buried: this route does not fire
beforeinput. That was checked at element level and again at document level in
the capture phase with isTrusted inspected, so it is not a listener-placement
artifact, and queryCommandSupported('insertText') stays true throughout. Gecko
simply does not synthesize beforeinput for this path. Chrome's path does fire a
trusted beforeinput with the correct inputType and data before input. The
practical consequence is that rich editors which build their model from
beforeinput, such as Lexical, ProseMirror and Slate, will not observe an edit
made this way on Firefox and can desync. Plain unmanaged contenteditable, which
is what fill and type_text promise, is unaffected.

Second known limit, and this one is not Firefox specific: an embedded newline
is silently dropped on both backends. Chrome's Input.insertText collapses it
away too. Multi-line contenteditable insertion would need insertParagraph or
explicit break handling on either backend.

No capability token was added for the beforeinput gap. The Capability union
exists so tools/list can hide a tool an agent must not call, and no tool should
require this one. Gating fill and type_text on it would remove both tools from
Firefox entirely, where they are correct for inputs, textareas, selects and
plain contenteditables. Tool descriptions are a static MANIFEST that tools/list
forwards verbatim filtered by name only, so there is no per-backend description
channel to carry the caveat either. It lives in the code as a doc comment.

select, input and textarea behavior is unchanged. Verified: firefox:smoke 9/9
against Firefox 153.0.3, tsc --noEmit clean, bun test 36/36, smoke 13/13.

Claude-Session-Id: 421b0104-b22d-48ad-bb94-d6a26306bf6b

- Claude Opus 5 via Claude Code
The previous commit gave twenty tools a single implementation, but it only stopped
importing the losing side, it did not remove it. That left roughly twelve hundred lines
of tool behavior on disk that nothing calls, which is worse than either outcome on its
own: a reader greps for how fill works, finds two implementations, and has no way to
tell from the file which one runs.

Deleted: pages.ts, input.ts, screenshot.ts, dialogs.ts, emulation.ts. Each was confirmed
to have zero importers outside itself before removal.

Kept, because they are still imported and the report that called them dead was wrong:
navigation.ts is imported by shared-tools.ts, and evaluate.ts by cdp/driver.ts. Deleting
those on the strength of the summary would have broken the build that the previous commit
verified as green. snapshot.ts stays as well, for resolveUid. console.ts, network.ts and
network_mock.ts were never duplicated and are untouched.

Collapsing two implementations is not finished when the losing side is merely unwired.

Claude-Session-Id: 421b0104-b22d-48ad-bb94-d6a26306bf6b

- Claude Opus 5 via Claude Code
Adds a second browser backend. Chrome keeps speaking CDP; Firefox speaks WebDriver BiDi,
which is what Firefox actually supports now that its CDP shim is gone. The two are
reached through one Driver interface, and twenty of the twenty seven tools have a single
implementation behind it.

The hard part was not the protocol surface, it was that CDP hands out node ids that are
browser-global and survive a reconnect, while BiDi hands out document-scoped handles that
do not. So the Firefox backend stamps its own uid onto the page as an attribute and
resolves it back through locateNodes. A uid that no longer resolves fails loudly rather
than resolving to the wrong element, which was the deciding argument against computing a
css path instead.

Tools Firefox cannot run are ABSENT from tools/list rather than throwing at call time, so
an agent discovers the gap instead of finding it mid task.

Known limits, stated rather than buried: text insertion into a contenteditable works on
Firefox but does not fire beforeinput, so editors that gate on that event will not observe
the edit, and an embedded newline is dropped on BOTH backends. Both are documented in the
code and in docs/recipes in the dotorg repo.

CI now runs a headless Firefox smoke that gates the badge, unlike the existing Chrome MCP
smoke, which is non gating because it depends on a browser CI does not control. Whether
that job needs xvfb on a real Linux runner is the one thing nobody here could verify, as
no Linux machine was available.

Verified before merge: Firefox smoke 9/9, Chrome smoke 13/13, 36/36 unit tests,
typecheck clean.

Claude-Session-Id: 421b0104-b22d-48ad-bb94-d6a26306bf6b

- Claude Opus 5 via Claude Code
Adds a second browser backend. Chrome keeps speaking CDP; Firefox speaks
WebDriver BiDi, since Firefox's own CDP shim is gone. Both backends sit
behind one Driver interface, with tools a backend cannot run absent from
tools/list instead of throwing mid task.

Two known limits, stated plainly:

- Firefox contenteditable insertion does not fire beforeinput, so any editor
  gating on that event will not observe the edit.
- An embedded newline is dropped on BOTH backends.

CI now runs a headless Firefox smoke test that gates the badge, in addition
to the existing Chrome smoke.

Version bumped in both package.json and src/mcp.ts, per the publish
workflow's own requirement.

Claude-Session-Id: 421b0104-b22d-48ad-bb94-d6a26306bf6b

- Claude Sonnet 5 via Claude Code
tsconfig.json deliberately leaves out bun-types, so any test or script that
uses Bun.serve or another Bun global fails tsc --noEmit with "Cannot find
name 'Bun'". The convention is a minimal hand-written ambient declare scoped
to the API that file uses, not adding the devDependency. It was not written
down anywhere before now.

Claude-Session-Id: 421b0104-b22d-48ad-bb94-d6a26306bf6b

- Claude Opus 5 via Claude Code
… version

The v1.1.0 publish failed with E404 on the registry PUT even though the
package exists, is owned by sblattj, and the npmjs.com Trusted Publisher
entry was configured correctly. Two separate traps were stacked on top of
each other.

First, npm's OIDC trusted publishing requires npm 11.5.1 or newer. The
Node 22 setup-node step ships npm 10.9.8, and on that older npm the CLI
never performs the OIDC token exchange at all. Provenance signing to
Sigstore still succeeds because it is a different OIDC path from registry
authentication, so the log looks fine right up until the PUT, then fails
with a confusing E404 rather than a permission error. An npm upgrade step
already existed for this, but nothing printed the resulting version, so a
prior read of the failing run's "Environment details" block (which only
shows the pre-upgrade npm 10.9.8) was mistaken for the live root cause.

Second, and the actual blocker in this run: actions/setup-node's
registry-url input makes it write an .npmrc with a placeholder
NODE_AUTH_TOKEN, visible in every step's env in the log even though no
such secret exists anywhere in the workflow. That placeholder is enough
for npm to conclude auth is already configured for the registry, so it
skips the OIDC exchange and sends the publish PUT with a garbage token
instead. The registry answers a permission-flavored 404 rather than a
401/403, which is npm's usual way of not confirming a package's existence
to an unauthorized caller, and reads exactly like a missing-package error
even though the package is right there.

Removing registry-url from setup-node stops it from writing that
placeholder-authed .npmrc, so npm actually performs the OIDC exchange.
The npm-version upgrade step stays, since 11.5.1+ is still genuinely
required, and now echoes `npm --version` so future logs prove which
version ran instead of leaving it to be inferred from the Environment
details block. The header comment's one-time prerequisites list is
updated to record both requirements together, since either one alone
still reproduces the same E404 from a different direction.

Claude-Session-Id: 421b0104-b22d-48ad-bb94-d6a26306bf6b

- Claude Sonnet 5 via Claude Code
… publish

The prior commit removed registry-url from actions/setup-node on the theory
that it was causing npm to skip the OIDC trusted-publish exchange. Testing
that against a real dispatch run disproved it: without registry-url, npm
does not attempt any authentication at all and fails immediately with
ENEEDAUTH ("You need to authorize this machine using `npm login`"), before
even reaching the point where it would sign provenance. That is strictly
worse than the original E404, which at least got as far as a rejected PUT.
npm's own docs example for GitHub Actions trusted publishing includes
registry-url on setup-node, so it is required, not the problem, and it goes
back in.

The header comment is rewritten to record what we actually learned instead
of the disproven theory: registry-url is required, and the "Allowed
actions" selector on a Trusted Publisher entry (npmjs.com, entries created
after 2026-05-20) must explicitly include `npm publish`, not just `npm
stage publish`. That selector is a strong candidate for the original E404,
since it would let provenance attestation succeed (a separate OIDC path)
while the actual publish PUT is rejected, matching the exact failure
pattern seen on the v1.1.0 run.

Also adds temporary `--loglevel verbose` to the publish step so the next
run's log shows whether npm attempts the OIDC exchange at all, rather than
leaving that to be inferred from which error message comes back.

No workflow run has been dispatched with this change yet, pending
confirmation of the actual Trusted Publisher configuration on npmjs.com.

Claude-Session-Id: 421b0104-b22d-48ad-bb94-d6a26306bf6b

- Claude Sonnet 5 via Claude Code
…use unconfirmed

Small follow-up to 52559f9. Two changes to the header comment, prompted by
team-lead independently re-reading npm's trusted-publishing docs:

- Records the Node 22.14.0+ requirement alongside npm 11.5.1+ (both are
  documented prerequisites; only the npm one had been written down before).
- Rewords the "Allowed actions" note so it reads as an unconfirmed lead to
  check, not an asserted mechanism. We already got burned once in this file
  by writing down a theory (registry-url suppresses OIDC) with more
  confidence than the evidence supported, and it was live on a public repo
  before being caught. The Allowed Actions selector on a Trusted Publisher
  entry is still the best explanation anyone has found for provenance
  succeeding while the publish PUT 404s, but nobody has verified it against
  the actual saved npmjs.com entry yet, so the comment says so plainly.

No functional change to the workflow steps.

Claude-Session-Id: 421b0104-b22d-48ad-bb94-d6a26306bf6b

- Claude Sonnet 5 via Claude Code
claimLease/readLease/releaseLease/releaseLeaseFor in src/leases.ts give a tab
exclusive ownership via one file per backend+targetId, written with the "wx"
flag so concurrent claims against the same tab race safely at the filesystem
level: exactly one write wins, the rest see EEXIST and get a LeaseConflictError
naming the current holder. Every successful claim mints a fresh nonce, which is
what later reclaim-on-staleness work needs to invalidate a superseded token by
construction. Tokens are opaque strings of the form backend:targetId:nonce,
parsed defensively by tokenParts so a malformed token is a no-op rather than a
throw.

Claude-Session-Id: 421b0104-b22d-48ad-bb94-d6a26306bf6b
A lease is reclaimable when any one of three conditions holds: the
owning pid is dead, lastUsedAt exceeds ttlMs, or the target id is
absent from a caller-supplied live-id list. claimLease now retries
through the same exclusive-create path on a stale hit instead of
overwriting the file in place, unlinking the stale record and minting
a brand new nonce on every attempt. That means a reclaimed lease's old
token can never match the new record: the previous owner's token is
invalidated by construction, not by an extra check.

staleReason takes liveIds as data so leases.ts stays free of any
browser dependency; every new test is a pure filesystem test.

Claude-Session-Id: 421b0104-b22d-48ad-bb94-d6a26306bf6b
assertLeaseOk is the single enforcement point every target-resolution
path calls. It refuses a call with no token against a held tab, a token
whose lease was released, a token for a different tab, a malformed
token, and (the subtle one) a token whose nonce was superseded by a
reclamation. That last check is what keeps reclamation from relocating
the two-owners-on-one-tab failure instead of fixing it: claimLease mints
a fresh nonce on every create, so a stalled agent that comes back after
its lease was reclaimed cannot drive the tab a second agent now owns.
Until now that guarantee had only one consumer, releaseLease.

An unleased tab with no token still passes, so every existing tool
keeps its current behavior exactly.

withLeaseScope/currentLease carry the token through AsyncLocalStorage.
The token arrives on a tool's args object many frames above
resolveTarget, and threading a parameter through all 33 tools would put
the burden on every future contributor to remember the check exists.
mcp.ts and cli.ts will open this scope once per dispatch instead, so a
tool added tomorrow is covered with no action from its author.

resolveTarget now picks the target first and then gates, so exactly one
assertLeaseOk call covers every selector branch. Selection logic moves
verbatim into pickTarget; opts.lease threads through openPage and
withPage for library callers.

Test note: spec section 9's NEGATIVE 3 case pins `now` on both assert
calls to the fresh claim's synthetic clock. Falling back to Date.now()
would age the fresh lease past its TTL and it would read as
reclaimable, which would make the case a staleness test rather than the
nonce test it is meant to be.

Claude-Session-Id: 421b0104-b22d-48ad-bb94-d6a26306bf6b
This is what makes the gate real for the MCP and CLI paths. Until now
assertLeaseOk only saw a token when a library caller passed opts.lease,
so a tool invoked over MCP or the CLI enforced nothing.

Both entry points now wrap their whole dispatch, BOTH backend branches,
in a single withLeaseScope call. The token rides the async context down
to whichever resolution path the tool eventually reaches, so no tool
signature changes and a tool added later is covered with no action from
its author. The Firefox branch is wrapped too, so Task 4 only has to add
the gate call inside resolveContext.

leaseFromArgs reads the key at the dispatch site and lives in leases.ts
rather than being duplicated, so the two entry points cannot disagree
about what counts as a token. An empty string is treated as absent: a
caller sending lease:"" means "no lease", and letting it through would
trip the malformed-token branch with a confusing message.

Verified end to end against a live Chrome, driving the real CLI:
  - leased tab, no token      -> refused, naming holder and pid
  - leased tab, correct token -> snapshot returned
  - leased tab, wrong nonce   -> refused as reclaimed
  - unleased tab, no token    -> unchanged, snapshot returned
  - lease file removed        -> access restored

Claude-Session-Id: 421b0104-b22d-48ad-bb94-d6a26306bf6b
sblattj and others added 22 commits August 25, 2026 09:01
…e:// (sblattj#6)

Every plain reload:true on Firefox no longer fails on the unsupported
ignoreCache argument, and file:// navigation gains automation-pref parity for
launched profiles plus an actionable NS_ERROR_FILE_ACCESS_DENIED error for the
OS-refused cases (snap/AppArmor). Chrome unchanged; 45 tools unchanged.
Unit suite 881 green; firefox:smoke 24/24 live-fired on Firefox 153.
…ser_tools/describe_tool meta-tools, MCP SDK 2.0

Default tools/list shrinks ~88% (about 20.2k -> about 2.5k tokens): 12 core
tools + two meta-tools; 33 tools in 12 activatable groups (CDP_TOOL_PROFILE=full
restores the 1.x listing exactly). Full per-tool prose moves to describe_tool
(src/toolDocs.ts, verbatim from the pre-2.0 manifest). Tool/param/schema shapes
unchanged; hidden tools remain callable by name. Runtime dep moves to
@modelcontextprotocol/server@^2.0.0. Carries the never-released 1.11.1 Firefox
BiDi navigation fixes. Also: seven corrupted one-line manifest descriptions
fixed, VERSION const un-drifted, private .cbm/design-plan artifacts untracked,
README + CHANGELOG updated.
2.0.0 shipped progressive disclosure as a home-grown mechanism: a `core`
profile of 12 tools plus a `browser_tools` meta-tool that activates the
other 12 groups on demand. This records the brief for 2.1 — establish
whether the MCP spec and ecosystem have converged on a standard way to do
lazy tool discovery, and re-base on it if so.

Carries the measurements rather than the changelog's claims, each probed
over real stdio JSON-RPC against the same entry point a registration uses:

  v1.11.0 / v1.9.5+1        45 tools
  v2.0.0 default (core)     14  (12 core + browser_tools + describe_tool)
  v2.0.0 CDP_TOOL_PROFILE=full  47

45 -> 47 is two additions and zero removals or renames, so `full` is a
superset of the pre-2.0 surface. 45 -> 14 hides a named list of 33.

Also records that the server negotiates whatever revision the client
offers, up to the SDK's LATEST_PROTOCOL_VERSION (2025-11-25), so the
protocol half of "is it really on MCP v2" is answered and the open half
is whether the code uses SDK 2.0 idioms or the 1.x low-level shape 2.0
kept working.

Two threads left explicitly unverified rather than assumed: whether any
client actually re-fetches tools/list on the listChanged notification
(if not, `core` is a trap on that client), and what the "2026-07-28"
string in the SDK is, given it is absent from SUPPORTED_PROTOCOL_VERSIONS.

🤖 Authored with Claude Code

— Claude Opus 5 via Claude Code

Claude-Session-Id: 53466bb2-5039-466b-89ec-9889c07da071
…list, dual-era serveStdio, browser_tools removed

v2.0.0 was a 2025-11-25 (legacy-era) server on the 2.0 SDK: probed raw over
stdio, `server/discover` answered -32601 and `tools/list` carried no
resultType/ttlMs/cacheScope, because `Server.connect(new StdioServerTransport())`
is the legacy path. The 2026-07-28 revision also says the tool set MUST NOT change
as a side effect of other requests on a connection, and the client best-practices
put progressive discovery on the HOST over a complete, deterministic, cacheable
list — the 2.0 `browser_tools activate` runtime toggle was that anti-pattern.

Now:
- src/mcp.ts serves via serveStdio (dual-era, legacy:'serve'): a modern opening
  gets server/discover (supportedVersions ["2026-07-28"]), resultType, the
  serverInfo _meta envelope, and ttlMs 3600000 + cacheScope public on
  tools/list and server/discover via cacheHints; a 2025-era initialize still
  works. Low-level Server kept on purpose (JSON-Schema manifest, no zod —
  CONTRACT.md rule 1; unlisted-but-callable is not expressible in McpServer).
- The listing is computed once (describe_tool first, then manifest order,
  filtered by backend availability and CDP_TOOL_PROFILE), frozen, byte-identical
  on every call; capabilities.tools.listChanged is now false.
- browser_tools removed. describe_tool's `name` is optional: no name returns
  the grouped [listed]/[hidden] catalog.
- CDP_TOOL_PROFILE default core -> full; also accepts a comma-separated group
  list (core always included; unknown group -> exit 1). Unlisted tools stay
  callable by name.
- VERSION moved to src/version.ts (asserted equal to package.json).
- Tests: test/mcp-protocol.test.ts (raw-stdio, both eras, -32022 control,
  wire-vs-manifest deep-equal gate, profiles, unlisted-callable),
  test/tool-profile.test.ts; test/mcp-smoke.ts also connects an SDK client
  pinned to 2026-07-28 and asserts the modern era.

Measured (raw stdio, bytes of the compact tools array, tokens ~ bytes/4):
  v2.0.0 default          14 tools   9,831 B  ~2,457 tok
  v2.1.0 default (full)   46 tools  33,886 B  ~8,471 tok
  v2.1.0 core             13 tools   9,066 B  ~2,266 tok
  instructions            2,019 -> 1,976 chars
Live hosts on 2026-09-01: Claude Code 2.1.258 and Codex CLI 0.147.0 both pin
the LEGACY era against this server (their binaries carry the modern client
strings but open with initialize), so dual-era serving is load-bearing; the
modern era is exercised by the test suite and the pinned SDK client.

Tool names, params and schema shapes: unchanged (manifest gate). No new
runtime dependency. Gates: bun run typecheck, bun test (906 pass), bun run
mcp:smoke (live Chrome round trip + modern-era connect).

🤖 Authored with Claude Code

— Claude Fable 5.1 via Claude Code

Claude-Session-Id: 2dcb74ca-ac87-41cc-b8cd-aecf641d4699
Comment-only. The release-bump prerequisite in the workflow header pointed at
the constant's pre-2.1.0 home.

🤖 Authored with Claude Code

— Claude Fable 5.1 via Claude Code

Claude-Session-Id: 2dcb74ca-ac87-41cc-b8cd-aecf641d4699
The v2.1.0 publish run failed in `bun test`: the "a tool the profile hides is
still callable by name" case called list_leases, which dials CDP_BASE, so it
passed on a machine with Chrome up and failed on the runner with
"Unable to connect". The assertion is about dispatch, not about the browser.

Now it calls list_mocks (in-process state, no browser), pins CDP_BASE to a
closed port so a local Chrome can never mask a regression, and adds the
removed browser_tools name as a refusal control (`unknown tool: browser_tools`).

🤖 Authored with Claude Code

— Claude Fable 5.1 via Claude Code

Claude-Session-Id: 2dcb74ca-ac87-41cc-b8cd-aecf641d4699
…pts the tool list

Gemini function declarations reject an array property without `items`.
Any client that shipped the whole cdp-toolkit tool list (opencode with 135
tools) to an OpenRouter Gemini lane got no response headers at all; litellm
sat on the request for the 600 s deployment timeout, and by the time the
fallback hop was tried the client had already retried, so every flash call
ended as a 499. Direct replay against OpenRouter with `"items": {}` on this
one property answers in ~2 s. It was the only array in tools/list without
items; the live bun src/mcp.ts tools/list confirms none remain.

🤖 Authored with Claude Code

— Claude Fable 5.1 via Claude Code

Claude-Session-Id: 2c113ef3-7de7-4a67-9648-34b2933db471
…i session daemon

Firefox serves exactly ONE WebDriver BiDi session per browser, so until now only one
cdp-toolkit process could drive a user-launched Firefox; a second one was refused after
the session wait. Measured on Firefox 153.0.3 with a new live harness
(test/firefox-multi-agent-smoke.ts, `bun run firefox:multi:smoke`): one server process
with 4 concurrent agents on 4 tabs already worked (4 in flight at once), four server
PROCESSES on the same Firefox did not (1 of 4 completes).

Now the first process attaching to an endpoint spawns a detached daemon
(src/bidi/mux-daemon.ts, dist/bidi/mux-daemon.js) that owns the real session and hosts a
zero-dependency loopback WebSocket multiplexer speaking BiDi (src/bidi/mux.ts: session.*
answered locally, everything else forwarded, events fanned out per client). Every process,
including the spawner, joins it through the unchanged connectBidiSessionUrl; the session
slot record (session-lease.ts) advertises the mux endpoint and names the daemon as holder.
The daemon exits on its own when idle (CDP_FIREFOX_MUX_IDLE_MS, default 15 s), when
Firefox goes away, or on SIGTERM/INT/HUP. CDP_FIREFOX_MUX=host keeps the session
in-process (launch mode, and the daemon's own mode); CDP_FIREFOX_MUX=off is the previous
one-process behavior.

Why a daemon rather than the holder process hosting the mux: Firefox assigns NEW
browsing-context ids whenever its session is re-created (measured: true even after a
clean session.end), so a holder process exiting would invalidate every other process's
tab ids, leases and origin records. With the daemon a client process dying is invisible
to the others. If the daemon itself dies, a new one takes over (existing pid-liveness
steal + Marionette orphan recovery) and the driver's no-such-target error now says the
session was re-established and tells the agent to re-resolve with list_pages.

Measured after (20/20 harness checks): 4 processes, 40 calls, wall 437 ms vs serial
1375 ms, 4 distinct processes in flight; SIGKILL of a server mid-run: 3 survivors finish
with 0 retries; SIGKILL of the daemon mid-run: all 4 survivors re-find their tab by url,
re-claim it and finish on the same tab; daemon exits 204 ms after Firefox is killed.
Side effect: tab ids are now stable across separate `cdp` CLI invocations on an attached
Firefox (before, every call re-created the session and renumbered every tab).

Also: createWsServer.close() hung under Bun once a client had connected and gone
(server.close callback never fires); it now also resolves when listening flips false.
firefox:coord:smoke scenarios 1-4 pin CDP_FIREFOX_MUX=off (they test the no-mux refusal
semantics); new scenario 5 covers the default mode.

🤖 Authored with Claude Code

— Claude Fable 5.1 via Claude Code

Claude-Session-Id: e800f345-9b71-4739-bdf4-9186543fb87e
…d BiDi session daemon

Bumps package.json and src/version.ts to 2.2.0 and dates the CHANGELOG section.
Ships the Firefox multi-process capability (6b8e979) and the evaluate_script.args
items-schema fix for Gemini hosts (d600c69). No new runtime dependency; Chrome
untouched; Firefox default behavior in attach mode now spawns the idle-exiting
session daemon (CDP_FIREFOX_MUX=off restores 2.1 behavior).

🤖 Authored with Claude Code

— Claude Fable 5.1 via Claude Code

Claude-Session-Id: e800f345-9b71-4739-bdf4-9186543fb87e
…pages on a background tab (v2.3.0)

Ports the danbuhler/claude-code-auto-authorize mechanism: Emulation.setFocusEmulationEnabled
makes a background tab believe it is focused, and Input.dispatchMouseEvent delivers the trusted
click a focus gate (Claude Code OAuth Authorize) demands — without stealing OS window focus.
Chrome-only (new emulate.focus capability); absent under firefox. Smoke-tested against a real
focus-gated fixture (bun run focus:smoke).
…an OpenAI-compatible endpoint (v2.4.0)

Cleaned page HTML (in-page strip of scripts/styles/hidden/attribute bloat,
optional CSS-selector scoping) is POSTed to an OpenAI-compatible chat
completions endpoint with the caller's JSON Schema as response_format;
returns schema-conformant data plus token usage (and cost when the
endpoint reports it) on every call.

Default endpoint is loopback llm-ferry (http://127.0.0.1:8090/v1, model
"schematron") so page content stays on the machine unless the operator
points CDP_EXTRACT_BASE_URL elsewhere; CDP_EXTRACT_API_KEY is env-only
and redacted from every error string. Extraction instructions live in
schema property descriptions (description-less schemas are rejected);
oversized pages fail with html_too_large naming the levers (selector,
clean:"aggressive", maxChars) instead of silently truncating. Outbound
fetch carries its own AbortSignal budget (90s default) since the MCP
dispatch has no whole-call timeout.

Both backends: registered in TOOLS and FIREFOX_TOOLS (backend-neutral
PageDriver evaluate only). New "extraction" profile group. 22 unit
tests (fetch-stubbed, browserless) + extract:smoke harness with a
loopback mock-endpoint mode; tool count 47 -> 48 across registry,
manifest, docs, and prose.
… not invalid JSON

A completion the endpoint cut off at the 8192-token output cap surfaced as
"endpoint returned invalid JSON (Unterminated string)", which reads as a
model or quoting defect. extract_page now reads choices[0].finish_reason:
"length" fails with a dedicated message naming the cap, the completion
token count, and the remedy (selector / smaller schema); any other parse
failure carries finish_reason next to the excerpt. Two unit tests pin
both paths; both fail on the pre-change tree.

Claude-Session-Id: 37f0e267-989a-4cf9-85a6-c0e48a0bc039
… for locally served open weights (v2.5.0)

extract_page sent the cleaned HTML as the ONLY user message and let
response_format: json_schema strict carry the schema. That is correct for
the HOSTED Schematron API, which injects the schema into the prompt
server-side, and wrong for an open-weight Schematron served locally (an MLX
pchamart/schematron8B-mlx-8bit lane): those weights were fine-tuned on a
prompt with the schema INSIDE the user message, so a bare-HTML prompt returns
garbage even under constrained decoding.

New optional arg `prompt`:
  - "html" (default) — byte-identical to the previous wire shape.
  - "schematron" — inference-net/Schematron-8B's construct_messages verbatim:
    a "You are a helpful assistant" system message plus a user message
    carrying the COMPACT JSON.stringify'd schema, the HTML, and the
    "MAKE SURE ITS VALID JSON." tail.

CDP_EXTRACT_PROMPT sets the default; the per-call arg wins over it; any other
value from either source is a CdpError naming both values, thrown before any
page or network work. response_format is sent in BOTH modes (llguidance
constrained decoding on mlx_vlm honours it, harmless elsewhere). The schema
text is charged to the prompt on top of the HTML — maxChars caps the HTML
only, and the true count still comes back in usage.promptTokens.

Registered everywhere the other args live: manifest inputSchema (the
no-arg-drift test now expects `prompt`), toolDocs description + params,
the extract.ts header env list, and the README env table, extraction section
and tool row. Five new unit tests pin the default wire shape (one user
message, no system message), the exact model-card string rebuilt from its own
literal pieces, env/arg precedence, and both rejection paths.

Version: the changelog's [2.4.0] section shipped without a package.json bump
or tag, so [Unreleased] becomes [2.5.0] (keeping the finish_reason Fixed
entry) and package.json / src/version.ts / the README sample banner move
2.3.0 -> 2.5.0. No tag created here.

bun test: 974 pass, 2 skip, 0 fail (was 969/2/0). tsc --noEmit clean.
dist/ is gitignored and unbuilt in this tree; nothing to rebuild.

Claude-Session-Id: 37f0e267-989a-4cf9-85a6-c0e48a0bc039
Measured against a locally served open-weight Schematron-8B under
llguidance-constrained decoding: identical messages returned
{"stories": []} in 6 completion tokens with response_format present,
versus a valid 5-story result in 285 tokens with it omitted. The
schema already rides inline in the schematron prompt, so constrained
decoding on top of it is redundant and, on real markup, collapses the
extraction. response_format is now omitted entirely (never sent as
null) in schematron mode; html mode is unchanged. Since nothing
constrains the shape server-side in schematron mode, extractPage also
now rejects a non-object parsed response (array/null/primitive) with
the existing invalid-JSON error shape.

Claude-Session-Id: 37f0e267-989a-4cf9-85a6-c0e48a0bc039
…brary import (v2.6.0)

Closes sblattj/cdp-toolkit#8

- mcp.js runs under Bun >= 1.1 and Node >= 22; new gating CI 'runtime'
  job boots dist/mcp.js under both and round-trips initialize/tools/list
- --transport streamable-http (--port/--host, loopback-validated) under
  Bun via SDK 2.0 createMcpHandler; stdio stays the default, unchanged
- importing dist/mcp.js / dist/cli.js no longer starts the server or
  prints usage: direct-run guard via realpath(argv[1]) === import.meta.url
- README runtime/streamable-http note; CHANGELOG 2.6.0

OpenCode-Session: 978aa410-85a6-4961-b3cd-d2ac3231e872
…arkers + frame: selector (v2.7.0)

Closes sblattj#7

- take_snapshot marks OOPIF iframe nodes with frame=<targetId>/frameUrl,
  including interactiveOnly; detection via /json/list + DOM.getFrameOwner
  (Page.getFrameTree omits OOPIFs on a page attachment, Chrome 153);
  failure-soft, never fails the snapshot
- new frame:<url-substring> selector (Chrome): shared-resolver arm with
  worker-style empty/ambiguity/miss semantics; page-management tools and
  Firefox refuse deliberately
- list_pages{all:true} + bare iframe targetIds documented across
  toolDocs, manifest, MCP instructions, README, CLI, CONTRACT, SKILL
- CHANGELOG 2.7.0 (AX recursion deferred: per-process uid collisions)

OpenCode-Session: 978aa410-85a6-4961-b3cd-d2ac3231e872
…adius, <100ms recovery

Proves the core claim with re-runnable numbers (bun run bench:wedge): a
wedged navigate rejects at the configured bound (15.03s vs the default
15s bound, 3.03s vs CDP_TIMEOUT_MS=3000), a witness tab answers at p95
3ms mid-brick, and close+reopen+evaluate recovers at p95 89ms with zero
/mcp restarts. The stuck page itself stays unresponsive while its load
is pending — reported as an observation: the blast radius is the tab,
not the server. Exits nonzero unless every wedge rejects in-bound and
every recovery lands under 1s, so it is CI-able.

Authored-with: opencode (ferry/flash)
A Chrome that had debugging enabled at runtime, through the toggle on
chrome://inspect/#remote-debugging, serves a different transport than one
started with --remote-debugging-port. Measured on Chrome 153: the browser
socket upgrades (101), a per-target socket is refused (403), and /json/version
and /json/list both 404. That is what a user gets when they attach to the
browser they were already using rather than relaunching it, and the toolkit
could not reach it at all.

Discovery now falls back to Target.getTargets over that one socket, and each
call reaches its page through a flat Target.attachToTarget session on it. The
HTTP path is unchanged: listTargets issues its original GET /json/list first
and unconditionally, and only a request that did not answer reaches the
fallback, so there is no added round-trip and no added failure mode.

The one-target-per-call guarantee holds. Target.getTargets is metadata-only and
attaches to nothing, so a 200-tab browser costs one round trip rather than one
attach per tab (measured: 201 pages, 411 targets, 0.03s). The per-command
timeout is bounded per command id rather than per socket, so sharing the socket
does not let one stuck tab block the rest — browser-ws:wedge blackholes a tab
and shows it rejecting at the 5s bound while a witness tab, list_pages, and a
freshly opened tab all answer in under 1ms on that same socket.

Nothing is auto-discovered. A runtime-toggled Chrome raises a modal consent
prompt for each new client, granting full access to a logged-in session, so the
browser is named explicitly with CDP_BROWSER_WS or CDP_USER_DATA_DIR and the
default profile is never scanned.

Co-Authored-By: Claude Opus 5 <[email protected]>
@github-actions

github-actions Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

prxref automated review: Approved

PR: Reach a Chrome that serves only the browser WebSocket · files reviewed: 17

🟥 0 error · 🟧 0 warning · 🟦 0 outofscope

No findings — nice work.


Reviewed by prxref · model=z-ai/glm-5.3-flash · 40806 tok · 83.2s

srizzo and others added 2 commits September 19, 2026 15:46
Three problems review found in the new code:

detectEndpoint cached the promise, not the result, so the first attempt
losing a race with a still-starting Chrome left every later call holding
that same rejection — including listTargets' fallback, which would then
report a dead endpoint for the life of the process. Evict on rejection;
a resolved detection is still cached exactly once.

The smoke test's fail() called process.exit, which does not unwind, so
the catch that kills Chrome and removes the mkdtemp profile never ran.
Every assertion failure leaked a browser and a profile directory. Throw
instead and let the existing handler do its job.

The wedge test waited the full 30s for an endpoint Chrome would never
print if it had already died. Reject on exit, as the smoke test does.

Co-Authored-By: Claude Opus 5 <[email protected]>
…r runs

The handshake is bounded because a dead port would otherwise stall the
whole detection, but the /json/version fetch right below it was not, so
a host that accepts TCP and never answers hung detectEndpoint — and with
it listTargets' fallback. It now settles in 5s.

The failure message said no socket was found in "the default Chrome
profile(s)", asserting a search the module deliberately never performs:
scanning a default profile is what raises Chrome's consent dialog on the
user's screen. Say what actually happened instead.

Also in the fixtures: the proxy's open() awaited a promise only onopen
resolved, so a failed upstream handshake queued client messages forever;
the smoke test computed the page-socket probe but never asserted on it,
so a proxy that upgraded /devtools/page/* would have passed the very
check that establishes the endpoint is browser-ws-only; and freePort was
dead code.

Co-Authored-By: Claude Opus 5 <[email protected]>
@srizzo

srizzo commented Sep 19, 2026

Copy link
Copy Markdown
Author

All eight findings from the two review passes are addressed in 6f40976 and fa38950. Each one was real; notes and evidence below.

First pass

🟧 detectEndpoint caches a rejected promise — correct, and the worst of the set: a first attempt losing a race with a still-starting Chrome would have made every later call report a dead endpoint for the life of the process. Now evicts on rejection. Recovery, with no resetEndpointCache between the calls:

1st call -> rejected: no DevTools endpoint at http://127.0.0.1
2nd call -> resolved: browser-ws ws://127.0.0.1:9/devtools/browser/abc

The dedup that matters is intact — two successful calls still return the same object, so detection isn't repeated.

🟧 fail() bypasses cleanup — correct. process.exit doesn't unwind, so the catch that kills Chrome and removes the mkdtemp profile never ran. It throws now. Verified by temporarily forcing a failure at the list_pages assertion:

FAILED: FORCED FAILURE to exercise the fail() cleanup path
leftover profile dirs: 0
leftover chrome procs: 0

🟦 No exit handler on Chrome in the wedge test — fixed; it now rejects on exit like the smoke test does.

Second pass

🟧 /json/version fetch has no timeout — correct, and you're right that it contradicted the module's own stated reason for bounding the handshake. Now bounded by AbortSignal.timeout(5_000). Against a server that accepts TCP and never answers:

settled in 5.00s

🟧 Proxy open() hangs if upstream never opens — correct. It now resolves on error/close too. The discriminating evidence: with a probe printing OPEN-HANDLER-COMPLETED at the end of the handler, against an upstream killed before the client connects —

old proxy:  client socket closed after 0.00s
new proxy:  OPEN-HANDLER-COMPLETED
            client socket closed after 0.00s

The client socket closed either way, which is why this is easy to miss; the handler itself only completes with the fix.

🟦 Error message claims default profiles were searched — the most worth fixing of the three marked out-of-scope, because the claim was backwards. Not scanning default profiles is the deliberate safety property here: connecting to a browser the operator didn't name is what raises Chrome's "Allow remote debugging?" modal on their screen. The message now says what happened:

no profile was searched — CDP_USER_DATA_DIR is unset, and no profile is
ever scanned unless you name one.

🟦 Fixture check never asserts the page socket is 403 — correct, and it undercut the one check that establishes the endpoint is browser-ws-only. Now asserted.

🟦 freePort() is dead code — deleted.

Verification after both commits

  • bun run browser-ws:smoke — PASS
  • bun run browser-ws:wedge — PASS (stuck tab rejects at the 5s bound; witness tab, list_pages, and recovery all 0ms)
  • bun test — 1010 pass / 2 skip / 0 fail
  • tsc --noEmit — exit 0

🤖 Generated with Claude Code

@sblattj

sblattj commented Oct 12, 2026

Copy link
Copy Markdown
Owner

Thanks @srizzo. This repo's history was rewritten, which moved main under this PR and closed it, and GitHub won't let me push the rebase to your fork because the history touches workflow files. Your three commits are carried over unchanged, with your authorship, in #10. Review continues there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants