Repository navigation
v1.8.0
Two passes since 1.7.0.
The first was a security scan-and-review of the controller, client and
deployment files. The automated scanners (bandit, semgrep, pip-audit, npm
audit, shellcheck) turned up nothing that survived triage. Everything under
Security came from reading the code and was confirmed with a reproduction
before it was fixed.
The second drove the controller the way an agent does, over MCP against a real
Chromium. It measured what each call costs a model and fixed what got in the
way. Sizes below are measured, and each fix was checked live.
If you run the controller directly without a token and reach it by a
non-loopback name, read the Host-header entry before upgrading — that
configuration now answers 400 until you list the name in
CONTROLLER_ALLOWED_HOSTS.
If you integrate over MCP or use CLI-authenticated providers, read
Changed. Four defaults changed:
- MCP results are compact (
detail="full"returns the old payload). - The default tool profile lists 20 tools (
MCP_TOOL_PROFILE=fullfor the
rest). browser.get_htmlreturns at most 20,000 characters per call (page with
offset).- The controller image leaves out the provider CLIs unless built with
INSTALL_AGENT_CLIS=true.
Two smaller upgrade notes:
./data/browser-profileand./data/downloadsbecome owned by uid 10001 on
the host, because the browser no longer runs as root./share/{token}/observereturns only what the share viewer renders.
Security
-
The navigation allowlist judged a different host than the browser loaded.
ALLOWED_HOSTSchecks parsed URLs with Python's urllib, but Chromium parses
them per the WHATWG URL standard, and for http(s) the two disagree on
backslashes. Forhttp://evil.com\@example.com/urllib reports
example.com(everything before the last@is userinfo to it) while
Chromium loadsevil.comwith path/@example.com/. Appending
\@<allowlisted-host>to any URL got it past the allowlist on navigate,
create_session'sstart_urland tab open — cloud metadata addresses
included. Checks now parse the URL in the form both parsers agree on
(app/url_safety.py). A differential fuzz of 31,174 generated URLs against
Chromium's ownnew URL()found 28 such bypasses before the change and none
after. -
Opening a tab skipped the navigation allowlist entirely.
POST /sessions/{id}/tabs/opencalledpage.goto()directly, so it reached
any http(s) host thatnavigaterefuses. The URL is now checked before a page
is created, and a refusal is a403. -
A tokenless controller accepted any Host header — DNS rebinding. A
controller without a bearer credential is permitted only on loopback, but
nothing checked the Host header unlessCONTROLLER_ALLOWED_HOSTSwas set.
Compose sets it; running the controller directly left it empty, and empty
meant any Host. A web page whose hostname re-resolves to127.0.0.1then
becomes a same-origin client of an API that asks for no credential, and the
MCP Origin check does not stop it because it accepts the origin the Host
header claims. Reproduced on a live server: a request with Host and Origin set
toattacker.examplecompleted an MCPinitializeand listed
/auth-profiles. WithCONTROLLER_ALLOWED_HOSTSunset and no credential
configured, only loopback Host names are now answered (/healthzexempt);
deployments with a token are unaffected.CONTROLLER_ALLOWED_HOSTSmatching also moved off Starlette's
TrustedHostMiddleware, which took everything before the first colon as the
host —[::1]:8000became[— so the::1entry shipped in compose and
.env.examplenever matched and IPv6 loopback clients were refused. -
Browser downloads could run script as the controller.
/artifacts
serves files the browser produced, including downloads whose name and bytes a
visited site chooses, from the controller's own origin. A downloaded.html
or.svgtherefore ran as the controller; on a tokenless loopback
deployment, opening one let it call every route, auth-profile export
included. Reproduced in Chromium: the downloaded page's
fetch('/auth-profiles')succeeded./artifactsresponses now carry a
sandboxContent-Security-Policy (opaque origin, no script; images still
render), and every response getsX-Content-Type-Options: nosniff,
X-Frame-Options: DENYandReferrer-Policy: no-referrer. -
Record ids from MCP arguments could name files outside their store. The
session, agent-job and approval file stores and the witness recorder built
paths asroot / f"{id}.json". REST path parameters cannot carry/, but MCP
tool arguments can, so ids like../plantedread a file next to the store
root and returned it as the store's own. Ids must now be one plain name; any
other id is simply not found. -
A mesh grant's
url_allowlistwas bypassable two ways. Only top-level
url/start_urlarguments were checked, sobrowser.execute_action— whose
target sits ataction.url— was never checked; and patterns were matched
withfnmatchover the whole URL, where*also matches/,?,#and
@, sohttps://*.example.com/*admittedhttps://evil.com/.example.com/.
URL arguments are now collected at any depth, and patterns match
component-wise. Behaviour change: a pattern without a scheme (such as
*.example.com) now matches the host only, and a URL with userinfo never
matches. Mesh is opt-in (MESH_ENABLED). -
Chromium and the display stack no longer run as root. Chromium runs
without its own sandbox in the container (Docker's default seccomp profile
leaves it no user namespace to build one in), so root inside the container
was the whole of what a renderer exploit started with. Chromium, Xvfb,
x11vnc and noVNC now run as the unprivilegedbrowseruser (uid 10001); the
entrypoint uses root only to take ownership of the mounted data directories.
Upgrading:./data/browser-profileand./data/downloadsbecome owned
by uid 10001 on the host. -
Optional VNC authentication. noVNC (6080) and raw VNC (5900) had no
authentication at all, which is safe only while both stay on loopback.
VNC_PASSWORDnow enables VNC authentication on the shared browser-node and
on isolated session containers, and noVNC prompts for it. It is a second
layer, not a gateway: the VNC protocol uses only the first 8 characters. -
Observations named form fields by what was typed into them. The
interactables, active-element and form-outline scripts named a field by
aria-label, placeholder, text and then its value. A login form's
fields readyou@example.com,pwandoninstead of Email, Password and
Remember me. A password typed into a field without a placeholder became that
field's name in every later observation, reaching model prompts, MCP clients
and logs, even though the type action had redacted it. Fields are now named
the way a person reads them:aria-labelledby,aria-label, their
<label>, button captions and imagealt. A field's value is never its
name. The same holds for rich-text editors (contenteditable) and
textbox-like ARIA widgets, whose text content is what was typed into them.
Interactables also report ARIA roles (textbox, checkbox, combobox, …) and
checked state. The three scripts share one naming helper, checked under Node
and in a real Chromium. -
Approving a governed tool call approved any arguments. The stand-in
decision an approval is matched against carried only the tool name, and
matching ignores the reason. An operator approvingbrowser.eval_js
approved code they never saw, and the approval covered whatever expression
came next. The canonical arguments are now hashed into the decision, so an
approval covers exactly one call. The operator reads a preview of the
arguments, with cookie and storage values and other credential fields
redacted.
Fixed
-
An omitted
session_idcould target a closed session. Resolution
counted persisted records of closed and interrupted sessions, so after one
close it picked the dead session or reported the call "ambiguous". With
MAX_SESSIONS=1, every create → close → create cycle broke the
convenience. Only live sessions are candidates now. -
An unusable session id was its own error message. A lookup raised
KeyError(session_id), which MCP surfaced as{"error": "ec7fba8336ac"}.
The error now says why: the session is closed, interrupted (its browser is
gone), recorded as active but not running on this controller (with a shared
session store, another instance may own it), or unknown. It carries a code
(session_closed,session_interrupted,session_not_live,
unknown_session) over MCP and REST. It is still aKeyError, so existing
404 handling is unchanged. -
browser.eval_jscould only fail. It required
workflow_profile=governed, a parameter its schema never advertised. Every
call now goes through approval: the first returnsapproval_requiredwith an
approval_id, and the retry carries it. -
The accessibility outline has been empty since Playwright removed
page.accessibility. Every observation reportedavailable: false. The
documented accessibility tree was missing from every preset, the OCR skip for
pages with extracted text never applied, and theaccessibility_focus_changed
verification signal could not fire. The unit tests faked the removed API, so
the bump passed.
The outline is rebuilt frompage.aria_snapshot(). That snapshot includes
what each field holds (a filled password field reads
textbox "Secret": hunter2), and the old outline copied avaluefor every
node, so the new one keeps roles, accessible names and states only. A large
page's snapshot (3 MB for 20,000 links) is parsed off the event loop. A test
now asserts that the pinned Playwright has the API. -
scripts/fixture_live.pyhas not worked since the DNS-rebinding guard.
Its TestClient sentHost: testserver, which a tokenless controller now
refuses unlessCONTROLLER_ALLOWED_HOSTSlists it. The script reported the
resulting 400 as "SKIP: live browser stack unavailable". It now uses a
loopback base URL, and a 4xx from the controller is a failure rather than a
skip. All six fixtures pass live. -
Policy refusals reached MCP agents as "Tool execution failed". Some
refusals raisePermissionError: a host outsideALLOWED_HOSTS, a retry
with an approval that is not granted yet, a path outside its root. The
gateway did not surface those, so a blocked navigation looked like a crash.
They now return their reason with codenot_permitted. Lookups that miss
return their reason with codenot_found: a mistypedauth_profilein
create_sessionnow says "No saved auth profile 'shop'", and an upload
names the file missing from the upload directory. Before, both said "Tool
execution failed". Those messages name what the caller asked for instead of
the resolved path under a data root. An upload throughexecute_actionnow
checks the file before asking for approval, so an operator is no longer asked
to approve an upload that could only fail. OS-level permission and file errors,
which can name server paths, stay opaque. The browser's own errors come back
with their reason too, codetimeoutorbrowser_error: a
wait_for_selectortimeout, an invalid selector, or an exception thrown by
an approvedeval_jsexpression. Playwright's call log and wrapper stack
frames are trimmed. Theapproval_required
payload also gains a top-levelapproval_id, which the tool descriptions
tell callers to send back. -
Page text lost its structure. The observation's
text_excerptsquashed
every run of whitespace, so a table's cells, a list's items and separate
paragraphs ran together into one line. Line breaks and innerText's tab
between table cells are kept now. -
Share links did not work for the people they were handed to. With a
bearer token configured,/share/{token}answered401to anyone without
the API token, and the screenshot the viewer renders came from/artifacts,
which needs the token too. The signed share token is now the credential for
the/share/{token}/...routes. Because those routes are unauthenticated,
they return only what the viewer renders. Behaviour change:
/share/{token}/observereturns the session id, URL, title and a screenshot
URL instead of the full observation, and screenshots are served through
/share/{token}/screenshots/{name}for that session only. A non-ASCII token
also produced a500; it is a403. -
Cron webhook triggers were unusable over the API.
POST /crons
generated awebhook_keyand then masked it in the response, so the key the
trigger endpoint demands existed only in the store file. It is now returned
once, on creation. The trigger route also answered a non-object JSON body with
a500, let a bodyjob_idoverride the job in the path, and turned a
non-ASCII key into a500instead of a403. -
A failed auth-profile import with
overwrite=truedestroyed the existing
login. The old profile was deleted before extraction began, so an archive
that failed part-way left a partial profile in its place. Imports now extract
into a staging directory and swap it in only once every member is written. -
The Glama inspection image has not started since 1.7.0. Its entrypoint
binds uvicorn with--host 127.0.0.1, which the controller cannot see, so it
assumed the fail-safeexposedscope and refused to start without a token.
The entrypoint now declaresAPI_BIND_SCOPE=loopback. -
CDP element intelligence embedded the caller's selector in JavaScript with
Python'srepr(), which mis-encodes some characters (no string break-out was
found by fuzzing, but the in-page query could target a different selector).
It now uses a real JSON/JS string literal. -
The Python client's
stream_eventsdisabled the connect timeout along with
the read timeout, so an unreachable controller hung it forever.
Added
-
Screenshots as MCP image content.
browser.screenshotand observe's
fastpreset return the screenshot as an image block after the JSON text.
Before, they returned only a path on the controller's disk and a URL on the
controller, and a model behind an MCP client can open neither. The file is
read back only from insideARTIFACT_ROOT, up to 4 MiB. -
browser.read_download. Reads a captured download as text, paged: the
latest one, or one bydownload_id. Files are read only from the session's
own downloads directory. Binaries (PDF, XLSX, images) are refused with their
artifact URL. UTF-8 and BOM-marked UTF-16 decode exactly, and other text is
flaggedlossy. As with page text from observe andget_html, contents are
not PII-scrubbed. -
A pending-approvals queue on the dashboard. It shows the action and
reason, with Approve and Reject buttons. Before, approvals appeared only
inside a finished job's replay. Reasons are rendered as text, a sensitive
action's typed text is never shown, and a governed tool call reads as the
tool rather than as its stand-in digest.
Changed
-
MCP results are compact. Every session reference in a tool result was
the full session record, about 1.9k characters. An action result also
carried the page before and after the action, each with its own copy of
that record.- Nested session summaries are now references (id, name, status, live,
current URL, title, takeover URL), andbrowser.list_sessionsreturns
references too. execute_actiondrops the pre-action snapshot, which itsverification
already summarises.- observe drops per-observation remote-access diagnostics.
- Interactables drop keys that are null for that element.
Measured on a login form at the end of this pass, counting both result
copies. The "after" figures include the restored accessibility outline,
about 600 characters of real content that the "before" figures lacked.execute_action: 19.6k → 9.4k characters- observe: 12.9k → 8.6k
- screenshot text: 4.8k → 1.3k
list_sessions: 13k for 6 sessions → 1.6k for 7
A compact result is a subset of the full one.
detail="full"on observe,
execute_actionandexecute_approvalreturns the full payload.
browser.get_session,browser.create_sessionand REST responses are
unchanged. The built-in providers' prompts use the same session reference. - Nested session summaries are now references (id, name, status, live,
-
The curated MCP profile lists the 20 tools a browsing agent needs. It
listed 37 tools, about 41k characters oftools/listcarried on every
request. Harness inspection, Witness verification and export, the readiness
check, memory profiles, traces, the network log, the console, page-error and
request-failure tails (observe carries the last ten), auth-profile
inspection, drag-and-drop and viewport sizing are now in thefullprofile.
The curated list is 25.0k characters. Calling a full-profile tool on a
curated controller namesMCP_TOOL_PROFILE=fullinstead of answering
"Unknown tool"./mcp/tools/calland mesh delegation serve tools through the
same profile, so a peer delegating one of the moved tools needs the full
profile too. -
The MCP server's instructions describe how the tools fit together.
Clients put these in the model's context on connect, and they were one
generic sentence. They now cover the observe → execute_action loop, reading
withget_html(text_only=true),find_elementsandread_download, the
approval_required→approval_idretry, and human takeover. A test keeps
every tool they name in the curated profile. -
browser.get_htmlis bounded and paged. It returned the whole
serialized DOM or page text in one result, routinely megabytes. It now
returns up tomax_chars(default 20,000, at most 1,000,000) fromoffset,
withtotal_chars,truncatedandnext_offset. Withtext_only=trueit
uses the same line-preserving text as the observation. Its deprecated
full_pageargument, which never did anything, is still accepted but no
longer advertised in the schema. -
The controller image leaves out the provider CLIs by default. Node.js
and the codex, claude and gemini CLIs (about 750 MB) are needed only for
*_AUTH_MODE=cli. Build withINSTALL_AGENT_CLIS=true(read from.envby
compose) to include them.docker-compose.host-subscriptions.ymlsets it,
andbootstrap_cli_auth.shstops with instructions when it is missing. A
missing CLI binary now names the setting in the production startup error and
inGET /agent/providers. -
Host-side developer scripts require Python 3.11, the floor every package
declares. They accepted 3.10. -
The unserved legacy dashboard page,
controller/app/ui/index.html, is
removed./uistill redirects to/dashboard. -
make testandmake test-localrun pytest, as CI does. They ran
unittest discover -s tests, which imports test modules outside thetests
package, sotests/__init__.py(the suite's environment defaults) never ran
and about 145 HTTP tests failed. It also skipped every pytest-style test,
roughly a third of the suite.make test-localnow runs all 1,091 tests. -
The three PyPI packages declare their license as the SPDX expression
MIT
instead of the deprecatedlicense = { text = "MIT" }table, which
setuptools stops building after 2027-02-18. Building them from source needs
setuptools 77 or later (auto-browser-client,auto-browser-mcp) or
hatchling 1.27 or later (auto-browser-langchain). pip's isolated builds
fetch these automatically. -
Controller dependencies: starlette 1.3.1 → 1.6.0, uvicorn 0.52.0 → 0.52.4,
pydantic-settings 2.14.2 → 2.15.0, cryptography 50.0.0 → 50.0.1, and ruff
0.16.1 → 0.16.5 for development. Starlette 1.5.1 limits aFileResponseto
100 byte ranges and rejects inverted ranges, and/artifactsis served
through it.
Documentation
-
docs/llm-adapters.mdnow covers the OpenAI-compatible provider family,
shows how to pointopenai_compatibleat any gateway or self-hosted server,
and states when a provider gets a named profile. -
docs/mcp-clients.mdshows direct HTTP setup for Claude Code, Cursor and
VS Code, with bearer and operator headers. It also covers pairing Auto
Browser with a web-search MCP server, including theALLOWED_HOSTSchange
that search results need. -
The README's release highlights were for v1.5.0 and sat above the
quickstart. A short "Recent Changes" section now follows the first demo.
ROADMAP is current again. -
AGENTS.mdgives coding agents the repo's commands and the invariants CI
enforces.