What changed, for somebody deciding whether to upgrade. Written for the person running OpenBot, not for the person who wrote the commit: a line belongs here when a deployment behaves differently afterwards, and does not when only the code moved.
Newest first. Unreleased is what is on main and not yet tagged.
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 onNODE_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.
- 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,chunksanddocument_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.
-
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 achannel.routedrow 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/fintechtold 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_succeededwas 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 PRbumps the version and promotes## Unreleasedto a numbered section; merging the pull request it opens is what publishes. Merging builds and pushes one image toghcr.io/copilotkit/openbot, signs a build provenance attestation for its digest, tags the commit and creates the GitHub Release withcontainer-images.jsonso 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.
migrationsrefuses a schema change with no migration written for it, and a snapshot that has drifted from the schema.imagebuilds the container, boots it with embedded PostgreSQL, and fails if it does not answer or if a supervised service is respawning. A singleverifycheck 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_EMAILSsays 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-providerstakes 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/peoplelists 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 inINITIAL_ADMIN_EMAILScannot be demoted or removed here, and nobody can do either to themselves. -
One container that runs the whole thing. The root
Dockerfilebuilds an image carrying the app, the API, a Bot computer, and optionally PostgreSQL, supervised together. PointDATABASE_URLat 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_commandruns 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 withintent == "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=onturns 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.
-
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
TRUNCATEnever reached it: anything holdingDATABASE_URLcould 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 withINSERTandSELECTonly, 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.comin 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_typetakes asubmitflag 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/computerslisted 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.254and 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 of0.0.0.0(which reaches every local port) is refused, and the container credential endpoints a hosted deployment must never expose — ECS and Fargate's169.254.170.2, Alibaba's100.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_URLdefaulted tolocalhost:4201and 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.shstill points it atagent-langgraphon 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
Mapin 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
:botIdand 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
resetdeletes that path recursively as root, so../../tmp/somethingdeleted 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_BROWSERSandCOMPUTER_BROWSER_IDLE_MSset 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_configandsaml_configas plaintext JSON, with the OAuth client secret for that company's directory inside them: the one secret here not going throughKEY_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 onBETTER_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/:namedeleted 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 itkind: "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.txtpointing 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 forcredentials/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.
sudowas granted asNOPASSWD: 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 andEMBEDDED_POSTGRES=onis 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, soapt-get installstill works andsudo cat /proc/1/environdoes not. It is a floor rather than a boundary: code a model wrote needs a computer per Bot withCOMPUTER_SUPERVISOR_URLand a sandbox under it withCOMPUTER_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
catof 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, sosleep 30 | catleft its children holding the pipes and the call never returned; the whole process group is signalled now. AtimeoutMsof 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
/execroute 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_TOKENin the query string of whatever it was told. -
COMPUTER_SHELL_ENVrefuses the names that run before a command. NamingGITHUB_TOKENis an operator deciding a Bot may use a token. NamingBASH_ENV,ENV,LD_PRELOADor 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 installtold 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
envin the one-container image printedKEY_ENCRYPTION_KEYand 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 inHTTP_PROXYis not inenv. Anything else is named inCOMPUTER_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 laptophttp://localhostcounts 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_helpis 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-langgraphends 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.
- A retention policy for the audit trail.
AUDIT_RETENTION_DAYSremoves 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 anUPDATEstill is under every condition. allowed_groupsis documented as a declaration, not a control. The tenant package writes it and nothing reads it on any access path, andusers.groupsis 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_ENVis 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 unlessOPENBOT_SINGLE_USER=truesays it was meant..env.exampleships 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_AUTHis still honoured. - Requires Better Auth 1.7, which adds an
issuerto every account. Migrations0002and0003add 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
ComputerProviderinterface 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.
First tag.