Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 5 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ TENANT_PACKAGE_DIR=../examples/fintech
# deployment mints, so its own conversations stay identifiable. Unset, the tenant package's id is
# used, which tells two packages apart but not two copies of one.
# DEPLOYMENT_ID=
# Sign-in. All of this is commented out, and a clone with none of it set is one administrator with
# no sign-in at all, which is how you reach the product without registering an OAuth client first.
# Somewhere other people can get to, an unconfigured deployment refuses to start rather than serving
# an open one. OPENBOT_SINGLE_USER=true says you meant it.
# Sign-in. The line below runs the deployment as one administrator with no sign-in at all, which is
# how you reach the product without registering an OAuth client first. Delete it and configure a
# provider before anybody else can reach this: while it is set, every visitor is an administrator.
# With no provider and this line gone, the deployment refuses to start rather than guessing.
OPENBOT_SINGLE_USER=true
#
# Configure ANY ONE of the three providers to turn sign-in on. Configure several and the sign-in
# screen offers several, which is the normal shape for a company mid-migration.
Expand Down
61 changes: 52 additions & 9 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,15 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
Two configurations now refuse to start:

- A provider configured with no `INITIAL_ADMIN_EMAILS`. Set it to at least one address.
- No provider at all with `NODE_ENV=production`. Configure one, or set `OPENBOT_SINGLE_USER=true`.
- No provider at all and no `OPENBOT_SINGLE_USER=true`. Configure a provider, or set that to say you
meant a deployment where every visitor is one administrator. This no longer depends on `NODE_ENV`,
which is unset by default and so let exactly the dangerous case through. A deployment already
running open needs the line added before it will start again.

Registering an OpenID Connect provider needs every host in its discovery document in
`TRUSTED_ORIGINS`, not only the issuer. Better Auth 1.7 checks each endpoint it finds, so a Google
issuer also needs `oauth2.googleapis.com` and `openidconnect.googleapis.com`. Registration is
refused with the untrusted host named.

Sessions survive and nobody signs in again.

Expand Down Expand Up @@ -63,8 +71,37 @@ Sessions survive and nobody signs in again.
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.
- **You can watch what a Bot is doing, not only what it is looking at.** The screen answered half the
question: a Bot spending two minutes in a terminal showed a blank browser and one grey line per
command, with the output nowhere. A command line in the transcript now opens to show what it
printed, its exit code, and whether it was cut short or stopped. Beside the screen there is an
Activity tab carrying every command, file read, file write and listing as they happen, newest
first, with a count on the tab so a Bot working away from the browser is visible without switching
to it. A saved file shows its path and size, never its contents. This is a live view of the open
conversation; the record is still the audit trail.
- **Sign-in is on the audit trail.** Rows for signing in, for being refused, and for the configured
administrator list granting somebody the role. Two questions had no answer before: who granted
themselves administrator by editing `INITIAL_ADMIN_EMAILS`, and whether somebody just removed had
ever been here, since removing them deletes the sessions that were the only evidence. A trail that
is unavailable never blocks a sign-in.

### Fixed
- **A deployment with no identity provider came up open by default.** Covered under Changed above,
and listed here too because it is the one on this list that was reachable from the internet.
- **Registering a company's identity provider was owned by whoever registered it.** Better Auth
answers its own listing route with only the providers the person asking registered, and refuses a
removal from anybody else, so a second administrator opened the Identity providers screen, found
it empty, and registered one that already existed. Worse, the row cascaded from that person's user
row: deleting the administrator who set sign-in up deleted the company's sign-in with them. What is
registered is a fact about the deployment, so reads and removals go through OpenBot's own
administrator-only routes against the whole table, and a provider outlives the person who added it.
- **A customer's client secret was in the clear.** The SSO plugin writes `oidc_config` and
`saml_config` as plaintext JSON, with the OAuth client secret for that company's directory inside
them: the one secret here not going through `KEY_ENCRYPTION_KEY`. Both are now encrypted at rest.
Rows written before this still read, and are re-encrypted the next time they are written. OAuth
access and refresh tokens use Better Auth's own encryption, keyed on `BETTER_AUTH_SECRET`.
- **A failed provider registration looked like a button that did not work.** The error was rendered
on the page behind the dialog, which was covering it.
- **A Bot could become root inside its container.** `sudo` was granted as `NOPASSWD: ALL`, and the
comment above it named the two conditions that made that acceptable: the container being one Bot's
alone, and not holding a database. The image meets neither, because the supervisor is deliberately
Expand Down Expand Up @@ -119,14 +156,20 @@ Sessions survive and nobody signs in again.

### Changed

- **A deployment with no identity provider is one administrator, without a flag.** That is how a
fresh clone reaches the product. Where `NODE_ENV=production`, an unconfigured deployment now
refuses to start instead, because a public URL where every visitor is an administrator is silent
and looks like it works. `OPENBOT_SINGLE_USER=true` replaces `OPENBOT_DEV_NO_AUTH`, which is still
honoured, and is how somebody says they meant an open deployment.
- **Requires Better Auth 1.7**, which adds an `issuer` to every account. Migrations `0002` to `0004`
add the column, backfill existing rows with their provider's real issuer, and then make it
required, so nobody is asked to sign in again.
- **Running with no sign-in takes a flag and nothing else.** It used to be locked with
`NODE_ENV=production`, which is exactly backwards: `NODE_ENV` is unset unless somebody sets it, so
a container on a VM with a hand-written env file and no identity provider served every visitor on
the internet as an administrator, silently, because nothing looked wrong from the outside. A
deployment with no provider now refuses to start unless `OPENBOT_SINGLE_USER=true` says it was
meant. `.env.example` ships that line switched on, so a clone still runs with no configuration at
all, and the line is greppable in a way a default never was. `OPENBOT_DEV_NO_AUTH` is still
honoured.
- **Requires Better Auth 1.7**, which adds an `issuer` to every account. Migrations `0002` and `0003`
add the column and backfill existing rows with their provider's real issuer, so nobody is asked to
sign in again. The column stays nullable on purpose: a rolling deploy runs migrations and then
serves from old and new replicas at once, and an old replica writes an account without it, so
making it required in the same release would break the first sign-in of everybody who landed on a
replica that had not been replaced yet. The constraint belongs to a later release.
- **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
Expand Down
19 changes: 13 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ your own machine.

> **Alpha, and under active development.** OpenBot is early. Expect rough edges and bugs, and expect things to move. Issues and pull requests are welcome.

> **Runs on your machine.** Everything below is written for a laptop. With no identity provider configured OpenBot admits every request as one administrator, so a fresh clone reaches the product without registering an OAuth client. [Sign-in](#sign-in) turns that off.
> **Runs on your machine.** Everything below is written for a laptop. `.env.example` carries `OPENBOT_SINGLE_USER=true`, which admits every request as one administrator, so a fresh clone reaches the product without registering an OAuth client first. [Sign-in](#sign-in) turns that off, and is required before anybody else can reach the deployment.

## What it is

Expand Down Expand Up @@ -124,7 +124,7 @@ as one replica for now.
| -------------------- | ------------------------------------------------------------------ |
| `/` | Start and browse channels. |
| `/agents` | Create, edit, duplicate, hide, delete, and launch coworkers. |
| `/channel/:id` | Converse with one coworker and view its live screen/profile panel. |
| `/channel/:id` | Converse with one coworker, watch its screen, and see what it ran. |
| `/bot` | Direct chat with a Bot; `?agent=<id>` selects one. |
| `/skills` | Create and enable personal skills. |
| `/settings` | User preferences. |
Expand All @@ -143,6 +143,7 @@ as one replica for now.
- **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`, `file.*` and `mcp.*`. Deny is evaluated before allow, a missing policy permits nothing, and a broken rule refuses rather than opens.
- **Watch what it is doing**: the screen shows what a Bot is looking at, and the Activity tab beside it shows what it ran, read and saved, with the output. A command line in the transcript opens to the same thing. A saved file shows its path and size, never its contents.
- **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.
- **Secrets never enter the transcript**: the trail records that a secret was requested and how long it was, not what it said.
- **Bring your own agent**: any AG-UI endpoint is a Bot, on a framework or hand written. Endpoints are validated with the same target checks used for browser navigation, and an auth header is stored write-only.
Expand Down Expand Up @@ -193,7 +194,7 @@ Settings worth knowing:

| Variable | Use |
| ------------------------------------ | ------------------------------------------------------------------------- |
| `OPENBOT_SINGLE_USER` | Admits every request as one administrator where an unconfigured deployment would otherwise refuse to start. |
| `OPENBOT_SINGLE_USER` | Admits every request as one administrator. Required when no identity provider is configured; `.env.example` ships it on. |
| `OPENAI_BASE_URL` | Answers the OpenAI-shaped calls from somewhere else: a gateway, a proxy. |
| `ANTHROPIC_BASE_URL`, `GOOGLE_GENERATIVE_AI_BASE_URL` | The same, for those two APIs. |
| `COMPUTER_TOKEN` | Secret every Bot computer request must present. `start.sh` sets one. |
Expand Down Expand Up @@ -231,9 +232,11 @@ More detail: [docs/architecture.md](docs/architecture.md).

## Sign in

Nothing configured means one administrator and no sign-in, which is how a fresh clone reaches the
product. Configure **any one** of Google, Microsoft or Okta to turn sign-in on. Configure more than
one and the sign-in screen offers each of them.
`.env.example` ships `OPENBOT_SINGLE_USER=true`, which is one administrator and no sign-in: how a
fresh clone reaches the product without registering an OAuth client first. Delete that line and
configure **any one** of Google, Microsoft or Okta before anybody else can reach the deployment.
With neither, it refuses to start rather than admitting everybody as an administrator. Configure
more than one provider and the sign-in screen offers each of them.

These four are needed whichever you pick:

Expand Down Expand Up @@ -264,6 +267,10 @@ OKTA_OAUTH_ISSUER=https://example.okta.com/oauth2/default

Restart. Accounts, sessions and roles are stored in the same PostgreSQL database as everything else.

A company's own SAML or OpenID Connect provider is registered while the deployment runs, under
Admin → Identity providers, and routed by email domain. An OIDC registration needs every host in the
provider's discovery document listed in `TRUSTED_ORIGINS`, not only the issuer.

- `INITIAL_ADMIN_EMAILS` is required, because nothing else grants the administrator role and no
screen can promote somebody afterwards. It is re-read on every sign-in, so editing it takes effect
the next time that person signs in.
Expand Down
122 changes: 122 additions & 0 deletions app/src/components/computer/activity-log.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* What the Bot has been doing on its computer, beside the screen that shows what it is looking at.
*
* The screen answered half the question. A Bot that spends two minutes in a terminal installing a
* package showed a blank browser and one grey line per command in the transcript, so the honest
* answer to "what is it doing" was "something, on a machine holding your logins". This is the other
* half: every command, what it printed, and what it exited with, as it happens.
*
* This session only. The record is the audit trail, which is on the server and survives a reload;
* this is a window, and it is allowed to be empty when a tab is opened on a conversation that
* already happened.
*/
import { IconFile, IconFolder, IconTerminal2 } from "@tabler/icons-react";
import { useSyncExternalStore } from "react";
import {
activityFor,
type ComputerActivity,
subscribeToActivity,
} from "@/lib/computers/activity";
import { CommandOutput } from "./command-output";

const ICONS = {
command: IconTerminal2,
read_file: IconFile,
write_file: IconFile,
list_files: IconFolder,
} as const;

/** What the line says was done. The subject beside it says what it was done to. */
const LABELS = {
command: "Ran",
read_file: "Read",
write_file: "Saved",
list_files: "Listed",
} as const;

function timeOf(at: number): string {
return new Date(at).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}

function Entry({ entry }: { entry: ComputerActivity }) {
const Icon = ICONS[entry.kind];
const failed =
entry.refused === true ||
(typeof entry.exitCode === "number" && entry.exitCode !== 0);

return (
<li className="border-border/60 border-b py-2 last:border-b-0">
<div className="flex items-baseline gap-2">
<Icon
aria-hidden
className={`size-3.5 shrink-0 translate-y-0.5 ${
failed ? "text-destructive" : "text-muted-foreground"
}`}
/>
<span className="shrink-0 text-muted-foreground text-xs">
{LABELS[entry.kind]}
</span>
{/*
The command wraps rather than truncating. In the transcript one line per call is what keeps
it readable; here the whole point is to see exactly what ran, and a long command with its
middle cut out is the one thing this pane must not do.
*/}
<code className="min-w-0 break-all font-mono text-xs">
{entry.subject}
</code>
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground tabular-nums">
{timeOf(entry.at)}
</span>
</div>

{entry.refused === true ? (
<p className="mt-1 pl-5 text-destructive text-xs">
{entry.output || "A boundary refused it."}
</p>
) : (
<div className="mt-1 max-h-64 overflow-auto pl-5">
<CommandOutput
{...(entry.exitCode !== undefined
? { exitCode: entry.exitCode }
: {})}
output={entry.output}
timedOut={entry.timedOut === true}
truncated={entry.truncated === true}
/>
</div>
)}
</li>
);
}

export function ActivityLog({ computerId }: { computerId: string }) {
const entries = useSyncExternalStore(
subscribeToActivity,
() => activityFor(computerId),
() => activityFor(computerId),
);

if (entries.length === 0) {
return (
<p className="py-6 text-center text-muted-foreground text-sm">
Nothing yet. Commands the Bot runs, and files it reads, appear here as
they happen.
</p>
);
}

// Newest first: what somebody watching wants is what just happened, without scrolling for it.
const newestFirst = [...entries].reverse();

return (
<ul className="min-w-0">
{newestFirst.map((entry) => (
<Entry entry={entry} key={entry.id} />
))}
</ul>
);
}
55 changes: 55 additions & 0 deletions app/src/components/computer/command-output.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* What a command printed.
*
* Shown behind the chevron on the transcript line and in the pane beside the screen, so the two say
* the same thing in the same shape. Monospace and pre-wrapped, because shell output is laid out in
* columns and reflowing it makes `ls -l` unreadable.
*
* A command that printed nothing says so. Blank space under an expanded line reads as a component
* that failed to render, and "it printed nothing" is a real and common answer.
*/
export function CommandOutput({
output,
exitCode,
truncated,
timedOut,
}: {
output: string;
/** Absent for a file read or a listing, which have no exit status. */
exitCode?: number;
/** The far side cut the output short. Said out loud rather than left to be inferred. */
truncated?: boolean;
/** The command ran too long and was stopped, so what is here is not the whole story. */
timedOut?: boolean;
}) {
const failed = typeof exitCode === "number" && exitCode !== 0;

return (
<div className="space-y-1.5">
{truncated ? (
<p className="text-muted-foreground text-xs">
Output was cut short at the start. What follows is the end of it.
</p>
) : null}
{output ? (
<pre className="whitespace-pre-wrap break-words font-mono text-xs leading-relaxed">
{output}
</pre>
) : (
<p className="text-muted-foreground text-xs italic">
It printed nothing.
</p>
)}
{timedOut ? (
<p className="text-destructive text-xs">
It ran too long and was stopped.
</p>
) : null}
{failed ? (
<p className="text-amber-600 text-xs dark:text-amber-500">
Exit code {exitCode}.
</p>
) : null}
</div>
);
}
Loading
Loading