Skip to content

Stop the tab marker from reaching what the agent reads - #608

Open
misterbridge wants to merge 16 commits into
browser-use:mainfrom
misterbridge:fix/tab-marker-leak
Open

Stop the tab marker from reaching what the agent reads#608
misterbridge wants to merge 16 commits into
browser-use:mainfrom
misterbridge:fix/tab-marker-leak

Conversation

@misterbridge

@misterbridge misterbridge commented Aug 14, 2026

Copy link
Copy Markdown

The 🐴 tab marker is written into document.title — the same channel the page writes to — and nothing removes it before an agent reads it back. So the marker, which describes the harness, reaches the agent as if it described the site.

Why this is worse than noise

The marker is not consistently present. Whether a title carries it is decided by a race between the harness and the page:

Situation Result
Page whose title is settled at load (plain SSR) harness writes last → marked
Page that sets its title client-side after load (SPA, React hydration) page writes last → clean forever
Tab reached by switch_tab() once already settled harness writes last → marked, whatever the page

Same URL, two access paths, opposite results:

after direct navigation, settled          clean   'Peru travel guide | Example'
after switch_tab() back onto that tab     MARKED  '🐴 Peru travel guide | Example'

The marker says nothing about the page — it says how the agent got there. That is exactly what makes it read as data: it correlates with real technical properties (render mode, load speed) without being one.

This has already misled agents, in this repo

Six domain-skills files mention the marker. Four document the trap correctly. Two took it for a property of the site and built on it:

  • agent-workspace/domain-skills/vercel/vercel.md:135"Page title prefix is a status signal — 🟢 Vercel = all systems nominal"
  • agent-workspace/domain-skills/atlas/overview.md:70"The app sets a green-dot emoji prefix on titles… Useful for wait_for conditions"

Both are pre-existing and untouched here. The same question was raised from outside the project in NousResearch/hermes-agent#85430: "It can be mistaken for actual website content during QA, so a documented explanation — or a less content-like marker / separate metadata field — would help prevent false findings."

What this changes

No marker at all when the browser is headless. With no window there is no one to read it, so it can only pollute. Detected from Browser.getVersion's user agent at connection time.

Every title a helper hands back is strippedpage_info(), current_tab(), list_tabs(), connection_status(), and the title the recorder traces into recording-summary.json. Only a leading marker-plus-space is removed; "Horses 🐴 for sale" and "🐴Emoji-first" survive untouched.

Marking moves from switch_tab() into the daemon, which is what knows whether the browser has a window. The fire-and-forget marking on load events is preserved unchanged — awaiting it costs ~4 s per navigation (#136).

SKILL.md gains the warning that was missing: the marker exists, helper-returned titles are stripped of it, and js("document.title") therefore disagrees with page_info()["title"] in a headed session. Three domain-skill files that quoted the old marked titles are corrected.

The follow-up, and why it comes second

The marker still vanishes when a page rewrites its own title after load — most modern sites, since an SPA or a hydrating framework sets its title after the load event fires. That is a separate bug and the fix is ready: misterbridge/browser-harness-fork#1, six commits on top of this branch, opened there rather than here so its diff shows those six alone. It moves here once this lands.

The order is not a convenience. Making the marker persistent without the stripping in this PR would turn an intermittent leak into a permanent one: today the marker often disappears on its own, so some reads come back clean by accident; re-applied on every title write, it would reach every single read.

Verified

  • pytest tests -q → 130 passed.
  • Live, headless Chrome 151: 0 of 25 title reads carried the marker, against 15 of 24 before the change — and the raw in-page document.title is never marked, confirming the skip fires rather than the stripping merely hiding it.

Summary by cubic

Stops the 🐴 tab marker from reaching agent-visible titles while keeping it visible to users in headed sessions. Previously the harness wrote the marker into document.title and helpers returned it; now headless sessions never mark, and helper/recorded titles strip a leading "🐴 " prefix.

  • Detect headless via Browser.getVersion once at connect; if undetermined, treat as headless. BH_TAB_MARKER=0/1 forces off/on.
  • Strip a leading "🐴 " from titles returned by page_info(), current_tab(), list_tabs(), connection_status(), and from recorder context.
  • Move marking to the daemon and unmark the previous tab on session switch; client-side helpers no longer write to document.title.
  • Match blank-page and startup-placeholder filters on stripped titles to avoid reusing the placeholder.
  • Add tab_marker module with shared marker/JS and unit tests; update SKILL.md and domain skills.

Migration notes

  • Remove waits/assertions that depend on a title prefix (e.g., 🟢/🐴); helpers no longer return it.
  • To read the literal page title in headed runs, use js("document.title"); it can differ from page_info()["title"].

Written for commit 6f5e184. Summary will update on new commits.

Review in cubic

The 🐴 prefix exists for the user watching the browser. Headless there is
no window and no reader, so the marker only ever reaches the agent, which
reads it back as part of the page's title.
The load-event path marks on every navigation, independently of the one
that fires when the session changes; the headless skip has to cover both.
Both now go through mark_tab_soon(), and the marker program moves into
tab_marker.py — derived from the MARKER constant, so a change to the
prefix cannot desynchronise the JS from the Python that reads it back.
Nothing told the daemon whether the browser it drives has a window.
Browser.getVersion does: Chrome reports HeadlessChrome in its user agent
when it runs without one. Detected once, at connect, so every marking
decision downstream is a flag read rather than a round trip.
page_info() is the title an agent reads most, and the marker rode along in
it: agents have reported the 🐴 as part of the site under test. The prefix
describes the harness, so it comes off on the way out — and only a leading
marker-plus-space does, since a page is free to use the emoji itself.
The CDP target list is a second channel the marker leaks through, and one
where it also makes the driven tab look different from every other tab in
the list the agent is choosing from.
`browser-harness status` prints it and agents read it back, so it is the
third and last channel the prefix reached the agent through.
list_tabs() hides the about:blank tab the harness opens at startup by its
title. The marker is prepended to exactly that tab, so the filter stopped
matching and the placeholder showed up in the list the agent chooses from.
Unmarking lived in switch_tab(), on the client side, which does not know
whether the browser has a window — the same reason marking had to move.
The daemon already sees both sessions when set_session swaps them.
It marked unconditionally, from the side that cannot know whether the
browser has a window, so a headless tab got the prefix back the moment the
agent switched tabs. set_session already tells the daemon to move it.
is_reusable_blank_page() recognises the harness's own startup placeholder
by its title, and the marker is prepended to exactly that tab — so the
daemon could attach to the placeholder it had just opened. Its sibling in
helpers.py already matches on the stripped title; now the two agree.
Every recorded action captured document.title raw into events.jsonl, and
video.py copies it into recording-summary.json — which the make-video
skill hands to an agent. The video's own tab labels never carried it; this
JSON was the last route the prefix took to a reader it misleads.
The marker was documented nowhere an agent reads, and it now shows up in
one place only: js("document.title") — the example in js()'s own docstring
— disagrees with page_info()["title"]. Say so in Gotchas.
These files told the next agent that page_info().title starts with the
harness's marker, or quoted a site's title with the prefix baked in. The
read APIs no longer return it, so those notes now mislead.
@browser-harness-review

Copy link
Copy Markdown

✅ Skill review passed

Reviewed 1 file(s) — no findings.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 10 files

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread src/browser_harness/daemon.py
Comment thread src/browser_harness/daemon.py
Browser.getVersion can fail or answer without a user agent. Leaving
self.headless false then let a headless session write the marker into
the titles the agent reads — the bug this branch closes. The marker is
cosmetic, so the safe side is to lose it on a headed tab.
Detection rests on a token in the user agent, over which this project has
no control. The caller who launched the browser can state the truth.
The mirror of BH_TAB_MARKER=0, for a browser that reports HeadlessChrome
while a human watches it. Document both in SKILL.md, and stop a stray
BH_TAB_MARKER in a developer's shell from deciding for the tests.
@misterbridge

Copy link
Copy Markdown
Author

Both points are addressed in the three commits just pushed. Thanks — the first one was a real hole.

Fail closed (daemon.py:376): agreed, and fixed. When Browser.getVersion raises, or answers without a user agent, the session is now treated as headless and does not mark. The marker is cosmetic; the titles it pollutes are not, so an undetermined mode costs a horse rather than a leak.

On the user-agent token (daemon.py:378): the factual claim does not hold on current Chrome. Measured today on Chrome 151.0.7922.138 / macOS, launched --headless=new, read both over HTTP /json/version and over CDP Browser.getVersion:

Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36

End to end, the skip does fire: in a headless session the raw in-page document.title reads T-42, never marked at all rather than stripped on the way out.

That said, the underlying concern is fair — this rests on one string the project does not control. I looked for a better signal and there is none:

Signal Verdict
Browser.getBrowserCommandLine Command line not returned because --enable-automation not set — the harness attaches to browsers it did not launch
SystemInfo.getInfo browser-target only, errors here
screen.width/height 1920×1080 headed vs 800×600 headless, but a headless run can be sized to anything
navigator.webdriver, colorDepth, plugins.length, navigator.connection, userAgentData.brands identical in both modes

So the detection stays, and BH_TAB_MARKER=0 / =1 now settles it explicitly when the caller knows better than the browser does — same shape as the existing BH_DOMAIN_SKILLS / BH_RECORD switches. Verified on a real headless browser: unset and 0 leave document.title untouched, 1 marks it while page_info() still returns the page's own title.

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