Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
|
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