github CopilotKit/OpenBot v0.0.2

latest releases: v0.1.2, v0.1.1, v0.1.0...
one month ago

Upgrading

AGENT_TOOL_TOKEN is generated for you on a laptop. scripts/start.sh mints one and writes it to
.env, the way it already did for MANAGED_AGENT_TOKEN. Without it no Bot could call a tool back
through the deployment, which is the correct default for a deployment and made every MCP tool dead
on arrival on a fresh clone. A value already set is kept, and .env.example still ships it empty,
so a deployment not using start.sh is unchanged and still fails closed.

start.sh also stops skipping work for services that are already answering. A Bot container is now
handed to docker compose on every run and the server is restarted when this run minted a secret,
because answering says a process is alive and not that it still agrees with the deployment. The cost
is that a run which rebuilds an image recreates the Bot containers, about five seconds; supervisor
already behaved this way.

Two configurations now refuse to start:

  • A provider configured with no INITIAL_ADMIN_EMAILS. Set it to at least one address.
  • No provider at all and no OPENBOT_SINGLE_USER=true. Configure a provider, or set that to say you
    meant a deployment where every visitor is one administrator. This no longer depends on NODE_ENV,
    which is unset by default and so let exactly the dangerous case through. A deployment already
    running open needs the line added before it will start again.

Registering an OpenID Connect provider needs every host in its discovery document in
TRUSTED_ORIGINS, not only the issuer. Better Auth 1.7 checks each endpoint it finds, so a Google
issuer also needs oauth2.googleapis.com and openidconnect.googleapis.com. Registration is
refused with the untrusted host named.

A Bot id may now contain only letters, digits, hyphen and underscore, and must start with a letter or
digit. The same rule container and volume names have always followed. A deployment whose
COMPUTER_BOT_ID breaks it refuses to start and says so, rather than answering 400 to everything.

AUDIT_RETENTION_DAYS is new and unset, which keeps the audit trail forever, as before. Set it to a
whole number of days to have old rows removed.

The local document index and the old connector tables are dropped by migration. documents,
chunks, document_acls and the four connector-bookkeeping tables are removed and their rows go
with them; this cannot be rolled back. A deployment that had been syncing into the local index loses
that copy, which is the point: answering now goes through a live system's own search.

An MCP server pointed at a credential that no longer exists loses the pointer. mcp_servers
now names its credential with a real foreign key, where the column was text against a uuid
primary key with nothing checking it — so a deployment is allowed to be holding a pointer to a vault
row that was deleted underneath it, and the screens read as though the server were still configured.
The migration clears those before adding the key, because it cannot add it otherwise. If this
happens, that connector correctly reports having no credential and an administrator registers it
again; nothing else is affected, and a deployment with no such pointer sees nothing.

The old Google Drive connector is gone, and it is not the new one renamed. It configured a
service account with domain impersonation and had the worker sync documents into a local pgvector
index guarded by our own ACL rows, so every person got the same answer computed from what one
credential could see, and revoking somebody's access left a cached copy of their documents behind.
/admin/connectors and its two screens, the connector catalogue and admin service, the sync
persistence and the worker's connector runner have all been removed. A deployment that was syncing
this way stops syncing and should enable the new connector at /admin/plugins/google-drive, where
each person connects their own account.

knowledge.yaml is still parsed and still refused when malformed, because it is part of the
deployment-package contract. Its sources: are now read by nothing.
MANAGED_AGENT_AG_UI_URL is no longer required to start. The one-container image does not carry a
Bot, so requiring it registered the shipped Risk Analyst against a host that was not there and every
conversation with it failed. Leave it unset for that image. A laptop scripts/start.sh still points
it at agent-langgraph. A URL with no MANAGED_AGENT_TOKEN still refuses to start; a leftover
token with no URL is ignored.

A .env copied from an older .env.example still has MANAGED_AGENT_AG_UI_URL=http://localhost:4201/ag-ui.
Unset it before docker run --env-file .env, or the coworker comes back.
The built-in Bot refuses to start without OPENAI_API_KEY. It used to start, report healthy, and
then fail every conversation, so a missing key looked like a working deployment. The LangGraph Bot
already refused the same way.

Sessions survive and nobody signs in again.

Changed

  • This deployment does not search documents itself. A Bot answers from a live system by calling
    that system's own search as the person asking, so the vendor decides what they may see and there is
    no second copy of anybody's documents here to keep in step, to secure, or to leave behind when
    somebody is removed. The local index that was being filled — documents, chunks and
    document_acls — and the connector that filled it have both been dropped. Retrieval over
    a copy of a customer's corpus is not a thing OpenBot does.

Added

  • A skill can say which tools it needs. POST /api/plugins/skills takes a tools list of
    serverId/toolName references, stored against the skill and returned with it. This is the unit
    tool retrieval will select over: a model picks a skill from its summary, and the skill says
    what to load. It grants nothing. A skill naming a tool a Bot was never granted still cannot
    call it, which is what keeps writing a skill open to anybody rather than to administrators
    only. A reference naming no tool this deployment has seen is refused when the skill is saved,
    so a typo is an error where it was written. Leaving the field out of a save leaves whatever was
    declared before, so nothing that predates it clears a declaration; sending an empty list is how
    a skill stops asking. Nothing consumes these yet — selection is the next piece, and until it
    lands a deployment behaves exactly as before.

  • A message with no @ goes to the coworker it is for. Typing without naming anyone used to
    reach the default coworker; to get a specialist you had to @ them. Now an untagged message is
    routed to the coworker whose purpose matches it, chosen against each coworker's own description by
    the deployment's own model, before the channel is pinned. It is named, not silent: the channel
    header is the coworker it went to, and a channel.routed row records the choice, the reason, and
    the candidates it chose between (never the message itself). @ still wins as an explicit override
    and skips routing entirely. If the router is uncertain or unreachable, it falls back to the same
    default the composer always used, and says so, rather than misroute or drop.

  • A Bot can answer from Google Drive, as the person asking. Ask a Bot a question whose answer is
    in a document and it answers from the live file rather than from an index, citing a link that opens
    it. A Bot granted these tools reads Drive on the asker's own grant, so two people asking the same
    question get the answers their own accounts can see, and neither sees the other's documents.
    Read-only: the scope requested is drive.readonly, so a write is refused by Google before this
    deployment has to. Nothing is cached — the refresh token is stored and an access token is minted
    per call, so revoking access at Google takes effect on the next one rather than when a cache
    expires.

    Setting it up takes two people and neither can do the other's half. An administrator registers a
    Google Cloud OAuth client and enables the connector at /admin/plugins/google-drive; each person
    then connects their own account, and there is deliberately no endpoint for an administrator to
    connect one on somebody's behalf. The redirect URI has to match what is registered character for
    character, and the connector page states the exact string to paste, because a mismatch fails at
    Google with a message that never mentions OpenBot. See
    docs/plugins/google-drive.md for the whole setup and for what each
    failure means.

    Disconnecting is not built yet. The account page says so and points at Google's own third-party
    access settings, which is what withdraws it today.

  • Each tool a connector offers has its own screen, at /admin/plugins/<connector>/tools/<tool>,
    with a switch per Bot. The connector page previously drew a button per Bot inside every tool row,
    which is a control per Bot per tool stacked in one list, and grew without bound as Bots were added.

  • Connected accounts, at /settings/connected-accounts. What a Bot may read as you, and the
    scope the vendor actually granted rather than the one that was asked for.

  • A tool result that found nothing says so. An empty result used to reach the model as an empty
    string, which reads as "the tool had nothing to say" rather than "there is nothing there" — and a
    model closes that gap from memory, which for a knowledge connector is the failure worth preventing.

  • The shipped Knowledge Bot answers from the tools it has. Its instructions in
    examples/fintech told it to say no source was connected, which was honest when none could be:
    the connector this replaces had been removed and nothing had taken its place. With a connector
    granted it became the opposite of honest — the Bot called a tool, was handed a file listing, and
    said it had no access anyway. It now reports what its tools return, says so plainly when it has no
    tool or a tool reports a problem, and does neither of the two things worth forbidding: answering
    from its own memory as though it came from a source, or claiming to lack access to something a tool
    has just returned. A deployment with its own tenant package is unaffected.

  • mcp.call_failed. A call this deployment permitted and the vendor did not complete now leaves
    a row of its own, carrying the vendor's own sentence. mcp.call_succeeded was written before the
    network call rather than after, so a call that died at the vendor recorded success and the Admin
    page agreed with it.

  • Releases are cut by a workflow, not by hand. Create release PR bumps the version and promotes
    ## Unreleased to a numbered section; merging the pull request it opens is what publishes. Merging
    builds and pushes one image to ghcr.io/copilotkit/openbot, signs a build provenance attestation
    for its digest, tags the commit and creates the GitHub Release with container-images.json so a
    deployment can name an exact digest rather than a tag somebody could move. See
    docs/releasing.md.

  • CI now runs the thing it ships. Two checks were added. migrations refuses a schema change
    with no migration written for it, and a snapshot that has drifted from the schema. image builds
    the container, boots it with embedded PostgreSQL, and fails if it does not answer or if a
    supervised service is respawning. A single verify check covers every job, so branch protection
    needs one entry. The same checks run again against the release commit when a release is published,
    so they gate the release rather than the proposal for one.

  • Sign in with Google, Microsoft or Okta. Any one of them turns sign-in on; configure several
    and the sign-in screen offers each, on matching buttons carrying each provider's own mark.
    INITIAL_ADMIN_EMAILS says who is an administrator. It is required whenever a provider is
    configured, because nothing else grants the role, and it is now a floor rather than a one-off:
    an address it names is made an administrator at every sign-in, so adding somebody to the list
    works even after they have already signed in.

  • SAML and OpenID Connect, registered while running. /admin/identity-providers takes the
    metadata a company's identity team supplies and registers their own IdP. Somebody then types their
    email address on the sign-in screen and the domain decides which provider they are sent to, so a
    company mid-merger can run two. Registering, changing or removing one is administrator-only, which
    the upstream plugin does not require: it guards those routes with a session, and anybody who could
    reach them could register a provider for a domain and mint themselves colleagues.

  • A People screen. /admin/people lists everybody who has signed in, with the provider they came
    through and when they were last here, and lets an administrator promote, demote, or remove
    somebody. Removing ends the session they are using and stops the next sign-in, keyed on the
    address so signing in again through the provider does not quietly create a new account. Every
    change is on the audit trail. Somebody named in INITIAL_ADMIN_EMAILS cannot be demoted or
    removed here, and nobody can do either to themselves.

  • One container that runs the whole thing. The root Dockerfile builds an image carrying the
    app, the API, a Bot computer, and optionally PostgreSQL, supervised together. Point DATABASE_URL
    at a database you already run and the built-in one never starts; leave it unset and the container
    is self-contained. See docs/deployment.md for the measured minimum sizes and
    the platforms it has been run on.

  • Bots can run commands. computer_run_command runs a command in the Bot's /workspace, so a
    Bot can install a tool, unpack what it downloaded, or run what it was asked to run instead of only
    driving a browser. Governed like every other action: the policy decides, the audit row is written
    first, and a rule can refuse a shell outright with intent == "run_command" or refuse particular
    commands. The command is recorded; its output is not.

  • The audit trail shows the command. A command row names what ran, the way a file row names the
    path, rather than reporting an element it was never about.

  • COMPUTER_SANDBOX=on turns on Chromium's own sandbox where the host permits user namespaces.
    Which way it went is printed at start-up either way.

  • New chat. The direct Bot chat has a button that starts a fresh conversation, which it had no way
    to do before: the thread was minted once and remembered for that Bot forever, so the only way out
    of a conversation was to clear the browser's storage by hand.

  • You can watch what a Bot is doing, not only what it is looking at. The screen answered half the
    question: a Bot spending two minutes in a terminal showed a blank browser and one grey line per
    command, with the output nowhere. A command line in the transcript now opens to show what it
    printed, its exit code, and whether it was cut short or stopped. Beside the screen there is an
    Activity tab carrying every command, file read, file write and listing as they happen, newest
    first, with a count on the tab so a Bot working away from the browser is visible without switching
    to it. A saved file shows its path and size, never its contents. This is a live view of the open
    conversation; the record is still the audit trail.

  • Sign-in is on the audit trail. Rows for signing in, for being refused, and for the configured
    administrator list granting somebody the role. Two questions had no answer before: who granted
    themselves administrator by editing INITIAL_ADMIN_EMAILS, and whether somebody just removed had
    ever been here, since removing them deletes the sessions that were the only evidence. A trail that
    is unavailable never blocks a sign-in.

Fixed

  • A ref could resolve against a page from a computer that no longer existed. The generation a
    computer stamps on a snapshot is unique only within one run of it, so a replaced container counts
    from one again and a ref the model is still holding matches a row nothing has overwritten. The
    policy then decides on an element from a dead page, and the audit row names it. Wiping a computer
    cleared the row for that reason and was the only thing that did; replacing one whose image changed
    did not, and the server was never told. A snapshot now carries which run of the computer took it,
    refs from an earlier run resolve to nothing, and the first snapshot of a new run replaces the old
    row however low its generation.

  • A migration stamped in the future silently swallowed the next one. Drizzle runs a migration only
    when its journal timestamp is later than the newest one the database has recorded, so a migration
    stamped ahead of real time raises that ceiling and every migration written after it is skipped
    until the clock catches up. drizzle-kit migrate reports success the whole time. One migration was
    hand-written a day into the future and did exactly that to the next one to arrive: the table was
    never created, and the only sign was an integration test failing on a relation that did not exist.
    The timestamps are corrected, an older inversion between two earlier migrations is corrected with
    them, and the journal is now checked by a test, because nothing else in the build would notice.
    If you ran a build between these, your database has the wrong ceiling recorded and will skip the
    next migration.
    Repair it with
    update drizzle.__drizzle_migrations set created_at = 1787359000000 where created_at = 1787444747113;
    or start from a fresh database, where migrations all run in one pass and ordering cannot bite.

  • Every Bot ran a model two generations old, and it was costing tool calls. The example package
    shipped gpt-4.1 as the default for every built-in Bot. Asked to open a page behind a sign-in,
    those Bots answered "would you like me to prompt you to sign in?" and called nothing, three times
    out of three, while the prompt forbids that sentence in as many words. On gpt-5.6-terra the same
    question produces the tool call first try, so the package now runs gpt-5.6-terra. It is a
    default, not a commitment: model.yaml still decides.

    The Bots that answer over AG-UI stay on gpt-5.5, each for its own measured reason. The framework
    Bot answers nothing at all on gpt-5.6-* through the Responses API — RUN_STARTED, then
    RUN_FINISHED, no text — and the hand-written one cannot use function tools on
    /v1/chat/completions with a 5.6 model unless reasoning is turned off, which is the wrong trade
    for a Bot whose job includes deciding when to ask a person for help. It refuses to start on such a
    model now rather than failing one silent tool call at a time. Where a 5.6 model is set deliberately,
    the Responses API is switched on for it automatically, because a deployment that set the model and
    did not know about that switch got a Bot which started, looked healthy, and failed on its first
    tool call.

  • A Bot browsed to a vendor this deployment already connects to. A Bot holding no grants was told
    nothing about connectors at all, so it treated a connected vendor as an ordinary website: asked
    about Google Drive it opened drive.google.com, met a sign-in page, and asked the person to sign
    in to an account the deployment had already connected. Every Bot is now told which vendors exist
    here, held or not, and says plainly which one it has not been granted rather than reaching for the
    browser.

  • A conversation was destroyed by a declined take-the-wheel. A Bot that asks for help with a
    sign-in and never gets it left a tool call nothing ever answered, and every later turn in that
    thread failed at the provider. This was fixed once for the framework Bot and not for the Bot in the
    box, which is the one behind the Browser Bot, so it went on happening where most people would meet
    it. Both now answer their own unanswered calls with the truth rather than a fake success.

  • A Bot refused because a person had the wheel was told its refs were stale. The computer flags a
    takeover, the surface branches on that flag, and the flag did not survive the server, so a Bot was
    sent back round the same action against the person who had just taken the browser. Reported and
    fixed by @beardthelion.

  • A person could not take the wheel unless the Bot offered it. The button appeared only after a
    Bot called for help, so the control a person needs depended on the Bot getting one instruction
    right, and when it did not there was nothing to press. It is there whenever the Bot is driving now.
    The Bot asking for help is still its own row, with its reason.

  • The first message of a new channel could be lost. A new channel's thread does not exist until
    its first run, so the join that restores history had nothing to settle against; the message was sent
    anyway after a deadline, while that join was still in flight, and the join finishing replaced it
    with the thread's messages, which were none. The deadline now ends the join and waits for it, so
    nothing is left in flight to overwrite anything. The transcript also says it is loading rather than
    showing an empty conversation, and the thinking line is visible for the first time: a CSS rule
    blanked the colour a gradient was built from, so the glyphs were painted with nothing. Reported and
    fixed by @zopeVaibhav.

  • The in-memory snapshot store disagreed with the table. The database only ever moves a snapshot
    forward; the in-memory one, which is what a test reaches for when it has no database, took whatever
    arrived last. A test could therefore prove a boundary property that is false in a deployment.
    Reported by @beardthelion, fixed by @NathanTarbert.

  • A computer that had opened nothing still reserved a browser-sized frame. That put a placeholder
    the height of a browser window into the middle of a conversation, above an answer that never
    involved the browser.

  • A Bot named after a deployment route was served without its guard. The computer router steps
    aside for /policy and /fleet, which are its own paths and not about a Bot, because Hono matches
    /* against zero segments and a single-segment path arrives as a Bot id. It stepped aside on the
    name alone, so it covered everything under those names too: /policy/status is /:botId/status
    with a Bot called policy, and for that whole subtree the access check was never called at all.
    The guard now steps aside only for the deployment path itself, and a Bot may no longer be named
    after one: a package declaring it is refused, and a deployment that already holds such a Bot
    refuses to start and names it rather than serving it. Reported and fixed by @beardthelion.

  • Upgrading never reached a Bot's computer. A computer is a container the supervisor makes, and it
    was reused by name whatever image it was built from, so once a Bot had one, rebuilding the image
    moved the tag and the container went on running the old one indefinitely with nothing to say so.
    docker compose down does not touch these either, because compose did not make them, so even a
    full teardown left them behind. That is worse than stale code: the computer is the browser, the
    workspace and the confinement around both, so a fix to any of them silently did not apply. A
    computer built from a different image is now replaced on next use. Its profile and its workspace
    are volumes and are kept, so a Bot comes back on the new image still signed in to what it was
    signed in to, with its files where it left them.

  • The audit trail could be erased with one statement. It is append-only because a database
    trigger refuses updates and deletes, and that trigger is row-level, so TRUNCATE never reached it:
    anything holding DATABASE_URL could empty the table and nothing raised. That is the case the
    guarantee exists for, since it is enforced in the database precisely because the application is not
    the only thing that reaches the table. A statement-level trigger now refuses a truncate, and it
    answers before the retention setting is read, so declaring a retention window no longer permits one
    either. Retention itself is unchanged: rows older than the window are still removed, and recent
    ones are still refused. The connection the application uses is the database owner in the shipped
    compose file, and an owner can still disable or drop a trigger; closing that needs a role with
    INSERT and SELECT only, which is a separate change. Reported by @beardthelion, who also named
    the failure mode of the obvious fix and saved it from shipping as one.

  • A declined take-the-wheel destroyed the conversation. A Bot that asks for help with a sign-in
    and never gets it left an assistant message holding a tool call that nothing ever answered, and
    every later turn in that thread failed at the provider. Declining once meant nothing you typed
    afterwards got an answer, with no way back but a new chat. Unanswered calls are now answered when
    the history is rebuilt, with the truth rather than a fake success: no result came, the run has
    ended, carry on without it and say what could not be done.

  • The audit trail could not say why a conversation went where it did. It recorded the router's
    choice and recorded nothing at all when a person named a coworker with @, which is
    indistinguishable from a row that failed to write. A mention is now recorded too, as the person's
    own choice, without asking the model a question they had already answered. The audit page names the
    coworker and separates the three cases: chosen by the person, matched by the router, or the default
    because nothing matched.

  • A Bot with half a connector sent people to a sign-in box. Granted a vendor's search but not its
    read, it found the document, could not read it, and opened the vendor's website to try, where it
    met a sign-in wall and asked the person to take the wheel. They already had access; the missing
    thing was the Bot's grant, and nothing said so. A gap in what a Bot holds is now reported as a gap:
    it names the capability it would need and says an administrator can grant it on that connector.

  • Answers arrived with no sign of where they came from. Asked a compliance question, a Bot
    replied with a filing obligation, a dollar threshold, a deadline and a retention period, and the
    audit trail for that turn held one row: the routing decision. A confident unsourced answer is
    indistinguishable from a confident wrong one. Every Bot is now told to cite what it read and to say
    plainly when an answer is from its own knowledge instead. It is told this by the deployment rather
    than per agent, so it cannot be missing from the next Bot somebody adds, and it is explicitly not
    an instruction to go hunting for a source.

  • A Bot browsed to a vendor it already had tools for. Granted Google Drive, asked what was in a
    document, it opened drive.google.com in its own browser, met a sign-in page that browser can
    never satisfy, and asked the person to sign in to an account they had already connected. A tool
    array says a tool exists; it does not say the tool is the way to reach that system, and it was
    competing with a page of prose about the browser that mentions connectors nowhere. A Bot is now
    told which systems it holds tools for, generated from its grants and placed before that prose, so
    enabling a connector changes what the Bot is told on its next run.

  • A question went to a coworker that had no way to answer it. Routing read the sentence somebody
    wrote about what a coworker is for, which is not the same as what it can reach, so a question about
    a Drive document went to the one whose description says "company knowledge" and which held no Drive
    grants. Candidates now carry the systems they hold tools for. Purpose still decides first: a
    specialist with no connectors is still right for a question about its specialism.

  • A deny rule about submitting a form was walked around by typing. computer_type takes a
    submit flag that presses Enter once the text is in, and the policy never saw a key, so a rule
    refused at the button and at the keypress let the third route through. Both shipped copies of that
    rule name both tools now, the key reaches the policy, and the audit row carries it — without it a
    row said a field was filled in rather than that a form was sent.

  • A Bot refused at the door left no trace. A callback that could not prove which Bot it was
    returned 401 and wrote nothing, so a Bot holding a token the deployment no longer accepted had
    every call refused, returned nothing to its own model, and the model told the person there were no
    results. A false negative delivered as an answer, with the audit trail agreeing nothing had
    happened. Recorded now as mcp.callback_refused, naming the tool and the reason but no Bot or
    actor, since both arrive in the credential that just failed to verify.

  • An unanswered request for the wheel followed a Bot around. Control belongs to a Bot's computer
    rather than to a conversation, so a request nobody took sat there indefinitely and every later
    conversation with that Bot showed a live prompt for work it was not doing, captioned with a reason
    written for somebody else. An unanswered ask now stops being shown after ten minutes and its reason
    goes with it. A person actually holding the wheel is never timed out.

  • /admin/computers listed nothing, ever. Admin addressed the fleet through a per-Bot route with
    a placeholder id, which stopped working when that route began checking whether the caller may act
    as the Bot in the path. The screen renders nothing while the list is null, so a deployment with two
    running computers looked like one with none. The fleet has a route of its own, still
    administrator-only.

  • Every shipped component was recorded twice on a first start. Two browsers announcing at once is
    ordinary and the insert was already safe for it; the answer was not, so the loser of that race
    named every component anyway and the caller wrote an audit row per name.

  • A Bot could reach the deployment's own network by writing the address a different way. The
    guard refused 169.254.169.254 and the private ranges as usually written, but not the same
    addresses spelled as an IPv6-mapped or NAT64 form, an integer, or with a trailing dot, so a Bot
    talked into fetching one still reached cloud metadata or an internal host. The address is
    canonicalised before it is checked now, the mapped form of 0.0.0.0 (which reaches every local
    port) is refused, and the container credential endpoints a hosted deployment must never expose —
    ECS and Fargate's 169.254.170.2, Alibaba's 100.100.100.200 — are refused even when the
    private-host opt-in is on. The same guard backs agent registration, so it is closed there too.

  • The supervisor could adopt a container it did not create. When starting a Bot's computer hit a
    name already taken, it started whatever held the name and handed it the deployment's computer
    token, so on a Docker host shared with anything else it could drive a stranger's container as a
    Bot's. It now refuses a container that does not carry its own namespace label, read from the
    container rather than inferred, so a second deployment on the same host is never adopted.

  • Removing somebody left the credentials they had granted this deployment sitting in the vault.
    Removing them from the People screen ended their sessions and stopped the next sign-in, and left the
    refresh token behind, unrevoked. They could not use it — the account comes from a session they no
    longer get — but "we removed their access" was not true of the token, which for a connector read as
    the person asking is the part that matters. Removing somebody now retires it, and each retirement is
    on the audit trail as mcp.account_disconnected. Deleting a person's row used to be worse, because
    it took the connection record with it and left the credential reachable by nothing at all; those are
    found and retired too. This stops the deployment holding a usable secret. It does not withdraw
    the grant at the vendor, which needs revoking there until disconnect ships, and the audit row says
    which of the two happened rather than implying both.

  • The one-container image registered a coworker it could not run. MANAGED_AGENT_AG_UI_URL
    defaulted to localhost:4201 and was required, so Risk Analyst appeared on the roster and every
    conversation with it failed. The URL is optional; the package omits that coworker when it is
    unset. scripts/start.sh still points it at agent-langgraph on a laptop.

  • A boundary rule applied on one server out of N. The policy is read from memory on every action,
    which is right, but memory was only ever filled at boot. An administrator's new deny rule was
    enforced by whichever process served the request and roughly one action in N went through it, while
    the admin screen reported success because the row really was saved and the audit trail agreed
    because it records the boundary each process started with. Both honest, and both describing
    something other than what the fleet was enforcing. A write now announces on Postgres in the same
    transaction and every server re-reads, including on reconnect, so a server that was down when the
    rule changed catches up rather than waiting for a restart. Reset travels the same way.

  • A ref resolved on one replica and nowhere else. The gateway turns the opaque ref in a click into
    the element it points at, and that mapping lived in a Map in the process that took the snapshot.
    On any other replica the ref resolved to nothing, so a deny rule written about the element did not
    match and the click went through, recorded as allowed with no rule. It is in Postgres now, keyed on
    the generation the computer stamped, so a ref from a superseded page still resolves to nothing.

  • Anybody signed in could act as anybody's Bot. The Bot id travels in the path and the acting
    routes checked only that somebody was signed in, so a signed-in person could drive another person's
    private Bot, reset its browser, read its screen and fire its granted tools. Every route under a Bot
    id now asks the store the same question the roster already asks, and a Bot that does not exist and
    one belonging to somebody else answer identically.

  • The computer fleet listing was open to any signed-in person. It ignores its :botId and returns
    every Bot's machine, so it told anybody who could reach it every Bot id in the deployment and
    whether each was running, private coworkers included. Administrator-only now.

  • A Bot id could name a directory outside the profiles volume. The id arrives as a URL segment or
    a header, was joined onto a filesystem path, and reset deletes that path recursively as root, so
    ../../tmp/something deleted it. Refused at the request boundary and again where the path is built.

  • A mistyped deny rule permitted instead of refusing. A rule that parsed and evaluated but
    answered with something other than true or false was neither a match nor an error, so
    deny: ["Submit order"] — what somebody writes who reads the list as labels — let the action
    through with nothing logged, while the rule sat on the Boundaries page looking as though it were in
    force. Any non-boolean answer is now a broken rule and takes the existing fail-closed path.

  • Rotating a Bot's key left the old one live. Editing a key wrote a new vault row and repointed
    the Bot at it, leaving the previous credential decryptable and still valid with nothing listing it,
    so rotation did not do the one thing rotation is for. Deleting a Bot left its key live too. Both
    revoke now.

  • Nothing recorded what changed about a Bot. Ten mutating routes wrote one audit row between them
    and there was no event type for any of the other nine. A Bot's endpoint is where conversation
    content is sent, so "who pointed this Bot at that host, and when" is the first question in an
    incident and could not be answered. Eight event types and eight rows now, recording what changed and
    never a value.

  • The people list and the channel list grew without bound. Both were read in full on every render,
    and reading one person ran the whole people aggregate over the deployment twice per role change.
    Both are paged now, and the people screen searches on the server so somebody can be found without
    walking pages.

  • A computer accumulated one browser per Bot, forever. COMPUTER_MAX_BROWSERS and
    COMPUTER_BROWSER_IDLE_MS set the two limits. Nothing closed an idle one, so a deployment
    where every employee has a Bot trends toward a resident Chromium per employee in one container until
    it is killed for memory. There is a cap and an idle timeout, and closing one costs only a relaunch
    because the profile is on disk.

  • The audit screen's filters were sequential scans. It filters by event type, by who did it and by
    what it was done to, and the only index was on the timestamp, over what becomes the largest table in
    the deployment. Each filter leads its own index now.

  • A deployment with no identity provider came up open by default. Covered under Changed above,
    and listed here too because it is the one on this list that was reachable from the internet.

  • Registering a company's identity provider was owned by whoever registered it. Better Auth
    answers its own listing route with only the providers the person asking registered, and refuses a
    removal from anybody else, so a second administrator opened the Identity providers screen, found
    it empty, and registered one that already existed. Worse, the row cascaded from that person's user
    row: deleting the administrator who set sign-in up deleted the company's sign-in with them. What is
    registered is a fact about the deployment, so reads and removals go through OpenBot's own
    administrator-only routes against the whole table, and a provider outlives the person who added it.

  • A customer's client secret was in the clear. The SSO plugin writes oidc_config and
    saml_config as plaintext JSON, with the OAuth client secret for that company's directory inside
    them: the one secret here not going through KEY_ENCRYPTION_KEY. Both are now encrypted at rest.
    Rows written before this still read, and are re-encrypted the next time they are written. OAuth
    access and refresh tokens use Better Auth's own encryption, keyed on BETTER_AUTH_SECRET.

  • A failed provider registration looked like a button that did not work. The error was rendered
    on the page behind the dialog, which was covering it.

  • Deleting a component in the playground could release one the build ships. DELETE /api/sandboxed/:name deleted from the shared components table by name, without checking
    which kind of component the name belonged to. Naming a compiled component removed its
    governance row, and the foreign keys took that component's per-Bot withholdings and its
    function grants with it. Withholding is the half that fails open: a published component is
    available to every Bot unless a row says otherwise, so the next catalogue announcement brought
    the component back published, and available to a Bot it had deliberately been kept from. The
    audit row called it kind: "sandboxed". The endpoint now refuses a name this surface does not
    own and answers 404, the way publishing already did. A governance row whose source is already
    gone is still this surface's to clear.

  • A write could follow a symlink out of the Bot's workspace. The confinement resolved the
    directory a write would land in but not the name it would land on, so a link left at notes.txt
    pointing outside was followed by the write; a read through the identical link was already refused.
    The gateway had already decided and written the audit row against the path as it was asked for, so a
    rule written for credentials/ never saw the file that was written and the trail named a file
    nothing had touched. A dangling link escaped the same way, because resolving the path throws where
    the write would still land. Links pointing back inside the workspace continue to work.

  • A Bot could become root inside its container. sudo was granted as NOPASSWD: ALL, and the
    comment above it named the two conditions that made that acceptable: the container being one Bot's
    alone, and not holding a database. The image meets neither, because the supervisor is deliberately
    not in it and EMBEDDED_POSTGRES=on is a documented way to run it. So root read another Bot's
    workspace, the API's environment, and the audit database recording what it did. The grant now names
    the package managers, so apt-get install still works and sudo cat /proc/1/environ does not. It
    is a floor rather than a boundary: code a model wrote needs a computer per Bot with
    COMPUTER_SUPERVISOR_URL and a sandbox under it with COMPUTER_RUNTIME=runsc, both of which this
    already supports and neither of which the single-container image can reach.

  • A command could take the computer down, or outlive being stopped. Output was accumulated in
    full and only trimmed at the end, so cat of a large file allocated until the process that owns
    the browser died; it is now bounded as it arrives, and still reports that it was truncated rather
    than quietly ending. A stop signalled bash alone, so sleep 30 | cat left its children holding the
    pipes and the call never returned; the whole process group is signalled now. A timeoutMs of zero
    or less killed the command before it started and called it a timeout; it has a floor as well as a
    ceiling.

  • Stop did not reach a running command. The /exec route never took the person's abort, so the
    plumbing for it was dead code and a stopped run left the command finishing inside the container.

  • The live-screen socket did not check the address it was given. Every acting path resolved
    through the gateway, which refuses a foreign or cloud-metadata address; this one asked the provider
    directly and then put COMPUTER_TOKEN in the query string of whatever it was told.

  • COMPUTER_SHELL_ENV refuses the names that run before a command. Naming GITHUB_TOKEN is an
    operator deciding a Bot may use a token. Naming BASH_ENV, ENV, LD_PRELOAD or the shell option
    variables is handing a Bot a hook into every later command, which is unlikely to be what was meant,
    so those are refused and said out loud rather than passed. A name that is not a variable name is
    now reported too, instead of quietly disappearing.

  • A deny rule naming one field refused every action that did not have it. deny: contains(command, "rm -rf"), the example the documentation gives, refused every click, keypress,
    navigation and file read in the deployment. Two correct behaviours combined into a wrong one: the
    policy context left out fields an action did not have, cel-js treats a missing field as an unknown
    identifier and throws, and a thrown deny counts as a match so that a mistyped deny refuses rather
    than quietly permitting. Every field is now bound, with a neutral value where the action has
    nothing to put there, so a rule about a shell answers honestly about a click instead of refusing
    it. Rules about the action they are for are unchanged. The audit row still omits what did not
    happen.

  • A command longer than 45 seconds reported failure while it carried on running. The transport
    gave every call the same deadline, which was shorter than the shell's own 120 second default and
    600 second maximum, so apt-get install told the person the computer had not responded and then
    finished installing inside the container. A command now gets a deadline that outlasts the shell,
    which reports a timeout itself and says so.

  • A Bot's shell no longer inherits the deployment's environment. Commands ran with the computer
    process's own environment, so env in the one-container image printed KEY_ENCRYPTION_KEY and
    the rest of .env. The shell now receives PATH, locale and terminal names, and the proxy
    variables. Userinfo is stripped from a proxy URL, so a password in HTTP_PROXY is not in env.
    Anything else is named in COMPUTER_SHELL_ENV.

  • A deployment served over plain HTTP could not start a conversation. The chat surface minted
    identifiers with crypto.randomUUID, which browsers withhold outside a secure context. On a
    laptop http://localhost counts as one, so this never showed up in development; on a real
    address it does not, and the surface did nothing at all when you pressed send. No message, no
    error. Ids now come from an API with no such restriction.

  • A Bot asked to be signed in, in words, and nothing happened. Handing over the browser is a tool
    call, and a sentence in the transcript is not one: "please sign in and let me know" leaves the
    person with no wheel to take and the page where it was. Bots wrote that sentence anyway, and one
    went further and asked for a username and password to be typed into a sign-in page nobody could
    reach. The guidance now says that calling computer_request_help is what asking means, names the
    sentences that are not it, and says the person cannot see the page at all until control is handed
    over. Asked to file an issue on a site it was not signed in to, a Bot now offers the wheel on the
    first attempt instead of the third.

  • A package Bot did not know it had a computer. The instructions that make the computer usable —
    snapshot before acting, and ask a person to take the wheel at a sign-in rather than reporting the
    task as impossible — were imported by the two shipped Bots and by nothing else, so a built-in agent
    knew only the role its package gave it. The tools were on offer to it the whole time. Asked to file
    an issue on a site it was not signed in to, it browsed to the page, said it could not, and never
    called computer_request_help, so nobody was ever offered the wheel. Built-in agents are now told
    the same thing the shipped Bots are told, wherever a computer is configured.

  • A chat could quietly forget everything and carry on. The browser remembers a thread id for each
    Bot, and nothing ever asked whether Intelligence still had that thread. Where it did not, the
    transcript loaded empty, every later message silently recreated an empty thread under the same id,
    and the Bot answered as though the conversation were new — with the reason nowhere but the server
    log, as a 404 flattened into a 500 by the time it reached the browser. A remembered thread is now
    checked before it is used: one the platform provably does not have is replaced, because there is no
    conversation left to lose, and a check that fails for any other reason keeps the thread and says on
    screen that earlier messages could not be loaded. A person reading a confident answer can now tell
    whether the Bot has read what came before it.

  • The first browser action a Bot was ever asked for failed. Creating a computer and starting it
    are two calls to Docker, and a name the daemon has not published yet answers the second with a 404.
    The supervisor treats that as a lost race and rebuilds, which is right, but it went straight back
    round: the retry landed a millisecond later, saw the same unpublished name, and spent the only
    other attempt on it. The whole request then failed as Docker being unreachable, the person was told
    the computer could not be started, and the next message worked. It waits one poll interval before
    rebuilding now, which is what the health wait already uses for the same question.

  • A framework Bot asked for a browser action and nothing happened. agent-langgraph ends a run
    when the model calls a tool the surface owns, which is how a tool that lives in the browser is
    supposed to work: the run finishes, the surface acts, and the next run carries the result. But the
    call was only reported to the surface from the node that executes this deployment's own tools, and
    that node is exactly what an ending run skips. The person saw their own message, no answer under
    it, and no explanation, because a run that finishes carrying nothing is not an error. Every Bot
    action in the browser was affected: opening a page, filling a form, asking for help at a sign-in.

Changed

  • A retention policy for the audit trail. AUDIT_RETENTION_DAYS removes rows older than the
    window it names, swept hourly by whichever server holds an advisory lock. Unset by default, because
    deleting somebody's audit trail because a default said so is the worse of the two failures. The
    trail stays append-only: the database permits a delete only when the transaction declares a
    retention window and only for rows already outside it, so removing recent rows is still impossible
    and an UPDATE still is under every condition.
  • allowed_groups is documented as a declaration, not a control. The tenant package writes it and
    nothing reads it on any access path, and users.groups is written by nothing either, so both halves
    of the rule are waiting on group membership arriving from the identity provider. Channel access is
    membership alone. The columns stay, because they are the right shape for the rule they are named
    for. Thanks to @NathanTarbert and
    @andreolf.
  • Running with no sign-in takes a flag and nothing else. It used to be locked with
    NODE_ENV=production, which is exactly backwards: NODE_ENV is unset unless somebody sets it, so
    a container on a VM with a hand-written env file and no identity provider served every visitor on
    the internet as an administrator, silently, because nothing looked wrong from the outside. A
    deployment with no provider now refuses to start unless OPENBOT_SINGLE_USER=true says it was
    meant. .env.example ships that line switched on, so a clone still runs with no configuration at
    all, and the line is greppable in a way a default never was. OPENBOT_DEV_NO_AUTH is still
    honoured.
  • Requires Better Auth 1.7, which adds an issuer to every account. Migrations 0002 and 0003
    add the column and backfill existing rows with their provider's real issuer, so nobody is asked to
    sign in again. The column stays nullable on purpose: a rolling deploy runs migrations and then
    serves from old and new replicas at once, and an old replica writes an account without it, so
    making it required in the same release would break the first sign-in of everybody who landed on a
    replica that had not been replaced yet. The constraint belongs to a later release.
  • Where a Bot's computer runs is now a plug. One ComputerProvider interface sits under the
    gateway, with the Docker supervisor as one implementation and a shared computer as another. A
    computer somewhere else is an adapter rather than a change to the governed path. Thanks to
    @mu-hashmi for the refactor.
  • The address a provider hands back is checked before anything is sent to it, and the cloud metadata
    addresses are refused whatever a provider says.
  • The container image runs as an unprivileged user rather than root.

Don't miss a new OpenBot release

NewReleases is sending notifications on new releases.