Repository navigation
browser: migrate callers onto the canonical interface (ADR-009 steps 3-6) — closes the #13204 guard gap #13236
Description
Activity
Started step 3 and stopped before changing anything — reading the first two real callers found three gaps in the phase-1/2 contract I shipped in #13226. All three are mine, and all three would have been papered over by migrating anyway.
1.
BrowserResultcannot carry whatcontent_reachreturnscontent_reach/backends/browser.pymapsresearch_url(extract_content=True)onto aContentResultwith two payloads:content = result.get("content") or {} text = content.get("text_content", "") structured = content.get("structured_data") or {} # <- no home in BrowserResult
BrowserResulthascontentbut nostructuredfield, so migrating as-is silently dropsstructured_data.2.
ExtractRequesthas nourl, so a stateless backend cannot serve itweb_fetch/fetcher.py::_fetch_playwrightrenders a URL through the container:result = await svc._post_and_parse("render", {"url": url, "wait": "networkidle", ...}) return result.get("html") or result.get("content")
Two problems.
ExtractRequestcarriessession_id/selector/max_charsbut nourl— fine for the session-holding stacks, impossible for the stateless container. And myContainerBrowserBackenddeclaresSCREENSHOTonly: I wrappedcapture_screenshotand missed that the container also exposes arenderendpoint. The capability matrix in ADR-009 under-declares that stack.3. The bigger one: callers want a specific stack, not "whatever can do X"
This is the design issue, and it is why I stopped.
The capability model assumes callers are indifferent to which backend serves them. The first two real callers are not:
web_fetchuses the container deliberately, so Playwright does not run in the backend's own process. Dispatching to "anyEXTRACT-capable backend" could route it in-process — exactly what that fallback exists to avoid.content_reachwants the research browser specifically: itsprobe()isrbm.PLAYWRIGHT_AVAILABLE, and its error handling readsblocked_by_guard, which is that stack's security(content_reach): browser + yt-dlp fetches keep a DNS-rebind TOCTOU window that pinning our own connector cannot close #13018 re-check.
Requiring a capability only one stack declares would technically pin the routing, but that is gaming the model, not using it.
The contract needs a way to express process locality or stack affinity as a first-class requirement — something like an
OUT_OF_PROCESScapability, or an explicitprefer=/pin=argument onget_browser()— before any caller migrates. Otherwise each migration silently changes which stack serves a request, and the failure only shows up under load or when a backend is down.What this means for the plan
ADR-009's step ordering should change. It nominated
content_reachfirst as "smallest, already guarded" — but it is the caller with the most stack-specific behaviour (structured extraction plusblocked_by_guardhandling).web_fetch's render path is smaller and more self-contained, but it needs gaps 2 and 3 closed first.Suggested revised order:
- Contract fixes first (no caller changes):
structuredonBrowserResult,urlonExtractRequest,EXTRACTdeclared and implemented onContainerBrowserBackendvia itsrenderendpoint, and a decision on how affinity is expressed. - Then
web_fetch(step 4) as the first migration, notcontent_reach. - Then
content_reach, oncestructuredexists. - Then
tool_handler(the security(browser): send_to_browser_vm and playwright_service bypass the SSRF/DNS-rebind guard the other browser paths enforce #13204 fix, and the behaviour change). - Then
api/playwright.
I have not written any of this yet — item 1 needs the affinity decision, and I would rather ask than guess, having just watched three assumptions fail on contact with real callers.
#13204 is unaffected by this and stays open; it is still closed only by the
tool_handlermigration.- added a commit that references this issue
on Aug 1, 2026 Contract gaps closed — merged as
dcb0e3b5d(PR #13256). This issue stays OPEN: no caller is migrated.What landed
Per your 2026-08-02 decision, locality is now a capability:
browser = await get_browser(requires={ Capability.EXTRACT, Capability.OUT_OF_PROCESS, # never in the backend's own process })
Every backend declares exactly one of
IN_PROCESS/OUT_OF_PROCESS, asserted by test — a backend declaring neither, or both, would letweb_fetchbe routed into the process it deliberately avoids.Also closed, both found the same way:
ExtractRequest.url, guarded by the registry exactly like a navigate URL. A stateless backend holds no current page, and that URL reaches the network the same way, so it cannot skip the check.BrowserResult.structured+NavigateRequest.extract.content_reachmapsresearch_url'sstructured_dataontoContentResult.structured; it had no home in the result, so migrating would have silently dropped it.research_urlnavigates and extracts in one round trip, so asking on the navigate request avoids navigating twice.ContainerBrowserBackendnow declaresEXTRACT, implemented against the container'srenderendpoint — correcting a phase-2 under-declaration where I wrapped onlycapture_screenshot.
Verified on
Dev_new_guiafter merge: 36 tests pass (was 30), bandit 0 issues.Revised step order
ADR-009 nominated
content_reachfirst as "smallest, already guarded". That was wrong — it is the caller with the most stack-specific behaviour (structured extraction plusblocked_by_guardhandling). Revised:Contract fixes✅ doneweb_fetch(_fetch_playwright→EXTRACT+OUT_OF_PROCESS) — now the cleanest first migrationcontent_reach, now thatstructuredexiststool_handler— the behaviour change, and the step that closes security(browser): send_to_browser_vm and playwright_service bypass the SSRF/DNS-rebind guard the other browser paths enforce #13204api/playwright, retiringis_url_allowed's regex allowlist
Before step 4
Step 4 will start rejecting internal/loopback URLs that
send_to_browser_vmaccepts today.is_url_allowedcurrently allowlists loopback and an internal VM range, which suggests some internal navigation is deliberate. Those call sites need enumerating first so the exception list is derived from real usage rather than guessed — and that step wants your sign-off, since it is a production behaviour change on the live chat path.#13204 remains open and is unaffected by this PR.
- added a commit that references this issue
on Aug 2, 2026 1 remaining item
- added a commit that references this issue
on Aug 2, 2026 - added a commit that references this issue
on Aug 3, 2026 Status correction — this issue is not close to done, and PR #13317 was mislabelled.
PR #13317 (merged) was titled and described as "ADR-009 step 5". Against the step numbering in this issue and in ADR-009 itself, it delivered the allowlist-retirement half of step 6 —
is_url_allowedno longer prefix-matches URLs, and its two call sites were updated. It did not touchchat_workflow/tool_handler.py's cascade, which is what step 5 actually is.Measured against the "Done when" list on
Dev_new_guiataa7f2c617:Step Caller State 3 content_reach/backends/browser.pydone (#13226 / PR #13300) 4 web_fetch/fetcher.pyrender fallbacknot started 5 chat_workflow/tool_handler.pycascadenot started — _web_search_via_playwrightand_web_search_via_browser_vmare both still present6 api/playwright.pyrepointnot started — still imports the research stack directly 6 retire is_url_allowedregex allowlistdone (PR #13317) So three of the four callers still name a stack directly, and
tool_handler's hand-rolled cascade is intact.#13204 therefore remains open on its original terms. Step 5 is the step that closes it structurally, and step 5 has not been done. PR #13317 narrowed the exposure — the guard that path did have was a prefix-match allowlist that accepted lookalike hosts, and that is now a resolving check — but narrowing a weak guard is not the same as routing the path through the interface.
Remaining work is steps 4, 5, and the
api/playwright.pyhalf of 6, in that order.- addedarea: browser-navigationWave 5 · cluster L — Browser & web navigationWave 5 · cluster L — Browser & web navigation
on Sep 1, 2026 Stale-issue sweep (never auto-closed — Dev_new_gui wasn't GitHub's default branch at merge time). Verified against current
origin/main; leaving open.Met:
content_reachandweb_fetchnow reach the browser viaget_browser()—autobot-backend/content_reach/backends/browser.py:26-29,124(#13313),autobot-backend/web_fetch/fetcher.py:256-265(#13309). A URL-allowlist regex-prefix bypass was also fixed along the way (#13317,is_url_allowednow resolves hosts viais_public_url_async).Not met — the issue's core ask ("no caller names a stack directly," the hand-rolled cascade removed):
autobot-backend/chat_workflow/tool_handler.pystill defines_web_search_structured_entries(:3021),_web_search_via_playwright(:3054),_web_search_final_fallback(:3074),_web_search_via_browser_vm(:3090) — the exact four functions named for deletion — callingsend_to_browser_vmdirectly. Noget_browser/autobot_shared.browserreference exists anywhere in that file.autobot-backend/api/playwright.pyis also never migrated (from services.playwright_service import (...)directly, noget_browser).None of #13236's own 5 PRs migrated
tool_handler.pyorapi/playwright.py— both remain unmigrated on current main.
Part of #12645 / #12756. Follow-up to #12651, which delivered ADR-009 phases 1–2 (merged
a6d7aa1ce, PR #13226).What exists now
autobot_shared/browser/holds the canonical contract —Capability, request/result types, capability dispatch with probe-based fallback, and the DNS-resolving URL guard — andautobot-backend/browser_backends/wraps the three stacks (in_process,worker,container).Nothing is repointed. Every caller still reaches its stack directly, and
register_all()is opt-in.What this issue covers — ADR-009 steps 3–6
Sequenced so each step is separately reviewable, per the ADR:
content_reach/backends/browser.py— smallest caller, and already guarded, so a regression is easy to see.web_fetch/fetcher.pyrender fallback.chat_workflow/tool_handler.py— delete the hand-rolled cascade (_web_search_via_playwright/_web_search_structured_entries/_web_search_final_fallback/_web_search_via_browser_vm) in favour of capability dispatch.api/playwright.py, and retireis_url_allowed's regex allowlist in favour of the shared guard plus an explicit, documented internal-host exception list.The reason this is its own issue, not a rider on #12651
Step 5 is a behaviour change, and it is the one that closes #13204.
Today
send_to_browser_vm()performs no URL validation and is reachable from the agent tool path. Routing it through the canonical interface means the DNS-resolving guard starts running on it — so URLs that path accepts today will begin failing: internal addresses, loopback, link-local. That is the intended fix, but it is a production behaviour change on the live chat path and needs its own review and rollout note rather than riding along with an additive interface PR.api/browser_mcp.py's existingis_url_allowedallowlists loopback and an internal VM range, which suggests at least some internal navigation is deliberate. Enumerate those call sites before step 6 — the exception list needs to be derived from real usage, not guessed.Done when
content_reach,web_fetch,tool_handlerandapi/playwrightall reach the browser throughautobot_shared.browser.get_browser();tool_handler's hand-rolled cascade is gone (the bug class in discovery(chat/search_web): duplicated Playwright service calls in fetch_full path #7478 with it);#13204is closed structurally — the guard runs on every navigate/fetch;Blocking note
#13204 remains open until step 5 lands. #12651 closing does not close it.