Skip to content

v1.11.2

Choose a tag to compare

@github-actions github-actions released this 29 Oct 15:08
· 244 commits to main since this release

A security release over 1.11.1, with four advisories:

  • GHSA-3pv7-p7w6-q93q (high): a web page could reach the browser desktop through VNC on port 5900,
    bypassing noVNC's Origin check.
  • GHSA-9wxr-2vcc-9x6c (moderate): governed approvals could be forged for a tool call, reused after their
    action started, or skipped by the cron tools.
  • GHSA-j5hf-3vw7-g4cp (moderate): content of a page outside ALLOWED_HOSTS reached the agent through
    popups, logs, downloads, cookies and the pages it chose.
  • GHSA-xq9j-p4rw-q2cq (moderate): a page could stop a session's witness receipts; each later action ran,
    then reported an error.

Two more security fixes, to witness verification and to staged skill candidates, have no advisory of
their own, and Fixed lists the rest, among them three that keep operators under named credentials out
of each other's MCP sessions, session ids and files.

To deploy the fixes:

  • update the controller: its image, or your source checkout followed by
    pip install -r controller/requirements.txt, which now pins idna;
  • update the browser-node image, which carries the VNC fix, and take the new docker-compose.yml,
    which no longer publishes 5900;
  • if ISOLATED_BROWSER_IMAGE names an image explicitly, point it at the 1.11.2 browser-node image;
  • close isolated sessions started before the upgrade, and recreate the containers.

The client, LangChain and MCP bridge packages change only in version; updating them alone does not
deploy the fixes.

Upgrade notes:

  • A VNC viewer pointed at port 5900 no longer connects. Open noVNC on 6080 instead
    (http://127.0.0.1:6080/vnc.html?autoconnect=true&resize=scale, or the session's takeover_url),
    which asks for VNC_PASSWORD when you set one. VNC_PORT is removed from .env.example, the compose
    file and make doctor, and a new isolated session's runtime object has no vnc_port (the stored
    record of an earlier session keeps it). See docs/deployment.md, "Gateway recommendations".
  • Ask again for a governed tool call that was pending or approved when you upgraded. An approval
    stored before 1.11.2 counts as a browser action's, which no tool call can spend: the agent repeats the
    call without its old approval_id (an approved one once you know it did not already take effect), and
    an operator approves the new request. A pending one left from before shows in the queue as a
    request_human_takeover action: reject it. With AUTONOMOUS_APPROVALS=true, that rejection stops the
    agent approving its repeated call itself only for a call that names no session: a session's rejection
    binds only within that session, which the upgrade's restart ends, so the agent approves the repeat in
    its new session unless you turn AUTONOMOUS_APPROVALS off.
  • An approval is spent when a call claims it, before its action runs. If the action then fails,
    retryable refusals such as element_not_stable included, the same approval_id is refused, where
    1.11.1 let it run the action again: "was spent when its action started (...), so the action may have
    taken effect: observe the page, or check the outcome, before acting again. Only to do it again, repeat
    the call without approval_id, which asks an operator for a new approval". A spent approval does not
    mean its action failed: check what it did before you approve a repeat. Only a browser action that ends
    before it begins gives its approval back (its session closing, or an upload's file going, after the
    claim included), and the answer then carries approval_given_back: retry an approved one with the
    same approval_id once the cause is gone, except when the note says its session has closed: nothing
    can run it then, so repeat the call in a live session without approval_id. Which endings give back,
    and the REST status of each refusal, are in docs/mcp-clients.md, "When an approval is spent, and
    when it is given back".
  • A reject sent after a browser action claimed its approval, but before the action began, stops
    the action (approval_rejected) and answers 200 with the record, still executed; the approval then
    becomes rejected. Once the action has begun, a reject is refused (a 409 over REST). A decision
    sent to another worker or replica while an action holds the approval is refused with a 409 ("has
    already been spent"), where 1.11.1 accepted a reject. See docs/mcp-clients.md, "Rejecting or
    approving while an action holds its approval", which also covers Windows hosts with the default file
    store.
  • A governed upload's file is checked before anyone is asked to approve it, in
    browser.execute_action and the agent loop: a missing file is refused (not_found over MCP) and
    queues nothing. browser.execute_approval and POST /approvals/{id}/execute check it before they
    claim the approval, so a file gone since the approval leaves it approved.
  • An agent can no longer ask for approval of a request_human_takeover or done action, or run
    one
    : it is refused with a 400 before anything is queued. POST /approvals/{id}/execute answers a
    409, without spending the approval, for a tool call's approval and for a done or a takeover approved
    before 1.11.2, each of which 1.11.1 answered there with a 400 ("Unsupported action" over MCP).
  • Approval webhooks and SSE approval events carry origin, a server-written action_summary and,
    for a tool call, arguments_size
    , and their reason goes out on one line: a newline as \n, a
    tab or another control as its code point ([U+0009]). A sensitive type reads "text hidden at the
    agent's request (sensitive)" in the queue, the confirm dialog, the webhook and the SSE summary, where
    the dashboard read "(sensitive text hidden)": the agent can mark any typed text sensitive.
  • A governed tool call's arguments are kept whole beside its approval (<id>.arguments in
    APPROVAL_ROOT, credentials left out), for GET /approvals/{id}/arguments and the queue's "View all
    N characters". The file goes when the call that spends the approval ends, or at the next start if the
    controller stopped during that call. A rejected one's goes once the rejection is older than
    APPROVAL_TTL_MINUTES (at the next approval request or start), and until then an operator can still
    approve it. One left pending, or approved and never run, keeps it until a call spends it, and nothing
    else sweeps it. See docs/mcp-clients.md, "What the operator is shown".
  • An approval an earlier version stored whose action 1.11.2 refuses is left out (a navigate to a URL
    whose host a browser reads otherwise, below): listings leave it out, and fetching or executing it
    answers as an unknown id, so nothing can execute it, and the agent asks again. Fetching or listing it,
    from either store, logs a warning that names it and where it fails, never what it holds (a navigate
    URL's query can carry a token).
  • The cron tools ask for approval under governed approvals. An agent that creates, triggers or
    deletes cron jobs with workflow_profile=governed, or on a controller with
    FORCE_GOVERNED_APPROVALS=true, now gets approval_required, and repeats the call with the
    approval_id once an operator approves; REST POST /crons and POST /crons/{id}/trigger, which need
    the bearer token, never ask. A job created by an earlier version keeps running as fast, and jobs
    cannot be edited: to govern one, delete it and create it again from a governed caller, or through
    POST /crons with "workflow_profile": "governed", or turn on FORCE_GOVERNED_APPROVALS, which
    forces every run to governed. A governed job's run cannot continue past an action that needs an
    operator's approval: it stops there, and its session closes with the run, so that approval, once
    approved, can no longer be executed: it stays approved, and triggering the job again asks for a new
    approval in a new session. With AUTONOMOUS_APPROVALS=true the agent approves such an action itself
    and the run carries on. Run work that needs an operator's approval in an interactive session, or keep
    the job fast under a reviewed script. See docs/mcp-clients.md, "Cron jobs and
    FORCE_GOVERNED_APPROVALS".
  • A page ALLOWED_HOSTS refuses is read again only after the agent navigates it to an allowed URL, or
    a blocked action's rollback brings it back.
    Until then observe, screenshots, the page-reading MCP
    tools and actions other than navigation refuse it (REST: 403), also after the controller has sent it
    back to about:blank, where they used to read the blank page, and after it has loaded an allowed URL
    on its own. A popup or blank window a refused page opened is refused with it. go_back leaves a
    refused page refused, where 1.10.0 through 1.11.1 let it leave a disallowed page; reload and
    go_forward are refused there, as before. A refused tab that is not the active one cannot be
    activated: close it, or open a new tab. See docs/deployment.md, "Reading a refused page again".
  • While a tab of the session can run a refused host's code, and for 2 seconds after it closes, the
    other tabs' own navigations are refused too.
    Such a tab was refused for where it went, a refused
    host's redirect or its opener, is navigating through a refused host, or, refused only because of
    another tab or still waiting for its opener, is on a blob: or data: page or a blank one the
    controller did not send it to. A tab that loads a page on its own meanwhile (a link the agent clicks
    while the refused tab is open, a redirect, a script, a reload) is sent back to about:blank, and
    whatever it had loaded, a form in progress included, is lost. Close the refused tab if it is still
    open, then navigate the other one: the refusal says so. Each of the agent's actions on the session (a
    navigate, a click, typing, a key press), and opening a tab onto a URL, first waits out what is left of
    those 2 seconds, for at most 2 seconds from the call, and not while a refused tab is still open; the
    wait comes before the action begins, so a call cancelled meanwhile gives a claimed approval back. A
    tab refused this way that is not the active one cannot be activated, also once the refused tab is
    closed ("That tab loaded while another tab of this session was refused, or within 2 seconds after a
    refused tab closed, ..."): close it, or open a new tab. See docs/deployment.md, "Other tabs while
    one is refused".
  • A navigation to a URL that names another host to a browser than it shows is refused, in every
    profile: a navigate action, POST /sessions/{id}/actions/navigate, POST /sessions/{id}/tabs/open,
    and a new session's or a fork's start_url. That is a backslash in its authority
    (https://evil.example\@bank.example/), a URL written without the // after its scheme or with extra
    slashes (https:u:p@host/), a percent-encoded host, an IPv4 address not written as a dotted quad
    (http://127.1/), a host a browser maps to another name beyond case folding and composition (one in
    full-width digits, say), or a control character (U+0000 to U+001F, a tab or a newline among them) in
    its scheme or host, which a browser drops. A backslash or a control in the path or the query is
    accepted. Each run of a cron job an earlier version stored with such a start_url fails before a
    browser opens, and the controller logs why: delete the job and create it again. See
    docs/mcp-clients.md, "What the operator is shown".
  • Downloads and browser.get_cookies follow ALLOWED_HOSTS. A download must come from a host on
    the list, through no off-list host on its redirects, and from a page on one, or it is cancelled and
    recorded as blocked. The host checked is the file's after its redirects, so a file an allowed host
    redirects to (a CDN, a file host) is refused unless that host is listed too, and listing it also lets
    pages navigate there; a link that passes an unlisted redirector (a tracking link, say) is refused even
    when the file's host is listed. A file a page generates (blob:, data:) is judged by the page that
    reports it. browser.get_cookies returns only cookies a listed host would receive; with
    ALLOWED_HOSTS empty or *, it filters nothing. See docs/deployment.md, "Refused downloads" and
    "Cookies an allowed host would receive".
  • The page-reading MCP tools and the REST CDP routes take the session lock, and are bounded.
    browser.eval_js, browser.get_html, browser.find_elements, the storage tools, browser.drag_drop
    and the browser://<session>/dom resource wait up to 10 seconds for a session another call holds and
    then fail, and give up on a page that does not answer within 30 seconds (a browser.find_elements
    text query within 10, query_timeout; browser.eval_js takes a timeout_ms of up to 60000 instead;
    a drag adds ACTION_TIMEOUT_MS for each end named by a selector or element_id, then holds the
    session up to 7 seconds more to remove its scroll watch and release the mouse; giving up does not stop
    the page's script). browser.wait_for_selector does not take the lock, and is refused if its page
    became refused, closed or not the active tab while it waited. The REST CDP routes (GET /sessions/{id}/cdp/element, POST /sessions/{id}/cdp/raw) answer 409 when they cannot get the
    session within 10 seconds, 403 for a page ALLOWED_HOSTS refuses, 503 when they cannot attach to the
    active tab, where 1.11.1 used the tab they were attached to before, and 504 when the browser does not
    answer, attach or detach within 10 seconds. See docs/deployment.md, "Reads and the page tools".
  • Witness verification fails on missing evidence, where it used to pass it. The first start after
    upgrading anchors every chain that has no anchor yet, as the chain stands, and marks the witness root
    (.anchors-migrated); no later start does. That includes a chain whose anchor was deleted on 1.11.1
    or earlier, so if its evidence matters, check the chains without a <id>.jsonl.anchor.json before
    that start. With WITNESS_ENABLED=false, a session with no receipts now verifies false ("no
    receipts recorded for this scope"), and the anchor.status value unanchored is gone. See
    docs/deployment.md, "Witness receipts: what a signature proves".
  • Skill candidates staged by an earlier version are refused when read or registered ("... induce it
    again"), and harness.list_candidates leaves them out, logging a warning for each: run the
    convergence again to stage them afresh. This only matters on a controller with a mesh identity, where
    candidates are signed.
  • Under named credentials, a subfolder of UPLOAD_ROOT named like a session id (12 lowercase
    hexadecimal characters, so a 12-digit timestamp too) is treated as that session's directory, even if
    you made it yourself. An upload refused for that reason says so: rename the folder.

Security

  • Raw VNC is gone: x11vnc listens on no TCP port, and noVNC on 6080 is the only way to the desktop.
    x11vnc served the browser-node display on TCP 5900, with no password unless VNC_PASSWORD was set,
    and its VNC library accepts WebSocket connections there, checking only that an Origin header is
    present. So a web page in a browser on the same host could reach the desktop around noVNC's Origin
    check, through the base compose file's 127.0.0.1:5900 publish or the host port each isolated session
    container published at ISOLATED_BROWSER_BIND_HOST, wherever that browser lets pages connect to local
    addresses without asking. The agent's own Chromium shares the container's network and needed no
    publish. In images from 1.5.1 on, which install it with Playwright 1.62, it is expected to ask before
    a public page connects to a loopback address (not tested); older images install an older Chromium,
    which may not ask. A page that got through had full view and control of a browser holding stored
    logins. Every release through 1.11.1 is affected. x11vnc now serves RFB only on a unix socket, in a
    directory only the browser user can enter, and the IPv6 port its library opened on 5900 by itself is
    closed too; websockify forwards noVNC to that socket, and nothing exposes or publishes 5900.
    Unchanged: noVNC has no password by default, and a program that is not a browser can send any Origin
    it likes, so anything on the host or the Docker network that can reach 6080 can still take over the
    desktop. Set VNC_PASSWORD wherever that matters.

Governed approvals:

  • An approval for a governed tool call can no longer be minted as a browser action, or spent as the
    wrong one.
    A governed tool call that is not a browser action (browser.eval_js,
    browser.set_cookies, ...) is approved through a stand-in request_human_takeover action whose text
    names the tool and a digest of its arguments, and approvals were matched on field values alone. An
    agent could ask for that stand-in through browser.execute_action, with a reason that previewed other
    arguments, which the dashboard showed as that tool call, and once an operator approved it, spend it on
    the arguments the digest named. Every release from 1.0.5 through 1.11.1 is affected (before 1.8.0 the
    stand-in carried no digest, so such an approval covered any governed tool call with the same risk
    category in its session). An approval now records its origin, action or tool_call, set only by the
    gateway: one origin's approval is refused for the other, a pending one is reused only within its
    origin, and the dashboard names a tool call only for origin: tool_call. An operator's rejection
    counts whatever the origin, so one made before the upgrade still holds where it binds (upgrade notes),
    unless it was made before 1.8.0, on a stand-in with no digest, which matches no current call. Still
    the case: the agent writes an approval's reason, so read the action as well as the reason.
  • An approval that let its action start is spent, whatever fails after. An approval was consumed
    only once its action had returned, so an action that went through and then failed (the settle after a
    click, the observation, a captcha), or a controller restart while it ran, left it approved, and the
    same approval_id ran the action again: a second payment click or upload. So did a governed upload
    with REQUIRE_APPROVAL_FOR_UPLOADS=false, and an upload whose file went in while Playwright's call
    raised. Every release through 1.11.1 is affected. The claim now spends it, only a browser action that
    ends before it began gives it back, and an action approval covers one operation. With STATE_DB_PATH
    set, an approval spent while SQLite was unavailable could also run again once SQLite was back. At a
    start on SQLite, SQLite's spent record now always wins over the file store's, and an operator's
    rejection over a file record not updated after it; SQLite takes the rest of what it missed, and the
    record it keeps is written back to the file store, so a later start whose SQLite fails cannot claim
    the approval again. If that write fails, SQLite stays primary, and if SQLite then cannot take the rest
    either, the controller refuses to start rather than run on a file store that could claim a spent
    approval. Every record write lands whole under the store's lock, and one whose SQLite half fails puts
    the file record back before it reports the failure, so the next start does not revive what the caller
    was told failed. A give-back reported as failed stays failed even when putting the file back fails
    too, since SQLite's spend wins. Not covered: two controller processes sharing one approval store (file
    or SQLite) can still both claim an approval, since the claim is atomic only within a process; several
    workers or replicas are not supported. The details, the approval guard's error codes and witness
    receipts included, are in docs/mcp-clients.md, "When an approval is spent, and when it is given
    back".
  • An approval whose session has no record follows the operator who asked for it. Who may use an
    approval follows its session's owner, and a session with no record (lost while the approval stayed)
    read as unowned, so under named credentials any operator could read, list, decide and execute another
    operator's approval, its reason included, which previews a tool call's arguments (credential fields
    left out). Such an approval now belongs to the operator its record names, and one that names nobody is
    refused under named credentials, as such a session id is (Fixed, below); a known session that nobody
    owns stays open to every operator.
  • What an operator is shown for an approval says what it covers. The webhook and the SSE event
    carried neither the origin nor the action, so a coordinate click whose reason copied a tool call's
    read the same as that tool call's approval. The queue showed a coordinate click as a bare click, an
    option pick without its value, and a selector where an element id beside it was what ran, and cut
    typed text at 200 characters without saying so. A tool call's preview hid every value under a key
    named value, password, token or secret, which hid a harness run's start URL and conditions,
    and its URL fields were accepted even where a browser reads another host in them than the controller
    does. Now the queue, the summary, the webhook and the SSE event list every field an action approval
    binds and mark one that does not act, nothing is cut without saying so, and characters that hide or
    reorder text (bidi overrides, zero-width marks, controls) are shown as their code point ([U+202E]).
    A tool call's preview leaves out credentials by tool and path, among them the userinfo and
    credential-named query values of five URL fields: browser.cdp_attach's URL, a cookie's URL, a
    harness run's start URL and mock final page URL, and a cron job's start_url. Those five are refused
    where a browser could read another host in them than the controller: a backslash or a control
    character (U+0000 to U+001F) anywhere, a percent-encoded host, one UTS 46 maps to another name beyond
    case folding and composition, an IPv4 address not written as a dotted quad, or a URL written without
    the // after its scheme or with extra slashes. A navigate's url is refused by its host alone
    (upgrade notes), and the queue and the summary show it with its host as a browser sends it (xn--),
    the host as written beside it. Witness receipts and audit entries carry an approval's origin and, for
    a tool call, the tool, where they named only the stand-in; the audit log stays operator-wide, so under
    named credentials every operator reads these. Still the case: letters that look alike across scripts,
    and right-to-left letters, are shown as they are; a credential in a navigate's URL is shown, and so is
    one in a URL's fragment, in a query parameter of another name, or anywhere but those paths; and with
    AUTONOMOUS_APPROVALS=true the agent approves its own calls, and an operator's rejection stops it
    approving the same request only within that session (in controller scope, for the same operator). The
    full rules are in docs/mcp-clients.md, "What the operator is shown" and "Controller-scope approvals
    and the audit log".
  • The cron tools now ask for approval under governed approvals. browser.create_cron_job,
    browser.trigger_cron_job and browser.delete_cron_job were never gated, and every run of a job
    started as fast unless FORCE_GOVERNED_APPROVALS (1.10.0 and later) was on, so a governed agent
    could plant a job that ran its writes without approval. Every release from 1.0.5 through 1.11.1 is
    affected, for an agent served the cron tools (only the full profile has them, unless
    MCP_TOOL_PACKS adds the cron pack). The first two are now write and the third destructive, and
    every run of a job starts under the workflow_profile of the call that created it. A run or step
    queued by a governed browser.queue_agent_run or browser.queue_agent_step call now runs governed
    too, whatever its request names, where it ran under the request's own profile, fast included. POST /crons stores the workflow_profile its body names (fast by default). The full tool profile now
    has 19 tools with a governed kind, not 16. Still the case: tools without one are not governed, the
    proxy persona tools among them, and browser.create_session, browser.fork_session and each run of a
    cron job, scheduled or triggered, open a start_url with no approval, though creating or triggering a
    job through the MCP cron tools asks for one (REST POST /crons and POST /crons/{id}/trigger never
    do). See docs/mcp-clients.md, "Cron jobs and FORCE_GOVERNED_APPROVALS".

ALLOWED_HOSTS:

  • A popup onto a host off ALLOWED_HOSTS is sent back to about:blank, and a page is refused for
    where it came from.
    Playwright reports a popup only once its first navigation has committed, so the
    listener that holds a page to the list came too late. A popup opened straight onto a disallowed host
    (by window.open, a target=_blank link, or a page timer) stayed there, kept running, wrote no
    navigation_blocked event, and fed its console messages and page errors into every observation and
    the console tools; shadow-browse sessions attached no listeners to their popups until their tabs were
    next listed. A blank window a refused page opened is same-origin with it and scriptable by it, so
    whatever the page had it log, fetch, title or download passed every URL check, and the agent could
    switch to it and observe it. Every release through 1.11.1 is affected: the guard that missed these
    arrived in 1.10.0, and before it no popup was sent back at all. The controller now checks the URL a
    page is already on when it attaches its listeners, to every popup in shadow-browse too, and drops a
    refused page's console messages and page errors. A page is also refused while it is tainted: it
    committed a refused URL, came through a refused host's redirect, loaded a page on its own while
    another page of the session was refused or within 2 seconds of its closing, or is a popup of a refused
    page. The TOTP autofill skips a refused page, also on a host in totp_hosts. The full rule is in
    docs/deployment.md, "When a page is refused".
  • The network log and request failures withhold what a refused page asks for. A request made by a
    page ALLOWED_HOSTS refuses, or one that navigates a top-level page to a refused host, was logged
    whole, the page's HTML included, and so were a service worker's requests after the refused page that
    registered it was blanked; request_failures kept the full URL of each failed request such a page
    made. Such requests are now recorded as "withheld": "page off ALLOWED_HOSTS", cut to their origin,
    with no headers or bodies, and so are a popup's first navigation, a top-level navigation that
    starts while a page of the session is refused, and a request the controller cannot attribute. A
    refused page's failed requests are left out of request_failures. Iframes and subresources an
    allowed page loads from other hosts are logged as before. See docs/deployment.md, "Logs of a
    refused page".
  • A download from a host off ALLOWED_HOSTS, or one a refused page starts, is cancelled, and
    browser.get_cookies returns only what a listed host would receive.
    Such a download, a file a
    refused page generated itself included, was saved and readable through browser.read_download and its
    artifact URL, and browser.get_cookies returned the cookies a page off the list set before it was
    blanked. A download's final URL, any off-list host its navigation passed, and the page it is reported
    on are now checked; nothing of a refused one is saved, and it is recorded as blocked, with its
    source's origin only, and a download_blocked audit event. browser.get_cookies judges host-only,
    domain and partitioned cookies as the browser sends them. See docs/deployment.md, "Refused
    downloads" and "Cookies an allowed host would receive".
  • A read is refused when its page went refused, closed or stopped being the active tab while it was
    read, and a refused page's URL is shown by its origin only.
    Reads checked the page's URL before
    they read it, and some again after, so a page that went to a refused host and came back, or an active
    tab that closed and handed the session to another, passed both checks and was read. The REST CDP
    routes read the active page whatever its host, and tab listings, session summaries, the snapshots an
    action returns, approval requests and the navigation_blocked event showed a refused page's URL
    whole. Each read now notes the active page and its count of refused commits first, and refuses
    afterwards if either changed, the page closed, or the page is refused now; the CDP routes also take
    the session lock and answer 403 as observe does, and those places now show a refused page's URL
    as scheme://host:port. The 1.10.0 note that raw CDP passthrough is unchanged no longer holds. See
    docs/deployment.md, "Reads and the page tools".
  • What a page ALLOWED_HOSTS refuses still does. Its request has already gone out when it is
    refused, with whatever the page put in its URL, and the page runs, sets cookies and shows in the
    takeover (noVNC) view until it is blanked, which waits for any call in flight on the session. A
    service worker it registered keeps running after that. What an allowed origin's own scripts do with
    what a refused redirect or a refused opener put in its URL is not tracked. A refused page can set a
    cookie on a parent domain that an allowed host receives, and browser.get_cookies returns it. A
    withheld network entry keeps the host's labels, which the page chooses. Playwright traces
    (ENABLE_TRACING, off by default) record the refused page too, and a storage-state export holds every
    host's cookies. Iframes and subresources from other hosts on an allowed page are not filtered, by
    design. As before, an internationalized host matches ALLOWED_HOSTS only in its xn-- form: list it
    that way and navigate to its xn-- URL (the Unicode form fails closed: the page is refused, not
    read). The full list is in docs/deployment.md, "What a refused page still does".

Witness receipts and skill candidates:

  • A page can no longer stop a session's witness chain from recording. Two faults ended recording,
    both since 1.7.0. The tail read that finds the last receipt decoded bytes from an offset as text, so
    once a chain passed 64 KiB and that window began inside a multi-byte character (a Japanese page, a
    euro sign), every later append failed. And a receipt holding U+2028, U+2029 or U+0085, which a page
    can put in its title, was cut in two by the line splitting, so every later append failed at any chain
    size, and verify, the receipt list and the bundle export failed too. Each later action ran, then
    reported an error, and its receipt was never recorded, so a caller that retried could repeat a click
    or a submit. The tail is now read as bytes and chains are split on newlines only; a chain stuck this
    way verifies, lists and exports again with no repair, but the upgrade's restart ends its session, and
    the receipts that were never written cannot be recovered.
  • Witness verification no longer reports missing evidence as valid. Anyone who could write the
    witness directory could remove receipts and still read valid: true: a missing or corrupt anchor
    read as unanchored, which passed, so cutting a chain's tail and deleting its anchor hid the cut,
    and so did the next receipt, which moved the anchor to the shortened chain. Now valid is false
    when the chain is gone or empty or its anchor is missing or unreadable, the chain must match the
    anchor exactly, and a later receipt no longer moves the anchor over a chain it does not describe.
    Still the case: someone with write access to the witness directory can make a cut chain verify, by
    rewriting the anchor, by deleting the chain with its anchor (the next receipt starts a new chain that
    verifies), or by deleting .anchors-migrated before a restart (which anchors a cut chain whose
    anchor is missing as it stands). For evidence that must survive that, pin a head hash somewhere the
    controller host cannot write. See docs/deployment.md, "Witness receipts: what a signature proves".
  • A staged skill candidate can no longer be edited, or copied to another directory, and still read as
    verified.
    On a controller with a mesh identity, where candidates are signed, someone who could write
    to the staging directory could change fields of candidate.json (safety_class, verifier_backend,
    name, ...), or copy a genuine candidate.json next to altered files, and the registry served it as
    verified. The signature now covers every field of candidate.json but the envelope itself and the
    files map, which is built from the directory the candidate was read from; that directory must be
    named for the candidate's skill_id and stay inside the staging directory. Still the case: no check
    covers the review files (contract.json and trace.json are never compared with the contract and
    trace hashes the envelope signs, and provenance.json and envelope.json are not hashed). See
    docs/convergence-harness.md, "Mesh Boundary".

Fixed

  • The session-limit error names only the sessions the caller may use. Under named credentials
    (API_BEARER_TOKENS), the error browser.create_session (and an observe or act tool that creates a
    session) returns at MAX_SESSIONS listed every live session id, other operators' included. It now
    lists the caller's own and unowned sessions, and counts the rest.
  • An MCP session (MCP-Session-Id) belongs to the operator who initialized it. Under named
    credentials, another operator holding the id could ping it, subscribe it to resources and
    DELETE /mcp it, though tool calls always ran as the caller's own operator. Any other operator now
    gets -32001 "Unknown MCP session". MCP session ids created before this release, or by a client
    that initialized without a named credential, stay usable by any operator until they are deleted or
    evicted. The browser://sessions update no longer reaches other operators' subscribers when an owned
    session closes. The session table keeps 500 sessions and evicts the least recently used, so one
    operator initializing many evicted another's idle session; an operator now keeps at most 100, and
    past that loses its own least recently used first. Unowned sessions are bounded by the 500 alone.
  • Under named credentials, a session id with no live session and no record is refused like another
    operator's.
    The routes and MCP tools that read a session's files by id (replay, audit, witness)
    served an orphaned id's files and events to every operator, and other routes answered it in words that
    told another operator's session from none. Routes and tools that name the session id now answer 404
    unknown_session before anything else. The chain of the auth-profile receipts (auth-profiles),
    which has no session record, is refused the same way: its witness routes and tools answer 404 to
    every operator. Its agent jobs, and its approvals that name no operator, are left out of listings and
    answer as if they did not exist, and an upload from its directory under UPLOAD_ROOT is refused
    (upgrade notes); other subfolders stay shared with every operator. Deployments without named
    credentials are unchanged.
  • Tab listings, the fast observe preset and the bot-challenge check no longer wait without end for
    a page stuck in a script.
    All three run under the session lock, so one tab whose renderer never
    answered stalled every call on the session. A tab's title now waits at most 2 seconds (the tab is
    listed without one), the fast preset's title at most 30 seconds (observe fails), and the
    bot-challenge probe that runs after every action at most 30 seconds (the page is then checked by its
    URL alone).
  • An auth-profile save that fails before its files are in place leaves the profile as it was, unless
    putting the previous state back fails too.
    A save wrote the state file first and then read the
    page's title for the profile's metadata, so when that read or write failed the new state stayed in
    place, on a first save with no metadata and so no owner. The title is now read first, and the new
    state and metadata are put in place together; a failure after that (recording the save, say) is
    reported as an error, and the new profile stays. A process killed during a save can still leave the
    new state beside the previous metadata, or the new metadata beside the previous state, and hidden
    files whose names start with .state.json or ..state.json, holding the previous login and the new
    one. A first save, and a named operator's re-save of a profile whose metadata names no owner (saved
    under the shared token or before owners), now puts its metadata, which names the owner, in place
    before its state: one killed between the two leaves at most that metadata beside no state or the
    previous one, and one whose state cannot be put back keeps the new metadata beside the new state, so
    neither leaves a new login with no owner that every operator could open under named credentials. See
    docs/deployment.md, "Auth profiles".
  • A policy block after a go_back or a reload is no longer rolled back with another go_back,
    which went one page further back, or to the page before the reloaded one. The page stays where the
    action left it, and one ALLOWED_HOSTS refuses is sent to about:blank.
  • The audit log's trim no longer drops an event whose text holds a Unicode line separator. It split
    the log at U+2028, U+2029 and U+0085 as well as at newlines, so such an event came back as fragments
    that no longer parse, and listings left it out for good. Text a page chooses reaches the log, a
    download's file name among them. The trim now splits on newlines only, and so does the convergence
    harness's trace, where an event holding one failed the run.
  • A navigate to a host ALLOWED_HOSTS refuses is refused before an approval is asked for. An
    operator was asked to approve a navigation that could never run, since navigate checked the list
    only once it ran. A host that leaves the list after the approve is refused when the approval is
    executed, and the approval is given back.
  • An action whose observation hits a permission error on a file reports it without the server's
    path.
    The OS error, path included, was returned as the action's observation_error (EACCES or
    EPERM: a screenshot in an artifact directory the controller may not write, say). It is now a fixed
    text, and the OS error is logged.