Repository navigation
Conversation
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
…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.
…checkout of PR code)
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]>
prxref automated review: ApprovedPR: 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 |
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]>
|
All eight findings from the two review passes are addressed in 6f40976 and fa38950. Each one was real; notes and evidence below. First pass🟧 The dedup that matters is intact — two successful calls still return the same object, so detection isn't repeated. 🟧 🟦 No exit handler on Chrome in the wedge test — fixed; it now rejects on exit like the smoke test does. Second pass🟧 🟧 Proxy 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: 🟦 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. 🟦 Verification after both commits
🤖 Generated with Claude Code |
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 thechrome://inspect/#remote-debuggingtoggle 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
chrome://inspecttoggle Chrome/json/version/json/list/devtools/page/<id>/devtools/browser/<uuid>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_snapshoton 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 0bun run mcp:smoke→ OK, 49 tools on both protocol erasThe 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. Sobun run browser-ws:wedgere-proves it, 5s bound: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:smokeagainst a proxy that serves the toggle-Chrome shape (404/403/101): 201 pages in 0.02s, 415 targets across 7 types.How it works
Target.getTargetsover the browser WebSocket instead of HTTP/json/list.Target.getTargetsreturns metadata and attaches to nothing, which is what preserves the repo's headline property.Target.attachToTargetwithflatten: true, giving flat sessions multiplexed over that one socket bysessionId.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, plusopenSession(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.PageConnectionis 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 andfilter: [{}]returned 6, the difference being exactly the iframe/worker types. The patch usesfilter: [{}]so the documentedframe:andworker:selector arms keep working.CDP_BROWSER_WS, or from aDevToolsActivePortfile in a profile dir the operator names viaCDP_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=Nscales the tab count.bun run browser-ws:wedge— the shared-socket wedge proof above.Both spawn their own disposable headless Chrome with a
mkdtempprofile 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
CdpConnectionto the structuralPageConnectionacross 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