Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8b19f5d
Add the isolated column and the Isolated Bots capability
davidmckayv Oct 28, 2026
9e77d68
Give each person a computer, with a seat per Bot inside it
davidmckayv Oct 28, 2026
2d6c349
Let a Bot's owner give it a computer of its own
davidmckayv Oct 28, 2026
3db29d4
Merge branch 'david/ppc-isolated' into david/ppc-keys
davidmckayv Oct 28, 2026
94a3196
Key every computer by person, with an isolated Bot keyed by itself
davidmckayv Oct 28, 2026
8a31abe
Fuse S1 computer keys and S2 Isolated Bots
davidmckayv Oct 28, 2026
a00ab75
Say everywhere that a person's Bots share one computer, and an Isolat…
davidmckayv Oct 28, 2026
8d354ac
Say on onboarding what every Bot gets, not a computer of its own
davidmckayv Oct 28, 2026
68e44ca
Read the Anthropic key and API version the 2.x SDK sends in the LangG…
davidmckayv Oct 28, 2026
3ad22cd
Launch the Chromium build CI installs in the enter-text test
davidmckayv Oct 28, 2026
bcd7dd5
Merge main
davidmckayv Oct 28, 2026
0161498
Keep onboarding's new introduction honest about computers
davidmckayv Oct 28, 2026
336358a
Merge main into per-person computers
davidmckayv Oct 28, 2026
c299092
Say plainly that a person's Bots share their computer, and fix the re…
davidmckayv Oct 28, 2026
994ebbb
Merge main into per-person computers
davidmckayv Oct 28, 2026
8f15946
Expect a Bot's old workspace volume among the names it had before
davidmckayv Oct 28, 2026
989970e
Say whose seat a Bot's computer shows, now that computers are per person
davidmckayv Oct 28, 2026
282ba57
Merge main into per-person computers
davidmckayv Oct 29, 2026
49e0b8d
Move a Bot's own browser onto per-person seats, and let Record run on…
davidmckayv Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
11 changes: 6 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -462,11 +462,12 @@ MANAGED_AGENT_TOKEN=
# AGENT_BOT_MODEL=gpt-5.5
# BOT_RESPONSES_API=false

# One computer per Bot. Unset, every Bot shares the computer at AGENT_COMPUTER_URL, suitable on a
# laptop and explicit about being one shared machine. Set, the supervisor gives each Bot a container
# of its own, with its own /workspace and its own browser profile. A person's own OpenBot browser
# runs only on a computer of its own, so it needs this (or OPENBOT_BROWSER_URL above): on the shared
# computer a Bot's shell could read the person's logins off the disk.
# One computer per person. Unset, every Bot shares the computer at AGENT_COMPUTER_URL, suitable on a
# laptop and explicit about being one shared machine. Set, the supervisor gives each person a
# container of their own, in which each of their Bots has its own folder and browser profile, and
# gives a Bot marked Isolated a container of its own. A person's own OpenBot browser runs only on a
# computer of its own, so it needs this (or OPENBOT_BROWSER_URL above): on the shared computer a
# Bot's shell could read the person's logins off the disk.
COMPUTER_SUPERVISOR_URL=
# Shared with the supervisor. Not the security boundary, its four verbs are, but it keeps anything
# else that can reach the port from cycling a Bot's computer.
Expand Down
55 changes: 49 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,18 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

**Before upgrading.** Fifteen things change for an existing deployment:
**Before upgrading.** Sixteen things change for an existing deployment:
- Bots now share one computer per person instead of each having its own. Migration
`0069_isolated_bots` adds `agent_profiles.isolated`, off for every existing Bot. With the
supervisor (laptop, Desktop, Compose), a person's computer is created the first time one of their
Bots acts, as `<namespace>-computer-person-<id>`, and the supervisor stops the old per-Bot containers
when it starts, keeping their volumes. A private Bot's logins are copied into its owner's computer
once; public Bots, the built-in ones included, sign in again once, because their old logins were
shared by everyone who used them. Helm `shared` moves private Bots' profiles the same way. On Helm
`sandbox` nothing is copied, so private Bots sign in again once there too. With the supervisor, a
private Bot's files come along the same way, into its folder on its owner's computer; a public
Bot's old files stay in its old volume. Old per-Bot volumes are left in place; remove them when you
are satisfied. Helm's computer memory defaults rise to a 2Gi request and an 8Gi limit.
- A hosted computer's browser is now headed by default. It uses a little more memory than headless
did; set `COMPUTER_BROWSER_MODE=headless` (Helm: `computers.browserMode: headless`) to keep the old
behaviour.
Expand Down Expand Up @@ -140,6 +151,35 @@ you to every device, and changes nothing for anybody else. The templates' own fe
OpenBot has no server for, such as OpenDots' Spaces, OpenDuet's morning brief and OpenMuse's mail and
calendar, are not part of the skins.

### Each person's Bots share one computer, and a Bot can have its own

Every Bot used to get a computer of its own, which on a laptop or Desktop meant a container per Bot
running all the time, and meant everyone who used a public Bot shared its logins. Now all the Bots a
person uses run on that person's one computer, each with its own browser profile and folder, so a
login belongs to the person and the Bot, never to everyone who uses it. Tick **Isolated** when you
make a Bot, or in its Setup, to give it a computer of its own that keeps its logins; everyone who
uses that Bot shares that computer. Administrators decide who may: Admin → Enterprise → Isolated
Bots, on for everyone by default, and can be turned off for everyone or allowed for chosen roles and
groups. When it is turned off for somebody, their isolated Bots run on their own computer from the
next run, and the Bot's Setup says why. A deployment with one shared computer (Helm `shared`, or no
supervisor) cannot give a Bot its own, and the checkbox says so.

As is the industry standard for an always-on agent's computer, the Bots on a person's computer are
not kept from each other: a command any of them runs, a public Bot somebody else made included, can
reach anything on that computer. Each Bot still keeps its own browser profile there, which is
stricter than the standard, where a person's Bots share their sign-ins too. The admin Computers
screen says this, and a Bot that must be kept apart is made Isolated.

An isolated Bot's one computer is shared by everybody who uses it, so its Setup says, before it is
made public and while it is, that anything signed in to there is theirs too. Only the Bot's owner's
switch decides whether it may be isolated: an administrator ticking Isolated on somebody else's Bot
is refused when that owner lacks the switch, rather than saving a tick the Bot never ran with.

A person's computer runs a browser for each of their Bots in use, so Helm's computer now requests
2Gi of memory with an 8Gi limit (was 1Gi and 4Gi); `computers.resources` in values.yaml says how to
size it. Removing a person or deleting an isolated Bot leaves its computer until an administrator
resets it from Admin → Computers, which signs every Bot on it out of everything.

### The audit log reads like a log

Admin → Audit fills the window and runs the table across the whole pane, with the title, the views
Expand Down Expand Up @@ -599,11 +639,14 @@ someone records, so recording a task needs no extension and no Connect step.
disconnects; the Bot acts again only once the recording has ended. Taking control now records who
took it, so on a shared Bot only that person can use the bar or record, someone else's recording
shows only that one is running, and nobody else sees its name, its step count or its microphone.
- **Record needs a computer the Bot does not share.** Where every Bot runs on one computer (Helm's
shared mode, or `AGENT_COMPUTER_URL` with no supervisor), Record is refused in every Bot's browser
and the panel says why: your Recorder connection would sit in the Bot's profile, where any other
Bot's shell could read it. The bar still works. The computer refuses it too. With a supervisor
(laptop, Desktop) or a Sandbox, each Bot has its own computer and Record works.
- **Record runs on your own computer, never the one everyone shares.** Where every person's Bots run
on one computer (Helm's shared mode, or `AGENT_COMPUTER_URL` with no supervisor), Record is refused
in every Bot's browser and the panel says why: your Recorder connection would sit in the Bot's
profile, where another person's Bot could read it. The bar still works. The computer refuses it too.
With a supervisor (laptop, Desktop) or a Sandbox, a Bot's browser is on your own computer, beside
only your own Bots, or on an isolated Bot's computer of its own, and Record works there. Your
hand-back ends only your own recording: another person recording in the same Bot's browser on their
own computer is never touched.
- **One connection per recording.** Record mints a Recorder connection for the person who pressed it
and Stop revokes it; Settings, Browsers lists it as "<Bot>'s browser" while it lives, and says
"recording now" for any browser that is recording.
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

**The AI assistant your company can actually own.** Same shape as ChatGPT, Claude or Grok, with one difference that matters: it runs on your infrastructure and you can change anything about it. Any agent stack, through AG-UI.

Each coworker gets a computer of its own: a real browser with its own logins, its own files, and only the tools you grant. Every action decided before it happens and recorded after.
Each coworker gets a real browser with its own logins, its own files, and only the tools you grant. Every action decided before it happens and recorded after.

[**Talk to an engineer**](https://copilotkit.ai/talk-to-an-engineer?ref=openbot_readme) · [**Have us build it with you**](https://copilotkit.ai/talk-to-an-engineer?ref=openbot_readme) · [**copilotkit.ai/openbot**](https://copilotkit.ai/openbot) · [**Quick start**](#quick-start) · [**Docs**](docs/README.md)

Expand Down Expand Up @@ -219,7 +219,7 @@ Leave `EMBEDDED_POSTGRES` off and set `DATABASE_URL` to point at a database you

## 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 computer per person**: the supervisor gives each person their own container, with a folder and browser profile for each of their Bots. Tick Isolated on a Bot to give it a container of its own. 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 command inherits PATH, locale, terminal and proxy variables, not the rest of the deployment's environment.
- **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`, `command`, `file.*`, `mcp.*` and `initiator.*` (what started the run, so a rule can refuse a scheduled routine what it would allow a person). Deny is evaluated before allow, a missing policy permits nothing, and a broken rule refuses rather than opens.
Expand Down Expand Up @@ -294,7 +294,7 @@ Settings worth knowing:
| `COMPUTER_TOKEN` | Secret every Bot computer request must present. `start.sh` falls back to a fixed dev value. |
| `SUPERVISOR_TOKEN` | Secret the supervisor requires. `start.sh` falls back to a fixed dev value. |
| `AGENT_TOOL_TOKEN` | Secret a Bot presents to call a granted tool back. `start.sh` sets one. Without it no Bot may call tools. |
| `COMPUTER_SUPERVISOR_URL` | Gives each Bot a computer of its own instead of one shared computer. |
| `COMPUTER_SUPERVISOR_URL` | Gives each person a computer of their 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. |
Expand All @@ -316,7 +316,7 @@ Full reference: [docs/configuration.md](docs/configuration.md).
| `agent-computer` | 4100 | Chromium plus `/workspace` and browser profile. |
| `agent-bot` | 4200 | Proof-of-concept AG-UI Bot. |
| `agent-langgraph` | 4201 | LangGraph AG-UI Bot. |
| `supervisor` | 4500 host / 4300 container | Creates and manages one computer per Bot. |
| `supervisor` | 4500 host / 4300 container | Creates and manages one computer per person, and one per Isolated Bot. |
| `worker` | none | Fires due routines by handing each run to the server. |
| PostgreSQL with pgvector | 5432 | Product data, policy, audit, credentials, grants, channels, and component metadata. |
| CopilotKit Intelligence | external | Durable threads and memory. |
Expand Down
55 changes: 50 additions & 5 deletions agent-computer/src/bot-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,58 @@ export class UnusableBotIdError extends Error {
}

/**
* Where this Bot's browser profile lives.
* A Bot's place on this computer: its browser profile, its folder and its session.
*
* The allow-list is the guarantee: an id with no separator and no dot cannot address anything but a
* WHY THE PERSON IS IN IT. One process can serve many people: Helm's `computers.mode: shared` runs a
* single computer for the whole deployment. Keyed by the Bot alone there, a public Bot had one
* profile, so whoever signed it into a site signed it in for everybody. On that computer the server
* names the person as well (`x-openbot-person-id`), and the seat is the pair, so each person's logins
* stay their own. A computer the supervisor or the sandbox makes belongs to one person already, and
* there the server names only the Bot and the seat is the Bot's id, exactly as before.
*
* `~` joins the two because it is in neither half: both are held to {@link isPlainBotId}, so the
* first `~` is where the person ends. It is not a path separator, cannot spell `..`, and a seat with
* one is still a single directory under the root.
*/
export const SEAT_SEPARATOR = "~";

/** The seat for this Bot, for this person when one is named. Throws on an unusable half. */
export function seatIdFor(botId: string, personId?: string): string {
if (!isPlainBotId(botId)) throw new UnusableBotIdError(botId);
if (personId === undefined) return botId;
if (!isPlainBotId(personId)) throw new UnusableBotIdError(personId);
return `${personId}${SEAT_SEPARATOR}${botId}`;
}

/** Whether a name is a seat this code could have made: a plain Bot id, or a plain person and Bot. */
export function isUsableSeatId(value: string): boolean {
const parts = value.split(SEAT_SEPARATOR);
if (parts.length === 1) return isPlainBotId(value);
return (
parts.length === 2 &&
isPlainBotId(parts[0] ?? "") &&
isPlainBotId(parts[1] ?? "")
);
}

/** The Bot and, where there is one, the person a seat belongs to. */
export function seatParts(seatId: string): {
botId: string;
personId?: string;
} {
const at = seatId.indexOf(SEAT_SEPARATOR);
if (at < 0) return { botId: seatId };
return { personId: seatId.slice(0, at), botId: seatId.slice(at + 1) };
}

/**
* Where this seat's browser profile lives.
*
* The allow-list is the guarantee: a seat with no separator and no dot cannot address anything but a
* single directory inside the root. The join stays here rather than at the call site so that there is
* one place a profile path can be built, and it is the checked one.
*/
export function profileDirectoryFor(root: string, botId: string): string {
if (!isPlainBotId(botId)) throw new UnusableBotIdError(botId);
return `${root}/${botId}`;
export function profileDirectoryFor(root: string, seatId: string): string {
if (!isUsableSeatId(seatId)) throw new UnusableBotIdError(seatId);
return `${root}/${seatId}`;
}
2 changes: 1 addition & 1 deletion agent-computer/src/browser-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { browserModeFromEnv, type BrowserMode } from "./browser-mode";
import { type BrowserMode, browserModeFromEnv } from "./browser-mode";

export type BrowserRuntime = {
backend: "managed" | "local-chrome";
Expand Down
58 changes: 58 additions & 0 deletions agent-computer/src/carry-over.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Handing a private Bot's old profile to the person it belonged to.
*
* WHY. On a computer many people share, a Bot's profile used to be keyed by the Bot alone
* (`/profiles/<bot>`), so every person using it shared its logins. Seats are now per person and Bot
* (see `seatIdFor`). For a private Bot that old profile had exactly one user, its owner, so the server
* asks for it to be moved to the owner's seat and nobody has to sign in again. A public Bot's old
* profile had no single owner and is never asked for: its users sign in again once.
*
* ONCE, BY CONSTRUCTION. A move leaves nothing behind to move a second time, and a seat that already
* has a profile keeps it: that is a person who has used the Bot since, and their newer logins win.
*
* Free of Playwright on purpose, like `profile-listing.ts`, so the rule is testable without a browser.
*/
import { rename, stat } from "node:fs/promises";
import { profileDirectoryFor } from "./bot-id";

/** What happened: moved, kept the seat's own profile, or there was nothing to move. */
export type CarryOverOutcome = "moved" | "kept" | "absent";

async function exists(path: string): Promise<boolean> {
return stat(path).then(
() => true,
() => false,
);
}

/**
* Move `/profiles/<botId>` to `/profiles/<seatId>`, once.
*
* `closeOld` closes a browser still open on the old profile first, so Chromium flushes it and nothing
* is writing to the directory while it moves. A rename, so it is atomic on the one volume both live
* on: a reader sees the old profile or the new one, never half of each.
*/
export async function carryOverProfile(
root: string,
botId: string,
seatId: string,
closeOld: () => Promise<unknown>,
): Promise<CarryOverOutcome> {
// A seat that is the Bot itself has nothing to inherit from.
if (seatId === botId) return "absent";
const from = profileDirectoryFor(root, botId);
const to = profileDirectoryFor(root, seatId);
if (await exists(to)) return "kept";
if (!(await exists(from))) return "absent";
await closeOld();
try {
await rename(from, to);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
// Gone, or the seat got a profile of its own in between: either way there is nothing to do.
if (code === "ENOENT") return "absent";
if (code === "EEXIST" || code === "ENOTEMPTY") return "kept";
throw error;
}
return "moved";
}
1 change: 1 addition & 0 deletions agent-computer/src/challenge.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Page } from "playwright";
import type { BrowserChallenge } from "../../shared/computer-control";
import type { Control } from "./control";

type Signal = {
cfMitigated?: string | null;
text: string;
Expand Down
4 changes: 2 additions & 2 deletions agent-computer/src/control-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from "node:fs";
import { join } from "node:path";
import type { HandoffRequest } from "../../shared/computer-control";
import { isPlainBotId } from "./bot-id";
import { isUsableSeatId } from "./bot-id";

export type StoredControl = {
version: 1;
Expand Down Expand Up @@ -134,7 +134,7 @@ export function createControlStore(
profilesDirectory: string,
botId: string,
): ControlStore {
if (!isPlainBotId(botId)) throw new Error("That is not a usable bot id.");
if (!isUsableSeatId(botId)) throw new Error("That is not a usable bot id.");
const directory = join(profilesDirectory, ".control");
const path = join(directory, `${botId}.json`);
return {
Expand Down
Loading
Loading