Skip to content

v1.10.0

Choose a tag to compare

@github-actions github-actions released this 20 Oct 01:42
· 29 commits to main since this release
3b5c186

A security update and correctness pass over 1.9.1: every finding was reproduced before it was
fixed. The client, LangChain and MCP bridge packages change too: see the client SDK, LangChain
and MCP bridge entries.

If you verify witness chains, restrict ALLOWED_HOSTS or use workflow_profile=governed, read
the upgrade notes first.

Upgrade notes:

  • Witness valid now covers the signatures. GET /sessions/{id}/witness/verify and
    browser.verify_witness pass valid only when the hash walk, the anchor and the signatures
    all pass; the hash walk and anchor alone are now chain_valid. A session recorded entirely
    before signing, or by a controller without its signing key, reports valid: false.
  • ALLOWED_HOSTS is enforced where a page ends up. A start URL that redirects off the list,
    a switch to a tab on a refused host, a cookie for another domain, and a page that later
    navigates itself off the list are now refused or sent back to about:blank.
  • Under workflow_profile=governed, browser.delete_memory_profile and browser.cdp_attach
    ask for approval
    , held under session_id: "controller". browser.cdp_attach is also
    refused when more than one operator credential is configured, and while live sessions still
    use the current browser.
  • REQUEST_RATE_LIMIT_EXEMPT_PATHS entries match one path exactly (/artifacts stays a
    tree). If you exempted a sub-path by listing its parent, list the sub-path itself.
  • STEALTH_ENABLED=true now applies its patches, so sessions with it on present differently
    to sites.
  • REST errors carry their real status. Routes that answered 500 for an unknown session, a
    timeout or a missing element now answer 404, 403, 400 or the action error's own status, with
    its code.
  • If you run scripts/codex_host_bridge.py on the host, update it. An older bridge answers
    400 to a text-only observation, and the new one caps request bodies and read time.
  • The client SDK raises ValueError for the ids "", . and .., and the LangChain
    AutoBrowserNode raises AutoBrowserToolError when a tool call fails.
  • Removed: download_url from the auth profile export response, and
    app.browser.dom_pruner.

Security

Many of these matter only if you run the controller with named credentials
(API_BEARER_TOKENS); each entry says who it affects. The ALLOWED_HOSTS entries apply to every
deployment that restricts it.

  • Saved session auth state is owned by the session's operator. storage_state_path is
    relative to the auth root, so another operator could name <session id>/state.json and open a
    browser logged in as the session's owner. A session that has an owner now leaves an
    .owner.json marker beside the state it saves (including forks), and a path into that
    directory is allowed only to that operator, even after the session closed or the controller
    restarted. State saved by an earlier release has no marker; it falls back to the owner on the
    session record, and stays open if there is none. An unreadable marker refuses.
  • Shadow-browse sessions belong to the session they were made from. They were created with no
    owner, so any operator could list and drive the copy of another operator's logged-in browser.
    There is still no session-limit check, so MAX_SESSIONS=1 setups can use shadow browsing.
  • Auth profile export leaves out the plaintext state.json when an encrypted
    state.json.enc exists.
    A plaintext copy saved before the encryption key was set used to
    ride along in the archive. This applies to every deployment. Profiles with only a plaintext
    state still export it.
  • An exported auth archive can be imported only by the operator who exported it. Export
    writes <archive>.exporter.json next to the archive; import refuses anyone else. Archives
    without it (made without named credentials, by an earlier release, or copied in from another
    box) import as before. The importer never becomes the owner: a profile keeps the owner
    recorded in its archive.
  • browser.cdp_attach is refused when more than one operator credential is configured. It
    replaced the one browser every session runs on, so under API_BEARER_TOKENS any operator could
    attach a Chrome they control and every session any operator created afterwards ran on it, open
    to their own CDP connection. Run a controller per operator, or set CDP_CONNECT_URL at startup.
    With a single credential (or none) nothing changes except that attach is now refused while live
    sessions still use the current browser (close them first), and the browser it replaces is
    disconnected instead of leaking.
  • Closing a session no longer closes the shared browser after a swap. The check compared the
    session's browser with manager.browser, which cdp_attach replaces; sessions now record whether
    their browser is their own (isolated runtime, shadow-browse window) and only those are closed.
  • Harness runs and staged skill candidates belong to the operator who started them. Any
    operator could list every run, read its trace (traces of live sessions carry page content),
    graduate someone else's candidate, and read or drift-check their staged skills. A run now
    records the verified operator, and its candidate inherits that owner, which is signed into the
    envelope so stripping it is caught. Other operators get a permission error and don't see these
    in lists. An owned candidate's id now also depends on its owner, so two operators staging the
    same contract no longer overwrite each other. Runs and candidates from earlier releases, or
    made without named credentials, stay open.
  • An unowned cron job fires as nobody. Triggering one by hand or by webhook ran it as the
    triggering operator, and the session and agent job it created became theirs. It now fires as
    the anonymous identity, the same as a scheduled fire. Owned jobs still fire as their owner.
  • Memory profiles belong to the operator who first saved them. Another operator could list,
    read, append to and delete a profile, and load it into their own session. Profile text goes
    into the agent's goal prompt, so that was a prompt-injection path between operators. Those
    calls are now refused for another operator's profile, which is also left out of lists, and the
    name-collision error no longer reveals other operators' profile names. Profiles saved without
    named credentials, or before this release, stay open, and appending to one doesn't claim it.
  • MCP resources/list names only sessions you may use. It listed every operator's session ids
    and their screenshot, dom, console and network URIs; resources/subscribe accepted them, and the
    update stream notified the subscriber whenever those sessions were active. Listing, subscribing
    and notifications now cover only sessions you may use. Unowned sessions are still listed for
    everyone, and reading another operator's session content was already refused.
  • Uploads can no longer be taken from another operator's session. A session's staged files
    live in UPLOAD_ROOT/<session id>/, and the upload path check also allowed the shared
    UPLOAD_ROOT, so another operator could upload <session id>/file (or the absolute path) into a
    page of their own. A file in a session's directory can now be uploaded only by the operator who
    owns that session, also after it closed; files placed directly in UPLOAD_ROOT stay shared.
    Deployments without named credentials have no owners and see no change.
  • GET /proxy-personas/{name} no longer returns the proxy password. It returned the stored
    persona, plaintext password included, to any caller with an API token; it now answers the same
    shape as the list (has_password). Session creation with proxy_persona is unchanged. This
    applies to every deployment.
  • Session creation checks where the start URL ended up. It checked the URL it was given but
    not the page it landed on, so an allowed start URL that redirected to a host outside
    ALLOWED_HOSTS left a live session there. It now refuses and rolls the session back.
  • Switching to a tab on a disallowed host is refused. activate_tab made any open tab the
    session's page, including a popup a page had opened onto a host the list refuses. It now
    refuses before the active page changes (REST: 403).
  • A blocked action that can't be rolled back no longer leaves the session, or its error, on the
    disallowed page.
    After location.replace(), or in a popup's first page, there is no history
    entry, so going back did nothing: the session stayed on the refused page and the 403 carried
    its text and a screenshot. The session now moves to about:blank, and the error reports only
    the URL.
  • browser.set_cookies is held to ALLOWED_HOSTS. It added cookies for any domain, so a
    caller confined to one site could plant a cookie on another. Each cookie's url or domain is
    checked first, and one disallowed cookie refuses the whole call.
  • ALLOWED_HOSTS holds after an action, not only at it. A page that navigated itself later
    (a timer, a late redirect, a popup's own script) stayed on the disallowed host, and observe
    returned its title and text. Every page the session owns now has a listener that sends a
    top-level page that lands off the list back to about:blank and records a navigation_blocked
    audit event. Until it has, observe, screenshots, action snapshots, the page-reading MCP tools
    and the DOM resource refuse (REST: 403), actions other than navigate and go_back are
    refused, and summaries leave the page title empty. The list governs the top-level page only:
    iframes and subresources from other hosts are not filtered, and raw CDP passthrough is
    unchanged.
  • Witness verification no longer accepts a chain with every signature stripped.
    GET /sessions/{id}/witness/verify and browser.verify_witness rejected an unsigned receipt
    after a signed one, but a chain with no signed receipt at all reported signatures.valid: true.
    Anyone who could write the witness directory could edit receipts, drop every signature,
    recompute the hashes and the anchor, and pass. Such a chain now fails with the reason
    scripts/verify_witness_bundle.py already gave. Unsigned receipts before the first signed one
    still pass. The top-level valid now passes only when the hash walk, the anchor and the
    signatures all do, so a client that reads that one field can't accept a stripped chain. The
    hash walk and anchor result alone moved to chain_valid. A session recorded entirely before
    signing existed, or by a controller that couldn't load its signing key, now reports
    valid: false with chain_valid: true, which is accurate. The new section in
    docs/deployment.md says what a signature can prove while the key sits beside the receipts,
    and how to pin a head hash for evidence that survives a compromised host.
  • Governed tools that name no session ask for approval. browser.delete_memory_profile and
    browser.cdp_attach ran unapproved even under workflow_profile=governed, because the approval
    needed a session to belong to. They now return approval_required like every other governed
    tool, and the approval is held in a controller-wide scope (session_id: "controller"). With
    named credentials it belongs to the operator who asked: another operator cannot list, decide,
    reuse or spend it. Approvals now record that operator in a new owner field; records written
    before it load as unowned. Agents that use governed see the new approval step on those tools.
  • PII scrubbing removes a whole private key. The pem_header pattern replaced only the
    -----BEGIN ... PRIVATE KEY----- line and left the key itself and the END line in scrubbed
    text, network bodies and console messages. It now removes the block from BEGIN to the matching
    END for every private-key label (PKCS#8, RSA, EC, DSA, OpenSSH, encrypted, PGP), with LF or CRLF
    line ends, and everything after a BEGIN that has no END. Public keys are no longer touched:
    they are not secret, and redacting their header only corrupted them. The pattern keeps its name,
    so PII_SCRUB_PATTERNS lists that name it still work.
  • Request bodies without a text content type are scrubbed. A fetch with a string body (no
    content type), application/yaml and unrecognized vendor types went into the network inspector
    verbatim. A body is now scrubbed whenever it is text: its type names text, or it has no
    recognized type and decodes as UTF-8. Images, audio, video, fonts, common binary types and
    non-UTF-8 bytes are left as they were. An application/html response is now scrubbed too.
  • Screenshot scrubbing without tesseract is no longer silent. Screenshot redaction draws over
    OCR-detected words, and tesseract ships only in the Docker image, so outside Docker every
    screenshot went out unredacted with no sign of it. GET /readiness now has a
    screenshot_scrubbing check that warns when tesseract is missing or OCR_ENABLED=false, the
    controller logs one warning, and observe, screenshot and action-snapshot results carry
    screenshot_pii_scrub: {"applied": false, "reason": ...}. The new section in
    docs/deployment.md lists what scrubbing covers and what it does not: page content, raw CDP
    (which can read back a typed password) and the console messages inside an observation.
  • Closed sessions keep their owner when Redis loses the record. With REDIS_URL set, the
    session store read only Redis, so an evicted key, a Redis restart without persistence or a
    dropped connection made a closed session look unowned, and another operator's ownership check
    let them in. It now falls back to the file copy, which is written first, and logs a Redis error
    when there is one.
  • The noVNC page can no longer be framed. The WebSocket origin guard stops a page from
    opening its own connection to the desktop, but a page the in-container browser visited could
    still embed http://127.0.0.1:6080/vnc.html, whose own WebSocket passes the guard. Every
    response noVNC serves now sends X-Frame-Options: DENY and
    Content-Security-Policy: frame-ancestors 'none'. docs/deployment.md now says what the guard
    and the headers each prevent. This applies to every deployment that exposes noVNC.
  • Client SDK ids can no longer retarget a request. The client put ids into URL paths as
    given, so get_session("../approvals/x/approve") reached another route. Every id in a path,
    the event stream included, is now percent-encoded, and "", . and .. raise ValueError.
    The client also gains close() and aclose() for an instance used outside with.
  • The Codex host bridge caps request bodies and times out stalled reads. If you run
    scripts/codex_host_bridge.py: a body over 32 MiB gets 413 without being read, a bad
    Content-Length gets 400, and a client that stalls for 30 s gets 408. Before, a 64 MB body was
    read and parsed, and one idle connection held a handler thread for good.

Fixed

  • The Playwright export script replays the session that ran. GET /sessions/{id}/export-script and browser.export_script produced page.goto('') and
    page.locator('').first.click(), in reverse order, starting at the page the session ended on:
    anyone who used the export got an unusable script. It now replays the steps oldest first from
    the session's own start URL, and says when the session had more than 5000 audit events
    ("truncated": true). Steps that targeted an element by element_id carry a comment, because
    that id exists only after observe() tags the live page. A start URL containing """ or a
    backslash escape such as \N{...} could stop the script compiling; it is now a one-line comment.
  • A closed session's replay script and trace are served. GET /sessions/{id}/export-script,
    GET /sessions/{id}/trace and browser.export_script answered 404 once a session closed, which
    is when trace.zip exists. They now read the stored session record; another operator's session
    and an unknown id still answer 404.
  • STEALTH_ENABLED=true runs the stealth patches. The init script was a bare arrow function,
    which add_init_script defined and never called, so no patch applied: operators who turned
    stealth on got none of it, and sessions with it on now present differently to sites. It also
    adds window.chrome.runtime when headless Chromium already defines window.chrome without it.
  • Stealth and ACTION_TIMEOUT_MS apply to every tab and popup. Both were set on a session's
    first page only, so tabs opened later and popups ran unpatched and waited Playwright's 30 s
    default. They are now set on the browser context, in session creation and in shadow browse.
  • The STEALTH_PROFILE canvas noise is applied once per read. The fingerprint script wrapped
    getImageData again on every getContext('2d') call, which returns the same context, so each
    call stacked another layer of noise, and enough calls overflowed the page's stack.
  • toDataURL leaves the canvas as it found it. With both stealth scripts on, each export read
    a pixel through the noisy getImageData and wrote it back, so the canvas drifted on every call.
    The base script now reads it with the native method.
  • REST errors keep their real status and code. On the session action routes, observe,
    screenshot compare, /approvals/{id}/execute and the auth-profile export and import, a
    catch-all ran before the app's error handlers: an unknown or closed session, a timeout or a
    missing element came back as 500 "Internal error", which hid the code agents act on. They now
    answer as themselves: 404 with the session message and code (unknown_session,
    session_closed), a browser action error's own status and payload, 403 for a refusal, 400 for
    a bad value and 404 for a missing file. Anything unexpected is still logged and answers 500.
    observe and wait on an unknown session now carry that message and code instead of a bare
    "Unknown session".
  • Agent steps work on a text-only observation. observe(preset="text") returns no
    screenshot, and PERCEPTION_PRESET_DEFAULT=text makes that the default, but the Claude, Gemini,
    OpenAI and OpenAI-compatible adapters (and the Codex CLI and host-bridge paths) read the
    screenshot unconditionally and crashed with KeyError or TypeError before sending anything.
    They now send a text-only request. If you run scripts/codex_host_bridge.py on the host,
    update it too: an older bridge answers 400 to a request without a screenshot.
  • The LangChain integration raises a clear error when a tool call fails. AutoBrowserNode
    raised JSONDecodeError or KeyError, or returned an empty state, when the controller reported
    isError: true. It now raises AutoBrowserToolError with the tool name, the error text, and
    code and retryable when the controller sent them, so a graph can tell a missing session from
    a bad selector. Building AutoBrowserTool without langchain-core now raises an ImportError
    naming pip install "auto-browser-langchain[langchain]" instead of "takes no arguments".
  • MCP tool results that are lists no longer break strict clients. Over the streamable MCP
    transport, a tool whose result is a list or a scalar (browser.list_sessions, for one) was
    sent with that value as structuredContent, which the MCP spec requires to be an object, so
    clients that validate the response rejected it. Such results now carry only the text block,
    which already held the same JSON. Thanks to @sean0407 (#192).
  • Network capture no longer buffers downloads. With body capture on, the inspector checks
    the content type and the declared length before reading a response, so a large binary or
    oversize body is no longer loaded into memory only to be dropped. A response with no declared
    length is still read and then size-checked.
  • A failed session create no longer leaves its network log behind. When a create failed
    after the session hook ran (an unreachable start URL, for one), the dead session's network
    inspector and CDP entry stayed, readable through /sessions/<id>/network/requests. They are
    now removed.
  • Isolated-session profiles, cookies included, are deleted on every exit path: when Docker
    has already lost the container, at startup for anything a crashed controller left (except the
    profile of a container that could not be removed), and when a container fails to start.
    ISOLATED_BROWSER_KEEP_CONTAINERS still keeps everything.
  • The Universal Verifier process stops when the caller gives up. A cancelled verification
    or a shutdown now kills and reaps the child instead of leaving it running.
  • The cleanup sweep no longer freezes the controller. The file walk runs in a worker
    thread, so sessions and requests keep responding during a sweep (it blocked for 13 s on 6,000
    files).
  • Session tunnels start in parallel. Concurrent provisions no longer wait out each other's
    startup grace period (four took 4 s, now about 1 s). A tunnel released while it is starting
    fails that provision cleanly, and a failed metadata write no longer leaks a log handle.
  • A pathological url_matches pattern can no longer hang the harness. Python's regex
    engine can't be interrupted, so matches now run in a short-lived child process with a 2 s
    limit. A postcondition that times out is not satisfied, a forbidden state that times out
    counts as hit, and both are listed in the result's details.regex_timeouts.
  • A bad Host header is refused first. It now gets the host check's 400 before the bearer
    token, operator id and rate limit are looked at, instead of a 401 or the missing-operator 400,
    and it no longer spends rate-limit budget. Every other answer is unchanged.
  • /healthz/deep is rate-limited. An entry in REQUEST_RATE_LIMIT_EXEMPT_PATHS exempted
    everything under it, so the deep health probe, which opens a browser context and renders a page
    on every call, was unlimited for a token holder. Entries now match one path exactly;
    /artifacts stays exempt as a tree, because the UI loads many files from it. If you exempted
    a sub-path by listing its parent, list the sub-path itself. /docs/oauth2-redirect is now
    counted.
  • An open share link no longer slows the session it shows. The share page polls every 5 s,
    and each poll ran a full observation (DOM, accessibility tree, OCR) under the session lock and
    kept the screenshot for good, so the agent waited on the viewer and the artifact directory
    filled with PNGs. A poll now takes one redacted frame plus the URL and title, and frames older
    than a minute are deleted.
  • Generated SKILL.md front matter is always valid YAML. The goal went into description
    unquoted, so Open the page: log in made the file unparseable and a goal with a line break
    could add keys of its own, such as status or safety_class, to a file that is then signed.
    It is now a quoted string that reads back exactly.
  • Releases publish only from green CI on main. A v* tag already had to point at a commit on
    main. It now also needs a successful CI push run on main for that exact commit, and the
    release workflow's actions are pinned to commit SHAs. For maintainers: tag after CI on main
    has finished. A tag pushed while CI is still running fails, and re-running the release
    workflow once CI is green publishes it.
  • The reverse-SSH sidecar writes reverse-ssh.json atomically and escapes it. A host, user
    or URL containing a quote or a backslash produced invalid JSON, and the controller could read
    a half-written file. A non-numeric port or interval now stops the sidecar at start instead.
  • The smoke scripts send the bearer token when API_BEARER_TOKEN is set, so make smoke-*
    works against a controller that requires it.
  • Docs: the login example reads the session's id (it read session_id, which the response
    doesn't have), docs/llm-adapters.md matches the current providers (OpenAI's Responses API,
    Claude's tool_choice: auto, async calls, the isolation modes), ROADMAP.md names the current
    release, and SECURITY.md lists all five published advisories.
  • The dashboard says when a request failed. Saving an auth profile, starting or reopening
    a login session, approving or rejecting, cancelling, resuming or discarding an agent job,
    removing a peer, and loading any table showed "Saved" or an empty list when the controller had
    answered with an error or could not be reached. Each now shows the controller's own error
    message, and one failing table no longer blanks the others.
  • Audit views show who did what. The dashboard's audit table and the session replay page read
    operator_id and data, which no audit event has, so those columns were always blank. They now
    show the event's operator id, and the replay page shows its details as JSON.
  • The MCP bridge reports a failed re-initialize. When the controller restarted and the
    bridge's notifications/initialized call then failed, the bridge kept the half-open session
    and the next call failed with "Session not initialized". It now drops that session, relays the
    controller's original error, and retries on the next call.
  • Named tokens count as authentication. GET /readiness, the runtime-policy warning and the
    remote-access info said the API was unauthenticated when only API_BEARER_TOKENS was set.
  • /version answers under REQUIRE_OPERATOR_ID, and /ui/* redirects. A version probe no
    longer needs an operator id, and an old bookmark such as /ui/sessions redirects to the
    dashboard as /ui does, instead of answering 401.
  • A network hook whose pattern contains / can be removed. DELETE /sessions/{id}/network/hooks/{pattern} never matched a pattern such as */api/*. A Host header
    such as [::1]evil.com is also no longer read as loopback.

Changed

  • Controller dependencies: FastAPI >=0.143.2,<0.144 (#177, #183), Starlette 1.8.0 and
    pydantic-settings 2.16.0 (#191); ruff 0.17.1 for development.

Added

  • FORCE_GOVERNED_APPROVALS makes governed approvals the server's rule. Off by default,
    because it stalls an existing agent loop on its first write. When on, every MCP tool that writes
    (the 16 with a governed kind) and every step of the agent loop (MCP-queued steps and runs, REST
    /agent/*, cron) needs an operator's approval, whatever workflow_profile the request carried.
    GET /readiness warns on a deployment with several operator credentials that leaves it off. It
    does not change the REST action routes (/sessions/{id}/actions/*), which a bearer-token holder
    can still call directly: it governs agents that act through MCP tools and the agent loop, not
    token holders. AUTONOMOUS_APPROVALS=true lets agents approve their own calls and cancels it.

Removed

  • download_url from the auth profile export response. It pointed at /auth-export/<archive>,
    which no route serves; the archive is the file named by archive_path in the auth root.
  • app.browser.dom_pruner (DOMPruner). Nothing in the controller called it, and it read keys
    that real observations don't have, so it pruned nothing. Only code importing it directly is
    affected.