Skip to content

Releases

v1.17.0

Choose a tag to compare

@github-actions github-actions released this 09 Nov 04:32
fae8b90

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_WINDOW above 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.md describes 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.txt and
    requirements-dev.txt pin 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, and scripts/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
    reason key, and the approval's reason, which for a governed tool call previews its arguments, and an operator's
    comment on an approval decision. Each URL there (a url key's) now has its query and fragment scrubbed as the run
    trace scrubs a URL's, by the PII scrubber (so not with PII_SCRUB_ENABLED or PII_SCRUB_NETWORK off): 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_TOKEN or 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 a 401'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 a 401'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
    422 shape), or one of those as repr() or json.dumps() writes it (a structured detail went through str(),
    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's json to parse (RecursionError) is answered as an unreadable
    one is, with its HTTP status, not as an unexpected bridge failure. An answer json parsed 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 the RecursionError or ValueError ended 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's AutoBrowserError quoted 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; its detail and
    its message now leave them out as the MCP bridges' answers do, any other user@ run included, and keep the detail's
    shape. The LangChain tool, AutoBrowserTool.list_tools and AutoBrowserNode raised httpx's HTTPStatusError for
    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 its name=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 the Authorization: Basic credentials 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 its httpx logger, 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...
Read more

v1.16.0

Choose a tag to compare

@github-actions github-actions released this 07 Nov 01:57

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 in TAKEOVER_TOKEN_FILE,
    ./data/takeover-token with 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, and scripts/takeover_link.sh reads it from TAKEOVER_TOKEN (the
    shell or .env) or data/takeover-token. A script or integration that took the password from POST /sessions,
    GET /sessions/{id}, GET /remote-access or the takeover result now calls
    GET /sessions/{session_id}/takeover-link with the X-Takeover-Token header.
  • 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 new APPROVAL_WEBHOOK_ALLOW_PRIVATE_TARGETS=true to 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 names metadata and metadata.google.internal, and an
    IPv6 address that spells a metadata address: mapped, translated, compatible, NAT64's well-known 64:ff9b::/96, the
    local-use 64:ff9b:1::/48 read at every RFC 6052 position (/32 to /96), 6to4, Teredo (the client's address) or
    an ISATAP interface id (0:5efe or 200: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_PROXY and 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.md lists every name that already passes. Two setups that need it: a sign-in
    kept in the desktop keyring (DBUS_SESSION_BUS_ADDRESS or XDG_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_COMMAND verifier now gets a fixed environment. A HARNESS_UV_COMMAND that needs a variable
    outside the provider CLIs' base names (CLI_BASE_ENV_NAMES and LC_*, listed in docs/deployment.md), such as its
    own key or setting, names it in the new HARNESS_UV_ENV_PASSTHROUGH (comma-separated).
  • An empty ISOLATED_TUNNEL_REMOTE_BIND_ADDRESS now stops startup. For a controller that reads
    ISOLATED_TUNNEL_REMOTE_BIND_ADDRESS itself (outside compose: docker-compose.isolation.yml passes
    ${ISOLATED_TUNNEL_REMOTE_BIND_ADDRESS:-127.0.0.1}, so there an empty value reached the controller as 127.0.0.1),
    an empty value, which under ISOLATED_TUNNEL_ACCESS_MODE=unsafe-public asked the bastion to bind the tunnel on
    every interface, now stops startup. Set * for every interface (IPv4 and IPv6) or 0.0.0.0 for IPv4 only; both
    need unsafe-public, and the bastion's GatewayPorts still decides.
  • Model names are now checked against a pattern. A provider_model outside 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 the generation of the process that
    created it, and each attempt a trace_hash, fields 1.15.0 rejects), including an older run this version's startup
    rewrote: one an earlier process left running (now interrupted) or converged with no candidate (now failed,
    or, for a run this version recorded, given the candidate it had staged). Such runs drop out of harness.list_runs,
    and harness.get_status fails on them.

Security

  • The model no longer receives an isolated session's noVNC password. create_session, get_session,
    get_remote_access and request_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's remote_access block and takeover result carry a fixed takeover_note
    instead. The full link comes from the new GET /sessions/{session_id}/takeover-link, which no MCP tool and no
    agent-facing package calls and the dashboard and the new scripts/takeover_link.sh read. Beside the bearer token,
    which agents hold, it demands a second secret agents are not given: the takeover token, in the X-Takeover-Token
    header, compared in constant time and required in every scope, API_ALLOW_TOKENLESS_LOOPBACK included. 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_refused without a token or the URL; a read is Cache-Control: no-store and audited as
    takeover_link_viewed without the URL or the password. The token is TAKEOVER_TOKEN when set; else the controller
    reads it from TAKEOVER_TOKEN_FILE (default /data/takeover-token, which docker-compose.yml pins, as it pins
    API_TOKEN_FILE, and passes a TAKEOVER_TOKEN from the shell or .env) or generates one there (mode 0600) on
    first start, and logs only a sha256 fingerprint. A TAKEOVER_TOKEN shorter 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 .env value that Starlette reads as latin-1) or equal to an API
    credential refuses startup, and so, while TAKEOVER_TOKEN is unset, does a TAKEOVER_TOKEN_FILE in one of eight
    directories: ARTIFACT_ROOT (served under /artifacts), UPLOAD_ROOT (uploadable into a page) and AUTH_ROOT
    (its profiles exported as archives), all three swept by maintenance, and the five mounted into a container:
    browser-profile and downloads in browser-node and tunnels and ssh in reverse-ssh, which compose mounts, and
    browser-sessions, whose <id>/profile and <id>/downloads directories the controller binds into each isolated
    session. The check looks for those five under the parent of ARTIFACT_ROOT, which is where compose's ./data lands
    in the image (/data); with ARTIFACT_ROOT moved elsewhere, compose's mounts stay under ./data and the check no
    longer covers them, so keep TAKEOVER_TOKEN_FILE out 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...
Read more

v1.15.0

Choose a tag to compare

@github-actions github-actions released this 05 Nov 17:40

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_MODE now stops the controller from starting. It used to fall back to the shared
    browser without a word. Only shared_browser_node and docker_ephemeral are accepted; surrounding spaces and
    capital letters are forgiven, but docker-ephemeral or dockerephemeral are 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 new CLI_PROVIDER_ENV_PASSTHROUGH setting (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 (for codex,
    that includes CODEX_API_KEY). With CLI_HOME unset, or set to a directory that cannot be created, a CLI call now
    fails (with an error that says to set CLI_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. Set CLI_HOME to 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 reads anchor_missing, anchor_unreadable, truncated or diverged, or whose hash
    walk fails, with or without --allow-unsigned; a chain_valid_at_export that is present and not exactly true
    fails too. The bundle now carries chain_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.
    Under HARNESS_ROOT (default /data/harness), runs/<id>/run.json, runs/<id>/strategy.md and a staged skill's
    candidate.json, envelope.json, provenance.json and drift.json could carry the token from the final URL, and
    older trace.json files were not text-scrubbed. The files are not rewritten. An attempt in run.json now carries
    scrubbed: true once its verification is scrubbed: an attempt without it (written by an earlier version) is
    scrubbed when read, so harness.get_status, harness.list_runs and harness.graduate no 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 that run.json holds and harness.get_trace (its trace.json), and for such a skill
    harness.list_candidates and harness.get_candidate (the candidate's envelope), can still return the token. A
    run.json written by this version has the new scrubbed field, and a 1.14.x controller cannot load such a file:
    its attempt record forbids extra fields, so after a downgrade harness.list_runs drops the run and
    harness.get_status fails on it. The next drift check rewrites drift.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_contains or url_contains) may now report degraded: the drift check scrubs the expectation,
    but that skill's trace.json was not scrubbed. Stage it again.
  • A queued agent job whose run or step reports error now finishes failed, not completed. error carries the
    reason the run or step gave, and result.status is still error. A client or automation that waits for completed
    and then reads result.status should wait for a terminal state and handle failed; a failed run is resumable, as
    an interrupted one is. Other outcomes (done, acted, takeover, approval_required, max_steps_reached) still
    finish completed.
  • 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 /sessions with a proxy_persona and the cron
    webhook POST /crons/{job_id}/trigger of 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_session and browser.trigger_cron_job return 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} and POST /crons/{job_id}/trigger answer 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 names CRON_STORE_PATH and 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_URL must end in /v1, as the Claude adapter already required. Vision targeting and the curator
    now follow it with /messages appended. A proxy base without /v1 (for example https://proxy.example) worked for
    vision before, because vision added /v1 itself, and now answers 404; add the /v1. The curator, which ignored the
    setting, now sends its Claude calls to whatever the setting names; with CURATOR_PROVIDER=openai or gemini it
    likewise follows OPENAI_BASE_URL (as <base>/chat/completions) or GEMINI_BASE_URL.
  • The repo's scripts read .env values literally. doctor.sh, compose_local.sh, bootstrap_cli_auth.sh and
    the smoke scripts do no $VAR or ${VAR} expansion, so DATA=${PWD}/data stays that text. Plain docker compose
    expands ${PWD} when it reads .env, but under make 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, \t and \v); a single-quoted value keeps its backslashes,
    except that \' is a quote. A value with text after its closing quote, a \0 escape 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, and docker compose reads that key from
    .env itself. The scripts also refuse, with a warning naming the key, these .env keys, 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_* and SMOKE_*_LABEL (SMOKE_URL, SMOKE_PROVIDER and
    SMOKE_GOAL, which doctor.sh reads, still load). On Git Bash and Cygwin the names are compared without regard to
    case (Path is refused like PATH). Any name that the script or bash itself already set without exporting it
    (HOSTNAME, OSTYPE and MACHTYPE, for example) is refused too. The scripts' own python3 calls now run with
    -I, so PYTHON* 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....
Read more

v1.14.1

Choose a tag to compare

@github-actions github-actions released this 05 Nov 05:36

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.yml must come after docker-compose.isolation.yml. Named before it, it lost
    ISOLATED_BROWSER_IMAGE to the isolation overlay's default, so docker_ephemeral sessions ran a local
    auto-browser-browser-node:latest (a stale build, or none) instead of the published browser-node. To check,
    run docker compose ... config and confirm ISOLATED_BROWSER_IMAGE names the ghcr.io image. Then close any
    isolated sessions started before you fixed the order.
  • Creating a session whose start_url fails to load now returns 400 browser_action_failed (retryable), not 500.
    A client that handled the 500 should handle the 400 the way it handles a failed navigate.

Fixed

  • A session whose start_url fails to load (a refused connection, an unresolved name, a timeout) is answered with
    400 browser_action_failed, retryable, as navigate answers 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.yml say to name the images overlay last when isolated sessions are on.
    Named before docker-compose.isolation.yml, it lost ISOLATED_BROWSER_IMAGE to that overlay's default, so
    isolated sessions ran a local auto-browser-browser-node:latest (a stale build, or none) instead of the
    published image.
  • docs/deployment.md states that under PII_SCRUB_SCREENSHOT_STRICT=true an action whose before-screenshot
    cannot be scrubbed is refused before it runs (503). With OCR unavailable, that means every action.

v1.14.0

Choose a tag to compare

@github-actions github-actions released this 04 Nov 16:47

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 plus chroot, clone and unshare
    (controller/app/browser/seccomp/); docker_ephemeral containers 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 both BROWSER_SANDBOX=off and
    BROWSER_SECCOMP_PROFILE=builtin (docs/deployment.md, "Chromium's sandbox"). On a host whose Docker daemon sets
    its own seccomp-profile, 1.14.0's profile replaces that one, and builtin means moby's built-in profile, not the
    daemon's: point BROWSER_SECCOMP_PROFILE and ISOLATED_BROWSER_SECCOMP_PROFILE at 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 ./data belongs 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 /data subtrees are handed to that user, and
    data/api-token stays readable by its owner. CONTROLLER_RUN_AS=root restores 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=on now also gates shared mode: every session is refused, and readiness fails, while the
    shared browser-node's connect-time state is not a verified on, including a browser-node image older than 1.14.0.
    Under on, browser.cdp_attach is refused; a CDP_CONNECT_URL Chrome 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-owner before
    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 ./data by hand then.
  • A capability-less uid 0 still owns the image: under cap_drop: [SETUID] or [SETGID], or a cap_add set
    without SETUID or SETGID, a compromised controller could rewrite the image and regain the container's capabilities
    on a restart. The entrypoint names them; prefer cap_drop: [ALL], user: "10002:10002", or read_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], or cap_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 as user: "10002:10002", or add cap_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 /proc that renderers really are sandboxed; where a host refuses the user namespace
    (Ubuntu 23.10 and later restrict it with AppArmor), auto runs Chromium unsandboxed with a WARNING that names the
    cause, on refuses to start, and off keeps 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=false names 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

Choose a tag to compare

@github-actions github-actions released this 04 Nov 09:17

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_HOSTS refusing 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 answers window_birth_watcher_unavailable
    (503, retryable), as the pre-request gate's own failure does, and /readiness reports the watcher. With
    ALLOWED_HOSTS empty or * nothing changes.
  • A popup becomes readable one CDP round trip after it is reported. activate_tab waits 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_ephemeral session 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
    /readiness reports the watcher. With ALLOWED_HOSTS empty or * nothing changes and nothing attaches.
    What changes for legitimate popups:

    • a reported popup becomes readable one CDP round trip later than before (activate_tab waits 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.

  • The controller refuses requests from docker_ephemeral session 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 on 127.0.0.1 through host.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.

  • /docs and /redoc load 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 of bash -x traces.

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_PASSWORD on the shared browser-node, so its VNC-authentication checks run.

v1.13.0

Choose a tag to compare

@github-actions github-actions released this 03 Nov 13:07

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_TOKEN now requires a token it generates. On first start, a loopback controller
    with no configured credential writes a random token to API_TOKEN_FILE (/data/api-token, which is
    ./data/api-token in 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 set API_BEARER_TOKEN or API_BEARER_TOKENS keep using their token, and the file is
    ignored. Point each client at the token:
    • stdio MCP clients (Claude Desktop, Cursor): set AUTO_BROWSER_TOKEN_FILE to the absolute path of
      data/api-token in your checkout, and use this release's bridge (uvx auto-browser-mcp@latest). An older bridge
      ignores that setting, so set AUTO_BROWSER_BEARER_TOKEN to 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.md covers Codex, Cursor, VS Code and Antigravity.

    • The Python SDK and LangChain: pass token_file="data/api-token" to AutoBrowserClient, AutoBrowserTool,
      AutoBrowserTool.list_tools and AutoBrowserNode. 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 /metrics uses authorization: { type: Bearer, credentials_file: <path> }. The
      controller resets data/api-token to mode 0600 at every start, so a Prometheus running as another user needs its
      own readable copy, or set API_BEARER_TOKEN and give it that.

    • Pages a browser opens directly, such as GET /sessions/{id}/replay and /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 /replay page has no tokenless replacement.

    • Update your checkout as well as the images. This release's .gitignore covers data/api-token. On an older
      checkout, add data/api-token to .gitignore yourself, or git add -A can 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. Set API_TOKEN_FILE to a path
      under your user, or set API_BEARER_TOKEN. On Windows the default resolves to C:\data\api-token on 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_FILE yourself 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=true restores 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_ephemeral session 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 in DEFAULT_PROXY_SERVER or 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 use ISOLATED_BROWSER_NETWORK_MODE=shared.
    • ISOLATED_BROWSER_NETWORK now applies only with ISOLATED_BROWSER_NETWORK_MODE=shared. With per_session it
      is ignored, with a warning.
    • per_session needs the controller to run as a container on the isolation Docker daemon. A controller run on the
      host needs shared, and so does one that reaches another daemon through ISOLATED_DOCKER_HOST. A
      docker-socket-proxy needs NETWORKS=1 as well as CONTAINERS=1 and POST=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, widen default-address-pools in the
      daemon's daemon.json.
  • Each isolated session's noVNC has its own random password, and isolated containers no longer receive the
    operator's VNC_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_PASSWORD now guards only the shared browser-node. Set it in docker_ephemeral deployments; readiness warns
      when it is missing.
  • Browser containers keep only five Linux capabilities (CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID),
    with no-new-privileges; browser-node also gets pids_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's cap_add; isolated session
    containers always get these five, and no setting widens them.
  • /docs, /redoc and /openapi.json answer 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 global bearerAuth scheme, 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 /metrics or /artifacts now 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_TOKEN or API_BEARER_TOKENS entry is shorter
      than 32 characters.
  • docker_ephemeral sessions 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 answered GET /json with 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 through host.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_PASSWORD did 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 with no-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_FILE and API_ALLOW_TOKENLESS_LOOPBACK (above).
  • --token-file / AUTO_BROWSER_TOKEN_FILE for the stdio bridge, read on every...
Read more

v1.12.4

Choose a tag to compare

@github-actions github-actions released this 03 Nov 10:03

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. If ISOLATED_BROWSER_IMAGE names 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 every docker_ephemeral session 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-features list:

    • adds AvoidCorsURLLoaderRestartOnRedirect, NetworkTimeServiceQuerying and AimEnabled;
    • drops BoundaryEventDispatchTracksNodeRemoval.

    The copy browser-node and the controller pass, which keeps storage partitioning on, follows it.

  • 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 with net::ERR_ABORTED. Under ALLOWED_HOSTS it still ends the refused navigation it
    belongs to, as before.

v1.12.3

Choose a tag to compare

@github-actions github-actions released this 03 Nov 06:43

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_TOKEN has 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_TOKEN made only of whitespace is no longer a credential (#252). It was compared like any other
    token, so Bearer followed 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 named API_BEARER_TOKENS, a controller reachable off the box or running with
    APP_ENV=production refuses 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 with Bearer <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 strips API_BEARER_TOKEN and WITNESS_REMOTE_API_KEY (named
    API_BEARER_TOKENS were already stripped), and ignores spaces and tabs around the token in the header it receives
    (Bearer must 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 LangChain AutoBrowserTool and AutoBrowserNode, the eval
    client behind scripts/agent_eval.py, and the dashboard. A client given a whitespace-only token sends no
    Authorization header. 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) reads ANTHROPIC_API_KEY, OPENAI_API_KEY or
    GEMINI_API_KEY from 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

Choose a tag to compare

@github-actions github-actions released this 03 Nov 00:34
12a4690

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-python 7.1.0, docker/login-action 4.7.0, and
    pypa/gh-action-pypi-publish 1.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_action tool with tool_choice, and some models refuse a named choice with a 400: Claude Opus 5.5, Sonnet
    5.5 and Fable 5.1 through openrouter (checked live on 2026-11-02), and, as DeepSeek documents, DeepSeek with
    thinking on (deepseek-flash and deepseek-v4-pro, set as DEEPSEEK_MODEL or provider_model). A 400 is not
    retried, so on these models /agent/step answered 400 and /agent/run ended with status: "error" at its first
    step, whose error_code was 400: the agent never acted. When the endpoint answers 400 and the error mentions
    tool_choice, the request is now sent once more without tool_choice. Both the message and the whole JSON error
    body are searched, because OpenRouter can carry the upstream text only in error.metadata. If the endpoint accepts
    that request, later requests from that provider to that base URL and model go without tool_choice from the start,
    until the controller restarts, and the controller logs the switch at INFO; 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_choice too, such as vLLM 0.14 and later started without --enable-auto-tool-choice and --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_KEY and OPENAI_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/providers still showed the
    provider as configured. Surrounding whitespace is now stripped, and a key that is only whitespace counts as unset.