A desktop shell that installs OpenBot and then becomes it
One window, on macOS, Windows and Linux. From a machine with nothing on it, one click fetches the
deployment for this release, pins every image to the digest the release published, generates the
secrets that are OpenBot's to generate, raises the containers, applies the migrations, installs the
dependencies, starts the host processes, waits until both the API and the app answer, and then shows
OpenBot in the same window. It is not a launcher for a browser tab. Stop takes the stack down, closing
the window hides it to the tray, a second launch hands over the window that already exists, and
quitting stops what starting started. Windows is told what it needs before anything else runs: WSL,
its kernel, virtualisation and administrator rights are each named with the command that fixes them,
because none of them is something OpenBot can fix on somebody's behalf.
A title or name that is only spaces is refused rather than stored
Sandboxed components, skills, custom servers and the component catalogue each accepted a title, slug
or id made entirely of whitespace, and stored it. What came back was a row nobody could identify and,
in the catalogue's case, entries that were empty strings. Each endpoint now refuses those the way it
refuses a missing field, and the catalogue drops empty entries instead of keeping them.
A malformed socket message no longer takes the screen down
The live screen and the channel socket both assumed every payload they received was an object of the
shape they expected. Anything else threw inside the handler, which in a browser means the surface
stops updating with nothing on screen to say why. Both check the shape first and ignore what does not
match.
The worker refuses a bad server URL when it starts, not when it first needs it
SERVER_INTERNAL_URL was read as a string and used as one. A value that was not an http or https URL
failed later, inside whichever call happened first, with an error about that call rather than about
the setting. The worker validates its environment up front now and normalises the URL, so a typo stops
it immediately and says which variable is wrong.
A sandbox template that is not valid JSON is reported as a sandbox error
A malformed template surfaced as a raw parse error from whatever tried to read it. It is a
SandboxError now, which is what the caller already handles and what the surface already knows how to
show.
A duration written in capitals is understood
durationMs accepted 30s and refused 30S. Units are read case-insensitively now.
The supervisor refuses a HostPort that is not a port
The value went through a bare parseInt, so 80abc became 80 and an out-of-range number was used as
given. Digits and range are checked, and anything else refuses to start rather than binding somewhere
nobody asked for.
The audit endpoint refuses a bad date or limit instead of guessing
An unparseable from or to, or a limit that was not a whole number, was coerced and the query ran
against whatever came out. All three are parsed strictly now and a bad one answers 400.
Chat content that does not parse is dropped rather than thrown
One malformed entry in a user message threw and took the whole message with it. The malformed part is
dropped and the rest is delivered.
The example package is read by a path the host accepts
It was addressed by a URL pathname, which is not a path on Windows. It is read by a real path now.
The app can be served from a built bundle, and listens on both loopbacks
The app's only start script was dev, which runs a Vite dev server. That meant NODE_ENV=development
on a freshly installed product: the SDK drew its developer inspector over the top, with hot reloading
and source maps behind it. There is a serve script now that builds once and serves the build. Both
it and dev hand Vite to Bun directly, because node_modules/.bin/vite begins #!/usr/bin/env node
and a machine with Bun and no Node exits 127 without saying why. Vite is also told to listen on ::,
since Node resolves localhost to ::1 and Bun to 127.0.0.1, so binding one of them left whoever
asked for the other looking at nothing.
A person's Stop is recorded as a stop, not as a failed action
Pressing Stop mid-action aborts the request, and the gateway wrote that outcome beside the decision
row as a failure — the same computer.action_failed type it writes when a computer is unreachable or
times out. The row's message already said the action was stopped, but anything counting failures by
type, the natural way to watch for outages, read every Stop as one. A stop now writes its own type,
computer.action_stopped: the action did not happen and nothing broke. The audit page already groups
it with the other did-not-happen outcomes, and a policy dry-run skips it the way it skips a failure,
since both sit beside a decision row that is already scored.
A malformed DATABASE_URL is refused without printing the password
DATABASE_URL is taken apart before it reaches Bun, and the string most likely to fail that parse is
one with a stray character in the password. The refusal for an unparseable value quoted the whole
string back to name the fault, which wrote the database password into the log line that reported it.
It now names the variable and the shape it expects, the way the other refusals beside it already do,
and never echoes the value.
Wiping a computer is recorded even if clearing its stored state fails
Resetting a computer destroys the profile first and wrote the audit row last, after two Postgres
deletes. A connection reset, a failover or a statement timeout in either delete threw before the row
was written, so the most destructive button in the product could leave a wiped computer -- every
login gone, no undo -- with nothing on the trail to say who wiped it or when. The row is now written
as soon as the profile is gone, which is the point after which nothing can be put back. A failure in
either delete is still reported to the caller.
Pressing Stop is recorded as a stop, not as a computer that is not running
The computer transport answered a Stop correctly only when it arrived before the request left. The
caller's signal is handed to fetch precisely so a Stop can also land mid-action, and a fetch
aborted that way rejects with an AbortError, which fell through to the message for a computer that
cannot be reached. The person was told their own click had failed because the assistant's computer
was not running, and the gateway wrote that sentence into the action's audit row as its failure --
so a deliberate stop read back as an outage. A genuinely unreachable computer and a timeout still
say what they said.
A component the server refused is no longer drawn anyway
A sandboxed component asks the server at call time whether the Bot may still use it, and a refusal
is recorded so the drawing can be replaced with a card saying so. The renderer looked that refusal
up under props.toolCall.id, which is the shape a tool HANDLER is given; a renderer's props carry
the id flat, as toolCallId. The lookup key was therefore always undefined, the refusal was never
found, and the component rendered as though it had been allowed -- so revoking a component from a
Bot did not take effect on screen until the five-second grant poll caught up, and a failed decision
request showed nothing at all.
A component whose name has a stray space is the same component
The catalogue announcement asked whether each component's name, title, kind and description
were more than whitespace, and then published the untrimmed strings. A name is a component's
identity -- it is what syncCatalogue compares against what is already published, what decide and
listForAgent look up, and what a grant names -- so a build shipping " weatherPanel " added a
second catalogue row beside weatherPanel: published, ungranted by anybody, and impossible to hold
a Bot back from under the name people use. The four fields are now stored as the strings the guard
approved.
A password with a % in it says so, instead of failing as URI error
DATABASE_URL is taken apart before it reaches Bun, and each part is percent-decoded. A part
holding a % that starts no escape -- postgres://openbot:100%pure@host:5432/openbot, which a
generated password produces often enough -- is a string new URL accepts and decodeURIComponent
rejects, so the server stopped with URIError: URI error and named neither the variable nor the
part. It now refuses with the same kind of sentence as every other malformed address: which part is
wrong, and that a literal % must be written %25.
A correctly encoded password is unaffected.
An empty Bot PORT is unset, so NaN never reaches Bun.serve
PORT= on agent-bot and agent-langgraph used to parse as NaN (?? does not treat empty as absent) and Bun.serve bound an ephemeral port while compose still published 4200/4201. A prefix typo (42o0) started on 42. Empty now means the shipped default; anything that is not a whole port number refuses to start.
The server connects to Postgres on Windows, and localhost is no longer a coin toss
Two separate faults, both of which stop a deployment reaching its own database and neither of which
says so.
The connection address went to Bun as a URL. Bun reads such a URL's path, the database name, as the
path of a unix socket, ignores the host and the port, and fails to open a socket Windows does not
have (oven-sh/bun#27713). The server could not reach Postgres there at all, while psql inside the
container and a plain TCP connection from the same machine both worked, which makes it look like a
network fault rather than a parsing one. The address is now passed in parts, and DATABASE_URL is
removed from the environment as it is read, because Bun prefers that variable to the parts it was
handed and would otherwise put the address straight back through the same parser. A URL with no host
or no database is now refused by name instead of connecting somewhere nobody chose.
Separately, Compose published its loopback ports on 127.0.0.1 only. localhost resolves to ::1
and 127.0.0.1 in an order the platform decides, and a client handed ::1 first does not fall back
to the other, so the same configuration worked on one machine and failed on the next for a reason
nothing in the error mentions. Every loopback port is now published on both addresses. Both are
loopback, so nothing became reachable from another host.
A bad COMPUTER_MEMORY_BYTES refuses to start the supervisor, instead of capping a computer at 512 bytes
COMPUTER_MEMORY_BYTES=512m used to parse as 512 via parseInt, which Docker accepts as a memory
cap Chromium cannot live in. Empty COMPUTER_MEMORY_BYTES= is still unset (no cap). A value that is
not a whole number of bytes now exits before any computer is created.
An IPv6 address in AGENT_ENDPOINT_ALLOWED_HOSTS now matches however it is written
The endpoint check compares the list against the address as the URL parser spells it, compressed
and lower-case, while the list kept each IPv6 entry as the operator wrote it. [0:0:0:0:0:0:0:1]:8443
was therefore a line that silently never matched, the failure the list's other refusals exist to
prevent. Stripping the brackets on both sides also folded two different names into one, so naming
[fd00::1:8443], an address, admitted [fd00::1]:8443, another address on a port, and the other way
round. Bracketed entries are now stored in the parser's spelling, with the port kept as written, and
compared with their brackets on; an entry the parser does not read as an address is refused at boot,
naming the entry, as a URL or a wildcard already was. Names and IPv4 entries are unaffected.
A Bot's own decline is only recorded against a Bot the caller may reach
A Bot reports that it declined a request through the person's session, and the audit row says
reportedBy: the Bot itself. The route wrote that row for any agent id in the path, without asking
whether the caller could reach that Bot, so any signed-in person could put a decline, in any words,
against any coworker, one they cannot see included, and an administrator reading the trail would take
it for something the Bot said. The route now asks the store first, as every other route on a Bot
does, and answers not found for a Bot the caller cannot reach, writing nothing.
The engine socket the supervisor is given can be pointed somewhere else
Compose mounted /var/run/docker.sock into the supervisor as a fixed path. That is correct for
Docker, and for Podman on macOS, where podman machine symlinks it to the rootless socket inside the
virtual machine. It is wrong for rootless Podman on Linux, where the path is either absent or, with
podman-docker installed, a symlink to /run/podman/podman.sock, the rootful socket, which is not
the one running. The supervisor held a dead socket and every attempt to give a Bot a computer failed
with a message about not reaching Docker.
The mount source is now ENGINE_SOCKET, defaulting to /var/run/docker.sock, so nothing changes
unless it is set. On rootless Podman on Linux, set it to $XDG_RUNTIME_DIR/podman/podman.sock.
A Bot's computer is waited for properly on Podman, and the supervisor can reach the engine there
Two things stopped OpenBot running on Podman, which nothing had tried before.
The supervisor could not reach the engine at all: The supervisor could not reach Docker. The socket
is there and the mount is right, but Podman's virtual machine runs SELinux and labels the socket in a
way a container is not allowed to read. The supervisor now declares label=disable, which is what
that needs and which changes nothing on Docker.
Then every cold start of a computer raced the first request to it. Readiness was read off the image's
HEALTHCHECK, and Podman does not report one: its images are OCI-manifest, the OCI image config has
no healthcheck field, and the instruction is dropped both when Podman builds an image and when it
pulls one that has it. With no health to read, the supervisor fell back to accepting a container that
was merely running, and a running container is not a browser that is answering, so the first request
arrived at a port nothing was listening on and came back as a computer that is not running. The
supervisor now states the healthcheck when it creates a computer instead of inheriting it, so
readiness no longer depends on how the image was built. On Docker the behaviour is unchanged.