The People screen keeps a person's last sign-in when their sessions go away
Last signed in was max(sessions.created_at), so it disappeared whenever the session rows behind
it did: on sign-out, on expiry, and when an administrator removed somebody. Restoring them did not
bring it back, and the person moved to the bottom of the list as somebody who had never signed in.
The moment of each sign-in is now recorded on the person, so the answer survives all three, and a
removed person's row shows when they were last here instead of leaving it out. Existing deployments
are backfilled from whatever sessions they still hold.
A message can carry files
Pick them, drag them onto the composer or paste them in: up to eight files on one message, images up
to 8 MiB and text files up to 1 MiB, in the four image formats a model reads (PNG, JPEG, GIF, WebP)
and four text ones (plain text, Markdown, CSV and JSON). A file is uploaded as it is staged rather
than when the message is sent, so the send is immediate and a send that fails keeps what was
attached to it, instead of asking somebody to find eight files again. The bytes live in the
deployment's own database and are served back from the app's own origin, which is also why the type
is decided by reading the file rather than by believing what the browser called it: something that
claims to be text and is not UTF-8 is refused rather than stored and served as accepted text on the
client's word.
What reaches the model is bounded separately from what may be uploaded, because the two limits pay
for different things. An image goes whole. A text file is read up to 120,000 characters, roughly
30,000 tokens, which is one attachment's share of a window that also holds the conversation and up
to seven other files, and the part sent says where it was cut so the model is not left answering
about a file it read only part of. A megabyte of text is therefore stored whole and read as its
first eighth or so, and the person who attached it is not yet told that.
Unsent attachments have to be swept, or somebody who stages 32 can attach nothing again
A file is stored when it is picked, not when it is sent, so every abandoned draft leaves bytes
behind and nothing in the image reclaims them. The Helm chart runs the sweep hourly and deletes
unsent files older than a day. Any other deployment has to run it:
bun scripts/cull-staged-attachments.ts from /app/server, one pass then exit, with the retention
window as its one optional argument. Unlike the routines sweep beside it, it needs only
DATABASE_URL, so an external cron can run it with one variable set.
This is not only about growth. A person may hold 32 unsent files across all their channels, which is
what bounds a client that ignores the eight-per-message cap, and the refusal on the 33rd tells them
anything still unsent is cleared within a day. That sentence is a promise made on the sweep's
behalf: where nothing runs it, the files are never cleared and anybody who reaches 32 can attach
nothing, in any channel, from then on. At 8 MiB a file, 32 staged files per person is the 256 MiB
to size storage against.
Fresh desktop installs pin the latest published deployment
The desktop app resolves GitHub's latest published release on first setup and downloads that exact
tag's source and image manifest. It records the version after both downloads finish and reuses it
on subsequent starts, so new installs no longer stay tied to the app's old v0.0.8 default.
The Bot computer refuses a malformed scroll or live input before the browser sees it
A non-finite wheel delta travelled into Playwright and came back as a 502 that read as a broken
computer, and any JSON object on the live-screen socket fell through to Input.insertText or
forwarded wrong-typed coordinates to CDP. Scroll deltas must be finite numbers now, and live
input must match its mouse, wheel, key, or text shape; anything else is a 400 naming the field.
A non-string plugin grant or tool call is refused before it reaches the store
POST /api/plugins/grants and POST /api/plugins/call checked presence, not shape, so a JSON
number, object, or whitespace string passed and failed inside the store as a 500. Refs and Bot
ids must be non-empty strings now, and anything else is a 400 naming what is required.
A fractional or infinite snapshot id is refused as malformed, not stale
A snapshotId of 1.5 or Infinity passed the acting routes and never matched the stored
integer, so the answer was a 409 stale snapshot and the caller retried a request that was
malformed. Non-integer ids are refused with a 400 naming the ref and its snapshot before any
decision or audit row.
A DATABASE_URL with a port of zero is refused at start-up
postgres://…:0/… parsed and booted, and every query then failed against a port nothing listens
on. Ports outside 1-65535 are refused with a sentence naming DATABASE_URL before a socket is
ever opened.
Setup installs the container engine, instead of telling somebody to go and get one
Setup ended at "Install Podman Desktop or Docker Desktop first" on any machine that had neither,
which is every machine this app is for: the step existed with nothing behind it, so the whole install
stopped at a download page. It installs one now, and a Compose with it, because Podman ships no
Compose implementation and a machine with a freshly installed Podman still cannot raise the stack.
Both are pinned to the digest of the release they were tested against and refused if it does not
match, because these are files this app then executes. Only what is missing is added: an engine
somebody already has is theirs, and a Compose that already answers is left alone. Windows installs
unattended; macOS and Linux each raise the platform's own authorization prompt, which is not
something to route around. The two plan sign-ins set the engine up as well, since they run in a
container themselves and previously named an obstacle with no way past it. Every engine command names
a resolved path rather than trusting the PATH this process was started with, so an engine installed a
minute ago can be used by the run that installed it.
Setup ends with a question the Bot has to answer
Every step before the last one proves that something started, which is not the same as proving the
answers work. A refused key, a lapsed plan or a model the account cannot use each give a stack that
comes up clean and a Bot that cannot answer, and handing over at that point means somebody finds out
later, inside the product, with no idea which of their answers caused it. Setup now ends on a
question with one checkable answer and waits for it. A run that produces no text is a failure here
rather than an empty answer, because the framework catches its own 401 and logs it, leaving the whole
of a refusal in the container's log and nowhere else. The sentence on screen is OpenBot's own and
names the choice to change, with the harness's log behind a disclosure for the developer half.
A subscription picks the Bot that can spend it
A plan is not a key, and only one Bot speaks each vendor's subscription. Signing in to a Claude plan
and keeping the default Bot gave a stack that came up clean and a Bot whose log read "Missing
credentials. Please pass an api_key", after two screens the person had answered correctly and with
no way to know which answer to change. A plan now re-points the Bot, and the model screen says which
Bot that will be while there is still a screen to say it on. A signed-in ChatGPT plan gets the Codex
model, because a plan token is a bearer for one address that langchain-openai pins on purpose and
cannot be reached by pointing OPENAI_BASE_URL at it. The vendor's own token store is kept beside
the .env as an owner-only file and mounted into the harness, so the renewals the provider makes
outlast the container: the access token on its own expires within the hour and nothing can renew it,
which would give a Bot that works in the morning and fails after lunch. The window also has an Edit
menu now, so the shortcut works on the screen whose own instruction is "paste the code it shows you";
macOS routes the clipboard through the menu bar, and a window without one has no Paste.
Signing in to CopilotKit from the window works
The sign-in that creates a key for somebody had never been run end to end, and it failed four times
in a row, each time silently or with a message that named nothing. The session is called cliToken,
not token, so the first exchange failed with "error decoding response body" and no way to tell
which field or which endpoint; a failure carries the response now, masked, because the one that
diagnosed this also carried a live session token. Project ids are numbers, and requiring a string
dropped every project, so the screen told somebody with ten of them that the account had none: an
empty list and an unreadable one are told apart now, because one of them is a lie a person cannot
argue with. The keys endpoint declares project_id as a number with no coercion, so the string "7"
came back as a validation error on the last step of the flow. And the project tiles drew as blank
white rectangles, because the tile rule overrode the background to white and not the colour, asking
somebody to choose between six empty boxes. The sign-in address is kept on screen the way the plan
sign-ins keep theirs, for the machine whose browser is not the one in front of the person.
An endpoint that needs no key can be connected
The compatible row names Ollama and vLLM in its own summary and then refused to continue without an
API key. Neither has one, so the two examples the screen offers by name were the two it would not
accept, and the way out was to invent a key and hope the endpoint ignored it. An address and a model
name are what that row needs. Both bundled Bots refused to start without OPENAI_API_KEY as well, so
fixing the screen alone would have given two dead containers complaining about a key that person's
server does not have: a base URL is a model and its key belongs to it, so a key is now required only
when nothing else names the endpoint, and plain OpenAI still refuses without one. Because a
deployment pulls the image the release pinned, and an image published before the Bots learned this
still refuses, a placeholder string is sent to an endpoint that reads no key. Ollama, vLLM, LM Studio
and llama.cpp all ignore the value. It is written in plain sight rather than put in the machine's
credential store, because it is not a credential, and a key somebody actually typed is used
unchanged. The model name reaches the bundled Bot too, which reads AGENT_BOT_MODEL and had been
left on a pin chosen for OpenAI's own catalogue.
A model name no longer outlives the answer that chose it
The compatible row is the only one that names a model, and switching away from it kept the name.
Answering with an OpenAI key after trying a local endpoint left BOT_MODEL=local-model, so the Bot
asked OpenAI for a model only that person's own server has, and the last screen said "That account
cannot use the model that was chosen" about a model this run never chose. The name is removed rather
than emptied, so the compose default applies, and taken out of the file as well, because the writer
keeps the lines it did not write and that is what let it survive.
Credentials go to the machine's own credential store, not the .env
The .env is a settings file, and a settings file is something somebody opens, reads out to support
or pastes into a chat. A model key, a plan token and the tokens these services prove themselves to
each other with are not settings. They go to the login Keychain on macOS, to DPAPI on Windows
encrypted to the signed-in user, and on Linux to an owner-only file, which is said out loud rather
than dressed up: no desktop Linux install can be assumed to be running a Secret Service daemon, and
refusing to save a credential because gnome-keyring is missing would fail more people than it
protects. The value never goes on a command line on any of them, since ps is readable by every
process the person runs. macOS goes through the Keychain itself rather than the security command,
whose password prompt truncates at 128 bytes with no error and an exit status of zero: an OpenAI
project key is 164 characters, so every one of them was stored cut short and read back cut short on
the next run, while the run that saved it worked fine. From the store the credentials reach the
containers and the host processes as environment, which compose resolves before it reads the .env,
so a secret arrives at exactly the services that declare it and is written down nowhere. What an
earlier version already wrote in plaintext is moved and then purged, or the change would have bought
nothing for anybody who already had OpenBot.
The credential store is asked once per run, not once per screen
Four Keychain dialogs every time the setup screen mounted, each needing a click before the window
would go on, and four more for navigating between setup and OpenBot. macOS authorizes every
individual read of a stored password unless the application is signed with an identity the item's ACL
already trusts; a development build is re-signed on every compile, so its ACL never matches, and the
wizard reads four secrets to arrive filled in. The store is asked once per name per process now and
the answer is held in memory, absence included, or a machine with nothing stored is asked on every
mount for something that was never there. Writes go through the same memory and forgetting clears it,
so the two cannot disagree. This does not remove the prompts on a first run, and nothing in this
process can: that decision belongs to the operating system and to the signature.
Stop stops the Bot that was picked, and the next Start no longer refuses because of it
Compose only acts on a profiled service when the profile is named, so Stop left the one container the
person actually chose running on their laptop after they had stopped the app, still holding its port.
The next Start then refused, saying something was already listening on 4206, about a container
OpenBot itself had started, which the person never saw and could not find, and there was no way
forward from that screen. A port this deployment already publishes is not a stranger on the port, so
the check reclaims our own and keeps its teeth for somebody else's.
Stop stops the host processes on Windows
Measured on Windows Server 2022: Stop took the containers down, reported success, and left the server
answering on 3001, the routines worker up, and both halves of the app answering on 3010. Only the
containers had gone. The handles a window holds cover what that window started and die with it, so a
window stopping a stack an earlier one started held nothing, and the Windows arm returned success
with a comment saying the host processes end with the session. They do not. The pids are written
beside the logs when the processes start and Stop reads them, ending each process together with its
children, since bun run serve starts the real server as a grandchild. A sweep of the ports this
deployment publishes stays as a second pass for a stack whose pid file is gone. Separately, a
byte-order mark in front of package.json, which Set-Content -Encoding UTF8 writes freely, made
the manifest unreadable and was reported as "the deployment is older than this version of OpenBot",
sending somebody looking for a newer installer over three bytes.
The installed app is served without a development server
"Show OpenBot" did nothing on a machine where the stack was up. The window said OpenBot was running,
the button was there, and clicking it had no effect at all. The app host process was dead: it was
started through vite preview under bun --bun, and Vite's proxy calls socket.destroySoon() when
an upstream response ends, which bun's sockets do not implement, so the process died with a TypeError
on the first call the app made. It served its page, exited, and nothing listened on 3010 from then
on, while the shell went on reporting a stack that was up, because the containers were. The app is
served by a small server of its own now: a directory and one forwarded prefix, which is all an
install needs, with no Node and no Vite at runtime. The websocket upgrade the live screen needs is
forwarded rather than answered with HTML, a miss under /assets is a 404 rather than the page, and
paths are confined to the directory, since the deployment's .env sits two levels above it. The
button also shows what it was told: the call behind it already answered "OpenBot is not answering on
port 3010 yet, so there is nothing to show", and the click handler threw that sentence away, which is
why a dead process looked like a dead button.
A conversation whose history this deployment cannot reach says so
Clicking a conversation in the rail drew the coworker's name and then nothing at all. The rail comes
from OpenBot's own database, so a channel is listed whatever the history store says, while the
messages live in the Intelligence project: pointing a deployment at a different project leaves the
platform answering THREAD_NOT_FOUND. That 404 is deliberately read as "no history" and has to stay
that way, because a thread id is minted before the thread exists, so a brand-new conversation 404s as
its normal opening move, and widening it would tell somebody their conversation was gone and invite
them to start it over. The two are told apart by lastMessageAt, which is set only once something
has been said: a conversation with none is genuinely new and silence is correct, while one that has
been spoken in and comes back empty has a history this deployment cannot reach. That one now says so,
in the notice slot beside the existing explanations for a deleted coworker and for turns that could
not be parsed.
bun run dev no longer starts a routines worker that cannot start
bun run dev fanned out across every workspace, and one of them is the routines worker. That worker
is handed DATABASE_URL, SERVER_INTERNAL_URL and WORKER_SHARED_SECRET by scripts/start.sh and
by nothing else, so the copy this command started read none of them and threw at boot on every run,
printing a stack trace in between the app's output and the server's. It has never started
successfully. The command now starts the app and the server, which is what README.md and
docs/development.md already say it does. Routines are unaffected: scripts/start.sh starts the
worker exactly as before, and on Kubernetes the CronJob does.
Double-clicking works while a person is driving a Bot's browser
A double click was sent to the Bot's browser as two separate first clicks, because every press said
it was the first one. Chrome fires dblclick on the page only when the second press says it is the
second, so the page never saw one at all and event.detail was always 1. Opening a row in a table,
expanding a node in a tree and double-clicking a word to select it were all things a person holding
the wheel simply could not do, with nothing on screen to say why — the clicks landed, they just each
counted as the first. The count the person's own browser worked out is now the one that is sent, so a
double click is a double click and a single one is unchanged.
Pressing Enter works while a person is driving a Bot's browser
Taking the wheel of a Bot's browser is mostly for the sign-in it cannot do itself, and Enter is how a
sign-in ends. Every keystroke reached the page, and Enter reached it as a key press that produces no
character — which Chrome delivers to the page's own listeners and then does nothing further with. So
the form did not submit, a new line in a text box did not start, and a button somebody had tabbed to
was not pressed, while anything on the page listening for the key saw it arrive. There was nothing on
screen to explain it: the keystroke was not refused, it simply had no effect, and the way out was to
click the submit button instead. Enter now carries the carriage return a keyboard sends, which is
what makes Chrome carry out what the key means. Measured against Chromium 151: every other editing
key — Backspace, Delete, Tab, Home, End and the arrows — already did what it meant and is unchanged,
and a single-line field still holds exactly what was typed into it.
A half-ticked box is no longer described to a Bot as ticked
The snapshot a Bot reads before it acts on a page says whether each box is ticked, and Playwright
writes that as [checked] for one that is and [checked=mixed] for one that is neither — which is
what the "select all" above a partly-ticked list carries. The parser treated any value other than
the string false as ticked, and mixed is one, so a half-ticked box was reported as done. A Bot
asked to select everything read it as already selected, clicked nothing, and said the rows were
chosen when most of them were not. mixed is now reported as not ticked, which is both the true
half of a yes-or-no answer and the one that gets the right action: clicking a half-ticked box ticks
it. An ordinary tick and an ordinary empty box are unchanged.
A Bot cannot end its turn by asking a person nothing
ask_person is how a Bot stops and puts something to a person instead of guessing, and a call with
no question in it was already meant to come back as a sentence telling it to say what it needs. That
only happened when the field was missing altogether. A question that was present and empty was
carried out: the Bot was told its question had been put to somebody, its turn ended there, and the
trail took an escalation row with nothing in its question — the row an administrator counts these by,
saying a person was asked something that was never said. On a deployment whose escalation route is a
duty desk rather than the person already in the conversation, it is a page to somebody with no
question on it. A blank question is now refused with the sentence that was already written for it,
and a question typed with room around it is recorded as the question rather than as the spacing.
A routine scheduled for Sunday says Sundays, whichever number it was written with
Crontab has always let Sunday be either 0 or 7, the scheduler here takes both, and a routine written
with 7 is stored and fires on Sunday like any other. Only the 0 spelling was recognised by the
sentence the Routines page draws and the Bot reads back, so a working weekend routine appeared on
that page as 0 9 * * 7 while its neighbour said "Sundays at 09:00" — the same schedule, described
two ways, with the raw one looking like something had gone wrong. Both spellings now read as Sunday,
and a list that names the day under both of its numbers says it once.
A tenant package's theme may carry a comment
A package's theme.css is checked at start-up against what it is allowed to define: the :root and
.dark blocks, the approved variables, no imports and no URLs. A CSS comment defines none of those
and was being read as though it did. One above the blocks — the line a hand-written stylesheet opens
with, saying whose brand it is and where the colours came from — was left over once the blocks were
set aside and refused as a second selector; one inside a block was split on the semicolons around it
and refused as a variable name, with the comment quoted back as the name it was not. Because the
package is read while the deployment starts, that was not a warning: the deployment did not come up,
over a comment, saying nothing about comments. Comments are now taken out before the file is read as
definitions, which also closes a comment wedged into the middle of url( as a way past the rule
above it.
Test connection stops reading once it has seen the agent answer
The button that checks an agent before it is registered sends it a real run and reads what comes
back, needing only the opening of the stream to tell an AG-UI agent from a web server that happens to
be reachable. It was reading the whole reply first and applying that limit afterwards, so the check
took as long as the agent's run did. An agent that streams for more than fifteen seconds — a Bot
working through a document, a model answering slowly — was given up on mid-answer and reported as
The agent started answering and the connection broke, about a connection that had not broken and an
agent that had answered correctly in its first two events. It now reads the opening it needs, closes
the connection, and answers in the time the agent took to start rather than the time it took to
finish.
A key pasted with a line break in it is now refused, instead of reported as an unreachable agent
The box that holds an agent's key takes whatever is pasted into it, and what comes off a clipboard is
not always what was on the screen: a long key copied out of a wrapped terminal line brings the wrap
with it, and a hyphen copied out of a document has often been turned into an en dash on the way.
Neither can be sent as an HTTP header — the runtime refuses the value outright — and neither was
being looked at. On Test connection that refusal surfaced as "This server could not reach that
address", with a suggestion about tunnels and firewalls, about an agent that was running perfectly
well and had never been dialled. Stored on the Bot it was quieter and worse: the form said saved, and
every turn that Bot took afterwards failed on a value nothing on screen said anything about. Both
places now check the value before accepting it and say which kind of character is in the way. The
character is named; the key never is.
A deployment directory pasted with a stray space goes where it says
The desktop setup screen asks where OpenBot should live, enables Start once that box is not blank
after trimming, and then sent the untrimmed string — the same trap the API URL, the gateway URL, the
intelligence key and the model key were taken out of, and this is the one of the five that is a
place on disk rather than a credential. A path copied with the space the selection picked up, or
with the newline a copied line carries, was used whole. A trailing space made a second directory
beside the one everything else means: the tray's Stop and the next launch both ask for the default
path, which has no space in it, so a person was left with a deployment nothing on screen could
reach. A leading space was worse, because a path starting with a space does not start with a
separator — it stopped being absolute, and the deployment was laid out relative to wherever the
window happened to be running from. The path is trimmed at both ends now. Spaces inside it are part
of a directory's name and are left alone.
The desktop app notices a busy port whichever loopback holds it
The shell refuses to start when something already holds port 3001 or 3010, because otherwise the
readiness check that follows is answered by a server it never started: everything reads green and
none of it is yours. That readiness check asks both loopback addresses on purpose, since a process
binds whichever one its runtime resolved localhost to — Node picks ::1, Bun picks 127.0.0.1 —
so an answer at either counts. The refusal in front of it asked only 127.0.0.1, which meant a port
held on ::1 alone was reported free and the start went ahead into it. Both addresses are asked
now, so the two agree on what "in use" means and the person is told which port is taken and what
OpenBot wanted it for.
A request for a secret no longer follows a Bot into tomorrow's conversations
An unanswered ask to take the wheel stops being shown after ten minutes, because control belongs to a
Bot's computer rather than to a conversation. The other prompt on that computer, the masked box a Bot
opens when it needs one value it must not be told, was never given the same treatment: it sat there
indefinitely, so every later conversation with that Bot was flagged as needing a person and showed a
request for a password, captioned with a label written for whoever asked half a day earlier. It now
expires on the same ten-minute window, and stops being answerable at the moment it stops being shown,
so a value typed into a box left open in an old tab is refused rather than sent to a page whose run
has ended. A request inside the window is unchanged, and a person actually holding the wheel is still
never timed out.
COMPUTER_BROWSER_IDLE_MS=0 now keeps browsers resident, as it says it does
Zero is the documented way to switch off the sweep that closes a Bot's browser after it has sat
untouched, and the sweep itself reads a timeout of zero as being switched off. The value never got
that far. It was read the way the cap on running browsers is, where zero would close every browser
the moment it opened and so has to be refused, and an operator who typed zero got the thirty-minute
default handed back instead. Their browsers went on being closed, which is a Bot signed out of a site
that only issues session cookies and a cold Chromium on its next turn. Zero is now kept for this one
setting. A blank variable, which is what an unset variable declared in a compose file arrives as, is
still not zero: it means "not set" and takes the default, as do a negative and anything that is not a
number.
A flag or a family emoji in a channel preview is no longer cut in half
The one line a roster draws is cut to a cap, and the cut walked code points -- right for a plain
emoji, wrong for every emoji built out of more than one. A flag is two regional indicators, a family
is three people joined by zero-width joiners, a thumbs-up with a skin tone is the thumb plus a
modifier, and a keycap is a digit plus a variation selector plus an enclosing mark. Landing the cut
inside any of those left a boxed letter, a dangling joiner or a bare digit in the sidebar, in the
generated channel title, and in the excerpt the titler is shown. The cut is now taken between
grapheme clusters, so what a person sees as one character is kept or dropped whole. Plain text is
cut in exactly the same place as before.
A scroll with an unusable deltaY is refused, rather than scrolling some other distance
POST /computers/:botId/scroll and POST /computers/:botId/human/scroll accepted any JSON number
as deltaY, and 1e999 is a JSON number: it parses to Infinity, passes the typeof check, and is
turned back into null by the hop to the Bot's computer, which reads the field as absent and scrolls
its own default distance. The caller was answered 200 for a scroll it had not asked for. A deltaY
that is not a finite number now answers 400 and the page is not touched, the way the timeout on
exec and the coordinates behind a person's click already did.
An MCP call carrying x-api-key is stopped the same as one carrying api-key
The check that keeps credentials out of MCP tool arguments compared each argument name against a
list, and api-key was on it while x-api-key was not -- so the spelling that is more obviously a
credential header was the one that went out. x- is the conventional prefix for a non-standard
header and says nothing about the value, so it is now dropped before the comparison. The same pass
adds the spellings of names already on the list that were missing from it: passwd and pwd for
password, auth_token and bearer_token and session_token for token, api_secret and
secret_key and signing_key for secret, and ssh_key for private_key. Nothing new counts as
a credential: an argument named x_axis, token_count, max_tokens or secretary is passed as
before.
One command to stop what start.sh started
Stopping the local stack meant four commands read off the end of a successful start, and the one
easiest to miss was the one that mattered: a Bot's computer is made by the supervisor rather than by
compose, so docker compose down left a Chromium running per Bot. bash scripts/stop.sh stops the
app, the routine worker, the API server, the compose services and every Bot computer, in that order,
and is safe to rerun. It kills a port holder only once that process has identified itself as
OpenBot, so an unrelated process on 3010 is named and left alone rather than killed. Nothing is
deleted: the database, the Bots' files and their browser profiles are volumes. --keep-computers
leaves the browsers signed in.
The trail says when an identity provider was added, not only when one was taken away
Whoever holds an identity provider decides who can sign in at all, and the audit trail recorded only
half of that. Removing one through the administration screen was written down; registering one was
not, because registration is the sign-in library's own endpoint and nothing this deployment owns ran
on the way through. The event type for it had been declared and never written. Removing a provider
through the library's endpoint rather than the screen was unrecorded for the same reason. Both are
now written where the deployment already stands in front of those routes to check that the person
asking is an administrator, so a provider appearing or disappearing names itself and whoever did it.
Two workers on one machine can no longer fire the same routine twice
Every process that claims work from the shared queue named itself after its hostname, and the queue
tells two claimants apart by that name alone. Two processes on one machine therefore had the same
name, so the lease meant nothing between them: one whose lease had lapsed was still told the item was
its own, and both went on to dispatch it. A routine fired that way opens two runs and sends the same
scheduled message twice. Nothing stops two workers running on one machine — the worker binds no port,
and scripts/start.sh looks for a process it does not start the way bun run dev does. The name now
always carries a random suffix, so a second process is a different claimant. It also keeps the
hostname, so a stuck claim still traces back to the machine holding it, and a blank HOSTNAME is no
longer read as a name — which had made every replica in a deployment share one.
The desktop app writes its .env readable only by its owner
The desktop .env holds KEY_ENCRYPTION_KEY and every minted token, and those are now long-lived:
the first start writes them and every later start reads them back. It was created at the default
umask (0644), so on a shared macOS or Linux machine another local user could read the vault key off
disk. The file is now narrowed to 0600 after it is written. Windows has no equivalent mode and its
single-user desktop profile is already the boundary, so the change is Unix-only.
The desktop app stops adding a banner to .env on every start
env::write keeps the lines it did not write, and its own header comment is one of them, so each
start preserved the previous banner and appended another. A deployment started fifty times had fifty
copies of "Written by OpenBot Desktop" and fifty blank lines stacked above its settings. The banner
is now recognised and replaced rather than kept, and comments somebody else put in the file are left
alone exactly as before.
Starting the desktop app again keeps the secrets the first start generated
The shell generated a fresh set of secrets every time Start was pressed, including the
KEY_ENCRYPTION_KEY that encrypts the credential vault. The database survives a stop, so the second
session of an installed OpenBot met a vault it could no longer read: every stored credential failed
to decrypt, with an error that named an operation rather than a cause. It also handed the server a
COMPUTER_TOKEN that no computer created before the restart holds. The secrets an existing .env
already carries are now kept, and only generated when there is nothing usable to keep — a value
published in this repository does not count, and neither does a KEY_ENCRYPTION_KEY the server would
refuse to start on.
The desktop app refuses a deployment download that writes outside its own directory
The shell fetches the release tarball and lays it out under the directory it manages. The check that
kept an entry inside that directory compared paths lexically -- root.join(path).starts_with(root)
-- and Path::starts_with matches components without resolving .., so app/../../elsewhere
started with the root and still landed outside it. An entry has to begin with a directory a
deployment wants, which app does, so the file filter did not stop it either. Every component of a
path inside the tree is now required to be an ordinary name, and the traversal is refused by name.
A credential pasted with a stray space into the desktop setup screen now works
The setup screen enables its button on value.trim() !== "" and then sends the untrimmed string, so
a key copied from a provider's dashboard with the space the selection picked up arrived intact. The
model key was trimmed on the way into .env; the API URL, the gateway URL and the intelligence key
entered on the same screen were not, so Compose passed the space through, the provider rejected the
credential, and the failure the person saw named neither the space nor the field. All four are now
trimmed the same way.
Example LangGraph and Mastra Bots no longer bind an ephemeral port on empty PORT=
An empty PORT= in compose or .env used to become NaN for those two example processes, so they listened on a random port while docs still named 4300/4400. They now use the same listenPort helper as agent-bot: empty is the documented default, and a prefix typo refuses to start.
An empty app port is the default, not a random one
APP_PORT= and SERVER_PORT= in a compose file or leftover .env used to become NaN for the Vite
dev and preview servers, so the UI bound an ephemeral port while the proxy target was http://localhost:.
Both empty values now mean the documented defaults (3010 and 3001), and a non-numeric value refuses to start.
The trail says what started a run, not only whose authority it had
A routine runs as the person who set it up, and a Bot handing work to another Bot runs as the person
who began the conversation. Both are correct, that is whose grants and whose connections are being
used, and both meant an action taken while somebody slept was written into the audit trail as though
they had taken it themselves. Telling the two apart meant correlating timestamps against
routine_runs by hand, and there was nothing at all to correlate a hop against.
Every audit row now also names what caused it: a person, a routine, another Bot handing work on, or
the deployment itself. It travels inside the signed run assertion, so a tool call, a hop, a Bot
stopping to ask its person and a stalled stream all say it, and a Bot cannot relabel its own run.
The Audit screen has a Started by column and a Nobody watching view that answers the
question directly. An unattended run is the one nobody is there to notice going
wrong, which is the reason it is worth being able to find.
The fourth of those exists so the column never overclaims. Two rows have no person behind them at
all: the boundary and isolation rows written at start-up, and the refusal written when a caller
cannot be identified at all. Those say the deployment, not a person, and they stay out of
Nobody watching, which asks what ran on somebody's authority rather than what the deployment did
by itself.
Nothing about existing rows changes. Every row already written, and every row a person's own click
writes from now on, reads as a person, because that is what it was.
The live screen and live channel updates work again, and one request can no longer end the app
The app opened its two WebSockets against its own address, so they travelled through Vite's /api
proxy. Vite is run through bun, and under bun that proxy does not carry a WebSocket: neither the
Bot's screen nor live channel updates ever connected, and the browser retried in a loop. Worse, an
upgrade the server answered with an ordinary HTTP response — a 503 when a Bot's computer is not
running, which is exactly when somebody opens the screen — crashed the process that served the app,
taking the server and the worker with it in development. Where Vite serves the app — the dev server
and the desktop's vite preview — both sockets now address the server directly, so nothing upgrades
through the proxy and the proxy no longer offers to carry one. In production the server serves the
app itself and answers the upgrade on the same origin, so the sockets stay on the browser's own
host, which is what an ingress terminating TLS on 443 requires and a fixed server port would break.