Repository navigation
Releases
v1.17.0
A security and correctness release over 1.16.0, from the review findings 1.16.0 left open and from reviews of this
release's own changes. The controller image now installs its whole Python dependency tree from a lock pinned at
releases at least 72 hours old. A restricted witness receipt withholds secrets at any depth; the MCP bridges, the
Python client and the LangChain tools no longer show the bearer token or a secret of the controller URL in an error; a
screenshot that is to be PII-redacted is no longer served before its redaction is done, and a download read with
browser.read_download is PII-scrubbed; and browser.get_html with text_only and browser.find_elements now read
the page in the controller's isolated world, so a page's own scripts cannot change what they see. Read the upgrade
note first: a MESH_TIMESTAMP_WINDOW above 250 seconds, or at or below 0, now stops startup. The controller image and
the client and LangChain packages change, and the MCP bridge package now requires this client; the browser-node image
changes only in version.
Upgrade notes
- A
MESH_TIMESTAMP_WINDOWabove 250 seconds, or at or below 0, now stops startup. Set it inside those limits
(the default is 30), on a controller with the mesh off too. With the mesh on, lowering it needs the wait that
docs/mesh.mddescribes for any lowering: the startup check assumes the last process ran with no larger window, so
start this release only once the old window has passed since the old controller last accepted an envelope (stop it,
then wait that long).
Security
- The controller image now installs its whole Python dependency tree from
controller/constraints.txt, pinned at
releases at least 72 hours old, and CI's Python test installs take no younger release.requirements.txtand
requirements-dev.txtpin only the packages the controller names, so every build took the newest release of each
package below them, however new: the 1.15.0 image carried pydantic-core 2.50.4, uploaded under 72 hours before it
was built. The file is uv's universal resolution of both files for Python 3.11 and later, with its command and
cutoff in its header. The host tests and the devcontainer install under it too, and CI's host tests hold what it
does not pin, the build backend of the editable install, to releases at least 72 hours old. The dependency audits
read the file as it is, where they resolved the top-level pins afresh: CI's, on Linux and Python 3.11 as the image
is, checks the versions the image installs, andscripts/release_audit.py's the versions the file gives the
platform and Python it runs on. A requirements file moved to a release the file pins at another fails CI's install
until the file is generated again. The client and LangChain tests still take the newest release of each package, as
those libraries' users do, but only one at least 72 hours old, their build backends included. Dependabot holds
version updates three days, as its configuration now says. (R5C-14) - A restricted witness receipt now withholds secrets at any depth, and in its metadata and approval reason too. A
receipt in restricted evidence mode (a confidential session, or a sensitive, high-risk or auth-material action)
blanked five top-level target keys (text,text_preview,file_path,password,totp_secret) and what a
select_option chose, and kept its metadata and its approval's reason as given. It now withholds, at any depth of the
target and the metadata, those keys, every key whose name holds a word the run trace redacts by (authorization,
cookie,api-key,api_key,token,password,secret), and the free text an agent or a tool call wrote: a
reasonkey, and the approval's reason, which for a governed tool call previews its arguments, and an operator's
commenton an approval decision. Each URL there (aurlkey's) now has its query and fragment scrubbed as the run
trace scrubs a URL's, by the PII scrubber (so not withPII_SCRUB_ENABLEDorPII_SCRUB_NETWORKoff): a navigate's
kept an OAuth code or a token in full. Receipts recorded without a policy outcome were standard, and kept the reason
in full, in the approval and again in the metadata: an approval's receipts (its request, decision and execution) now
take the evidence mode of the action it approves, as the approval describes it, so those of an action marked
sensitive, a high-risk one or one that moves auth material are restricted as that action's own receipt is, in a
normal session too (a field an action finds sensitive only as it runs restricts that action's own receipt, not its
approval's, whose reason can quote what the action was given: mark the action sensitive to restrict those too); and
every approval receipt of a confidential session, like a human takeover's receipt there, is restricted. The approval
store still keeps an approval's reason and its decision's comment, and the audit log a takeover's reason and the
comment. A hosted Witness service and an exported bundle receive the receipt as recorded. - The MCP bridges no longer show the agent a character or a byte of a token they cannot send. A bearer token
holding a character outside latin-1 (from--bearer-token,AUTO_BROWSER_BEARER_TOKENor the token file), which an
HTTP header cannot carry, was answered as an unexpected bridge failure quoting http.client's encoding error, which
names that character and its position in the token. Such a token is now not sent, as one holding a line break has
not been since 1.16.0, and a401's answer says which source holds it; a token inside latin-1 is still sent. A
token file that is not UTF-8 text is now named as such in a401's answer, which quoted the decode error, naming
one of the token's bytes and its position. - The MCP bridges' answers no longer show the bearer token, a query value, or a secret of the URL quoted another
way. 1.16.0's answers replaced the base URL's userinfo, password, query and fragment, each whole, as written or
percent-decoded. A server's error detail that quoted the token, a query value alone (bad key ..., or FastAPI's
422shape), or one of those asrepr()orjson.dumps()writes it (a structured detail went throughstr(),
which doubles a backslash) was shown as it came, and a JSON-RPC error the bridges forwarded from a proxy, an MCP
gateway or the controller was not redacted at all. The answers now also replace the token the request carried and
each query value of 8 or more characters, in each of those forms; a structured detail is shown as JSON after its
strings are redacted; a forwarded JSON-RPC error's message and its data are redacted, the data keeping its shape (a
result is never rewritten); and the line a bridge prints on stderr for an unexpected failure is redacted as its
answer is. A list or an object nested more than 100 deep in a detail or the data is replaced by
[redacted: nested too deep], not walked, since a walk that deep would run out of stack and end the bridge with no
answer. A response body nested too deep for Python'sjsonto parse (RecursionError) is answered as an unreadable
one is, with its HTTP status, not as an unexpected bridge failure. An answerjsonparsed but cannot write back (on
Python 3.14 the two limits differ) is answered with a JSON-RPC error for its request, with a null id if the id is
not a string or a number, and a line from the client too deep to parse, or holding an integer of more than 4300
digits, with-32700, where theRecursionErrororValueErrorended the bridge with no answer. A short secret is
no longer replaced again inside an earlier[redacted], and a path's@(/a@b/mcp) is no longer read as a
userinfo and dropped. - The Python client's and the LangChain tools' errors no longer show the bearer token or a secret of the controller
URL. The client'sAutoBrowserErrorquoted a server's error detail as it came, so a proxy's or gateway's detail
that echoed the base URL (its password, a token in its query) or the bearer token was shown whole; itsdetailand
its message now leave them out as the MCP bridges' answers do, any otheruser@run included, and keep the detail's
shape. The LangChain tool,AutoBrowserTool.list_toolsandAutoBrowserNoderaised httpx'sHTTPStatusErrorfor
an answer that was not 2xx, whose message names the request URL whole, password, query and fragment included; it now
names the URL without them (the path is kept), in httpx's words otherwise, and leaves them out of the server's
reason phrase too. httpx's own errors (a connection or protocol failure), which both let through, are now raised
with them left out as well, their type and request kept: the one for an answer httpx cannot read quotes what the
server sent, which can echo the request line or a header. The URL's secrets count there, and in a server's detail,
as httpx sends them too, percent-encoded where the URL held a space or a character outside ASCII, and the query as
httpx writes it again for the parameters it now goes as (a space as+), in the client's each of itsname=value
pairs alone too (a request's own parameter replaces one of the same name, so the query is then not sent whole), and
so do theAuthorization: Basiccredentials httpx sends for the URL's user name and password (their base64); the
MCP bridges, which share the client's redaction, send no such header. httpx itself still logs every request at INFO
on itshttpxlogger, naming the URL as sent (userinfo and query included) and the server's reason phrase: the
packages do not own that logger, and a filter added at import would change the logging of everything else in the
process, so they leave it alone, and their READMEs now say to keep a secret in the token, not in the base URL. A
tool's own error (isError), which the LangChain tool returns...
v1.16.0
A security and correctness release over 1.15.0 (GHSA-gmvj-rj2h-7528), from the open findings of the review behind
1.15.0 and from reviews of this release's own changes. The model no longer receives an isolated session's noVNC
password: the full takeover link now comes from a route that also demands a takeover token, which agents are not
given. Codex behind the host bridge no longer gets the whole environment of the bridge's shell; Playwright's driver,
Tesseract and the controller's other helper processes get an allowlisted environment too (a Docker credential helper,
when one is configured, is a known limit); and on Linux the controller and the bridge make themselves non-dumpable, so
a process of the same user cannot read their environment through /proc. The Python client, the LangChain tools, the
MCP bridges and the repo's scripts no longer send their tokens through an environment proxy to a controller on the
same machine. Read the upgrade notes first: a person opens an isolated session's takeover link from the dashboard or
scripts/takeover_link.sh with the takeover token, an approval webhook on a private name or address needs
APPROVAL_WEBHOOK_ALLOW_PRIVATE_TARGETS=true, a Codex setup behind the bridge that needs another variable passes it
with --pass-env, and a controller rolled back to 1.15.0 cannot read the harness runs this version saved. The
controller image and the client and LangChain packages change, and the MCP bridge package now requires this client;
the browser-node image changes only in version.
Upgrade notes
- An isolated session's full takeover link now needs the new takeover token. An agent that handed a person the
takeover link of an isolated (docker_ephemeral) session now hands over a link without the noVNC password. The
person opens the full link from the dashboard ("Open noVNC") or prints it with
scripts/takeover_link.sh <session_id>; a link opened without the password makes noVNC ask for it. Both need the
new takeover token (TAKEOVER_TOKEN, else the token the controller generates inTAKEOVER_TOKEN_FILE,
./data/takeover-tokenwith the shipped compose): the dashboard asks for it on the first "Open noVNC" of an
isolated session in a tab, before it opens the window (Chrome and Edge show no prompt in a tab that is not in
front), and again on the click after a refusal, andscripts/takeover_link.shreads it fromTAKEOVER_TOKEN(the
shell or.env) ordata/takeover-token. A script or integration that took the password fromPOST /sessions,
GET /sessions/{id},GET /remote-accessor the takeover result now calls
GET /sessions/{session_id}/takeover-linkwith theX-Takeover-Tokenheader. - An approval webhook on a private name or address now needs
APPROVAL_WEBHOOK_ALLOW_PRIVATE_TARGETS=true. An
approval webhook whose host is a private name (a service on the compose network, a LAN host) was reachable by name
before and is now refused, as is one at a shared address (100.64.0.0/10, such as a Tailscale node): every address
that is not global is. Set the newAPPROVAL_WEBHOOK_ALLOW_PRIVATE_TARGETS=trueto allow it. The flag lifts the
name and address checks, so loopback (localhost,127.0.0.1) and every private range are allowed too; cloud
metadata stays refused whatever it says:169.254.0.0/16,100.100.100.200,fe80::/10,fd00:ec2::254,
fd20:ce::254(GCP),fd00:c1::a9fe:a9fe(OCI) and the namesmetadataandmetadata.google.internal, and an
IPv6 address that spells a metadata address: mapped, translated, compatible, NAT64's well-known64:ff9b::/96, the
local-use64:ff9b:1::/48read at every RFC 6052 position (/32to/96), 6to4, Teredo (the client's address) or
an ISATAP interface id (0:5efeor200:5efe, then the IPv4 address) after any prefix. Without the flag such an
address is refused, too, when the IPv4 address it spells is not global. A network-specific NAT64 prefix (one an
operator chose elsewhere) cannot be recognised. Behind a proxy (HTTPS_PROXYand the like) the controller must
still be able to resolve the webhook host itself: it checks the addresses before it posts by name through the proxy,
and a name it cannot resolve is not posted to. - Codex behind the host bridge gets only allowlisted variables; pass any other it needs with
--pass-env. The
Codex host bridge now gives Codex only an allowlisted environment: a Codex setup that needs another variable from
the bridge's shell passes it with--pass-env NAME(repeatable), which hands that value to Codex and to every
command Codex runs.docs/deployment.mdlists every name that already passes. Two setups that need it: a sign-in
kept in the desktop keyring (DBUS_SESSION_BUS_ADDRESSorXDG_RUNTIME_DIR, where the bus is not at its default
path) and a node version manager with a non-default home (VOLTA_HOME,ASDF_DATA_DIR,MISE_DATA_DIR). - The
HARNESS_UV_COMMANDverifier now gets a fixed environment. AHARNESS_UV_COMMANDthat needs a variable
outside the provider CLIs' base names (CLI_BASE_ENV_NAMESandLC_*, listed in docs/deployment.md), such as its
own key or setting, names it in the newHARNESS_UV_ENV_PASSTHROUGH(comma-separated). - An empty
ISOLATED_TUNNEL_REMOTE_BIND_ADDRESSnow stops startup. For a controller that reads
ISOLATED_TUNNEL_REMOTE_BIND_ADDRESSitself (outside compose:docker-compose.isolation.ymlpasses
${ISOLATED_TUNNEL_REMOTE_BIND_ADDRESS:-127.0.0.1}, so there an empty value reached the controller as127.0.0.1),
an empty value, which underISOLATED_TUNNEL_ACCESS_MODE=unsafe-publicasked the bastion to bind the tunnel on
every interface, now stops startup. Set*for every interface (IPv4 and IPv6) or0.0.0.0for IPv4 only; both
needunsafe-public, and the bastion'sGatewayPortsstill decides. - Model names are now checked against a pattern. A
provider_modeloutside the model-name pattern (below) is
refused with a 422, and an agent job saved before the upgrade with such a model fails when it runs and cannot be
resumed. - After a rollback to 1.15.0, the harness runs this version saved cannot be read. A controller rolled back to
1.15.0 cannot read a harness run this version has saved (each run now records thegenerationof the process that
created it, and each attempt atrace_hash, fields 1.15.0 rejects), including an older run this version's startup
rewrote: one an earlier process leftrunning(nowinterrupted) orconvergedwith no candidate (nowfailed,
or, for a run this version recorded, given the candidate it had staged). Such runs drop out ofharness.list_runs,
andharness.get_statusfails on them.
Security
- The model no longer receives an isolated session's noVNC password.
create_session,get_session,
get_remote_accessandrequest_human_takeover(REST and MCP) carried it in the takeover URL's fragment, which
over MCP reached the calling model, and with a session tunnel up the URL was the public one. No MCP tool result, and
no response of a route the agent-facing packages (the Python client, the MCP package, the LangChain integration)
call, carries it now; an isolated session'sremote_accessblock and takeover result carry a fixedtakeover_note
instead. The full link comes from the newGET /sessions/{session_id}/takeover-link, which no MCP tool and no
agent-facing package calls and the dashboard and the newscripts/takeover_link.shread. Beside the bearer token,
which agents hold, it demands a second secret agents are not given: the takeover token, in theX-Takeover-Token
header, compared in constant time and required in every scope,API_ALLOW_TOKENLESS_LOOPBACKincluded. A malformed
session id is a 400; then, on a controller that loaded no takeover token, every request is a 503; otherwise a
missing or wrong token is a 403, checked before the session lookup (so before a 404 for an unknown, closed or, under
API_BEARER_TOKENS, another operator's session: the route is exempt from the app-wide session check) and audited as
takeover_link_refusedwithout a token or the URL; a read isCache-Control: no-storeand audited as
takeover_link_viewedwithout the URL or the password. The token isTAKEOVER_TOKENwhen set; else the controller
reads it fromTAKEOVER_TOKEN_FILE(default/data/takeover-token, whichdocker-compose.ymlpins, as it pins
API_TOKEN_FILE, and passes aTAKEOVER_TOKENfrom the shell or.env) or generates one there (mode 0600) on
first start, and logs only a sha256 fingerprint. ATAKEOVER_TOKENshorter than 32 characters, outside printable
ASCII or containing a space (the token file's rule, so the variable and the file accept the same tokens and every
client sends one unchanged: curl sends a UTF-8.envvalue that Starlette reads as latin-1) or equal to an API
credential refuses startup, and so, whileTAKEOVER_TOKENis unset, does aTAKEOVER_TOKEN_FILEin one of eight
directories:ARTIFACT_ROOT(served under/artifacts),UPLOAD_ROOT(uploadable into a page) andAUTH_ROOT
(its profiles exported as archives), all three swept by maintenance, and the five mounted into a container:
browser-profileanddownloadsin browser-node andtunnelsandsshin reverse-ssh, which compose mounts, and
browser-sessions, whose<id>/profileand<id>/downloadsdirectories the controller binds into each isolated
session. The check looks for those five under the parent ofARTIFACT_ROOT, which is where compose's./datalands
in the image (/data); withARTIFACT_ROOTmoved elsewhere, compose's mounts stay under./dataand the check no
longer covers them, so keepTAKEOVER_TOKEN_FILEout of them. The other data roots are read by names the controller
builds (a fixed name, or an id or profile name with a fixed suffix), so no route hands a token there to an age...
v1.15.0
A security and correctness release over 1.14.1, from an internal review of the whole controller and of this release's
own fixes. Read the upgrade notes first: provider CLIs now get a fixed environment and need a usable CLI_HOME, a
misspelled SESSION_ISOLATION_MODE stops startup, an unreadable proxy persona file or cron store answers 503, and the
repo's scripts read .env literally. On Linux with per-session networks, the fix that keeps the controller's
published port in place when it joins a session network needs Docker Engine 28 or later (older engines get a warning).
The controller and browser-node images change; the LangChain package's crewai extra now installs langchain-core;
the client and the MCP bridge packages change only in version.
Upgrade notes
- A misspelled
SESSION_ISOLATION_MODEnow stops the controller from starting. It used to fall back to the shared
browser without a word. Onlyshared_browser_nodeanddocker_ephemeralare accepted; surrounding spaces and
capital letters are forgiven, butdocker-ephemeralordockerephemeralare errors. - A provider CLI (
claude,codex,gemini) now gets only a fixed set of environment variables. If your CLI
needs one that is not in the set, name it in the newCLI_PROVIDER_ENV_PASSTHROUGHsetting (comma-separated
variable names, empty by default). The set is the operating-system basics (PATH,HOME,TEMP,LANG,LC_*,
proxy and certificate variables) plus that provider's own key, endpoint and config-directory variables (forcodex,
that includesCODEX_API_KEY). WithCLI_HOMEunset, or set to a directory that cannot be created, a CLI call now
fails (with an error that says to setCLI_HOME) where the controller's user has no home directory it can write, or
where its private directory is refused (see Security); before this release the CLI started in the controller's
working directory instead. SetCLI_HOMEto a writable directory there; the image's default,/data/cli-home, is
one. - A Witness bundle whose chain failed the exporter's own check now fails the offline verifier. That covers, for
example, a chain whose anchor readsanchor_missing,anchor_unreadable,truncatedordiverged, or whose hash
walk fails, with or without--allow-unsigned; achain_valid_at_exportthat is present and not exactlytrue
fails too. The bundle now carrieschain_reason_at_export(verify's reason, as a sentence) and
chain_anchor_status_at_export(the anchor's status code), and the verifier prints the reason, so the failure says
why. A bundle made by an older version has no reason, and one whose chain failed at export no longer passes
--allow-unsigned. - Harness runs and staged skills written by earlier versions may still hold a URL token on disk, and return it.
UnderHARNESS_ROOT(default/data/harness),runs/<id>/run.json,runs/<id>/strategy.mdand a staged skill's
candidate.json,envelope.json,provenance.jsonanddrift.jsoncould carry the token from the final URL, and
oldertrace.jsonfiles were not text-scrubbed. The files are not rewritten. An attempt inrun.jsonnow carries
scrubbed: trueonce its verification is scrubbed: an attempt without it (written by an earlier version) is
scrubbed when read, soharness.get_status,harness.list_runsandharness.graduateno longer return the token
from its verification (a scrubber that fails on such a record leaves the run listed, with only the verdict kept and
a warning in the log). What is read back from the other files is not scrubbed again, so for such a run the staged
candidate thatrun.jsonholds andharness.get_trace(itstrace.json), and for such a skill
harness.list_candidatesandharness.get_candidate(the candidate's envelope), can still return the token. A
run.jsonwritten by this version has the newscrubbedfield, and a 1.14.x controller cannot load such a file:
its attempt record forbids extra fields, so after a downgradeharness.list_runsdrops the run and
harness.get_statusfails on it. The next drift check rewritesdrift.json. Delete those runs and staged skills
where that matters; running the task again stages a clean copy (a staged skill's files cannot be edited, as they are
covered by its signed envelope). A skill staged before this release with a PII expectation (an email in
text_contains,dom_containsorurl_contains) may now report degraded: the drift check scrubs the expectation,
but that skill'strace.jsonwas not scrubbed. Stage it again. - A queued agent job whose run or step reports
errornow finishesfailed, notcompleted.errorcarries the
reason the run or step gave, andresult.statusis stillerror. A client or automation that waits forcompleted
and then readsresult.statusshould wait for a terminal state and handlefailed; a failed run is resumable, as
an interrupted one is. Other outcomes (done,acted,takeover,approval_required,max_steps_reached) still
finishcompleted. - A proxy persona file that cannot be read now answers 503, with a fixed text.
GET /proxy-personas(list and by
name),POST /proxy-personas,DELETE /proxy-personas/{name},POST /sessionswith aproxy_personaand the cron
webhookPOST /crons/{job_id}/triggerof a job with one answer 503 with the detail "the proxy persona file
(PROXY_PERSONA_FILE) is unreadable; fix or move it. It was not changed.", and the MCP persona tools,
browser.create_sessionandbrowser.trigger_cron_jobreturn the same text. Before this release a corrupt file
read as an empty set, so the list answered[]and a persona session create answered 404; a client that handled
those should handle the 503. The file's path and the cause are in the server log. - The REST cron routes now answer 503 for a cron store that cannot be used. A corrupt store made
GET /crons,
POST /crons,GET /crons/{job_id},DELETE /crons/{job_id}andPOST /crons/{job_id}/triggeranswer 500; a
store holding JSON other than an object could instead answer 400 (create) or 404 (delete, trigger), and on Python
3.11 a store whose folder could not be searched made the trigger answer 403. All of these now answer 503 with a
fixed detail that namesCRON_STORE_PATHand not the path, as the persona-file note above says for its routes. - Fewer pages count as bot challenges (
captcha_detected). An agent or runbook that relied on that code for any
of these will now see the action succeed: a page whose title or text only says "access denied", "security check",
"unusual activity", "suspicious activity", "bot detected" or "captcha" (Akamai's "Access Denied" block page
included); a page with an invisible reCAPTCHA v3 or v2 badge, an invisible hCaptcha or Turnstile frame, or any
provider frame that is not visible; a provider's own docs or marketing pages
(developers.google.com/recaptcha/docs,www.hcaptcha.com,www.google.com/recaptcha/about/,
www.google.com/recaptcha/admin); and a title that merely contains "just a moment". A challenge frame the page
keeps hidden until it needs it is not seen until it is shown. Cloudflare's block page and DataDome's frame still
count. ANTHROPIC_BASE_URLmust end in/v1, as the Claude adapter already required. Vision targeting and the curator
now follow it with/messagesappended. A proxy base without/v1(for examplehttps://proxy.example) worked for
vision before, because vision added/v1itself, and now answers 404; add the/v1. The curator, which ignored the
setting, now sends its Claude calls to whatever the setting names; withCURATOR_PROVIDER=openaiorgeminiit
likewise followsOPENAI_BASE_URL(as<base>/chat/completions) orGEMINI_BASE_URL.- The repo's scripts read
.envvalues literally.doctor.sh,compose_local.sh,bootstrap_cli_auth.shand
the smoke scripts do no$VARor${VAR}expansion, soDATA=${PWD}/datastays that text. Plaindocker compose
expands${PWD}when it reads.env, but undermake up(compose_local.sh) the scripts' literal value is
exported and wins, for every${VAR}in the compose files. Double-quoted values take the escapes of compose-go's
reader (\",\\,\$,\a,\b,\f,\n,\r,\tand\v); a single-quoted value keeps its backslashes,
except that\'is a quote. A value with text after its closing quote, a\0escape inside double quotes (compose
reads it in a way not worth copying), or a quote that spans several lines (a PEM; its lines are skipped up to the
closing quote) is not exported, with one warning naming the line number, anddocker composereads that key from
.envitself. The scripts also refuse, with a warning naming the key, these.envkeys, which change how the
shell, the loader or the programs the scripts start behave:BASH_ENV,ENV,BASH_FUNC_*,SHELLOPTS,
BASHOPTS,IFS,PATH,PS4,PROMPT_COMMAND,CDPATH,GLOBIGNORE,POSIXLY_CORRECT,BASH_COMPAT,
EXECIGNORE,FUNCNEST,TMOUT,LC_ALL,LC_CTYPE,HOME,PYTHON*,BROWSER,PERL5OPT,PERL5LIB,
NODE_OPTIONS,COMSPEC,PATHEXT,LD_*,DYLD_*,ROOT_DIR,COMPOSE, and the smoke scripts' own
SMOKE_PROJECT,SMOKE_COMPOSE_*,SMOKE_SESSION_*andSMOKE_*_LABEL(SMOKE_URL,SMOKE_PROVIDERand
SMOKE_GOAL, whichdoctor.shreads, still load). On Git Bash and Cygwin the names are compared without regard to
case (Pathis refused likePATH). Any name that the script or bash itself already set without exporting it
(HOSTNAME,OSTYPEandMACHTYPE, for example) is refused too. The scripts' ownpython3calls now run with
-I, soPYTHON*variables in the shell do not reach them either.
Security
- **The Playwright connect path is no longer printed by the sandbox report or by CI....
v1.14.1
A patch release over 1.14.0. A session whose start page fails to load now gets a 400 the agent can act on instead
of a 500. Closing shadow browse no longer logs a false warning. The docs fix a compose ordering that ran isolated
sessions on a stale local browser image. The controller image changes; browser-node and the client, LangChain and
MCP bridge packages change only in version.
Upgrade notes
- If you run the published images with isolated sessions, check the order of your compose files.
docker-compose.images.ymlmust come afterdocker-compose.isolation.yml. Named before it, it lost
ISOLATED_BROWSER_IMAGEto the isolation overlay's default, sodocker_ephemeralsessions ran a local
auto-browser-browser-node:latest(a stale build, or none) instead of the published browser-node. To check,
rundocker compose ... configand confirmISOLATED_BROWSER_IMAGEnames theghcr.ioimage. Then close any
isolated sessions started before you fixed the order. - Creating a session whose
start_urlfails to load now returns 400browser_action_failed(retryable), not 500.
A client that handled the 500 should handle the 400 the way it handles a failednavigate.
Fixed
- A session whose
start_urlfails to load (a refused connection, an unresolved name, a timeout) is answered with
400browser_action_failed, retryable, asnavigateanswers the same failure. It used to be a 500 "Internal
error". The message names Chrome's net error (net::ERR_CONNECTION_REFUSED) or the timeout, or else says the
browser could not open the page. It never repeats Playwright's text, which carries the URL, and the controller's
log keeps that text's first line. The session is closed as before. Shadow browse's page gets the same answer. - Closing shadow browse no longer logs a WARNING that the pre-request gate could not detach from a target that was
already closed. That case is logged at DEBUG; a detach that fails otherwise is still a WARNING.
Changed
- The README and
docker-compose.images.ymlsay to name the images overlay last when isolated sessions are on.
Named beforedocker-compose.isolation.yml, it lostISOLATED_BROWSER_IMAGEto that overlay's default, so
isolated sessions ran a localauto-browser-browser-node:latest(a stale build, or none) instead of the
published image. docs/deployment.mdstates that underPII_SCRUB_SCREENSHOT_STRICT=truean action whose before-screenshot
cannot be scrubbed is refused before it runs (503). With OCR unavailable, that means every action.
v1.14.0
A hardening release over 1.13.1. The controller no longer runs as root, and Chromium runs with its own sandbox in
browser-node and in every isolated container, so a renderer exploit no longer starts with every syscall the
container allows. BROWSER_SANDBOX=on refuses any session whose browser is not verifiably sandboxed. Read the
upgrade notes first: the controller changes user and hands its data over at the first start, and browser-node runs
under a custom seccomp profile. The controller and browser-node images change; the client, LangChain and MCP bridge
packages change only in version.
Upgrade notes
- A default compose install now runs browser-node under a custom seccomp profile that lets the container create
user namespaces. Chromium's own sandbox needs them, and Docker's default profile refuses them to a container
without CAP_SYS_ADMIN. The profile is moby's default pluschroot,cloneandunshare
(controller/app/browser/seccomp/);docker_ephemeralcontainers get it too. It exposes the kernel's
user-namespace code to code in the container, and in exchange every renderer runs sandboxed. That is the better
trade for a browser that renders arbitrary pages: a renderer exploit no longer starts with every syscall the
container allows. To keep the old setup under compose, set bothBROWSER_SANDBOX=offand
BROWSER_SECCOMP_PROFILE=builtin(docs/deployment.md, "Chromium's sandbox"). On a host whose Docker daemon sets
its ownseccomp-profile, 1.14.0's profile replaces that one, andbuiltinmeans moby's built-in profile, not the
daemon's: pointBROWSER_SECCOMP_PROFILEandISOLATED_BROWSER_SECCOMP_PROFILEat your profile plus the
auto-browser entry to keep it. - The controller now runs as the owner of your data directory, or as uid 10002, not as root. A Linux checkout
whose./databelongs to you runs it as you; Docker Desktop and named volumes run it as uid 10002 (never
browser-node's 10001). It holds no capabilities and runs with no_new_privs. At start, the
root-owned files earlier releases left in the controller's own/datasubtrees are handed to that user, and
data/api-tokenstays readable by its owner.CONTROLLER_RUN_AS=rootrestores the root controller; it is not
recommended, and readiness warns while it is set. With the isolation overlay on Docker Desktop, the controller
joins group 0 to use the Docker socket. BROWSER_SANDBOX=onnow also gates shared mode: every session is refused, and readiness fails, while the
shared browser-node's connect-time state is not a verifiedon, including a browser-node image older than 1.14.0.
Underon,browser.cdp_attachis refused; aCDP_CONNECT_URLChrome is the operator's own and is reported as
attached, not checked.- Rolling back: after running an older release on the same data, delete
data/.auto-browser-ownerbefore
starting 1.14 again, so the files the older release created as root are handed over. A container without
DAC_OVERRIDE or DAC_READ_SEARCH cannot look inside its own unreadable directories: chown./databy hand then. - A capability-less uid 0 still owns the image: under
cap_drop: [SETUID]or[SETGID], or acap_addset
without SETUID or SETGID, a compromised controller could rewrite the image and regain the container's capabilities
on a restart. The entrypoint names them; prefercap_drop: [ALL],user: "10002:10002", orread_only: true
with a tmpfs for/tmp, which screenshot OCR needs (docs/deployment.md). - A controller that cannot switch users (
cap_drop: [ALL],cap_drop: [SETUID]or[SETGID], orcap_add
without SETUID or SETGID) starts as a uid 0 with no capabilities at all, and refuses to start, in one line, when
it cannot enter the API token's directory or read the token the controller would read. A directory it cannot see,
not there yet or under one it cannot search, is left to the controller, which creates it or says why it cannot.
Run it asuser: "10002:10002", or addcap_add: [CHOWN, DAC_READ_SEARCH, SETUID, SETGID](docs/deployment.md).
Data owned by 65534 (root-squashed NFS) runs as 65534.
Security
- Chromium runs with its own sandbox in browser-node and every isolated container (
BROWSER_SANDBOX=auto, the
default). It launched with the sandbox off, so a renderer exploit ran with every syscall the container allowed.
browser-node now checks in/procthat renderers really are sandboxed; where a host refuses the user namespace
(Ubuntu 23.10 and later restrict it with AppArmor),autoruns Chromium unsandboxed with a WARNING that names the
cause,onrefuses to start, andoffkeeps the old behaviour. Readiness reports every browser's state. - The controller no longer runs as root (see the upgrade notes).
- A closed isolated session's profile and downloads are removed. A controller that is not root could not delete
the browser user's files, and the error was ignored, so the session's cookies stayed on disk; they are now emptied
as the browser user, and anything left is a WARNING with its path. - The controller reads the browsers' endpoint files without following links, and no error quotes their
content: a link planted at a Playwright endpoint file could have put a file the controller can read, its API token
included, into its log. An endpoint is a short ws:// or wss:// URL in printable ASCII, or it is refused.
Fixed
- When screenshot PII scrubbing cannot run, the WARNING (and the error, under
PII_SCRUB_SCREENSHOT_STRICT=true)
says what its reason needs. Both said to run the Docker image, which ships tesseract, even when the controller
already ran it and its OCR run failed, for example for want of a writable temporary directory under
read_only: true. A failed OCR run now names that check,OCR_ENABLED=falsenames the setting, and the cause of
the failure is logged at DEBUG.
Added
- A weekly CI check (
scripts/check_chromium_freshness.py) fails when Chrome stable for Linux is a major ahead of the
Chromium the pinned Playwright bundles, when a newer Playwright bundles a newer Chromium, or when moby's default
seccomp profile has moved away from the one the sandbox profile is built from.
Changed
- CI's compose-smoke prints each browser's measured sandbox state, and checks that the controller runs as the owner
of./data.
v1.13.1
A security release over 1.13.0 (GHSA-q9w4-3w52-f9f2). A page on a host ALLOWED_HOSTS refuses could open a window that
reached the agent anyway, and with docker_ephemeral isolation the controller answered API requests from a session's
own Docker network. 1.13.1 judges every window when it is created, refuses requests from session networks, and runs
shadow browsing's Chromium with its sandbox on. Only the controller image changes: the browser-node image and the
client, LangChain and MCP bridge packages change only in version.
Upgrade notes
- With
ALLOWED_HOSTSrefusing any host, each browser gets a window-birth watcher (one browser-level CDP
session). A browser it cannot attach to gets no session: creating one answerswindow_birth_watcher_unavailable
(503, retryable), as the pre-request gate's own failure does, and/readinessreports the watcher. With
ALLOWED_HOSTSempty or*nothing changes. - A popup becomes readable one CDP round trip after it is reported.
activate_tabwaits for its verdict, at most
10 seconds, rather than refuse it. A popup a clean tab opens within 2 seconds of a refused tab closing is refused:
open it again after those 2 seconds. - Requests from a
docker_ephemeralsession network are refused (403). Nothing in the shipped setup calls the
controller from a session's browser container. - Shadow browsing launches its headed Chromium sandboxed, and a controller running as root refuses it. On Linux
the sandbox needs unprivileged user namespaces.
Security
-
A window that a refused page opened could reach the agent. The controller judged a popup by its opener
when Playwright reported it, and Playwright reports a window only once its first navigation commits. A window
whose first navigation committed nothing was reported after the agent's next navigate had cleared the opener,
so it was judged clean and read. The controller now judges every window when its CDP target is created, by its
opener, and closes a refused page's window that Playwright never reports once nothing justifies it. It fails
closed: a browser it cannot watch gets no session (window_birth_watcher_unavailable, 503, retryable), and
/readinessreports the watcher. WithALLOWED_HOSTSempty or*nothing changes and nothing attaches.
What changes for legitimate popups:- a reported popup becomes readable one CDP round trip later than before (
activate_tabwaits for it, at most
10 seconds), and what it logged meanwhile reaches the agent once it is judged clean, except for a popup that
closes before its target id is read (within milliseconds of loading, longer on a busy browser), which is
left unjudged and loses what it logged; - with the watcher attached and its reads succeeding, the one new refusal is a popup opened within 2 seconds of
a refused tab closing; - a window the controller cannot judge (its target id cannot be read, or it opened while the watcher was down)
is refused as unjudged, and the refusal says so and how to open it again; - each reported popup and open-tab tab keeps one page-level CDP session for its life: reading its target id
opens it, and detaching it lost the failed-request and download events of the windows that page opens.
The rarer fail-closed cases are listed in
docs/deployment.md. - a reported popup becomes readable one CDP round trip later than before (
-
The controller refuses requests from
docker_ephemeralsession networks. It joins each session's network to
reach that browser, so in 1.13.0 code that broke out of a session's browser could still reach the API at the
controller's address there, with only the token in its way. Browser containers never call the
controller, so it now answers 403 to any request whose direct peer is on a live session network, before the Host
check, the rate limiter and the token check. Forwarded-for headers are ignored. A session network that reports no
subnet is not used: the session fails to start rather than run unfenced. On Docker Desktop a session container can
still reach the API port published on127.0.0.1throughhost.docker.internal, where the controller sees the
host's own address; the Host allowlist and the token refuse it there. -
Shadow browsing runs its headed Chromium with Chromium's sandbox on. It launched with
--no-sandbox, and since
the shipped controller image has no browser for it, that meant host runs, where the sandbox works. A controller
running as root, where Chromium cannot start sandboxed, now refuses shadow browsing. When the sandboxed launch
fails (on Linux it needs unprivileged user namespaces), the error says so, and the controller log has Chromium's
own message. -
/docsand/redocload pinned Swagger UI (5.34.0) and ReDoc (2.5.4) with Subresource Integrity hashes, and
a Content-Security-Policy that admits nothing else from another origin (no Google Fonts, no third-party favicon,
no ReDoc logo from cdn.redoc.ly). Swagger's Authorize box takes the API token, and the pages loaded a floating
major version from a CDN with no integrity check. -
The smoke and doctor scripts no longer put the API token on curl's command line, where every local user could
read it in the process list. It reaches curl as a config on stdin, and stays out ofbash -xtraces.
Fixed
- A browser container that could not be removed no longer keeps its session network (and its address-pool slot)
until the next start: it is disconnected first. - A foreign Docker network holding a session network's name gives an error naming it and what to do, instead of
Docker's bare conflict. - The shadow-browse route logs why it refused (disabled, a root controller, no sandbox); callers still get a
generic 400.
Changed
- CI's compose-smoke runs with a
VNC_PASSWORDon the shared browser-node, so its VNC-authentication checks run.
v1.13.0
A security release over 1.12.4 (GHSA-4c52-f6vr-xrrf). A browser container that a page compromised could take over
other sessions' browsers and desktops and, on a controller without a token, the controller's API. 1.13.0 isolates each
docker_ephemeral session on its own network with its own noVNC password, makes a local controller generate and
require a token, and drops the browser containers' Linux capabilities. It changes a default that clients rely on:
read the upgrade notes first. Update your checkout (its compose files carry the container hardening), the
controller and browser-node images, and the client, LangChain and MCP bridge packages, together.
Upgrade notes
- A controller with no
API_BEARER_TOKENnow requires a token it generates. On first start, a loopback controller
with no configured credential writes a random token toAPI_TOKEN_FILE(/data/api-token, which is
./data/api-tokenin your checkout with the shipped compose, which pins that path). It then demands that token on
every API and MCP request; health checks,/version, the dashboard page and, on loopback, the API docs are exempt.
Deployments that already setAPI_BEARER_TOKENorAPI_BEARER_TOKENSkeep using their token, and the file is
ignored. Point each client at the token:-
stdio MCP clients (Claude Desktop, Cursor): set
AUTO_BROWSER_TOKEN_FILEto the absolute path of
data/api-tokenin your checkout, and use this release's bridge (uvx auto-browser-mcp@latest). An older bridge
ignores that setting, so setAUTO_BROWSER_BEARER_TOKENto the file's contents instead. The repo's own wrapper
(make stdio-bridge,scripts/mcp_stdio_bridge.py) finds the file itself for a loopback URL. -
HTTP MCP clients: send
Authorization: Bearer <token>. For Claude Code, run this from the auto-browser folder:claude mcp add --transport http --scope user auto-browser http://127.0.0.1:8000/mcp \ --header "Authorization: Bearer $(cat data/api-token)"docs/mcp-clients.mdcovers Codex, Cursor, VS Code and Antigravity. -
The Python SDK and LangChain: pass
token_file="data/api-token"toAutoBrowserClient,AutoBrowserTool,
AutoBrowserTool.list_toolsandAutoBrowserNode. A relative path resolves against the working directory. The
client reads the file when it is constructed; the LangChain tools and the bridge read it on every call. -
The dashboard asks for the token once per tab, or takes it in the URL as
/dashboard#token=<token>. -
Curl and scripts send the header.
-
A Prometheus scrape of
/metricsusesauthorization: { type: Bearer, credentials_file: <path> }. The
controller resetsdata/api-tokento mode 0600 at every start, so a Prometheus running as another user needs its
own readable copy, or setAPI_BEARER_TOKENand give it that. -
Pages a browser opens directly, such as
GET /sessions/{id}/replayand/artifacts/...links, cannot send
the header. They now need the token, as they already did on token deployments. For an agent run, the dashboard's
replay panel (which takes the job id) works; a session's/replaypage has no tokenless replacement. -
Update your checkout as well as the images. This release's
.gitignorecoversdata/api-token. On an older
checkout, adddata/api-tokento.gitignoreyourself, orgit add -Acan commit it. -
Host runs (not Docker): a loopback controller with no token that cannot write
API_TOKEN_FILE(default
/data/api-token, rarely writable outside a container) now refuses to start. SetAPI_TOKEN_FILEto a path
under your user, or setAPI_BEARER_TOKEN. On Windows the default resolves toC:\data\api-tokenon the
current drive, whose inherited permissions let other local users read and replace it, so set it there too. -
A token you place in
API_TOKEN_FILEyourself must be at least 32 printable characters, or the controller
refuses to start. It is used in every scope, so a mounted secret works for an exposed deployment too. -
API_ALLOW_TOKENLESS_LOOPBACK=truerestores the tokenless loopback API. It is ignored in production, and it
reopens the hole: any container on the compose network can drive that controller.
-
- Each
docker_ephemeralsession gets its own Docker network (ISOLATED_BROWSER_NETWORK_MODE=per_session, the
new default). The controller joins that network for the session's life.- An isolated browser no longer reaches compose-network services by name or container address: browser-node's
Playwright server, or a proxy sidecar named inDEFAULT_PROXY_SERVERor a proxy persona. Ports published on the
host stay reachable, browser-node's noVNC included, and so does the controller. Publish such a proxy another way,
or useISOLATED_BROWSER_NETWORK_MODE=shared. ISOLATED_BROWSER_NETWORKnow applies only withISOLATED_BROWSER_NETWORK_MODE=shared. Withper_sessionit
is ignored, with a warning.per_sessionneeds the controller to run as a container on the isolation Docker daemon. A controller run on the
host needsshared, and so does one that reaches another daemon throughISOLATED_DOCKER_HOST. A
docker-socket-proxy needsNETWORKS=1as well asCONTAINERS=1andPOST=1.- Session networks are named
<container>-net. At startup the controller removes leftover session networks along
with leftover containers, so run one isolation controller per Docker daemon. - Docker's default address pools allow about 30 bridge networks per daemon, shared with the default bridge, the
compose network and other projects. Above roughly 25 concurrent sessions, widendefault-address-poolsin the
daemon'sdaemon.json.
- An isolated browser no longer reaches compose-network services by name or container address: browser-node's
- Each isolated session's noVNC has its own random password, and isolated containers no longer receive the
operator'sVNC_PASSWORD.- The takeover URL in a session's own record carries the password in its URL fragment, so noVNC connects without
asking. That record is what creating or getting a session, remote access and the takeover request return, over
REST and MCP, so over MCP it reaches the calling model. Treat that URL as a secret. - The password is 8 characters, the most VNC authentication uses, and that authentication can be cracked offline by
anyone who watches the connection. Use the HTTPS tunnel modes when the takeover leaves the machine. - Lists, observations and stored records keep the password-free URL.
VNC_PASSWORDnow guards only the shared browser-node. Set it indocker_ephemeraldeployments; readiness warns
when it is missing.
- The takeover URL in a session's own record carries the password in its URL fragment, so noVNC connects without
- Browser containers keep only five Linux capabilities (
CHOWN,DAC_OVERRIDE,KILL,SETGID,SETUID),
withno-new-privileges; browser-node also getspids_limit: 2048, which counts threads. The controller runs with
no-new-privileges. With the isolation overlay, IP forwarding is also off in the controller. A custom image built
on browser-node that needs more capabilities has to add them to the compose service'scap_add; isolated session
containers always get these five, and no setting widens them. /docs,/redocand/openapi.jsonanswer without a token on a loopback deployment only, so a browser can
open them, with or without a configured token. Swagger's Authorize button takes the token, and the OpenAPI schema
now declares a globalbearerAuthscheme, which changes generated clients. An exposed deployment still requires
the token there.
Security
- A local controller generates and requires its own token.
- Before, a controller with no
API_BEARER_TOKEN(the default compose setup) answered unauthenticated requests
whose Host header named a loopback host. Native code in any container on the compose network can send that
header, and it reached stored-login export, approvals and agent runs on the operator's provider keys. - A wrong-token guess at
/metricsor/artifactsnow counts against the per-IP rate limit. - The escape hatch never applies in production.
- The controller logs a startup warning when a loopback
API_BEARER_TOKENorAPI_BEARER_TOKENSentry is shorter
than 32 characters.
- Before, a controller with no
docker_ephemeralsessions no longer share a network. Every session's browser container used to join the
controller's network, as did browser-node. Each one's Playwright server answeredGET /jsonwith its WebSocket
path and checked neither Host nor Origin. Code in one session's container could therefore drive every other
session's browser.- Each isolated session's noVNC has its own password. A published noVNC port was reachable from other sessions'
containers: by container address, and on Docker Desktop throughhost.docker.internal. Native code can forge the
Host and Origin headers noVNC checks, so without a password the session's desktop was open to it. A shared
VNC_PASSWORDdid not help, because each container held a copy its browser user could read. - Browser containers drop every Linux capability except the five their entrypoint uses. Browser-node, the isolated
browser containers and the controller run withno-new-privileges. Chromium runs without its own sandbox in these
containers, so this narrows what a renderer exploit gets. - The isolation controller no longer forwards IP traffic between the session networks it joins
(net.ipv4.ip_forward=0). - The stdio bridge never follows a redirect. Python's urllib re-sent a request answered 301, 302 or 303 as a GET
to the redirect's target with the same headers, so a redirecting endpoint received the bridge's token, on any host.
MCP never redirects, and the bridge now reports a redirect as an HTTP error.
Added
API_TOKEN_FILEandAPI_ALLOW_TOKENLESS_LOOPBACK(above).--token-file/AUTO_BROWSER_TOKEN_FILEfor the stdio bridge, read on every...
v1.12.4
A browser update over 1.12.3. The browser-node image moves from Chromium 151 to Chromium 156, which has the fixes
for two V8 bugs that were exploited in the wild. Update the browser-node and controller images together. The client,
LangChain and MCP bridge packages change only in version; updating them alone does not deploy the fix.
Upgrade notes
- Update the controller and browser-node together. Both run Playwright 1.64.1, and Playwright's client and
server must match. IfISOLATED_BROWSER_IMAGEnames an image explicitly, point it at the new browser-node image,
and close isolated sessions started before the upgrade. - Running the controller from a checkout rather than the image: after installing the new requirements, run
python -m playwright install chromium, so that Playwright's own Chromium is the new one too.
Security
-
Chromium 156.0.8078.4, up from 151.0.7922.34: Playwright 1.64.1 on the pip and npm sides together, up from
1.62.0. The browser that renders pages, in browser-node and in everydocker_ephemeralsession container, now
includes the fixes for:- CVE-2026-85046, a V8 type confusion fixed in Chrome 152.0.7977.82;
- CVE-2026-87491, a V8 out-of-bounds write fixed in Chrome 153.0.8010.36;
- the other security fixes Chrome shipped from 152 to 156.
Google reported both V8 bugs exploited in the wild, and both are on CISA's Known Exploited Vulnerabilities list.
Playwright's build trails Chrome stable, which was 156.0.8078.79 on 2026-10-29, so a few of the latest Chrome 156
fixes are not in it yet.
Changed
-
Playwright 1.64's default
--disable-featureslist:- adds
AvoidCorsURLLoaderRestartOnRedirect,NetworkTimeServiceQueryingandAimEnabled; - drops
BoundaryEventDispatchTracksNodeRemoval.
The copy browser-node and the controller pass, which keeps storage partitioning on, follows it.
- adds
-
A top-level navigation answered 204 no longer shows up in a session's request failures. Playwright 1.64 reports
it as finished, not failed withnet::ERR_ABORTED. UnderALLOWED_HOSTSit still ends the refused navigation it
belongs to, as before.
v1.12.3
A patch release over 1.12.2. Whitespace around the controller's bearer token and the skills curator's API key no longer
matters, and one security fix: an API_BEARER_TOKEN made only of whitespace worked as a credential. Update the
controller (its image, or your source checkout) to get the fixes. The client, LangChain and MCP bridge packages change
too: they strip the token they send. If your token has spaces or tabs around it, update the controller before the
clients (Upgrade notes).
Upgrade notes
- The 32-character floor for
API_BEARER_TOKEN(production, or reachable off the box) now measures the token without
its surrounding whitespace. A token that cleared it only because of padding is refused at startup; replace it with a
longer one. - If your
API_BEARER_TOKENhas spaces or tabs around it, update the controller before the clients. The new clients
send the token stripped, which an older controller compares with the padded value and refuses; the new controller
accepts both.
Security
- An
API_BEARER_TOKENmade only of whitespace is no longer a credential (#252). It was compared like any other
token, soBearerfollowed by the same whitespace authenticated under uvicorn's default HTTP parser, and a run of 32
or more spaces even cleared the production length floor at startup. It now counts as unset, as an empty
API_BEARER_TOKEN=already did. Without namedAPI_BEARER_TOKENS, a controller reachable off the box or running with
APP_ENV=productionrefuses to start ("API_BEARER_TOKEN is required"), and one on loopback otherwise runs as the
documented no-token default and logs the existing warning. Exploiting it needed an operator to have set such a token;
nothing ships one.
Fixed
- Whitespace around the bearer token no longer matters (#252). The controller compared the whole
Authorization
header withBearer <API_BEARER_TOKEN>, byte for byte, so a token pasted with a space or newline around it matched
only a client that sent the same whitespace, and a trailing newline (common in secret files) matched nothing the HTTP
parser would pass through. The controller now stripsAPI_BEARER_TOKENandWITNESS_REMOTE_API_KEY(named
API_BEARER_TOKENSwere already stripped), and ignores spaces and tabs around the token in the header it receives
(Bearermust still be followed by a space), so curl, the repo's shell helpers and third-party MCP clients that send
a padded token still match. The rate limiter keys on the same normalized header, so every spelling of one token shares
its bucket. The first-party senders strip the token before sending: the MCP stdio bridge (--bearer-token,
AUTO_BROWSER_BEARER_TOKEN), the Python SDK client, the LangChainAutoBrowserToolandAutoBrowserNode, the eval
client behindscripts/agent_eval.py, and the dashboard. A client given a whitespace-only token sends no
Authorizationheader. Padding the HTTP library refused (a newline after the bridge's token, a space before the
witness key) used to put the whole value in the bridge's error reply and in the controller's witness-delivery warning;
it is stripped before sending now. - The skills curator's API key is stripped too (#251). 1.12.2 strips whitespace around the eight provider keys in
the controller's settings, but the curator (CURATOR_ENABLED) readsANTHROPIC_API_KEY,OPENAI_API_KEYor
GEMINI_API_KEYfrom the environment itself, so a key with a space or newline around it still went out as-is and the
provider refused it. The curator now strips it as well, and a key that is only whitespace counts as unset.
v1.12.2
A controller patch release over 1.12.1. The OpenAI-compatible providers now work on models that refuse a named
tool_choice, a provider API key pasted with whitespace around it works, and the controller's dependencies are
updated. The client, LangChain and MCP bridge packages change only in version; update the controller image to get it.
Changed
- Controller dependencies: FastAPI 0.162 (
>=0.162.0,<0.163, up from>=0.143.2,<0.144; #235), and starlette
1.10.0, pydantic-settings 2.16.1 and idna 3.20 (#239). Building the controller from source now needs setuptools
84.2.1 or later (#234); pip's isolated build fetches it. Dev: ruff 0.17.3 (#239). - CI and release actions:
actions/setup-python7.1.0,docker/login-action4.7.0, and
pypa/gh-action-pypi-publish1.14.3 (#236). The last is the step that publishes to PyPI: 1.14.3 drops the revoked
emSign root CA from the trust store it uses for Trusted Publishing and attestations
(GHSA-5xjx-3w67-w9x9, critical).
Fixed
- OpenAI-compatible providers no longer stop at a 400 on
tool_choice(#244, #246). Every request named the
browser_actiontool withtool_choice, and some models refuse a named choice with a 400: Claude Opus 5.5, Sonnet
5.5 and Fable 5.1 throughopenrouter(checked live on 2026-11-02), and, as DeepSeek documents, DeepSeek with
thinking on (deepseek-flashanddeepseek-v4-pro, set asDEEPSEEK_MODELorprovider_model). A 400 is not
retried, so on these models/agent/stepanswered 400 and/agent/runended withstatus: "error"at its first
step, whoseerror_codewas 400: the agent never acted. When the endpoint answers 400 and the error mentions
tool_choice, the request is now sent once more withouttool_choice. Both the message and the whole JSON error
body are searched, because OpenRouter can carry the upstream text only inerror.metadata. If the endpoint accepts
that request, later requests from that provider to that base URL and model go withouttool_choicefrom the start,
until the controller restarts, and the controller logs the switch atINFO; if it refuses it, its error is raised
and nothing is remembered. Endpoints that honour the named choice are unchanged: it is still sent first, and any
other error is handled as before. The cost is one extra rejected request per model per controller process, or one
per request already in flight when the first refusal comes back. An endpoint that refuses the request without
tool_choicetoo, such as vLLM 0.14 and later started without--enable-auto-tool-choiceand--tool-call-parser,
is not helped, and pays the extra request on every step. - A provider API key with a space or newline around it now works (#248). The eight provider keys
(OPENAI_API_KEY,ANTHROPIC_API_KEY,GEMINI_API_KEY,OPENROUTER_API_KEY,XAI_API_KEY,DEEPSEEK_API_KEY,
MINIMAX_API_KEYandOPENAI_COMPATIBLE_API_KEY) were sent exactly as set. On OpenRouter, a key pasted with a
leading space made every request a 401 ("Missing Authentication header") while/agent/providersstill showed the
provider as configured. Surrounding whitespace is now stripped, and a key that is only whitespace counts as unset.