Run OpenBot as one container, and give a Bot a shell - #62
Merged
Conversation
Signed-off-by: Muhammad Hashmi <mhashmi@berkeley.edu>
Signed-off-by: Muhammad Hashmi <mhashmi@berkeley.edu>
Signed-off-by: Muhammad Hashmi <mhashmi@berkeley.edu>
Signed-off-by: Muhammad Hashmi <mhashmi@berkeley.edu>
Signed-off-by: Muhammad Hashmi <mhashmi@berkeley.edu>
`resolveBaseUrl` returns a URL and the next line puts it into `fetch` with this deployment's computer token on it. That was fine while the only supervisor was ours, answering on loopback. It stops being fine with a hosted provider, where the address arrives from a third party's API and we call whatever it says. Deliberately not the navigation check. That one refuses private hosts, which is correct for a Bot browsing and exactly wrong here: our own supervisor answers `http://127.0.0.1:<port>` for a container on this machine, so reusing it would refuse the ordinary case. What survives is what holds however the address was produced. The scheme has to be one a computer speaks, and the cloud metadata addresses are refused whatever anything says, because that is how a container's credentials leave it and no supervisor has a reason to name one. Groundwork for A10, where the supervisor becomes somebody else's API.
The CopilotKit runtime reports a handful of events about itself and every deployment built on it looks the same in them, so there is no way to tell which of that traffic came through OpenBot. One field on those events answers it by filtering. No new events and no new pipeline. Carried on `global_properties`, which the sink treats as the pass-through bag for `oss.runtime.*` and spreads into the analytics event. `OPENBOT_ACCESSIBILITY_DISABLED=true` switches it off. The SDK's own `COPILOTKIT_TELEMETRY_DISABLED` and `DO_NOT_TRACK` still stop the events entirely, this field with them. Anything other than `true` or `1` leaves it on. A deployment that typed something else has not opted out, and reading a typo as consent to stop would be a setting that appears to work and does not.
Signed-off-by: Muhammad Hashmi <mhashmi@berkeley.edu>
Signed-off-by: Muhammad Hashmi <mhashmi@berkeley.edu>
Signed-off-by: Muhammad Hashmi <mhashmi@berkeley.edu>
…lows A browser alone cannot install a tool, read a file it just downloaded, or run the thing it was asked to run. `computer_run_command` runs a command in the Bot's workspace through the same gate as every other action: the target is resolved, the CEL policy decides, an audit row is written, and only then does anything happen. The command is recorded in full; its output never is, because output is the one part that can carry a page's contents back into the audit log. Bounded rather than trusted. Two minutes by default and ten at most, 64KB of output kept from the end, and the abort signal kills the child rather than detaching from it. The sandbox flag is the other half. `--no-sandbox` was unconditional, which is what a default container seccomp profile forces, but Playwright re-adds the flag on its own unless `chromiumSandbox` says otherwise, so a deployment that could have had the sandbox silently did not. Now COMPUTER_SANDBOX=on means it, and either way the choice is printed at start-up.
A laptop runs the app, the API, a browser, and a database. Deploying that shouldn't mean learning Kubernetes first, so the image carries all four and s6 supervises them: the app is served by the API process from a built directory, the browser runs beside it, and Postgres starts only when no external one was given. Point DATABASE_URL at a managed Postgres and the embedded one never starts; leave it unset and the container is self-contained. s6 rather than a shell script or supervisord because a browser that dies should take the container down and let the platform restart it, not leave an API answering requests it cannot serve. Runs as pwuser, not root. Chromium rendering the open internet as root inside a container that also holds the database is the pairing to avoid, and the earlier version had it. One replica, and the deployment doc says so out loud: this image holds state a load balancer would split. The fleet shape is A6. Minimum sizes in the doc are measured, not guessed, and /dev/shm gets its own note because the platforms that cap it at 64MB make Chromium fail in a way that reads like a bug in us.
Takes Muhammad Hashmi's refactor from #57, which is the right shape: a `ComputerProvider` with `locate`, `status`, `stop`, `reset`, `list`, and an optional `warm`, with the Docker supervisor as one implementation behind it. The gateway resolves the address per call through that interface, so where a Bot's computer runs stops being a question the rest of the server can see. A hosted backend is now an adapter rather than a change to the gateway. `client.ts` becomes what he made it, a transport that takes an address and knows nothing about lifecycle, and `runCommand` moves onto the gateway alongside every other governed call. The Daytona adapter that came with it is held back for now and lands separately once remote computers are ready to be turned on end to end.
The gateway resolves a computer's address per call and sends this deployment's computer token to whatever comes back. With one loopback supervisor that was our own string; with a provider seam it is whatever the backend published. So one check, and deliberately not the navigation check: the scheme must be one we speak, and the cloud metadata addresses are refused whatever a provider says, because that is how a container's credentials leave it. Private addresses stay allowed, since our own supervisor answers with 127.0.0.1 and refusing that would refuse the normal case.
There is no way today for somebody running OpenBot to find out what changed between two commits other than reading them, and the commits are written for whoever touches the code next. So one file, newest first, written for the operator: a line lands when a deployment behaves differently afterwards, and does not when only the code moved. The PR template asks for the line, or for a sentence saying why there isn't one. Backfilled with what is on this branch and not yet released.
Three things found by driving the built container rather than reading it. The first attempt to install a package failed. The shell runs as an unprivileged user, `sudo` is there and needs no password, and nothing told the model any of that, so it ran `apt-get install` bare, got permission denied, and offered to explain how to do it somewhere else. The tool description now says so, and the same request installs on the first try. The audit row for a command read "not in the current snapshot" under what it acted on. A command has no page element and never will, the same as a file action, and the row now carries the command instead. The deployment doc still said PostgreSQL was not in the image, and the README had no deployment section at all.
The Boundaries page lists what a CEL rule may ask about, and the shell added a field the list did not mention. An operator reading that page would conclude there was no way to write a rule against a command, when `contains(command, "...")` works and refuses before anything runs.
`crypto.randomUUID` is only defined in a secure context. A laptop never sees this, because `http://localhost` counts as one; a deployment reached at `http://<address>` does not, so the function is simply absent. The chat surface called it directly in five places, all of them on the submit path. The throw landed inside a React event handler, was swallowed, and the surface did nothing: no message, no error, no clue. The channel was created, so it looked like the Bot had nothing to say. Found by deploying the container to a real address and pressing send, which is the only place this is visible. Ids now come from `crypto.getRandomValues`, which has no such restriction, and the fallback is the case under test rather than the one nobody runs. TLS is still what a deployment should have. This is about failing where an operator can see it.
#60 put every browser request behind lib/client and #51 put the computer address guard in client.ts. This branch had already restructured both files, so neither side could be taken wholesale. client.ts stays as this branch left it, navigate-only. The address guard #51 added is not lost: the provider seam moved it up into gateway.ts, where it runs on the resolve step of every acting path, and target.ts and its tests are untouched. admin/computers.tsx takes the new lib/computers modules, and the egress reading moves with it. Absent still reads "Egress not reported" and null still reads "Leaves directly", because a provider that does not report is not the same as a computer that leaves directly. computer_run_command passes its body unserialised. callComputer now takes a body rather than a RequestInit and serialises it itself, so the caller that stringified first was about to send a JSON string of a JSON string. Git merged those two changes without a conflict, and only the stray headers key made it visible. It now also forwards the stop signal, the way every other computer tool does.
The chat's fallback id and the client that mints the thread arrived from different directions and touched the same two imports. Both are needed: tryClient mints, newId is what the chat falls back to when the deployment cannot be asked.
davidmckayv
requested review from
MikeRyanDev,
guidovizoso and
tylerslaton
as code owners
August 20, 2026 22:33
guidovizoso
approved these changes
Aug 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
Deploying OpenBot meant running four things and a database. It now means running one container.
docker build -t openbot . docker run -p 3001:3001 --env-file .env \ -e EMBEDDED_POSTGRES=on -v openbot-data:/var/lib/postgresql/data openbotThe image carries the app, the API that serves it, the browser the Bots drive, and PostgreSQL if you
ask for it. Point
DATABASE_URLat a database you already run and the embedded one never starts.s6 supervises the lot, so a browser that dies takes the container down and lets the platform restart
it rather than leaving an API answering requests it cannot serve. It runs as
pwuser, not root.A Bot gets a shell.
computer_run_commandruns a command in its/workspace, so it can installa tool, unpack what it downloaded, or run what it was asked to run instead of only driving a browser.
Through the same gate as everything else: resolve, decide, audit, act. A rule can refuse a shell
outright with
intent == "run_command"or refuse particular commands withcontains(command, "...").The command is recorded in full; its output never is, because output is the part that can carry a
page's contents into the audit log. Two minutes by default, ten at most, 64KB kept from the end.
Where a Bot's computer runs becomes a plug. One
ComputerProviderinterface under the gateway,with the Docker supervisor as one implementation and a shared computer as another. A computer
somewhere else is an adapter, not a change to the governed path. This is
@mu-hashmi's refactor from #57, which was the right
shape; thank you. The Daytona adapter that came with it is held back and lands separately.
COMPUTER_SANDBOX=onturns on Chromium's own sandbox where the host permits user namespaces.Playwright re-adds
--no-sandboxunless told otherwise, so a deployment that could have had thesandbox silently did not.
Also: a
CHANGELOG.md, written for the person running OpenBot rather than the person who wrote thecommit, and a PR template line asking for an entry.
What driving it found
Three things that only appear when you run the built image rather than read it.
A deployment served over plain HTTP could not start a conversation.
crypto.randomUUIDis onlydefined in a secure context.
http://localhostcounts as one, so a laptop never sees this; a realaddress does not, and the function is simply absent. The chat minted ids with it in five places, all
on the submit path. The throw landed inside a React event handler, was swallowed, and pressing send
did nothing at all: no message, no error, no clue. Found by deploying to EC2 and pressing send.
The first package install failed. The shell runs unprivileged,
sudois there and needs nopassword, and nothing told the model, so it ran
apt-get installbare and offered to explain how todo it elsewhere. The tool description now says so.
The audit row for a command said "not in the current snapshot" under what it acted on. A command
has no page element and never will, the same as a file action. It carries the command now.
Where it runs
still in process memory, which is why
docs/deployment.mdsays one replica and says why.other replica took and fails to find them. This image is the single-box shape on purpose; the
fleet shape is A6.
EMBEDDED_POSTGRES=on,where there is exactly one process; with an external database they stay a release step.
container and is deliberately not published.
Boundary and audit
computer_run_commandgoes through the gateway like every other acting call: resolve, decide,audit, then act.
Changelog
CHANGELOG.md, underUnreleased. This PR also introduces the file.Proof
Driven in Chrome against the built image, twice: locally, and on a fresh EC2 box (Ubuntu 24.04,
amd64, built from source on the host so the Dockerfile is proven on a clean machine and an
architecture other than my Mac).
sudo apt-get update && sudo apt-get install -y jq, thenjq --version→ jq-1.7, first trypwuser,/workspaceowned by itvectorcreated, migrations applied at startcontains(command, "uname")→ Blocked, named the rule, never reached the computer744 tests pass, typecheck and format clean.