Skip to content

Run OpenBot as one container, and give a Bot a shell - #62

Merged
davidmckayv merged 21 commits into
mainfrom
feat/a7-control-plane
Aug 20, 2026
Merged

Run OpenBot as one container, and give a Bot a shell#62
davidmckayv merged 21 commits into
mainfrom
feat/a7-control-plane

Conversation

@davidmckayv

Copy link
Copy Markdown
Contributor

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 openbot

The image carries the app, the API that serves it, the browser the Bots drive, and PostgreSQL if you
ask for it. Point DATABASE_URL at 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_command runs a command in its /workspace, so it can install
a 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 with contains(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 ComputerProvider interface 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=on turns on Chromium's own sandbox where the host permits user namespaces.
Playwright re-adds --no-sandbox unless told otherwise, so a deployment that could have had the
sandbox silently did not.

Also: a CHANGELOG.md, written for the person running OpenBot rather than the person who wrote the
commit, 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.randomUUID is only
defined in a secure context. http://localhost counts as one, so a laptop never sees this; a real
address 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, sudo is there and needs no
password, and nothing told the model, so it ran apt-get install bare and offered to explain how to
do 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

  • New state that outlives a request? None new. The gateway's snapshot cache is unchanged and
    still in process memory, which is why docs/deployment.md says one replica and says why.
  • What happens on the second replica? A click resolves element refs against a snapshot the
    other replica took and fails to find them. This image is the single-box shape on purpose; the
    fleet shape is A6.
  • Anything serialised? Nothing new. Migrations run at start only with EMBEDDED_POSTGRES=on,
    where there is exactly one process; with an external database they stay a release step.
  • Anything fanned out to a browser? Nothing new.
  • New listener, port, or schedule? One port, 3001. The browser listens on 4100 inside the
    container and is deliberately not published.

Boundary and audit

  • computer_run_command goes through the gateway like every other acting call: resolve, decide,
    audit, then act.
  • A refused command writes a row and never reaches the computer.
  • Command text comes from the model and is recorded; output is not, and is never trusted.

Changelog

  • CHANGELOG.md, under Unreleased. 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).

Browser opened example.com, screenshot rendered in the channel
Shell sudo apt-get update && sudo apt-get install -y jq, then jq --version → jq-1.7, first try
User runs as pwuser, /workspace owned by it
Database embedded PostgreSQL, vector created, migrations applied at start
Audit both commands recorded verbatim, no output in the row
Boundary contains(command, "uname")Blocked, named the rule, never reached the computer

744 tests pass, typecheck and format clean.

mu-hashmi and others added 21 commits August 20, 2026 09:21
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
davidmckayv merged commit c647fa4 into main Aug 20, 2026
3 checks passed
@davidmckayv
davidmckayv deleted the feat/a7-control-plane branch August 20, 2026 22:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants