Repository navigation
v1.10.0
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
validnow covers the signatures.GET /sessions/{id}/witness/verifyand
browser.verify_witnesspassvalidonly when the hash walk, the anchor and the signatures
all pass; the hash walk and anchor alone are nowchain_valid. A session recorded entirely
before signing, or by a controller without its signing key, reportsvalid: false. ALLOWED_HOSTSis 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 toabout:blank.- Under
workflow_profile=governed,browser.delete_memory_profileandbrowser.cdp_attach
ask for approval, held undersession_id: "controller".browser.cdp_attachis also
refused when more than one operator credential is configured, and while live sessions still
use the current browser. REQUEST_RATE_LIMIT_EXEMPT_PATHSentries match one path exactly (/artifactsstays a
tree). If you exempted a sub-path by listing its parent, list the sub-path itself.STEALTH_ENABLED=truenow 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
itscode. - If you run
scripts/codex_host_bridge.pyon 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
ValueErrorfor the ids"",.and.., and the LangChain
AutoBrowserNoderaisesAutoBrowserToolErrorwhen a tool call fails. - Removed:
download_urlfrom 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_pathis
relative to the auth root, so another operator could name<session id>/state.jsonand open a
browser logged in as the session's owner. A session that has an owner now leaves an
.owner.jsonmarker 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, soMAX_SESSIONS=1setups can use shadow browsing. - Auth profile export leaves out the plaintext
state.jsonwhen an encrypted
state.json.encexists. 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.jsonnext 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_attachis refused when more than one operator credential is configured. It
replaced the one browser every session runs on, so underAPI_BEARER_TOKENSany 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 setCDP_CONNECT_URLat 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 withmanager.browser, whichcdp_attachreplaces; 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/listnames only sessions you may use. It listed every operator's session ids
and their screenshot, dom, console and network URIs;resources/subscribeaccepted 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 inUPLOAD_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 inUPLOAD_ROOTstay 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 withproxy_personais 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_HOSTSleft a live session there. It now refuses and rolls the session back. - Switching to a tab on a disallowed host is refused.
activate_tabmade 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. Afterlocation.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 toabout:blank, and the error reports only
the URL. browser.set_cookiesis held toALLOWED_HOSTS. It added cookies for any domain, so a
caller confined to one site could plant a cookie on another. Each cookie'surlordomainis
checked first, and one disallowed cookie refuses the whole call.ALLOWED_HOSTSholds 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, andobserve
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 toabout:blankand records anavigation_blocked
audit event. Until it has, observe, screenshots, action snapshots, the page-reading MCP tools
and the DOM resource refuse (REST: 403), actions other thannavigateandgo_backare
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/verifyandbrowser.verify_witnessrejected an unsigned receipt
after a signed one, but a chain with no signed receipt at all reportedsignatures.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.pyalready gave. Unsigned receipts before the first signed one
still pass. The top-levelvalidnow 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 tochain_valid. A session recorded entirely before
signing existed, or by a controller that couldn't load its signing key, now reports
valid: falsewithchain_valid: true, which is accurate. The new section in
docs/deployment.mdsays 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_profileand
browser.cdp_attachran unapproved even underworkflow_profile=governed, because the approval
needed a session to belong to. They now returnapproval_requiredlike 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 newownerfield; records written
before it load as unowned. Agents that usegovernedsee the new approval step on those tools. - PII scrubbing removes a whole private key. The
pem_headerpattern 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,
soPII_SCRUB_PATTERNSlists that name it still work. - Request bodies without a text content type are scrubbed. A
fetchwith a string body (no
content type),application/yamland 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. Anapplication/htmlresponse 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 /readinessnow has a
screenshot_scrubbingcheck that warns when tesseract is missing orOCR_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.mdlists 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_URLset, 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 embedhttp://127.0.0.1:6080/vnc.html, whose own WebSocket passes the guard. Every
response noVNC serves now sendsX-Frame-Options: DENYand
Content-Security-Policy: frame-ancestors 'none'.docs/deployment.mdnow 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, soget_session("../approvals/x/approve")reached another route. Every id in a path,
the event stream included, is now percent-encoded, and"",.and..raiseValueError.
The client also gainsclose()andaclose()for an instance used outsidewith. - 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-Lengthgets 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-scriptandbrowser.export_scriptproducedpage.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 byelement_idcarry a comment, because
that id exists only afterobserve()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}/traceandbrowser.export_scriptanswered 404 once a session closed, which
is whentrace.zipexists. They now read the stored session record; another operator's session
and an unknown id still answer 404. STEALTH_ENABLED=trueruns the stealth patches. The init script was a bare arrow function,
whichadd_init_scriptdefined 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
addswindow.chrome.runtimewhen headless Chromium already defineswindow.chromewithout it.- Stealth and
ACTION_TIMEOUT_MSapply 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_PROFILEcanvas noise is applied once per read. The fingerprint script wrapped
getImageDataagain on everygetContext('2d')call, which returns the same context, so each
call stacked another layer of noise, and enough calls overflowed the page's stack. toDataURLleaves the canvas as it found it. With both stealth scripts on, each export read
a pixel through the noisygetImageDataand 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}/executeand 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 thecodeagents act on. They now
answer as themselves: 404 with the session message andcode(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.
observeandwaiton an unknown session now carry that message andcodeinstead of a bare
"Unknown session". - Agent steps work on a text-only observation.
observe(preset="text")returns no
screenshot, andPERCEPTION_PRESET_DEFAULT=textmakes 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 withKeyErrororTypeErrorbefore sending anything.
They now send a text-only request. If you runscripts/codex_host_bridge.pyon 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
raisedJSONDecodeErrororKeyError, or returned an empty state, when the controller reported
isError: true. It now raisesAutoBrowserToolErrorwith the tool name, the error text, and
codeandretryablewhen the controller sent them, so a graph can tell a missing session from
a bad selector. BuildingAutoBrowserToolwithoutlangchain-corenow raises anImportError
namingpip 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 asstructuredContent, 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_CONTAINERSstill 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_matchespattern 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'sdetails.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/deepis rate-limited. An entry inREQUEST_RATE_LIMIT_EXEMPT_PATHSexempted
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;
/artifactsstays 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-redirectis 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.mdfront matter is always valid YAML. The goal went intodescription
unquoted, soOpen the page: log inmade the file unparseable and a goal with a line break
could add keys of its own, such asstatusorsafety_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 successfulCIpush run onmainfor 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.jsonatomically 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_TOKENis set, somake smoke-*
works against a controller that requires it. - Docs: the login example reads the session's
id(it readsession_id, which the response
doesn't have),docs/llm-adapters.mdmatches the current providers (OpenAI's Responses API,
Claude'stool_choice: auto, async calls, the isolation modes),ROADMAP.mdnames the current
release, andSECURITY.mdlists 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_idanddata, 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'snotifications/initializedcall 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 onlyAPI_BEARER_TOKENSwas set. /versionanswers underREQUIRE_OPERATOR_ID, and/ui/*redirects. A version probe no
longer needs an operator id, and an old bookmark such as/ui/sessionsredirects to the
dashboard as/uidoes, 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.comis 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_APPROVALSmakes 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, whateverworkflow_profilethe request carried.
GET /readinesswarns 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=truelets agents approve their own calls and cancels it.
Removed
download_urlfrom the auth profile export response. It pointed at/auth-export/<archive>,
which no route serves; the archive is the file named byarchive_pathin 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.