Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
739570c
Give each Bot a computer on Daytona when an API key is configured
mu-hashmi Aug 20, 2026
bc70631
Keep Daytona reset monotonic across delayed sandbox indexes
mu-hashmi Aug 20, 2026
d74049c
Retry Daytona resets after transient deletion failures
mu-hashmi Aug 20, 2026
a10ad1e
Make Daytona computer lifecycle reads non-provisioning and bounded
mu-hashmi Aug 20, 2026
6fa57d8
Keep computer status reads renderable during supervisor failures
mu-hashmi Aug 20, 2026
0d24f4d
Check the address a supervisor hands back before calling it
davidmckayv Aug 20, 2026
3e02bfb
Tell OpenBot traffic apart in the analytics that already go
davidmckayv Aug 20, 2026
e8b291b
Unify computer backends behind a deep provider and single gateway module
mu-hashmi Aug 20, 2026
5124bfd
Make computer providers honest and race-safe
mu-hashmi Aug 20, 2026
325ae72
merge: resolve configuration and dependency conflicts with origin/main
mu-hashmi Aug 20, 2026
3753e56
Give a Bot a shell, and let Chromium sandbox itself where the host al…
davidmckayv Aug 20, 2026
5ce3522
Run the whole thing as one container, with Postgres as a choice
davidmckayv Aug 20, 2026
35ea202
Merge: put a provider seam under the computer
davidmckayv Aug 20, 2026
1d38374
Check the address a provider hands back before calling it
davidmckayv Aug 20, 2026
a5e1a3f
Keep a changelog, and ask a PR to add to it
davidmckayv Aug 20, 2026
4ee0b99
Say what a Bot's shell can do, and show the command it ran
davidmckayv Aug 20, 2026
cd55f47
Say that a rule can match on the command
davidmckayv Aug 20, 2026
0a4feea
Stop the chat dying silently on a deployment without TLS
davidmckayv Aug 20, 2026
e26e2c5
Merge main: take the one client, keep the provider seam
davidmckayv Aug 20, 2026
14de830
Merge: keep both the minted thread and the one client
davidmckayv Aug 20, 2026
c85db03
Use a neutral host in the computer address test
davidmckayv Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Build context hygiene. Without this the whole tree ships to the daemon, including every
# node_modules and every local artifact, which is slow and puts a developer's .env in an image.
node_modules
**/node_modules
.git
.env
.env.*
!.env.example
dist
**/dist
app/dist
.nx
*.log
.DS_Store
assets/*.svg
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,4 @@ COMPUTER_RUNTIME=
# that granted the tool, which is where the grant, the policy and the audit row are. Absent, no Bot
# may call tools back and it is told so rather than being quietly allowed.
AGENT_TOOL_TOKEN=

5 changes: 5 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ conditional update, `LISTEN`/`NOTIFY` for fan-out.
- [ ] New refusals and new failures each write a row.
- [ ] Nothing new is trusted from the client that the server can resolve itself.

## Changelog

- [ ] A line in `CHANGELOG.md` under `Unreleased`, or a sentence on why a deployment behaves no
differently afterwards.

## Proof

<!-- What you ran, and what you saw. Screenshots or a recording for anything with a surface. -->
48 changes: 48 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Changelog

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.

## Unreleased

### Added

- **One container that runs the whole thing.** The root `Dockerfile` builds an image carrying the
app, the API, a Bot computer, and optionally PostgreSQL, supervised together. Point `DATABASE_URL`
at 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](docs/deployment.md) for the measured minimum sizes and
the platforms it has been run on.
- **Bots can run commands.** `computer_run_command` runs 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 with `intent == "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=on`** turns on Chromium's own sandbox where the host permits user namespaces.
Which way it went is printed at start-up either way.

### Fixed

- **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
laptop `http://localhost` counts 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.

### Changed

- **Where a Bot's computer runs is now a plug.** One `ComputerProvider` interface 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](https://github.com/CopilotKit/OpenBot/pull/57) 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.

## 0.0.1

First tag.
192 changes: 192 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
# OpenBot, whole, in one container.
#
# WHAT THIS IS FOR. Everything a laptop runs, minus the database, in one image on one port. Deploy it
# anywhere that runs a container and you get what `scripts/start.sh` gives you locally: the app, the
# API, and a browser the Bots can drive.
#
# WHAT IS NOT HERE, AND WHY.
#
# PostgreSQL. A container filesystem does not survive a redeploy and the audit trail is the
# product. `DATABASE_URL` points at a managed instance, which is one click on every platform this
# is meant to run on.
#
# The supervisor. It exists to give each Bot its own container, which needs a Docker socket, which
# no serverless container platform permits. Without it every Bot shares the browser below, exactly
# as they do on a laptop with no supervisor configured. Per-Bot isolation is A6.
#
# THE BASE IS PLAYWRIGHT'S, not Bun's, because Chromium and its system libraries have to stay
# matched and that image is the only place that is guaranteed. The tag must move with the
# `playwright` dependency in `agent-computer/package.json`. Bump both or neither.

FROM mcr.microsoft.com/playwright:v1.62.1-noble AS base

# unzip is not in the Playwright image and bun's installer needs it.
# Bun is pinned. The installer takes whatever is newest otherwise, so the runtime drifts from the
# one the lockfile was resolved against and an image built next month is not the image built today.
ARG BUN_VERSION=1.3.14
# Into /usr/local rather than /root/.bun, because the runtime stage runs as `pwuser` and cannot read
# root's home. Set before the install, or the installer has already chosen the wrong directory.
ENV BUN_INSTALL=/usr/local
ENV PATH="/usr/local/bin:${PATH}"
RUN apt-get update && apt-get install -y --no-install-recommends unzip xz-utils \
&& rm -rf /var/lib/apt/lists/* \
&& curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}"


FROM base AS deps

WORKDIR /src

# Manifests first, so editing a source file does not reinstall the world.
COPY package.json bun.lock ./
COPY tsconfig.base.json bunfig.toml ./
COPY app/package.json app/package.json
COPY server/package.json server/package.json
COPY worker/package.json worker/package.json
RUN bun install --frozen-lockfile

COPY agent-computer/package.json agent-computer/package.json
RUN cd agent-computer && bun install

# A second tree with the build-time dependencies left out, for the runtime stage to take. Vite,
# biome and the test tooling are a gigabyte that nothing in a running container imports.
RUN mkdir -p /prod && cp package.json bun.lock /prod/ \
&& cp -r app/package.json /prod/app-package.json \
&& cd /prod && mkdir -p app server worker \
&& cp /src/app/package.json app/package.json \
&& cp /src/server/package.json server/package.json \
&& cp /src/worker/package.json worker/package.json \
&& bun install --frozen-lockfile --production


FROM deps AS app-build

COPY app app
COPY scripts scripts
COPY shared shared
# The server's source as well: the app's prebuild step reads the tenant package through
# `server/src/tenant-package`, so the app cannot be built without it.
COPY server server
COPY examples examples
RUN bun run --cwd app build


FROM base AS runtime

# s6 rather than supervisord. The deciding difference is that s6 brings the container down when a
# supervised process exits, which is what makes the platform restart it. supervisord stays alive and
# the container keeps reporting healthy while the API inside it is dead.
ARG S6_OVERLAY_VERSION=3.2.1.0
# `TARGETARCH` is filled in by the builder. s6 names its tarballs by uname, so amd64 and arm64 have
# to be translated. Hardcoding one of them builds fine on the other and then fails at start with an
# exec format error, which reads as a broken image rather than a wrong download.
ARG TARGETARCH
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz /tmp/
RUN case "${TARGETARCH}" in \
amd64) S6_ARCH=x86_64 ;; \
arm64) S6_ARCH=aarch64 ;; \
*) echo "unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
esac \
&& curl -fsSL -o /tmp/s6-overlay-arch.tar.xz \
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${S6_ARCH}.tar.xz" \
&& tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz \
&& tar -C / -Jxpf /tmp/s6-overlay-arch.tar.xz \
&& rm /tmp/s6-overlay-*.tar.xz

WORKDIR /app

COPY --from=deps /prod/node_modules node_modules
COPY --from=deps /src/package.json package.json
COPY --from=deps /src/bun.lock bun.lock
COPY --from=deps /src/server/node_modules server/node_modules
COPY --from=deps /src/agent-computer/node_modules agent-computer/node_modules

COPY server server
COPY shared shared
COPY examples examples
COPY agent-computer/src agent-computer/src
COPY agent-computer/package.json agent-computer/package.json

# The built app, served by the API on the same origin. There is no CORS in this server, so this is
# not a convenience: two origins would simply fail.
COPY --from=app-build /src/app/dist app/dist
ENV APP_DIST_DIR=/app/app/dist

COPY docker/s6 /etc/s6-overlay

# PostgreSQL, for the deployment that wants one thing to run rather than two.
#
# OFF UNLESS ASKED FOR. Set `EMBEDDED_POSTGRES=on` and the container runs its own; leave it and
# `DATABASE_URL` points wherever you like. The trade is the one you would expect: a database inside
# a container lives and dies with that container unless /var/lib/postgresql is a mounted volume, and
# the audit trail is the thing you would be losing.
RUN apt-get update && apt-get install -y --no-install-recommends \
postgresql-16 postgresql-16-pgvector \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /var/lib/postgresql/data /var/run/postgresql \
&& chown -R postgres:postgres /var/lib/postgresql /var/run/postgresql

# A Bot can install what a task needs.
#
# `sudo` for one user, no password, because a package manager that cannot install is not one, and
# "install a tool then use it" is the whole point of giving a Bot a shell.
#
# BE CLEAR WHAT THIS COSTS. It means a Bot can become root inside its container. That is acceptable
# when the container is the Bot's alone and is contained from below, which is why per-Bot computers
# and gVisor are not optional extras next to this feature; they are what makes it sane. In a
# container shared between Bots, or one holding a database, a Bot with sudo can reach all of it.
RUN apt-get update && apt-get install -y --no-install-recommends sudo \
&& rm -rf /var/lib/apt/lists/* \
&& echo 'pwuser ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/pwuser \
&& chmod 0440 /etc/sudoers.d/pwuser

# THE PACKAGE MANAGER AND THE SHELL STAY. Both were removed here once as hardening, which was
# backwards: a Bot being able to open a shell and install what a task needs is a requested feature,
# not an oversight. Removing them hardens the image by deleting the product.
#
# What makes that safe is not their absence. It is that a Bot reaches them the same way it reaches
# anything else, through the gateway: resolve, decide against the policy, write the audit row, then
# act. A command is a decision like a click is.

# Where a Bot's files live. Mount a volume here to keep them across a redeploy; without one they are
# as durable as the container, which for a trial is the honest default.
ENV WORKSPACE_DIR=/workspace
ENV PROFILES_DIR=/profiles

# The browser is on loopback inside this container and reachable from nowhere else, which is why the
# private-host allowance is on: the server is browsing to its own sibling process, not the internet.
ENV AGENT_COMPUTER_URL=http://127.0.0.1:4100
ENV AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true

# NOTHING THAT MATTERS RUNS AS ROOT.
#
# s6 stays root because that is the only way it can drop each service to a different user, and they
# genuinely differ: the browser and API run as `pwuser`, the database as `postgres`. One shared
# account would put the process that renders the open internet in the same skin as the one holding
# the audit trail.
#
# This matters more than usual here. Chromium is launched with `--no-sandbox` unless the host can
# support its sandbox, and with that flag the process user IS the boundary, so root would mean a
# page exploit lands as root.
#
# The two directories the browser writes are its workspace and its profile, the second being what
# keeps a Bot signed in between turns. Owned here, because a non-root process cannot create them at
# the root of the filesystem and the failure surfaces as EACCES on the first navigation.
RUN mkdir -p /workspace /profiles \
&& chown -R pwuser:pwuser /workspace /profiles /app

# Where the embedded database answers, when there is one. Overridden by whatever you set, so an
# external database needs no special case: set DATABASE_URL and EMBEDDED_POSTGRES stays off.
ENV EMBEDDED_POSTGRES=off
ENV DATABASE_URL=postgres://openbot@127.0.0.1:5432/openbot

ENV NODE_ENV=production
ENV PORT=3001
EXPOSE 3001

# One port out. The browser's 4100 is deliberately not exposed: it holds real logins and its only
# caller is the process next to it.
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
CMD bun -e "const r = await fetch('http://127.0.0.1:3001/health'); process.exit(r.ok ? 0 : 1)"

ENTRYPOINT ["/init"]
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui),

## Requirements

- Docker, for PostgreSQL, browser computers, the supervisor, and the shipped Bots.
- Docker, for PostgreSQL and the shipped Bots.
- [Bun](https://bun.sh) 1.3+, for the app and API server.
- A CopilotKit Intelligence project and license. A free plan is available, and Intelligence can be self-hosted.
- A model key. The proof-of-concept Bot uses OpenAI; the LangGraph Bot can use OpenAI, Anthropic, or Google.
Expand Down Expand Up @@ -96,6 +96,21 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui),

`scripts/start.sh` starts Docker services, applies migrations, starts the API server on port 3001, starts the app on port 3010, and checks that the services answer their own health routes before printing next steps.

## Deploy it

One image carries the app, the API, the browser the Bots drive, and optionally PostgreSQL. Same
`.env`, no Kubernetes.

```sh
docker build -t openbot .
docker run -p 3001:3001 --env-file .env \
-e EMBEDDED_POSTGRES=on -v openbot-data:/var/lib/postgresql/data openbot
```

Leave `EMBEDDED_POSTGRES` off and set `DATABASE_URL` to point at a database you already run.
[docs/deployment.md](docs/deployment.md) has the minimum sizes, the platform notes, and why this runs
as one replica for now.

## Try it

- Open `/bot` and ask: `Open news.ycombinator.com and tell me the top story.`
Expand Down Expand Up @@ -125,6 +140,7 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui),
## Features

- **A computer per Bot**: the supervisor gives each Bot its own container, its own `/workspace` volume and its own browser profile. Set `COMPUTER_RUNTIME=runsc` to run them under gVisor where the host supports it.
- **A shell, not just a browser**: a Bot can run a command in its workspace, install what it needs, and process a file it saved. Through the same gate as everything else, so a rule can refuse a shell outright or refuse particular commands, and the command is on the record either way.
- **The gateway is the only way in**: it resolves the target from a server-held snapshot, evaluates the policy, writes the audit row, and only then calls the computer. There is no path that acts without the record existing first.
- **CEL policy, fail closed**: rules can inspect `tool.name`, `intent`, `bot.id`, `actor.id`, `page.url`, `page.host`, `element.*`, `key`, `file.*` and `mcp.*`. Deny is evaluated before allow, a missing policy permits nothing, and a broken rule refuses rather than opens.
- **Take the wheel**: a Bot that hits a login wall or a 2FA prompt asks for help. Control is handed over in the same panel and recorded as `computer.help_requested`, `computer.control_taken` and `computer.control_released`. While a person is driving, Bot actions are refused rather than queued.
Expand Down Expand Up @@ -181,6 +197,8 @@ Settings worth knowing:
| `SUPERVISOR_TOKEN` | Secret the supervisor requires. `start.sh` sets one. |
| `COMPUTER_SUPERVISOR_URL` | Gives each Bot a computer of its own instead of one shared computer. |
| `COMPUTER_RUNTIME` | Set to `runsc` to run computers under gVisor, where the host has it. |
| `COMPUTER_SANDBOX` | Set to `on` for Chromium's own sandbox, where the host permits it. |
| `EMBEDDED_POSTGRES` | Set to `on` for a database inside the deployment container. |
| `AGENT_COMPUTER_POLICY` | JSON action policy. Malformed JSON stops server startup. |
| `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS` | Lets a Bot reach this machine's own services. |
| `TENANT_PACKAGE_DIR` | Directory containing tenant YAML. Defaults to `../examples/fintech`. |
Expand Down
37 changes: 37 additions & 0 deletions agent-computer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
WorkspaceFileError,
WorkspacePathError,
} from "./workspace";
import { createShell } from "./shell";

/**
* The Bot's computer: one long-lived browser, reachable over HTTP.
Expand Down Expand Up @@ -165,6 +166,9 @@ const workspace = createWorkspace(process.env.WORKSPACE_DIR ?? "/workspace");
* mounted volume so sign-in state survives the container.
*/
const profiles = createProfiles(process.env.PROFILES_DIR ?? "/profiles");
// Rooted in the same workspace the file tools use, so a command and a written file see one
// directory rather than two.
const shell = createShell(process.env.WORKSPACE_DIR ?? "/workspace");

/**
* The id normally arrives as a header on every request. This is the fallback for a caller that has no
Expand Down Expand Up @@ -746,6 +750,39 @@ serve<StreamData>({
}
}

/*
* A command on this computer.
*
* Nothing here decides whether it may run: the gateway already asked the deployment's policy and
* wrote the audit row before this was called. Refusing again here would be a second, quieter
* policy nobody configured.
*/
if (url.pathname === "/exec" && request.method === "POST") {
const body = (await request.json().catch(() => null)) as {
command?: unknown;
timeoutMs?: unknown;
} | null;
if (typeof body?.command !== "string" || !body.command.trim()) {
return json({ error: "A command is required." }, 400);
}
try {
return json(
await shell.run({
command: body.command,
...(typeof body.timeoutMs === "number"
? { timeoutMs: body.timeoutMs }
: {}),
signal: request.signal,
}),
);
} catch (error) {
return json(
{ error: describe(error, "The command could not be run.") },
500,
);
}
}

if (url.pathname === "/files/write" && request.method === "POST") {
const body = (await request.json().catch(() => null)) as {
path?: unknown;
Expand Down
Loading