Skip to content

v1.8.0

Choose a tag to compare

@github-actions github-actions released this 25 Sep 17:46
· 395 commits to main since this release
5aa8340

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=full for the
    rest).
  • browser.get_html returns 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-profile and ./data/downloads become owned by uid 10001 on
    the host, because the browser no longer runs as root.
  • /share/{token}/observe returns only what the share viewer renders.

Security

  • The navigation allowlist judged a different host than the browser loaded.
    ALLOWED_HOSTS checks parsed URLs with Python's urllib, but Chromium parses
    them per the WHATWG URL standard, and for http(s) the two disagree on
    backslashes. For http://evil.com\@example.com/ urllib reports
    example.com (everything before the last @ is userinfo to it) while
    Chromium loads evil.com with path /@example.com/. Appending
    \@<allowlisted-host> to any URL got it past the allowlist on navigate,
    create_session's start_url and 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 own new URL() found 28 such bypasses before the change and none
    after.

  • Opening a tab skipped the navigation allowlist entirely.
    POST /sessions/{id}/tabs/open called page.goto() directly, so it reached
    any http(s) host that navigate refuses. The URL is now checked before a page
    is created, and a refusal is a 403.

  • 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 unless CONTROLLER_ALLOWED_HOSTS was set.
    Compose sets it; running the controller directly left it empty, and empty
    meant any Host. A web page whose hostname re-resolves to 127.0.0.1 then
    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
    to attacker.example completed an MCP initialize and listed
    /auth-profiles. With CONTROLLER_ALLOWED_HOSTS unset and no credential
    configured, only loopback Host names are now answered (/healthz exempt);
    deployments with a token are unaffected.

    CONTROLLER_ALLOWED_HOSTS matching also moved off Starlette's
    TrustedHostMiddleware, which took everything before the first colon as the
    host — [::1]:8000 became [ — so the ::1 entry shipped in compose and
    .env.example never 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 .svg therefore 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. /artifacts responses now carry a
    sandbox Content-Security-Policy (opaque origin, no script; images still
    render), and every response gets X-Content-Type-Options: nosniff,
    X-Frame-Options: DENY and Referrer-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 as root / f"{id}.json". REST path parameters cannot carry /, but MCP
    tool arguments can, so ids like ../planted read 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_allowlist was bypassable two ways. Only top-level
    url/start_url arguments were checked, so browser.execute_action — whose
    target sits at action.url — was never checked; and patterns were matched
    with fnmatch over the whole URL, where * also matches /, ?, # and
    @, so https://*.example.com/* admitted https://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 unprivileged browser user (uid 10001); the
    entrypoint uses root only to take ownership of the mounted data directories.
    Upgrading: ./data/browser-profile and ./data/downloads become 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_PASSWORD now 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 read you@example.com, pw and on instead 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 image alt. 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 approving browser.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_id could 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 a KeyError, so existing
    404 handling is unchanged.

  • browser.eval_js could only fail. It required
    workflow_profile=governed, a parameter its schema never advertised. Every
    call now goes through approval: the first returns approval_required with an
    approval_id, and the retry carries it.

  • The accessibility outline has been empty since Playwright removed
    page.accessibility.
    Every observation reported available: false. The
    documented accessibility tree was missing from every preset, the OCR skip for
    pages with extracted text never applied, and the accessibility_focus_changed
    verification signal could not fire. The unit tests faked the removed API, so
    the bump passed.
    The outline is rebuilt from page.aria_snapshot(). That snapshot includes
    what each field holds (a filled password field reads
    textbox "Secret": hunter2), and the old outline copied a value for 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.py has not worked since the DNS-rebinding guard.
    Its TestClient sent Host: testserver, which a tokenless controller now
    refuses unless CONTROLLER_ALLOWED_HOSTS lists 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 raise PermissionError: a host outside ALLOWED_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 code not_permitted. Lookups that miss
    return their reason with code not_found: a mistyped auth_profile in
    create_session now 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 through execute_action now
    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, code timeout or browser_error: a
    wait_for_selector timeout, an invalid selector, or an exception thrown by
    an approved eval_js expression. Playwright's call log and wrapper stack
    frames are trimmed. The approval_required
    payload also gains a top-level approval_id, which the tool descriptions
    tell callers to send back.

  • Page text lost its structure. The observation's text_excerpt squashed
    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} answered 401 to 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}/observe returns 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 a 500; it is a 403.

  • Cron webhook triggers were unusable over the API. POST /crons
    generated a webhook_key and 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
    a 500, let a body job_id override the job in the path, and turned a
    non-ASCII key into a 500 instead of a 403.

  • A failed auth-profile import with overwrite=true destroyed 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-safe exposed scope and refused to start without a token.
    The entrypoint now declares API_BIND_SCOPE=loopback.

  • CDP element intelligence embedded the caller's selector in JavaScript with
    Python's repr(), 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_events disabled the connect timeout along with
    the read timeout, so an unreachable controller hung it forever.

Added

  • Screenshots as MCP image content. browser.screenshot and observe's
    fast preset 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 inside ARTIFACT_ROOT, up to 4 MiB.

  • browser.read_download. Reads a captured download as text, paged: the
    latest one, or one by download_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
    flagged lossy. As with page text from observe and get_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), and browser.list_sessions returns
      references too.
    • execute_action drops the pre-action snapshot, which its verification
      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_action and execute_approval returns the full payload.
    browser.get_session, browser.create_session and REST responses are
    unchanged. The built-in providers' prompts use the same session reference.

  • The curated MCP profile lists the 20 tools a browsing agent needs. It
    listed 37 tools, about 41k characters of tools/list carried 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 the full profile.
    The curated list is 25.0k characters. Calling a full-profile tool on a
    curated controller names MCP_TOOL_PROFILE=full instead of answering
    "Unknown tool". /mcp/tools/call and 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
    with get_html(text_only=true), find_elements and read_download, the
    approval_required → approval_id retry, and human takeover. A test keeps
    every tool they name in the curated profile.

  • browser.get_html is bounded and paged. It returned the whole
    serialized DOM or page text in one result, routinely megabytes. It now
    returns up to max_chars (default 20,000, at most 1,000,000) from offset,
    with total_chars, truncated and next_offset. With text_only=true it
    uses the same line-preserving text as the observation. Its deprecated
    full_page argument, 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 with INSTALL_AGENT_CLIS=true (read from .env by
    compose) to include them. docker-compose.host-subscriptions.yml sets it,
    and bootstrap_cli_auth.sh stops with instructions when it is missing. A
    missing CLI binary now names the setting in the production startup error and
    in GET /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. /ui still redirects to /dashboard.

  • make test and make test-local run pytest, as CI does. They ran
    unittest discover -s tests, which imports test modules outside the tests
    package, so tests/__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-local now runs all 1,091 tests.

  • The three PyPI packages declare their license as the SPDX expression MIT
    instead of the deprecated license = { 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 a FileResponse to
    100 byte ranges and rejects inverted ranges, and /artifacts is served
    through it.

Documentation

  • docs/llm-adapters.md now covers the OpenAI-compatible provider family,
    shows how to point openai_compatible at any gateway or self-hosted server,
    and states when a provider gets a named profile.

  • docs/mcp-clients.md shows 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 the ALLOWED_HOSTS change
    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.md gives coding agents the repo's commands and the invariants CI
    enforces.