Repository navigation
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. The browser-node endpoint
ws://<host>:9223/<path>is inbrowser-sandbox.json, and that path is the Playwright server's only secret. The
compose smoke printed the whole file for each browser, and CI printed the state and the browser-node log into the
public CI log:sandbox-report.mjs,scripts/assert_browser_sandbox.py(its state line and its failure lines),
scripts/check_sandbox_gate.py(its mismatch and failure lines), two inline state prints in the sandbox steps and
thewrote <file>: <endpoint>line thatbrowser-node/server.mjslogs when it starts. Each now replaces the path
with<redacted>(scheme, host and port stay), as does thethe sandbox check failed: ...detail that
browser-node/sandbox.mjswrites into the state and the log when a connect error names the URL. The controller does
the same for what it prints itself: itsconnected to browser node ...log line, the connect failure message (which
names a configuredBROWSER_WS_ENDPOINT) and theendpointin the browser_sandbox readiness details. A failed
connect no longer chains Playwright's own error to that message, because that error names the endpoint with its path
and/readyz, deep health and startup log the chain; the last error is logged at warning level with the path
redacted instead. The state file and the endpoint file themselves still hold the path (the controller reads it
there). /healthz/deepnow gives up on a browser that does not answer. The probe waited on the browser connection with
no limit, so a stuck browser node held the request open (and the controller's browser lock with it). That step is
now bounded by the same 5 seconds as/readyz, and a timeout answers 503 withdeep_health_probe_timed_out. The
wait is bounded, not the connect: it is left to finish in the background rather than cancelled mid-connect, which
leaked a half-acquired browser connection. While it runs, later probes wait on that same connect instead of each
starting another, so polling through an outage does not pile up connects behind the browser lock. Once the browser
is connected, the rest of the probe (opening a context and a page, rendering the fixture) shares one 10-second
limit, answering the same 503deep_health_probe_timed_out, so a browser that connects but never answers no longer
holds the request open. Closing the probe's context gets 2 more seconds; a close that hangs, or one cut short
because the 10 seconds ran out, is left to finish in the background and logged at warning level. A context the
browser creates after the probe gave up on it is closed when it appears. A successful answer also names its fixture
asfixture(orembedded) rather than an absolute server path./readyz, which any caller may poll (it needs no
token and is not rate limited), now waits the same way: it gave up after 5 seconds by cancelling the connect, which
leaked a half-acquired connection per probe; it now stops waiting and leaves the connect to finish, sharing it with
every other probe.- An
approval_idpresented on an action that needs no approval no longer makes its Witness receipt say the action
required one. The receipt recorded the id withrequired: true, so a caller could make a receipt read as if the
action had been approval-gated. The receipt still shows the id, as the record of what was presented, with no status;
requiredis now true only for an action that needed an approval or ran under one. - A disabled cron job can no longer be run through its webhook or
trigger_cron_job. The scheduler skipped a
disabled job, but both manual paths started it anyway. They now refuse with "job is disabled; enable it to run it"
(HTTP 409 on the webhook route; over MCP an error withcode: cron_job_disabled). A wrong webhook key is still
answered 403, so a disabled job does not tell a stranger its state. - Page-started downloads are capped. A page could start any number of downloads of any size, and each was written
whole to the session's artifact folder. A download larger than the newDOWNLOAD_MAX_BYTES(default 256 MiB) is
deleted once Playwright has saved it, and once a session holdsDOWNLOADS_MAX_PER_SESSIONfiles (default 100)
further downloads are cancelled and not saved. Both are recorded in the session's downloads asrefused, with the
reason. Raise the settings if you download larger or more files on purpose. The browser's own copy of every download
(kept, refused, failed, or cancelled mid-save) is deleted once it has been handled, so refused downloads do not pile
up inside the browser until its context closes. A download whose save fails leaves no file behind (so it does not
count toward the cap) and is recorded asfailedwith nopath,filenameorurl. One whose save is cancelled
midway writes no record, and the browser's copy is deleted in the background once the browser has finished with it.
It also leaves no file, with one narrow exception: Playwright opens the file in a thread of its own, so a
cancellation that lands during that open can leave an empty file (which then counts toward the cap) or, on Windows,
a file the still-open handle keeps from being deleted; that is logged, and the cancellation still goes on. - Provider CLI children no longer inherit the controller's whole environment.
claude,codexandgeminiran
with a copy of the controller's environment, so the bearer token, the Witness remote key, Stripe keys and every
other provider's API key were visible to them, and to anything a prompt-injected CLI could run. They now get an
allowlist (see the upgrade note). They also start inCLI_HOMEinstead of the controller's working directory,
because the Gemini CLI loads a.envfrom its working directory and its parents, which on a host checkout would
hand the withheld secrets back. WithCLI_HOMEunset they start in a private directory the controller makes for
this user:$XDG_RUNTIME_DIR/auto-browser-cliwhen that is an absolute path to a directory the user owns that no
one else can write, otherwise~/.cache/auto-browser/cli-home(under%LOCALAPPDATA%on Windows), never the shared
system temp dir. On Linux and macOS it, and any parent it needs, is created 0700 and checked on every use: a
symlink, another owner or another mode, or a parent (up to the first root-owned one) that group or others can write
without the sticky bit or that another user owns, refuses that place and the next one is tried; a directory a
cleaner removed is made again. It holds empty.envand.gemini/.envfiles, which stop the Gemini CLI looking for
a.envin its parents (the user's home is one). When no place can be used, the CLI call fails with an error that
says to setCLI_HOMEto a writable directory; the paths, owners and modes refused are in the server log only. The
Codex CLI's per-call directory is now made under the CLI home too, not in the system temp dir. WithCLI_HOMEset
to a directory that cannot be created (a host run with no writable/data), that private directory is their working
directory and theirHOME, and a warning says CLI mode needsCLI_HOMEset to a writable directory. In the image,
when the controller runs as a host user id andCLI_HOMEcannot be created, the fallback refuses/home/controller
(a sticky directory owned by uid 10002), so setCLI_HOMEthere. - Harness traces no longer keep typed secrets or URL tokens, and induced skills no longer claim to be credential
free. A trace redacted only keys with secret-sounding names, so the text typed into a field marked sensitive (or
found sensitive when the step ran), tokens in a URL query or fragment, and PII in free text all stayed in
trace.json. Typed text of a sensitive action is now redacted (and so are itsvalueandlabel, for a
select_option, and itsusername, in its action and its model decision alike, and in the action's executed
target), and every string in the events, and in the final observation and network entries as they are stored
(trace.jsonand the staged skill candidate), goes through the PII scrubber (URLs through the URL scrubber) when
PII_SCRUB_ENABLEDis on. The harness verifier still checks the original observation, so a forbidden email or a
requireddom_containsgives the same verdict as before. A URL is recognised in any letter case and anywhere in a
string.extracted_data, the task's own output, is not text-scrubbed, but a URL in it still has its query and
fragment scrubbed. A staged skill candidate's signed metadata used to saycredential_free: truefor every
candidate; nothing computed it, so the field is removed. The verifier's result, which copies the page URL, was also
written as it came torun.json,drift.json,strategy.mdand a staged skill'scandidate.json,envelope.json
andprovenance.json, so anaccess_tokenin the final URL survived there. It is now scrubbed the same way
wherever it is stored, and the run still verifies the original observation. The harness run responses
(harness.start_convergence,get_status,list_runsand the CLI's--json) now return that scrubbed
verification, so a URL token shows as[REDACTED]in them too. - Confidential sessions now keep restricted evidence for every action, not only for the session record. The
Witness policy chose the evidence mode for an action without looking at the session's profile, so in a
confidentialsession an ordinary action's receipt kept before/after evidence and the typed text preview. The
action now starts from the session's mode, as the session record already did. Standard sessions are unchanged. - A restricted receipt no longer keeps what a
select_optionchose. In restricted evidence mode (a confidential
session, or a sensitive action) the receipt's target hidtext,text_preview,file_path,passwordand
totp_secretbut kept thevalue,labelandindexof aselect_option. All three are now[REDACTED]there
(one left unset staysnull, so the receipt still shows which way the option was chosen). Standard receipts are
unchanged. - Typed text is scrubbed before it reaches the audit log and action receipts. A
typeaction stores the first 80
characters of what was typed (text_preview) in an ordinary field, and that went to disk as typed. With
PII_SCRUB_ENABLEDon, the whole text is now run through the PII scrubber first and then cut to 80 characters, so a
secret that straddles the limit is still caught. A Playwright export now fills[REDACTED]where the typed text
matched a PII pattern, so replay such a script with the real value substituted by hand. With scrubbing off, nothing
changes. - The network log masks secret-bearing headers by name pattern, not only from a fixed list. Any header whose name
has a word such as token, secret, password, auth, session, cookie, csrf, signature or key (split on-and_), or
contains token, secret, password or apikey, now logs as[MASKED]. Before,X-GitHub-Token,X-Vault-Token,
X-Session-Id,Api-Keyand similar were stored in clear.Keep-Alive,:authority,Accept*andContent-*
are unchanged. observeno longer returns the OCR word list that screenshot redaction blacks out. Theocrfield carried
redaction_blocks(every word with its box, including the emails and card numbers the screenshot hides) and the OCR
text unscrubbed.redaction_blocksis gone from the result, and the OCRtext_excerptand block text now go
through the PII scrubber whenPII_SCRUB_ENABLEDis on. A block that belongs to a match spanning words (a card
number OCR'd as four blocks, a split phone number) is replaced whole, using the same line grouping as the screenshot
redaction.screenshot_diffnow redacts PII like every other screenshot path. It captured the comparison shot unredacted,
so the saved baseline and the second shot held the page's raw pixels even withPII_SCRUB_SCREENSHOTon. Both are
now redacted before they settle on disk, and the diff image is computed from them. The earlier screenshot it
compares against may be raw (PII_SCRUB_ACTION_SCREENSHOTS=false, or an earlier OCR failure), so a redacted copy of
it is made and compared instead, anda_urlpoints at that copy. When OCR is unavailable or redaction fails, the
result carries the samescreenshot_pii_scrubnotice asobserve, and strict mode withholds the shot.
Fixed
-
Asking for an auth profile that does not exist no longer creates an empty folder for it. Each
GETof a missing
profile name left a directory underAUTH_ROOT/profiles, so any name could add one. Reads now create nothing; only
saving a profile does. -
A harness contract of only optional postconditions no longer passes with nothing satisfied. The verifier passed
it at confidence 1.0 even when the page matched none of them. When every postcondition is optional, at least one
must now be satisfied; a contract with any required postcondition is judged as before. -
Harness traces of a live agent run now record its screenshots. The trace built from an agent result held only
actionevents, so a run that took screenshots never met ascreenshotsevidence requirement. Each step that has a
screenshot now adds ascreenshotevent (the same event type the mock attempt records) before its action, because
the screenshot is the page the agent saw before it acted, and a second one after its action when the action's result
carries the screenshot of the page after it (execution.after). -
The Codespaces overlay now accepts the forwarded host name. The forwarder is expected to send
<codespace>-8000.app.github.devas Host (not yet observed in a Codespace), and the overlay's loopback-only host
list would refuse it with "Invalid host header". It now defaultsCONTROLLER_ALLOWED_HOSTSto the loopback names
plus*.app.github.dev, and.devcontainer/allow-forwarded-host.shappends the exact forwarded name to a
CONTROLLER_ALLOWED_HOSTSthat.envalready sets (for example one copied from.env.example). It runs when the
dev container is created and again each time it starts, so a.envcopied in afterwards is covered too. It edits a
copy kept beside.env(mode kept, removed on any exit) and moves it into place, keeps a CRLF.envas it was, and
leaves a quoted value or one with a trailing# commentuntouched, saying so without printing the value. If you
set it by hand, include*.app.github.devin it. -
pip install auto-browser-langchain[crewai]now installslangchain-core. Recent CrewAI releases no longer
depend on LangChain, so the CrewAI example failed atAutoBrowserTool(...)with anImportErrorasking for the
langchainextra. Thecrewaiextra now includes it. -
The reverse-SSH sidecar now reports
inactivewhen its tunnel ends. The entrypoint ran underset -e, so an
autosshthat exited with an error (or was stopped) ended the script before the final status was written, and
reverse-ssh.jsonkept sayingactiveafter the tunnel was gone. The exit status is unchanged. -
Human-like typing now has a time limit. It paused 40-130 ms after every character with no overall cap, so a
5000-character text held the session for about 11 minutes. Typing now takes about 24 seconds at most: the pauses of
a long text are shortened to fit, and once the time is used up the rest of the text is inserted as plain text
without key events, except that a newline, carriage return or tab in it is still pressed as Enter or Tab, so a text
ending in a newline still submits a form. A keystroke or the final insert that the page does not acknowledge now
fails the action with the retryabletyping_timeout(after the time left, and at least 2 seconds) instead of
holding the session until the browser answers. The 24 seconds are the 20 of the budget, up to 2 for a keystroke
started just before it ends, and 2 more that the rest of the text shares, however many newlines or tabs it holds. A
short text keeps its usual pace. -
Ordinary pages are no longer mistaken for bot challenges. After each action the controller looked for words such
as "captcha", "security check" and "access denied" anywhere in the URL, the title and the first 500 characters of
the page, so a blog post about CAPTCHAs, a?q=captchasearch or a docs paragraph about HTTP 403 failed an action
that had already run withcaptcha_detectedand asked for a human takeover. A challenge is now recognised by
exactly these: a visible frame from a challenge provider (Google reCAPTCHA, hCaptcha, Cloudflare Turnstile and
challenges, Arkose Labs, GeeTest, DataDome) or any visible frame whose host name contains "captcha" (matched on the
host, not the path or query); the page itself being on a Cloudflare challenge host,recaptcha.net,
captcha-delivery.com, or under/cdn-cgi/challenge-platform/or on one of the paths where google.com serves the
challenge (/recaptcha/api2/,/recaptcha/enterprise/,/recaptcha/api/fallback); a title that is exactly "Just a
moment..." or "Attention Required! | Cloudflare", or ends in "used Cloudflare to restrict access"; or the "verify
you are human" prompt in the page text. Invisible widgets do not count: a frame that is not rendered, or whose
address sayssize=invisible(reCAPTCHA v2 and v3) orcheckbox-invisible(hCaptcha), is ignored, so a login page
with a reCAPTCHA v3 badge no longer fails every action. A visible provider widget counts even when it is already
solved (a ticked reCAPTCHA checkbox, a Turnstile "Success!" box): that is deliberate for now, because the probe
cannot tell a solved widget from an open one, so every action on a page that shows one still fails with
captcha_detecteduntil the widget is gone. -
Element intelligence now works on a real browser.
get_element_intelligenceasked Chromium for computed styles
without first enabling the DOM and CSS agents, so the style lookup failed there. It now enables both for the length
of the read and switches the CSS agent off again afterwards (a CSS agent left on keeps streaming stylesheet events
for the life of the session), each enable and disable bounded at 5 seconds. The DOM agent stays asDOM.getDocument
leaves it, because turning it off discards every node id, among them thenode_idthe read returns and the ones a
raw chain still means to use. A read cut off by the route's 10-second limit switches the CSS agent off in the
background rather than making the 504 wait for it. A rawCSS.getComputedStyleForNodeor
CSS.getMatchedStylesForNodethrough/cdp/rawis scoped the same way, so it no longer fails on a session that has
not read an element yet. -
A refused fork or create now says why. With the default
MAX_SESSIONS=1, forking a session (which counts as a
new session) or creating a second one answered a bare409 {"detail":"Conflict"}. The 409 now carries the
session-limit message, naming the limit and the live sessions. Other conflicts still answer "Conflict". Forking
needsMAX_SESSIONSof at least 2; this is now stated indocs/architecture.mdand in thebrowser.fork_session
tool description. A refused fork now checks the limit first, so it no longer writes the source session's cookies to
disk (and deletes them) on the way to the refusal; a fork at the limit therefore answers the limit before it checks
the page (Chrome's error page, a page the allowed-hosts list refuses, a missing start URL). Shadow browse is not
refused by the limit, and says so; once open, a shadow session does count toward it. -
Vision targeting now posts to the right Anthropic URL. With the default
ANTHROPIC_BASE_URL(which already ends
in/v1) it called/v1/v1/messagesand every vision lookup failed. It now uses the same<base>/messagesform as
the Claude adapter. The curator's Claude calls (CURATOR_PROVIDER=claude) went straight to
https://api.anthropic.com/v1/messageswhateverANTHROPIC_BASE_URLsaid; they now follow it too, so a gateway or
proxy you route Anthropic traffic through sees them. The curator's OpenAI and Gemini calls followOPENAI_BASE_URL
andGEMINI_BASE_URLthe same way, and itsanthropic-versionheader comes fromANTHROPIC_VERSION; the curator
is built from the controller's own settings rather than the cached ones. -
A proxy persona file that cannot be read is no longer overwritten. An unreadable or corrupt file read as an
empty set, so the next save rewrote it and erased every other persona. Reads and saves now fail, and the file is
left as it was; a missing file still reads as empty. REST answers 503 and MCP returns the same fixed text, "the
proxy persona file (PROXY_PERSONA_FILE) is unreadable; fix or move it. It was not changed."; the file's path and the
cause are in the server log only. A file in a folder the controller may not read now gets the same answer; its bare
permission error used to answer 500 on the persona routes (409 on a save), 403 onPOST /sessionsand on the cron
webhook, and "Tool execution failed" over MCP. With named tokens (API_BEARER_TOKENS) the startup ownerless-persona
warning logs the error and carries on; without them the file is not read at startup. -
Saving or deleting a proxy persona no longer erases the others' hand edits. A save rewrote the file from the
normalized view, so an entry the store ignores (a misspelledserver) and any extra key on another entry were
dropped. Only the named persona is replaced or removed now; every other entry is written back as it was, in the
file's own order (a save sorted the entries, which could change which of two entries for one name was used). An
update of a persona keeps its place in the file and the keys added to its own entry by hand, where it kept only the
store's fields. A name that two entries share after a hand edit ("a "beside"a") is no longer saved or deleted,
since removing both could drop another operator's: REST answers 409 and MCP returns "the proxy persona file
(PROXY_PERSONA_FILE) holds more than one entry for this name (their keys differ only in surrounding spaces); remove
all but one by hand. It was not changed.", and the server log names the entries; a caller who may not use the
persona that name resolves to (the last of the entries) gets the answer it gets for another operator's persona. A
save no longer overwrites an entry the store ignores under the same name when another operator owns it. -
Two actions on one session no longer mix up their Witness context. An action's risk category and approval id
were parked on the session until its turn for the session lock, and the first action to finish cleared them, so an
action that had been waiting could be receipted with no approval and no risk, or with another action's. Each action
now carries its own context. -
One unparseable Witness receipt line no longer hides the rest. Listing a session's receipts and exporting its
bundle failed (HTTP 400) on the first line that did not parse. They now skip it and reportunparseable_lineswith
how many they skipped (in the listed window, the lastlimitlines, for a listing; in the whole chain for a
bundle). The bundle keeps the receipts that parse, and its chain check still fails on the gap
(chain_valid_at_exportis false, with verify's reason inchain_reason_at_exportand the anchor's status code in
chain_anchor_status_at_export). With a gap itshead_hashisnull, andscripts/verify_witness_bundle.pynow
fails a bundle whoseunparseable_linesis present and anything but the integer 0, or whosechain_valid_at_export
is present and anything buttrue,--allow-unsignedor not (an unsigned chain that is intact exportstrue; a
bundle made before the field passes without it). It prints the bundle's own text (the reason, the scope, ids) with
control characters escaped and cut at 200 characters, so a bundle cannot hide or rewrite lines of its output. Both
fields are the exporter's own report and are unsigned, so this catches an honest incomplete export, not a forged
one. A torn tail (an append cut short, with no newline) is skipped by the listing and the bundle alike and counted
by neither; the bundle saystorn_tail: true, and its chain check fails on the fragment until the next append sets
it aside. -
Two MCP tool calls without a
session_idno longer create two sessions. Each saw no live session and created
its own. Creating the implicit session is now serialized among calls from the same verified operator (all callers
without a token share one lock), so the second call uses the session the first one made. A call that finds a live
session, a tool that never creates one, and a call from another verified operator do not wait behind a create. The
lock is per owner: a token operator and an unauthenticated caller can still each create a session when both find
none live, and the token operator's next session-less call then answersambiguous_session(an unauthenticated
caller sees only the unowned session). -
Cancelling a cron trigger while it queues the run no longer strands the job. The slot that marks a job as
running and the browser session it had opened were only released when queuing raised an ordinary error, so a
cancellation left the session open and every later trigger of that job answeredprevious_run_activeuntil the
controller restarted. Both are now released on cancellation too. A cancellation after the queue accepted the job now
withdraws that job before the session closes, so no queued job is left on a closed session. A withdrawn job is
recorded asinterrupted(audit eventagent_job_interrupted), as a restart records the jobs it stops, not as an
operator's cancel; like any interrupted run it can be resumed by hand, and nothing re-runs it on its own. That also
holds when the cancellation lands while the job's record is still being written (for any queued agent job, not only
a cron run): the record is withdrawn once it has landed, where before it stayedqueuedand the next start ran it.
A job that cannot be taken back is logged as an error with its id, where it used to be passed over in silence. The
cron service also hands its finish callback to the queue with the job, so a job that finishes before the trigger
returns still releases its slot and session. When a worker was already running the job, the run is released once:
its session is not closed a second time, and a trigger that took the slot in the meantime keeps it. -
An agent run or step that ends in an error is now recorded as a failed job. The job queue recorded every job as
completedwhatever the run returned, so a run that ended inerrorlooked successful in the job list. The job is
nowfailedwith the error the run reported; runs that end indone,takeover,approval_requiredor
max_steps_reachedstaycompleted, and a failed run can still be resumed. -
Cancelling an agent job now works when its audit events cannot be written. The cancel recorded
cancellingand
wrote its audit event before it stopped the job, so an audit log the controller could not write made the cancel
answer an error and left the job running. The job is now stopped first, the cancel answers the job's state, and an
audit event that fails during a cancellation, a withdrawal or an interruption is logged with the job's id instead of
raised. A job stopped as it starts, before its run begins, is now recorded like any other stopped job (cancelled
when an operator cancelled it,interruptedwhen a shutdown or a withdrawal stopped it), where its record could
staycancellingorrunninguntil the next restart. A controller shutdown with a job running no longer waits
forever when the job's interruption cannot be recorded (its audit event or, on a full disk, its own record). -
A provider's reply that holds two different decisions is now refused, not read for the first one. The parser
took the first decision-shaped JSON object anywhere in the text, so JSON the model quoted from the page (for example
a click on#delete-account) beat the decision the model then gave, and taking the last one instead would let a
quote after the decision win. The prompt asks for exactly one decision, so a reply that holds more than one
different decision-shaped object (in a markdown code fence or not, or nested in one wrapper object or array) now
fails as an unparseable reply does, and the step fails. The same decision given twice (fenced and bare, say) is
still accepted. A reply nested too deeply to decode also fails as unparseable instead of raisingRecursionError. -
An action that fails unexpectedly or is cancelled after it began now leaves an audit entry and a receipt. Only
known failures were recorded, so a cancellation or an unforeseen error mid-action left no trace of an action whose
effect may have happened. Whatever interrupts an action that began (during its operation, while a known failure is
handled, or while the page is read after it), it now leaves oneerrorentry and receipt (the error type only, not
its text), and then the error or cancellation carries on as before. The record says the outcome is unknown, or, when
the action certainly ran, that it ran and says what was cut short (reading the page after it, or recording it). A
cancellation that lands while an action's record is being written no longer cuts the writes short (the
actions.jsonlline, the audit entry and the receipt, written one after another); they finish behind it. The
session's next action waits up to 5 seconds for them before it starts, and so do closing the session and a
takeover's record (which is now written the same way, so a cancelled takeover request no longer leaves part of its
record); the controller's shutdown waits up to 5 seconds in all for any still being written, and logs those it gave
up on. A cancellation that lands while an unexpected error is being recorded wins: the action ends cancelled (so a
cancelled agent job ends cancelled, not failed), and the error is in the server log and, by its type, in the record;
a second cancellation while a cancelled action is recorded is absorbed. A write that itself fails (a full disk) can
still leave a partial record. An action that failed before it began is still not recorded, and its approval is still
given back; but a policy refusal before the action began whose handling (going back, the snapshot) fails or is
cancelled is still recorded asblocked, without reading the page. -
The cron service now says so at every start when its store was quarantined and is empty. A corrupt store is
moved aside and the first read fails, but the next start found no file and ran with no jobs and no sign that they
were in thecrons.corrupt-*.jsonfile. While such a file exists and the store holds no jobs, each start logs a
warning naming it, also on a deployment without APScheduler, and saying that moving or deleting that file silences
it. Without APScheduler, a corrupt cron store now stops startup once, as it already did with it. A store that cannot
be read (rather than a corrupt one) is not moved, so it stops every start until it can be read, with or without
APScheduler. -
A cron store that cannot be used no longer shows its path to callers, and only a corrupt one is set aside. Its
error carried the store's path and the parser's message, which MCP callers received as they were. MCP callers now
get "the cron store (CRON_STORE_PATH) is unreadable; cron jobs are not loaded until it is fixed. The server log has
the cause and where the file is." (the REST cron routes now answer 503 with the same text; the Upgrade notes say
what they answered before), and the path and the cause are in the server log. Any error moved the store aside, so a
passing read error (a sharing violation, a brief permission fault) set a good, hand-edited store aside; now only a
store that does not parse, is not UTF-8 or holds JSON other than an object is moved, and one that cannot be read is
left where it is. A store holding JSON other than an object used to load, and its callers then failed on it. -
An MCP tool result is now always valid JSON. A
NaNor infinite number in a result was written as the bare word
NaNwithisErrorfalse, which strict JSON parsers reject, and a value that could not be serialized (a date, an
object) answered "Tool execution failed" although the tool had already acted. NaN and infinities now becomenull;
dates and times, paths, UUIDs and decimals become their text, and an enum its value. A result holding any other kind
of object is not sent as that object's text (which could carry fields nobody meant to send): it answers an error
saying the tool ran but its result could not be encoded. Error answers (an approval required, a failed browser
action and other refusals) are encoded the same strict way, so aNaNin an error's details isnulltoo, and a
detail that cannot be encoded is left out of the answer. MCP resource reads (browser://sessions,
browser://audit/eventsand a session'sconsoleandnetwork) are encoded the same strict way too; one that
cannot be encoded is logged at warning level and not served. -
Page-controlled strings are now capped in observations and element reads. A page could set a link's URL, a
field's name, type or role, a form's action or method, the title or the focused element's name or id to megabytes of
text, and it went whole into interactables, the active element, the form outline and so into model prompts. The same
held forfind_elements(href,id,value,class) and the element-intelligence asset references (src,
href,currentSrc,backgroundImage). URLs are cut at 2,000 characters, the title at 200, and a name, id, type,
role, method, value or class at 120, where the script reads them. A URL that was cut ends in…[truncated], so a
client does not follow it as if it were whole. A title is also cut at 200 wherever the controller reads it itself:
the session summary, the tab list, the fast perception preset, the share snapshot, the bot-challenge report, an
approval request's observation and a saved auth profile'ssaved_from_title. The element-intelligenceattributes
are cut the same way (an address attribute at 2,000 with the marker, any other value at 120) and limited to 50, as
are the inline event names. The bot-challenge report names only the frames that matched, as scheme, host and path
without the query, instead of every frame's full address.find_elementsalso cuts a regexmatchat 500
characters and itscontext_textto the requested context on each side, and says when it cut avalue,idor
class(value_truncated,id_truncated,class_truncated; a class list is cut between classes). The caps are
applied again in Python on what the page's own script returned, because that script runs where a page can replace
String.prototype.slice; the same goes for the asset references of an element read. A cut never leaves half of an
emoji: not in those fields, in thecontext_textwindow, in an element's label, a heading or the text excerpt of an
observation. Thevalueof a password field, a one-time-code field or any other field the typing check treats as
secret is nownullwithvalue_redacted: true, as typing into one is not echoed either. The check reads the
field'stype,name,id,autocomplete,placeholderandaria-labelup to 1,024 characters each, not the
120 shown elsewhere, so a long name cannot pushpasswordout of it; a field with a longer attribute than that has
itsvaluewithheld too, as the check could not read all of it. -
The compose smoke scripts no longer remove your own stack's containers.
smoke_isolated_session.shand its
tunnel twin removed every container and network labelledauto-browser.managed=trueon the daemon, their own
docker_ephemeral controller did the same when it started, and all three scripts (smoke_reverse_ssh.shnever
removed by label) randocker compose downin the folder's default project. Each now runs in its own compose
project (auto-browser-smoke, orSMOKE_COMPOSE_PROJECT; an exported or.envCOMPOSE_PROJECT_NAMEis ignored),
refuses to start, before it changes anything, when the daemon cannot be listed, when this checkout's own stack is
running in another project (it shares./data; the project directory is compared however the platform spells the
path, so a WindowsC:\Users\me\repolabel is found), when the smoke's own project already has running containers
(a smoke run is in progress, or a killed run left this behind; the message gives thedocker compose -p auto-browser-smoke down --remove-orphansthat clears it), whenSMOKE_COMPOSE_PROJECTnames this checkout's own
default compose project, or (the two isolated smokes) when anyauto-browser.managedcontainer or network exists,
which they check again after the image build and before the controller starts, and removes only the session
containers and networks named<project>-session-...that it created. To run a smoke beside your stack, stop that
stack, or (if it has no isolated session containers) use another checkout and setAPI_PORTandNOVNC_PORTin the
environment (they win over that checkout's.env); the two isolated smokes ignoreISOLATED_DOCKER_HOSTand
ISOLATED_HOST_DATA_ROOTfrom.env, so their controller reaps only the local daemon that was checked, and the
reverse-SSH smoke, whose controller would otherwise readSESSION_ISOLATION_MODE,ISOLATED_DOCKER_HOSTand
ISOLATED_HOST_DATA_ROOTfrom.env, pins them (shared_browser_nodeand empty, in
docker-compose.smoke-shared.yml, which only that script adds); the smoke still rebuilds the shared
auto-browser-browser-node:latesttag, which your controller launches isolated sessions from. If a killed run left
session containers behind, the refusal names them and the command that removes them. -
A
.envline is no longer executed as shell by the repo's scripts.doctor.sh,compose_local.sh,
bootstrap_cli_auth.shand the smoke scripts loaded.envby sourcing it, so a value such asX=$(cmd)rancmd,
and an unquoted&,;or(in a value broke the script. They now readKEY=VALUElines as text: blank lines
and#comments are skipped, quoted values lose one level of quotes, an unquoted# commentis dropped,KEY= # noteis empty, a UTF-8 BOM is ignored, and a value keeps every=and$in it. Keys that change how the shell or
the loader behaves (BASH_ENV,PATH,LD_*and the others listed under Upgrade notes) are refused with a
warning, and a line that cannot be read is skipped with a warning naming its line number. The values are still
passed to the programs the scripts start, so keep.envto values you trust. -
A skipped multi-line
.envvalue no longer leaks its later lines as keys. A double-quoted value with a\0
escape was skipped when the escape was met, before its closing quote, so on a value that spans lines the later lines
were read asKEY=VALUE(BAD="\0000,MAX_SESSIONS=5000,"exportedMAX_SESSIONS). The loader now reads on
to the closing quote and skips the whole value with its one warning. -
The smoke preflight finds this checkout's stack however Windows or WSL spells its folder. It compared the
compose project directory by exact text, so on Git Bash or CygwinC:\USERS\me\Repodid not match
/c/Users/me/repo(Windows paths ignore case), and a WSL/mnt/c/Users/me/repodid not match either, and the smoke
started beside a running stack it should have refused to touch. Both now compare equal. A/mnt/c/...folder is
read as the C drive only on Git Bash and Cygwin, or when it is compared with a Windows-style folder; on a Linux host
it stays an ordinary directory, so a stack at/mnt/c/repois not mistaken for one at/c/repo. -
A harness trace of a live run now ends on the page the last action left, not the page before it. The final
observation was the observation each step made before acting, so a run that ended on an action was verified against
the page it started that action on. It is now the action'safter(a full observation) whenever that was read; a
later step's own observation still replaces it (a loop-guard takeover step, which only repeats the previous step's
observation, does not). When the page after the action could not be read (the action'safterholds an
observation_errororsnapshot_error), the earlier observation is kept with the session's finalurland
title, so its other fields, the page text among them, are from before the action. -
The harness verifier now searches the page text a real observation carries. It looked for
text_contains,
dom_containsand a forbiddentext_containsintext,body_text,content,titleand the like, but an
observation keeps the page text intext_excerpt, so a forbidden phrase present only there passed and a required
one failed.text_excerptis now searched for both. -
A stored harness trace scrubs the text next to a URL and the PII in a URL's path. A URL ran to the next
whitespace, so in{"url":"https://example.com","email":"alice@example.com"}the email was part of the "URL" and
got only the URL scrubber, which reads the query and fragment. A URL now ends at a double quote, an angle bracket or
whitespace (or at an apostrophe that closes a quoted string, not one inside it, as in?q=o'neil&token=...),
without a trailing run of,;)]}, so the text after it is scrubbed as text, and the host and path of every URL are
read by the text scrubber too (https://a.example/users/alice@example.com/profile).extracted_datais still not
text-scrubbed. A staged skill'surl_containsexpectation is scrubbed the same way when drift is checked. -
A newly staged harness skill is no longer reported degraded at once. A staged skill keeps a scrubbed trace, so a
requireddom_containsortext_containson an email that passed in the live run no longer matched when the drift
monitor checked the stored copy. The monitor now scrubs the expected strings the way the stored trace was scrubbed
before it verifies. Aurl_containsvalue with a?or#goes through the URL scrubber for its query and
fragment and the text scrubber for its host and path, as the stored URL was; one with neither but an=
(email=...,&email=...) is scrubbed as a piece of a query; a path or bare text is scrubbed as text, as the
stored path was. A skill whose recorded page no longer holds the expected text still reports degraded. Limits: a
text expectation (text_contains,dom_contains) or aurl_containsthat is wholly PII (a bare email) is scrubbed
to the bare replacement, so it is checked as "some redaction is present" in the stored text or URL; and the stored
copy is compared under the scrub settings in force now, so changingPII_SCRUB_*after staging can still report a
fresh skill degraded. -
A live harness run now records its model decisions, and a mock run no longer counts as having screenshots. The
trace of a live agent run had nomodel_decisionevents, so a contract that requiredmodel_decisionsevidence
passed on a mock attempt and always failed on a live one. Each step with a decision now adds one, before its action
and redacted like the action. The other way round, the mock attempt's screenshot event (available: false) met a
requiredscreenshotskind with no image; a screenshot event now counts only when it carries ascreenshot_pathor
screenshot_urland is not marked unavailable. So a mock run (--mock-final-url,mock_final_observation) of a
contract that requiresscreenshotsevidence now fails withmissing_evidence, and a drift check of an existing
mock-staged candidate whose contract requires screenshots now reports it degraded. The mock attempt's decision and
action events now saysimulated: true. They still meetmodel_decisionsandactionsevidence, by construction
(the default contract requiresactions, so a mock run could not converge otherwise); a mock-staged candidate is
markedsimulated. -
The controller no longer hangs at startup with more than 100 queued agent jobs. It waited to put every queued
record on a queue that holds 100 before any worker existed, so record 101 blocked startup forever. The workers now
start first and a background task puts the records on the queue, oldest first; shutdown stops it. -
Creating an isolated session could move the controller's published port to the session's network and cut the
request that created it. On Linux, with per-session networks and a compose project whose network name sorts after
the session prefix (the projectweborprodwith the defaultbrowser-sessionprefix), joining the new session
network made Docker pick it as the controller's default gateway, which is where published ports are programmed; CI's
compose smoke saw the firstPOST /sessionsend with an empty reply. The controller now joins session networks at
gateway priority -1, so its own networks keep the gateway. This needs Docker Engine 28 or later (API 1.48); on an
older daemon the field is ignored, and the controller logs one warning per start when a session network's name sorts
before its own network's, naming the remedies (Engine 28, or anISOLATED_BROWSER_CONTAINER_PREFIXthat sorts after
the compose network's name). Whether later requests through the moved port were served was not checked.
Changed
-
The stdio bridge docs now say it handles one request at a time.
docs/mcp-clients.mdexplains that a slow tool
call holds up the calls behind it, and where to change the 60-second per-request timeout. No behaviour changed. -
CI now fails when the real-Chromium tests cannot run. The tests that drive a real Chromium skip when none is
installed, so the CI leg that installs one could lose it and stay green. SettingAUTO_BROWSER_REQUIRE_CHROMIUM=1
makes a missing Chromium fail the test run instead of skipping, and the Python 3.11 host-test leg sets it. A
developer without Chromium is unaffected: unset, the tests still skip. -
More tests for Witness's session policy and receipt redaction.
evaluate_sessionis now tested for each
protection profile, andredact_targetfor each redacted key in standard and restricted mode. No behaviour changed.