Skip to content

refactor(browser-control): consolidate to a single default browser path - #1779

Merged
bobleer merged 3 commits into
GCWing:mainfrom
bobleer:refactor/browser-control-consolidation
Jul 26, 2026
Merged

refactor(browser-control): consolidate to a single default browser path#1779
bobleer merged 3 commits into
GCWing:mainfrom
bobleer:refactor/browser-control-consolidation

Conversation

@bobleer

@bobleer bobleer commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Background

Follow-up to #1777. The audit identified seven overlapping browser-control paths as the main reason the agent performs poorly at browser operations: the model oscillates between two full automation stacks (the agent-browser CLI skill and the ControlHub CDP domain), each with its own @eN ref namespace, its own browser instance, and mutually invisible login state — refs from one stack fail in the other, and recovery guidance pointed in both directions.

This PR makes the ControlHub browser domain the single default path and fixes the correctness issues that remained on it.

Changes

Single default stack

  • The agent-browser builtin skill is now default-off in every mode (DISABLE_COMPUTER_USE rule added to AGENTIC_POLICY/TEAM_POLICY). Both opt-in paths are preserved and contract-tested: user overrides, and explicit /skill agent-browser invocation (the default-hidden whitelist now includes the computer-use group — without this, explicit invocation would have broken).

Interaction correctness on the ControlHub path

  • iframe click fix: element_center accumulates ancestor frame offsets (rect + border) along the frameElement chain, so clicks inside same-origin iframes land on the element instead of iframe-local coordinates. Cross-origin frames return a structured error advising a fresh snapshot and re-target; missing coordinates now error instead of silently clicking (0,0). All element-targeting actions (click, hover, fill, …) benefit.
  • Removed the dead frame/frame_main actions: they wrote active_frame state that nothing ever read, so the model believed it had switched frames when nothing changed. The unread session state is deleted too.

Recoverable errors

  • High-frequency failures now carry stable error codes and recovery instructions at the source: element not found → NOT_FOUND ("take a new snapshot and use a fresh @en ref"), dead session/WebSocket → WRONG_TAB ("call browser.connect or switch_page"), CDP/wait timeouts → TIMEOUT. The phrase-matching classifier in map_dispatch_error remains as fallback only.

Honest observability at path boundaries

  • open_builtin now states the built-in panel is display-only and not observable by the agent, routing reading/interaction to connect + snapshot.
  • open_url/open_file results explain the opened window cannot be observed or controlled by BitFun and route to the browser domain / desktop actions respectively — previously the model would open a URL and then try to drive the browser with desktop clicks.
  • The WebFetch description routes login/JS-dependent pages to the browser domain.

Plan update

  • Records the product decision that ai.computer_use_enabled and browser control are independent switches: disabling computer use must not hide or disable the ControlHub browser domain; phase 6 will add a separate ai.browser_control_enabled (default on) plus a browser_control permission intent.

Verification

  • cargo check --workspace passes (verified with pipefail; includes bitfun-desktop)
  • cargo test -p bitfun-core browser: 27 passed (new iframe/structured-error/open_builtin tests included)
  • cargo test -p bitfun-core control_hub computer_use skills: 263+ passed
  • cargo test -p bitfun-agent-runtime (incl. skill_contracts): all passed, with new contract tests for default-off in every mode and explicit-invocation opt-in
  • node scripts/core-boundaries/checker.mjs passes

bowen628 added 2 commits July 26, 2026 18:40
The audit found seven overlapping browser-control paths; the model
oscillating between two full automation stacks (agent-browser CLI vs
ControlHub CDP, each with its own @en ref namespace, browser instance
and login state) was the largest source of failed browser operations.

- Make agent-browser default-off in every mode so the ControlHub
  browser domain is the single default path; explicit /skill invocation
  and user overrides still opt in (extend the default-hidden whitelist
  so explicit invocation keeps working)
- Fix iframe click coordinates: element_center now accumulates ancestor
  frame offsets along the frameElement chain; cross-origin frames
  surface a structured error advising snapshot + re-target; missing
  coords error instead of clicking (0,0)
- Remove the dead frame/frame_main actions and the unread active_frame
  session state they wrote; they never affected execution context
- Emit structured error codes with recovery instructions at the source
  (element not found -> NOT_FOUND "take a new snapshot", dead session ->
  WRONG_TAB "reconnect or switch_page", timeouts -> TIMEOUT) instead of
  relying on phrase-matching classification; phrase matching stays as
  fallback only
- State non-observability where control leaves the CDP stack:
  open_builtin marks the panel display-only, open_url/open_file explain
  the opened window cannot be observed or controlled and route to the
  right tool, WebFetch description routes login/JS pages to the browser
  domain
- Record the product decision that ai.computer_use_enabled and browser
  control are independent switches (browser control stays available
  when computer use is off) in the refactor plan
…on safe

An end-to-end review of the consolidated browser path (walking a real
task: open -> log in -> find -> click -> observe -> read) found the
advertised workflow could not actually be executed by a model.

Results the model never saw:
- The tool pipeline prefers `result_for_assistant`, and every browser
  action passed only a one-line count there, so `snapshot` returned
  literally "Snapshot: 42 interactive elements" and the @en refs the
  documented connect -> snapshot -> click flow depends on never reached
  the model. Same for list_pages/tab_query (no page ids, so switch_page
  was unusable), get_html, cookies, network/console/errors and
  meta.capabilities. Each now returns the payload the next decision
  needs; screenshot returns a real image attachment instead of a byte
  count and no longer duplicates base64 in the data field.

Actions that silently did the wrong thing:
- snapshot renumbered @en from 1 without clearing the previous pass's
  data-cdp-ref attributes, so after scrolling (viewport-filtered
  numbering) a stale attribute earlier in document order won the
  querySelector and click acted on the wrong element while reporting
  success. Refs are now cleared across the document, open shadow roots
  and same-origin frames before renumbering.
- click/hover dispatched mouse events at a point without hit-testing it;
  an occluded target reported success. The center point is now verified
  with elementFromPoint and a covered target is refused with a
  recoverable error.
- press_key sent no text/windowsVirtualKeyCode, so Enter never triggered
  native form submission.
- browser.fetch wrapped CORS/transport failures in a "Fetched <url>"
  success line; they are now structured errors that explain the
  same-origin constraint, and HTTP status is surfaced in the summary
  while the response body is preserved.

Frontmost-app detection (regression from the guard activated in GCWing#1777):
- On Windows the foreground "name" is the window *title*, and the
  Chromium check matched bare substrings, so any window titled "Search"
  matched "arc" and had all desktop input blocked. Foreground apps now
  carry a process_name (executable basename on Windows, process name on
  macOS) and matching is whole-identity, falling back to token matching
  on the display name only when no identity is available.
- The guard covers the app_*/interactive_*/visual_* variants that could
  previously bypass it, exempts the alt+tab/command+tab focus switch so
  a non-browser task can escape, and short-circuits on explicit
  non-browser selectors instead of querying the host.

Also: select/wait accept @en refs, stale refs report STALE_REF instead
of a generic not-found, unresolvable cross-origin iframes are explained
instead of retried forever, prompts no longer promise the user's login
state (connect uses BitFun's managed profile), open_builtin is routed as
the display-only path, prompts stop pointing unconditionally at the
default-off ComputerUse tool, and the agent-browser skill no longer
argues against itself when explicitly invoked.
@bobleer

bobleer commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Update: end-to-end review pass

After the consolidation commit I ran an adversarial end-to-end review of the resulting path — four reviewers (task flow, prompts/agent UX, diff correctness, capability gating), each finding independently verified against the code before being accepted. 33 findings survived verification; the fixes are in fix(browser-control): make browser results actionable and app detection safe.

Two of them explain why browser automation failed in practice regardless of which stack was used:

The @eN workflow was never executable. convert_tool_result prefers result_for_assistant when a tool supplies one, and every ControlHub browser action supplied only a one-line count. A model calling snapshot received the literal string "Snapshot: 42 interactive elements" — the refs that connect → snapshot → click @e1 depends on stayed in the data field and never reached it. The same applied to list_pages/tab_query (no page ids → switch_page unusable), get_html, cookies, network/console/errors and meta.capabilities; screenshot returned a byte count and no image at all. Every one of these now returns what the next decision needs, and screenshot returns a real image attachment.

snapshot could make click act on the wrong element while reporting success. Refs were renumbered from 1 on each pass without clearing the previous pass's data-cdp-ref attributes, and numbering is viewport-filtered — so after an auto_scroll, click @e2 could resolve to a stale attribute earlier in document order. Refs are now cleared across the document, open shadow roots and same-origin frames before renumbering.

Also fixed, and worth calling out because it is a regression from #1777: on Windows the foreground app name is the window title, and the Chromium check used bare substring matching — so any window titled "Search" matched "arc" (s-e-arc-h) and had all desktop input blocked by the newly-activated guard. Foreground apps now carry a process_name and matching is whole-identity.

The rest: click/hover hit-test their target point instead of reporting success on an occluded element; press_key sends text/windowsVirtualKeyCode so Enter submits forms; browser.fetch no longer reports CORS failures as successful fetches; select/wait accept @eN refs; stale refs report STALE_REF; cross-origin iframes are explained rather than retried forever; prompts no longer promise the user's login state (connect uses BitFun's managed profile) and no longer point unconditionally at the default-off ComputerUse tool.

Verification: cargo check --workspace, cargo test -p bitfun-core (1494 passed), -p bitfun-agent-runtime, -p bitfun-agent-tools (103), bitfun-desktop builds, node scripts/core-boundaries/checker.mjs.

Known remaining (not in this PR): a guarded desktop action still queries the frontmost app twice per call (guard + result augmentation); caching it within a call needs plumbing the snapshot through and is better done with the per-session host-state work in phase 3 of docs/plans/computer-use-refactor-plan.md.

The occlusion hit-test compared document.elementFromPoint against the
target with contains(), but elementFromPoint stops at a shadow host and
host.contains(shadowChild) is false — so every element inside an open
shadow root (which resolve/snapshot deliberately support) was reported
as occluded by its own host and refused. Descend through open shadow
roots' elementFromPoint before comparing.
@bobleer
bobleer merged commit 024d01f into GCWing:main Jul 26, 2026
5 checks passed
1688mengdie pushed a commit to 1688mengdie/BitFun that referenced this pull request Jul 26, 2026
…th (GCWing#1779)

* refactor(browser-control): consolidate to a single default browser path

The audit found seven overlapping browser-control paths; the model
oscillating between two full automation stacks (agent-browser CLI vs
ControlHub CDP, each with its own @en ref namespace, browser instance
and login state) was the largest source of failed browser operations.

- Make agent-browser default-off in every mode so the ControlHub
  browser domain is the single default path; explicit /skill invocation
  and user overrides still opt in (extend the default-hidden whitelist
  so explicit invocation keeps working)
- Fix iframe click coordinates: element_center now accumulates ancestor
  frame offsets along the frameElement chain; cross-origin frames
  surface a structured error advising snapshot + re-target; missing
  coords error instead of clicking (0,0)
- Remove the dead frame/frame_main actions and the unread active_frame
  session state they wrote; they never affected execution context
- Emit structured error codes with recovery instructions at the source
  (element not found -> NOT_FOUND "take a new snapshot", dead session ->
  WRONG_TAB "reconnect or switch_page", timeouts -> TIMEOUT) instead of
  relying on phrase-matching classification; phrase matching stays as
  fallback only
- State non-observability where control leaves the CDP stack:
  open_builtin marks the panel display-only, open_url/open_file explain
  the opened window cannot be observed or controlled and route to the
  right tool, WebFetch description routes login/JS pages to the browser
  domain
- Record the product decision that ai.computer_use_enabled and browser
  control are independent switches (browser control stays available
  when computer use is off) in the refactor plan

* fix(browser-control): make browser results actionable and app detection safe

An end-to-end review of the consolidated browser path (walking a real
task: open -> log in -> find -> click -> observe -> read) found the
advertised workflow could not actually be executed by a model.

Results the model never saw:
- The tool pipeline prefers `result_for_assistant`, and every browser
  action passed only a one-line count there, so `snapshot` returned
  literally "Snapshot: 42 interactive elements" and the @en refs the
  documented connect -> snapshot -> click flow depends on never reached
  the model. Same for list_pages/tab_query (no page ids, so switch_page
  was unusable), get_html, cookies, network/console/errors and
  meta.capabilities. Each now returns the payload the next decision
  needs; screenshot returns a real image attachment instead of a byte
  count and no longer duplicates base64 in the data field.

Actions that silently did the wrong thing:
- snapshot renumbered @en from 1 without clearing the previous pass's
  data-cdp-ref attributes, so after scrolling (viewport-filtered
  numbering) a stale attribute earlier in document order won the
  querySelector and click acted on the wrong element while reporting
  success. Refs are now cleared across the document, open shadow roots
  and same-origin frames before renumbering.
- click/hover dispatched mouse events at a point without hit-testing it;
  an occluded target reported success. The center point is now verified
  with elementFromPoint and a covered target is refused with a
  recoverable error.
- press_key sent no text/windowsVirtualKeyCode, so Enter never triggered
  native form submission.
- browser.fetch wrapped CORS/transport failures in a "Fetched <url>"
  success line; they are now structured errors that explain the
  same-origin constraint, and HTTP status is surfaced in the summary
  while the response body is preserved.

Frontmost-app detection (regression from the guard activated in GCWing#1777):
- On Windows the foreground "name" is the window *title*, and the
  Chromium check matched bare substrings, so any window titled "Search"
  matched "arc" and had all desktop input blocked. Foreground apps now
  carry a process_name (executable basename on Windows, process name on
  macOS) and matching is whole-identity, falling back to token matching
  on the display name only when no identity is available.
- The guard covers the app_*/interactive_*/visual_* variants that could
  previously bypass it, exempts the alt+tab/command+tab focus switch so
  a non-browser task can escape, and short-circuits on explicit
  non-browser selectors instead of querying the host.

Also: select/wait accept @en refs, stale refs report STALE_REF instead
of a generic not-found, unresolvable cross-origin iframes are explained
instead of retried forever, prompts no longer promise the user's login
state (connect uses BitFun's managed profile), open_builtin is routed as
the display-only path, prompts stop pointing unconditionally at the
default-off ComputerUse tool, and the agent-browser skill no longer
argues against itself when explicitly invoked.

* fix(browser-control): pierce shadow roots in the click hit-test

The occlusion hit-test compared document.elementFromPoint against the
target with contains(), but elementFromPoint stops at a shadow host and
host.contains(shadowChild) is false — so every element inside an open
shadow root (which resolve/snapshot deliberately support) was reported
as occluded by its own host and refused. Descend through open shadow
roots' elementFromPoint before comparing.

---------

Co-authored-by: bowen628 <bowen628@noreply.gitcode.com>
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.

1 participant