From a568947adf16ddbe3709f5426295c271f0d29efd Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 21 Aug 2026 08:36:46 -0700 Subject: [PATCH 1/4] Close five ways the sign-in release would have embarrassed us Found in review of #67, all in the auth work itself rather than in what it replaced. Running with no sign-in was gated on 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 as an administrator, silently, because nothing looked wrong from the outside. It now takes an explicit OPENBOT_SINGLE_USER=true and refuses to start without one. .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. The most dangerous boolean in the codebase now has a test file. accounts.issuer no longer takes NOT NULL in the same release that adds the column. A rolling deploy runs the migrations and then serves from old and new replicas at once, and an old replica inserts an account without the column: under NOT NULL the release would have broken the first sign-in of everybody who landed on a replica that had not been replaced yet. The constraint belongs to a later release, once no replica predates the column. Registered identity providers are facts about the deployment rather than about whichever administrator pasted the metadata in. Better Auth answers GET /sso/providers with the ones the person asking registered themselves and refuses a delete from anybody else, so a second administrator saw an empty screen and registered a provider that already existed, and the row cascaded from the registrar's user row, so the person who set sign-in up leaving took the company's sign-in with them. Reads and removals now go through our own admin-gated routes against the whole table, and the foreign key is set null. The client secret for a customer's directory was the one secret here not going through KEY_ENCRYPTION_KEY. The SSO plugin gives no hook, so the seam is the adapter: oidc_config and saml_config are ciphertext at rest, plaintext rows written before this still read, and OAuth access and refresh tokens use Better Auth's own encryptOAuthTokens. Sign-in left no trace at all. Nothing recorded that somebody who could edit INITIAL_ADMIN_EMAILS had granted themselves the administrator role, and revoking a person deletes the sessions that were the only evidence they had ever been here. There are now rows for signing in, for being turned away, and for the configured floor granting the role, and they never block a sign-in when the trail is down. Also: a failed registration showed its error on the page behind the dialog, so the dialog sat there looking as though the button had not worked. --- .env.example | 9 +- app/src/lib/identity-providers/mutations.ts | 16 +- app/src/lib/identity-providers/queries.ts | 41 +- .../_authed/admin/identity-providers.tsx | 15 +- .../drizzle/0004_account_issuer_required.sql | 1 - ...entity_provider_outlives_its_registrar.sql | 3 + server/drizzle/meta/0004_snapshot.json | 349 +++++++++++++----- server/drizzle/meta/_journal.json | 6 +- server/src/app.ts | 79 +++- server/src/audit.ts | 25 ++ server/src/auth/dev-actor.ts | 30 +- server/src/auth/encrypt-sso-config.ts | 185 ++++++++++ server/src/auth/identity-provider-store.ts | 87 +++++ server/src/auth/index.ts | 131 ++++++- server/src/auth/roles.ts | 22 +- server/src/db/schema/core.ts | 27 +- server/src/index.ts | 25 +- server/tests/config.test.ts | 25 +- server/tests/encrypt-sso-config.test.ts | 147 ++++++++ server/tests/roles.test.ts | 61 ++- server/tests/single-user.test.ts | 70 ++++ 21 files changed, 1180 insertions(+), 174 deletions(-) delete mode 100644 server/drizzle/0004_account_issuer_required.sql create mode 100644 server/drizzle/0004_identity_provider_outlives_its_registrar.sql create mode 100644 server/src/auth/encrypt-sso-config.ts create mode 100644 server/src/auth/identity-provider-store.ts create mode 100644 server/tests/encrypt-sso-config.test.ts create mode 100644 server/tests/single-user.test.ts diff --git a/.env.example b/.env.example index 63fa2d52..ce629bfa 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/app/src/lib/identity-providers/mutations.ts b/app/src/lib/identity-providers/mutations.ts index fec6cf36..d59a94a4 100644 --- a/app/src/lib/identity-providers/mutations.ts +++ b/app/src/lib/identity-providers/mutations.ts @@ -78,16 +78,22 @@ export function registerIdentityProviderMutationOptions( }); } +/** + * Remove one. + * + * Our own route, not Better Auth's `delete-provider`, which refuses unless the person asking is the + * one who registered it. That left a provider nobody could remove the moment the administrator who + * set it up had left, which is exactly when somebody needs to. + */ export function deleteIdentityProviderMutationOptions( queryClient: QueryClient, ) { return mutationOptions({ mutationFn: async (providerId: string): Promise => { - await client("/api/auth/sso/delete-provider", { - method: "POST", - body: { providerId }, - fallback: FALLBACK, - }); + await client( + `/api/admin/identity-providers/${encodeURIComponent(providerId)}`, + { method: "DELETE", fallback: FALLBACK }, + ); }, onSuccess: () => invalidateProviders(queryClient), }); diff --git a/app/src/lib/identity-providers/queries.ts b/app/src/lib/identity-providers/queries.ts index 1c9e3b39..8104bcbf 100644 --- a/app/src/lib/identity-providers/queries.ts +++ b/app/src/lib/identity-providers/queries.ts @@ -15,6 +15,13 @@ export type IdentityProvider = { domain: string; /** Which protocol it speaks. SAML is what most enterprise identity teams hand over. */ protocol: "saml" | "oidc"; + /** + * Whether the person who registered it still has an account here. Null once they are gone. + * + * Shown so somebody auditing a deployment can see that a provider outlived whoever set it up, + * which is the normal case a year in and used to be the case where the provider vanished instead. + */ + registeredBy: string | null; }; export const identityProviderKeys = { @@ -25,34 +32,20 @@ export const identityProviderKeys = { /** * The registered providers. * - * Read from Better Auth's own route rather than one of ours, because the plugin owns the table and a - * second reader would be a second answer. The payload carries no client secret or signing key: the - * fields below are all this asks for. + * Read from our own admin route, not Better Auth's `GET /sso/providers`. That one answers with the + * providers the person asking registered themselves, so two administrators saw two different + * deployments and the second one to open this screen found it empty and registered a provider that + * already existed. What is registered is a fact about the deployment. + * + * The payload carries no client secret or signing certificate; the server's projection cannot express + * them. */ export function identityProviderListQueryOptions() { return queryOptions({ queryKey: identityProviderKeys.list(), - queryFn: async (): Promise => { - // `{ providers: [...] }`, not a bare array. Better Auth's own routes carry their own - // envelope, which is why this reads the body rather than passing a key to `client`. - const response = await client("/api/auth/sso/providers", { + queryFn: (): Promise => + client("/api/admin/identity-providers", "providers", { fallback: "Could not load identity providers", - }); - const { providers = [] } = (await response.json()) as { - providers?: { - providerId: string; - issuer: string; - domain: string; - samlConfig?: unknown; - }[]; - }; - - return providers.map((provider) => ({ - providerId: provider.providerId, - issuer: provider.issuer, - domain: provider.domain, - protocol: provider.samlConfig ? "saml" : "oidc", - })); - }, + }), }); } diff --git a/app/src/routes/_authed/admin/identity-providers.tsx b/app/src/routes/_authed/admin/identity-providers.tsx index 64f21231..124ff2f9 100644 --- a/app/src/routes/_authed/admin/identity-providers.tsx +++ b/app/src/routes/_authed/admin/identity-providers.tsx @@ -64,7 +64,9 @@ function IdentityProvidersPage() { const [open, setOpen] = useState(false); const [draft, setDraft] = useState(EMPTY); - const failure = register.error ?? remove.error; + // A removal failure belongs on the page: there is no dialog to put it in. A registration failure + // is shown inside the dialog instead, where the person who caused it is looking. + const failure = remove.error; function submit(submission: React.FormEvent) { submission.preventDefault(); @@ -286,6 +288,17 @@ function IdentityProvidersPage() { )} + + {/* + Here as well as on the page behind, because while this is open the page behind it is + not visible. A registration refused by the identity provider or by Better Auth left + the dialog sitting there unchanged, which reads as the button not working. + */} + {register.error ? ( +

+ {register.error.message} +

+ ) : null} + + + + {/* + Both mounted, one hidden. Unmounting the screen would drop its socket and its polling, so + looking at the terminal for a moment would cost the live view and the take-the-wheel prompt + that rides on it. + */} +
+ + + {name || "Agent"}'s screen + +
+ +
+ +
); diff --git a/app/tests/computer-activity.test.ts b/app/tests/computer-activity.test.ts new file mode 100644 index 00000000..4164f64d --- /dev/null +++ b/app/tests/computer-activity.test.ts @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { + activityFor, + clearActivity, + recordActivity, +} from "../src/lib/computers/activity"; +import { outputOf } from "../src/lib/copilot/computer-tools"; + +/** + * What a Bot did on its computer, other than browse. + * + * The screen said what it was looking at and nothing said what it was doing: a shell command was one + * grey line with the output nowhere, so a person watching a Bot work on a machine holding their + * logins had to take the model's word for what it printed. + * + * `outputOf` is the part that reads a result, and it is the part that gets it wrong: it looked for + * `contents` on a file read, which is the name on the way in rather than the way back, and the pane + * said "It printed nothing" about a file the Bot had just read out loud. These pin the field names. + */ + +describe("reading what a call printed", () => { + test("a command is its stdout", () => { + expect(outputOf({ ok: true, stdout: "ripgrep 14.1.0\n", stderr: "" })).toBe( + "ripgrep 14.1.0\n", + ); + }); + + test("a failing command keeps its stderr, which is the whole message", () => { + expect( + outputOf({ + ok: true, + stdout: "", + stderr: "ls: cannot access '/nope'", + exitCode: 2, + }), + ).toBe("ls: cannot access '/nope'"); + }); + + test("both streams, in the order a terminal shows them", () => { + expect(outputOf({ ok: true, stdout: "one", stderr: "and a warning" })).toBe( + "one\nand a warning", + ); + }); + + test("a file read is its text", () => { + // `text`, not `contents`. `contents` is the field on the way in, and reading it back gave an + // empty pane for a file that had just been read. + expect(outputOf({ ok: true, path: "notes.md", text: "hello" })).toBe( + "hello", + ); + }); + + test("a listing marks folders the way a terminal does", () => { + expect( + outputOf({ + ok: true, + entries: [ + { path: "notes", kind: "folder" }, + { path: "notes.md", kind: "file", bytes: 5 }, + ], + }), + ).toBe("notes/\nnotes.md 5 bytes"); + }); + + test("a refusal is its reason, which is the useful part", () => { + expect( + outputOf({ ok: false, refused: true, reason: "A boundary said no." }), + ).toBe("A boundary said no."); + }); + + test("something with none of those fields gives nothing rather than a guess", () => { + // The pane then says the call printed nothing, which is honest. Inventing a summary from an + // unrecognised shape is how a view starts lying about what happened. + expect(outputOf({ ok: true })).toBe(""); + }); +}); + +describe("the activity a pane shows", () => { + beforeEach(() => { + clearActivity("bot-1"); + clearActivity("bot-2"); + }); + + test("keeps what a Bot ran, in the order it ran it", () => { + recordActivity("bot-1", { kind: "command", subject: "ls", output: "a\nb" }); + recordActivity("bot-1", { + kind: "command", + subject: "pwd", + output: "/workspace", + }); + + expect(activityFor("bot-1").map((entry) => entry.subject)).toEqual([ + "ls", + "pwd", + ]); + }); + + test("keeps one Bot's work out of another's", () => { + recordActivity("bot-1", { kind: "command", subject: "ls", output: "" }); + + expect(activityFor("bot-2")).toEqual([]); + }); + + test("a computer nothing has happened on is empty rather than undefined", () => { + expect(activityFor("never-used")).toEqual([]); + }); + + test("wiping a computer forgets what ran on it", () => { + // Reset deletes the machine those commands ran on, so leaving them on screen would describe + // something that no longer exists. + recordActivity("bot-1", { kind: "command", subject: "ls", output: "" }); + clearActivity("bot-1"); + + expect(activityFor("bot-1")).toEqual([]); + }); + + test("stops growing, because this is a pane and not an archive", () => { + for (let index = 0; index < 250; index += 1) { + recordActivity("bot-1", { + kind: "command", + subject: `command-${index}`, + output: "", + }); + } + + const kept = activityFor("bot-1"); + expect(kept).toHaveLength(200); + // The oldest go first: what somebody watching wants is what just happened. + expect(kept[0]?.subject).toBe("command-50"); + expect(kept.at(-1)?.subject).toBe("command-249"); + }); + + test("every entry is distinguishable, so two identical commands both show", () => { + recordActivity("bot-1", { kind: "command", subject: "ls", output: "" }); + recordActivity("bot-1", { kind: "command", subject: "ls", output: "" }); + + const [first, second] = activityFor("bot-1"); + expect(first?.id).not.toBe(second?.id); + }); +}); From 98eabfd62ff52dbedb48a47922964e67ba677fd5 Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 21 Aug 2026 08:50:33 -0700 Subject: [PATCH 3/4] Bring every doc in line with tonight's changes The changelog carries the history and the README stays about getting started. Four things moved: running with no sign-in takes a flag rather than a NODE_ENV guess, the issuer constraint is deferred to a later release and the reason is now written down where the next person will hit it, registering an OIDC provider needs its discovery endpoints trusted, and there is a second surface beside the screen showing what a Bot ran. --- CHANGELOG.md | 61 ++++- README.md | 19 +- docs/architecture.md | 11 +- docs/configuration.md | 25 +- docs/deployment.md | 9 +- docs/development.md | 7 + server/drizzle/meta/0004_snapshot.json | 343 ++++++------------------- server/drizzle/meta/_journal.json | 2 +- 8 files changed, 191 insertions(+), 286 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 945ff421..e279fc5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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 @@ -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 diff --git a/README.md b/README.md index 12e58624..855a9ad1 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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=` selects one. | | `/skills` | Create and enable personal skills. | | `/settings` | User preferences. | @@ -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. @@ -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. | @@ -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: @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 025527c9..96ead995 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -87,6 +87,12 @@ While a person controls the browser, Bot actions are refused rather than queued. Secret entry is separate from chat content. The audit trail records that a secret was requested or supplied and the character count, not the secret value. +## Watching a Bot work + +Two surfaces beside the conversation. The screen is the live browser, proxied over a websocket and gated on the same question as every other route about that Bot. The Activity tab is what the Bot did away from the browser: every command with its output and exit code, every file read, write and listing, newest first. + +Activity is held in the browser for the open conversation and is gone on reload. It is a window rather than a record; the record is the audit trail, which is server-side, survives restarts, and is what an investigation reads. A saved file contributes its path and size and never its contents, matching the write route, which declines to echo them because a Bot may be saving something it was told in confidence. + ## Coworkers and channels A coworker is a durable Bot profile: @@ -150,8 +156,11 @@ Connector credentials are stored through the credential vault and referenced by - Sign-in is Google, Microsoft or Okta from the environment, plus SAML and OpenID Connect providers registered at runtime and routed by email domain. One resolver answers both questions a run asks about a person, whose threads these are and which Bots they may run, so the two can never disagree. - `INITIAL_ADMIN_EMAILS` is a floor: an address it names is made an administrator at every sign-in and cannot be demoted from the People screen. Everybody else's role is decided there, and every change writes an audit row. - Registering, changing or removing an identity provider is administrator-only. Better Auth's SSO plugin guards those routes with a session alone, which would let any signed-in person register a provider for a domain. +- A registered identity provider belongs to the deployment, not to whoever registered it. Better Auth scopes its own listing and removal to the registering user and cascades the row from that user, so two administrators saw two different deployments and deleting the one who set sign-in up would have deleted the company's sign-in. Reads and removals go through OpenBot's own administrator-only routes against the whole table. +- A provider's client secret and SAML signing material are encrypted at rest with `KEY_ENCRYPTION_KEY`, through a wrapper on the Better Auth storage adapter, since the plugin stores them as plaintext JSON. OAuth access and refresh tokens use Better Auth's own encryption, keyed on `BETTER_AUTH_SECRET`. +- Signing in, being refused, and being granted the administrator role by configuration each write an audit row. Without them nothing recorded that somebody who could edit `INITIAL_ADMIN_EMAILS` had promoted themselves, and revoking a person deleted the sessions that were the only evidence they had been here. - Removing somebody deletes their sessions and denies their address, because deleting the user row alone is not removal: the next sign-in through the provider recreates it. -- With no identity provider configured, every request is one fixed administrator. That is refused with `NODE_ENV=production` unless `OPENBOT_SINGLE_USER=true` says it was meant. +- With no identity provider configured, the deployment refuses to start unless `OPENBOT_SINGLE_USER=true` says every request may be one fixed administrator. The lock is that flag and nothing else: it used to be `NODE_ENV`, which is unset by default and so admitted exactly the deployment it existed to catch. - `KEY_ENCRYPTION_KEY` must be a base64-encoded 32-byte value. The example key is refused with `NODE_ENV=production`. - Credential plaintext is encrypted at rest, never returned by APIs, and redacted from audit events. - Browser navigation allows `http` and `https`; cloud metadata addresses are refused under every configuration. diff --git a/docs/configuration.md b/docs/configuration.md index 83b5cfb5..9e209563 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,7 +34,7 @@ All four Intelligence values are required together. Missing any of them stops se | Variable | Default | Meaning | | -------------------- | ---------------------------------- | ------------------------------------------------------------------- | | `PORT` | `3001` | API server port. | -| `NODE_ENV` | unset | `production` enables startup refusals for local-only settings. | +| `NODE_ENV` | unset | `production` refuses the example `KEY_ENCRYPTION_KEY`. It does not decide whether sign-in is required; see `OPENBOT_SINGLE_USER`. | | `TENANT_PACKAGE_DIR` | `../examples/fintech` | Tenant package directory, resolved from `server/`. | | `DEPLOYMENT_ID` | the tenant package's id | Names this deployment inside a shared Intelligence project. | | `OPENAI_API_KEY` | unset | Default model key for built-in agents and both shipped Bots. | @@ -96,7 +96,7 @@ Two things are worth knowing before pointing a deployment at any gateway. Not ev | Variable | Meaning | | ---------------------------- | -------------------------------------------------------------------------------------- | -| `OPENBOT_SINGLE_USER` | One fixed administrator and no sign-in. Only read when no identity provider is configured, and only needed where `NODE_ENV=production` would otherwise refuse to start. | +| `OPENBOT_SINGLE_USER` | One fixed administrator and no sign-in. **Required** when no identity provider is configured, or the deployment refuses to start. Ignored when one is. | | `GOOGLE_OAUTH_CLIENT_ID` | Google OAuth client id. | | `GOOGLE_OAUTH_CLIENT_SECRET` | Google OAuth client secret. | | `MICROSOFT_OAUTH_CLIENT_ID` | Microsoft Entra ID application id. | @@ -107,9 +107,16 @@ Two things are worth knowing before pointing a deployment at any gateway. Not ev | `OKTA_OAUTH_ISSUER` | Which Okta, for example `https://example.okta.com/oauth2/default`. | | `BETTER_AUTH_SECRET` | At least 32 characters. Required with any provider. | | `BETTER_AUTH_URL` | Public API server base URL, where OAuth callbacks return. Required with any provider. | -| `TRUSTED_ORIGINS` | Comma-separated app origins accepted by the API. | +| `TRUSTED_ORIGINS` | Comma-separated app origins accepted by the API, plus every host in a registered OIDC provider's discovery document. | | `INITIAL_ADMIN_EMAILS` | Comma-separated administrators. **Required** with any provider. | +**With no provider at all, `OPENBOT_SINGLE_USER=true` is required.** A deployment that configures +nothing to sign anybody in and does not say that was deliberate refuses to start, naming what to +configure. It used to come up open unless `NODE_ENV=production`, which is unset by default and so +missed exactly the deployment that needed catching: a container on a VM with a hand-written env +file served every visitor as an administrator and looked like it was working. `.env.example` ships +the line switched on, so a clone still runs with no configuration at all. + **Any one provider turns sign-in on**, and several may be configured at once. Each provider's id and secret must be set together, Okta additionally needs its issuer, and any of them requires `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL` and `INITIAL_ADMIN_EMAILS`. Every incomplete combination is @@ -122,6 +129,18 @@ screen, which is what guarantees a way back in. Everybody else's role is decided SAML and OpenID Connect providers are not configured here. They are registered while the deployment runs, under Admin → Identity providers, and routed by email domain. +**Registering an OpenID Connect provider needs its endpoints in `TRUSTED_ORIGINS`.** Better Auth +fetches the discovery document and refuses any endpoint inside it that is not a trusted origin, which +is what stops a registration pointing the deployment at an address of somebody else's choosing. It is +every host in the document and not only the issuer, so a Google issuer also needs +`oauth2.googleapis.com` and `openidconnect.googleapis.com`; a typical Okta tenant serves all of them +from one host and needs only that. A registration refused this way names the host it objected to. + +What is registered belongs to the deployment rather than to whoever registered it. Every +administrator sees the same list and can remove any of it, and a provider outlives the person who +added it. The client secret and any SAML signing material are encrypted at rest with +`KEY_ENCRYPTION_KEY`. + The redirect URI to register with each provider is `/api/auth/callback/`, where `` is `google`, `microsoft` or `okta`. diff --git a/docs/deployment.md b/docs/deployment.md index d08c4808..70d3c3ed 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -74,10 +74,11 @@ and the fix would not be available. `COMPUTER_TOKEN` is generated at start if you do not set one. Both processes that need it are inside the container, so there is nothing to share it with. -**Authentication is required.** With no identity provider configured the image refuses to start, -because `NODE_ENV=production` is set and a public URL where every visitor is an administrator fails -silently. Configure Google, Microsoft or Okta, or set `OPENBOT_SINGLE_USER=true` to say you meant an -open deployment. +**Authentication is required.** With no identity provider configured the deployment refuses to start, +because a public URL where every visitor is an administrator fails silently: it looks like it works. +Configure Google, Microsoft or Okta, or set `OPENBOT_SINGLE_USER=true` to say you meant an open +deployment. This does not depend on `NODE_ENV`, which is unset unless something sets it and so used +to let a hand-written env file through. **Put TLS in front of it.** Not only for the cookies. A page served from `http://
` is not a secure context, which removes a set of browser APIs that are present on `http://localhost` and so diff --git a/docs/development.md b/docs/development.md index 66d8b9d9..3c5093cb 100644 --- a/docs/development.md +++ b/docs/development.md @@ -61,6 +61,13 @@ generator produced. If the generated SQL will not work — `ADD COLUMN ... NOT N that already has rows — split it instead: generate the column nullable, add the data step, then generate the constraint. +**A constraint that tightens an existing column belongs to a later release**, not to the release that +adds the column. A rolling deploy runs the migrations and then serves from old and new replicas at +once, and an old replica writes rows without the new column: under `NOT NULL` its writes start +failing, so the release that added the column breaks for everybody who lands on a replica that has +not been replaced yet. Ship the column nullable, let the fleet turn over, then tighten it. `issuer` +on `accounts` is the worked example, and the reason `0004` is not what it originally was. + **A data step is its own migration**, created with the flag that exists for it: ```sh diff --git a/server/drizzle/meta/0004_snapshot.json b/server/drizzle/meta/0004_snapshot.json index f5ede8b8..40d05989 100644 --- a/server/drizzle/meta/0004_snapshot.json +++ b/server/drizzle/meta/0004_snapshot.json @@ -123,12 +123,8 @@ "name": "accounts_user_id_users_id_fk", "tableFrom": "accounts", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -201,12 +197,8 @@ "name": "agents_package_id_deployment_packages_id_fk", "tableFrom": "agents", "tableTo": "deployment_packages", - "columnsFrom": [ - "package_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["package_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -320,12 +312,8 @@ "name": "channel_agents_channel_id_channels_id_fk", "tableFrom": "channel_agents", "tableTo": "channels", - "columnsFrom": [ - "channel_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -333,12 +321,8 @@ "name": "channel_agents_agent_id_agents_id_fk", "tableFrom": "channel_agents", "tableTo": "agents", - "columnsFrom": [ - "agent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -346,10 +330,7 @@ "compositePrimaryKeys": { "channel_agents_channel_id_agent_id_pk": { "name": "channel_agents_channel_id_agent_id_pk", - "columns": [ - "channel_id", - "agent_id" - ] + "columns": ["channel_id", "agent_id"] } }, "uniqueConstraints": {}, @@ -387,12 +368,8 @@ "name": "channel_memberships_channel_id_channels_id_fk", "tableFrom": "channel_memberships", "tableTo": "channels", - "columnsFrom": [ - "channel_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -400,12 +377,8 @@ "name": "channel_memberships_user_id_users_id_fk", "tableFrom": "channel_memberships", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -413,10 +386,7 @@ "compositePrimaryKeys": { "channel_memberships_channel_id_user_id_pk": { "name": "channel_memberships_channel_id_user_id_pk", - "columns": [ - "channel_id", - "user_id" - ] + "columns": ["channel_id", "user_id"] } }, "uniqueConstraints": {}, @@ -527,12 +497,8 @@ "name": "channels_package_id_deployment_packages_id_fk", "tableFrom": "channels", "tableTo": "deployment_packages", - "columnsFrom": [ - "package_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["package_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -540,12 +506,8 @@ "name": "channels_last_message_agent_id_agents_id_fk", "tableFrom": "channels", "tableTo": "agents", - "columnsFrom": [ - "last_message_agent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["last_message_agent_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -642,12 +604,8 @@ "name": "chunks_document_id_documents_id_fk", "tableFrom": "chunks", "tableTo": "documents", - "columnsFrom": [ - "document_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["document_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -688,12 +646,8 @@ "name": "connector_cursors_connector_instance_id_connector_instances_id_fk", "tableFrom": "connector_cursors", "tableTo": "connector_instances", - "columnsFrom": [ - "connector_instance_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["connector_instance_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -763,12 +717,8 @@ "name": "connector_instances_credential_id_credentials_id_fk", "tableFrom": "connector_instances", "tableTo": "credentials", - "columnsFrom": [ - "credential_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -894,9 +844,7 @@ "deployment_packages_tenant_id_unique": { "name": "deployment_packages_tenant_id_unique", "nullsNotDistinct": false, - "columns": [ - "tenant_id" - ] + "columns": ["tenant_id"] } }, "policies": {}, @@ -990,12 +938,8 @@ "name": "document_acls_document_id_documents_id_fk", "tableFrom": "document_acls", "tableTo": "documents", - "columnsFrom": [ - "document_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["document_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1123,12 +1067,8 @@ "name": "documents_connector_instance_id_connector_instances_id_fk", "tableFrom": "documents", "tableTo": "connector_instances", - "columnsFrom": [ - "connector_instance_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["connector_instance_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1198,12 +1138,8 @@ "name": "intelligence_channel_mappings_user_id_users_id_fk", "tableFrom": "intelligence_channel_mappings", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1211,12 +1147,8 @@ "name": "intelligence_channel_mappings_channel_id_channels_id_fk", "tableFrom": "intelligence_channel_mappings", "tableTo": "channels", - "columnsFrom": [ - "channel_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1224,10 +1156,7 @@ "compositePrimaryKeys": { "intelligence_channel_mappings_user_id_channel_id_pk": { "name": "intelligence_channel_mappings_user_id_channel_id_pk", - "columns": [ - "user_id", - "channel_id" - ] + "columns": ["user_id", "channel_id"] } }, "uniqueConstraints": {}, @@ -1328,12 +1257,8 @@ "name": "sessions_user_id_users_id_fk", "tableFrom": "sessions", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1343,9 +1268,7 @@ "sessions_token_unique": { "name": "sessions_token_unique", "nullsNotDistinct": false, - "columns": [ - "token" - ] + "columns": ["token"] } }, "policies": {}, @@ -1411,12 +1334,8 @@ "name": "sso_providers_user_id_users_id_fk", "tableFrom": "sso_providers", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1426,9 +1345,7 @@ "sso_providers_provider_id_unique": { "name": "sso_providers_provider_id_unique", "nullsNotDistinct": false, - "columns": [ - "provider_id" - ] + "columns": ["provider_id"] } }, "policies": {}, @@ -1513,12 +1430,8 @@ "name": "sync_runs_connector_instance_id_connector_instances_id_fk", "tableFrom": "sync_runs", "tableTo": "connector_instances", - "columnsFrom": [ - "connector_instance_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["connector_instance_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1560,12 +1473,8 @@ "name": "user_roles_user_id_users_id_fk", "tableFrom": "user_roles", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1573,10 +1482,7 @@ "compositePrimaryKeys": { "user_roles_user_id_role_pk": { "name": "user_roles_user_id_role_pk", - "columns": [ - "user_id", - "role" - ] + "columns": ["user_id", "role"] } }, "uniqueConstraints": {}, @@ -1648,9 +1554,7 @@ "users_email_unique": { "name": "users_email_unique", "nullsNotDistinct": false, - "columns": [ - "email" - ] + "columns": ["email"] } }, "policies": {}, @@ -1751,12 +1655,8 @@ "name": "webhook_subscriptions_connector_instance_id_connector_instances_id_fk", "tableFrom": "webhook_subscriptions", "tableTo": "connector_instances", - "columnsFrom": [ - "connector_instance_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["connector_instance_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1846,12 +1746,8 @@ "name": "agent_preferences_user_id_users_id_fk", "tableFrom": "agent_preferences", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1859,12 +1755,8 @@ "name": "agent_preferences_agent_id_agents_id_fk", "tableFrom": "agent_preferences", "tableTo": "agents", - "columnsFrom": [ - "agent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1872,10 +1764,7 @@ "compositePrimaryKeys": { "agent_preferences_user_id_agent_id_pk": { "name": "agent_preferences_user_id_agent_id_pk", - "columns": [ - "user_id", - "agent_id" - ] + "columns": ["user_id", "agent_id"] } }, "uniqueConstraints": {}, @@ -1985,12 +1874,8 @@ "name": "agent_profiles_agent_id_agents_id_fk", "tableFrom": "agent_profiles", "tableTo": "agents", - "columnsFrom": [ - "agent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1998,12 +1883,8 @@ "name": "agent_profiles_owner_user_id_users_id_fk", "tableFrom": "agent_profiles", "tableTo": "users", - "columnsFrom": [ - "owner_user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -2057,12 +1938,8 @@ "name": "component_exclusions_component_name_components_name_fk", "tableFrom": "component_exclusions", "tableTo": "components", - "columnsFrom": [ - "component_name" - ], - "columnsTo": [ - "name" - ], + "columnsFrom": ["component_name"], + "columnsTo": ["name"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -2070,12 +1947,8 @@ "name": "component_exclusions_agent_id_agents_id_fk", "tableFrom": "component_exclusions", "tableTo": "agents", - "columnsFrom": [ - "agent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2083,10 +1956,7 @@ "compositePrimaryKeys": { "component_exclusions_component_name_agent_id_pk": { "name": "component_exclusions_component_name_agent_id_pk", - "columns": [ - "component_name", - "agent_id" - ] + "columns": ["component_name", "agent_id"] } }, "uniqueConstraints": {}, @@ -2137,12 +2007,8 @@ "name": "component_functions_component_name_components_name_fk", "tableFrom": "component_functions", "tableTo": "components", - "columnsFrom": [ - "component_name" - ], - "columnsTo": [ - "name" - ], + "columnsFrom": ["component_name"], + "columnsTo": ["name"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2150,10 +2016,7 @@ "compositePrimaryKeys": { "component_functions_component_name_function_name_pk": { "name": "component_functions_component_name_function_name_pk", - "columns": [ - "component_name", - "function_name" - ] + "columns": ["component_name", "function_name"] } }, "uniqueConstraints": {}, @@ -2363,12 +2226,8 @@ "name": "mcp_tools_server_id_mcp_servers_id_fk", "tableFrom": "mcp_tools", "tableTo": "mcp_servers", - "columnsFrom": [ - "server_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["server_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2376,10 +2235,7 @@ "compositePrimaryKeys": { "mcp_tools_server_id_name_pk": { "name": "mcp_tools_server_id_name_pk", - "columns": [ - "server_id", - "name" - ] + "columns": ["server_id", "name"] } }, "uniqueConstraints": {}, @@ -2452,12 +2308,8 @@ "name": "plugin_grants_agent_id_agents_id_fk", "tableFrom": "plugin_grants", "tableTo": "agents", - "columnsFrom": [ - "agent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2465,11 +2317,7 @@ "compositePrimaryKeys": { "plugin_grants_kind_ref_agent_id_pk": { "name": "plugin_grants_kind_ref_agent_id_pk", - "columns": [ - "kind", - "ref", - "agent_id" - ] + "columns": ["kind", "ref", "agent_id"] } }, "uniqueConstraints": {}, @@ -2719,12 +2567,8 @@ "name": "skills_owner_user_id_users_id_fk", "tableFrom": "skills", "tableTo": "users", - "columnsFrom": [ - "owner_user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2740,62 +2584,37 @@ "public.acl_effect": { "name": "acl_effect", "schema": "public", - "values": [ - "allow", - "deny" - ] + "values": ["allow", "deny"] }, "public.agent_type": { "name": "agent_type", "schema": "public", - "values": [ - "built_in", - "remote_ag_ui" - ] + "values": ["built_in", "remote_ag_ui"] }, "public.connector_type": { "name": "connector_type", "schema": "public", - "values": [ - "google_drive", - "onedrive" - ] + "values": ["google_drive", "onedrive"] }, "public.credential_kind": { "name": "credential_kind", "schema": "public", - "values": [ - "model", - "connector", - "agent", - "mcp" - ] + "values": ["model", "connector", "agent", "mcp"] }, "public.role": { "name": "role", "schema": "public", - "values": [ - "admin", - "user" - ] + "values": ["admin", "user"] }, "public.sync_status": { "name": "sync_status", "schema": "public", - "values": [ - "pending", - "running", - "succeeded", - "failed" - ] + "values": ["pending", "running", "succeeded", "failed"] }, "public.agent_visibility": { "name": "agent_visibility", "schema": "public", - "values": [ - "public", - "private" - ] + "values": ["public", "private"] } }, "schemas": {}, @@ -2808,4 +2627,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 98f44f25..cd4abf64 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -38,4 +38,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} From f54800675b6162cdc088a6d39522bae44d3a5714 Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 21 Aug 2026 09:07:56 -0700 Subject: [PATCH 4/4] Keep the history in the changelog, not the reference docs Four passages explained a setting by describing what it used to be. That is the changelog's job. --- docs/architecture.md | 2 +- docs/configuration.md | 7 +++---- docs/deployment.md | 3 +-- docs/development.md | 2 +- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 96ead995..9c108b30 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -160,7 +160,7 @@ Connector credentials are stored through the credential vault and referenced by - A provider's client secret and SAML signing material are encrypted at rest with `KEY_ENCRYPTION_KEY`, through a wrapper on the Better Auth storage adapter, since the plugin stores them as plaintext JSON. OAuth access and refresh tokens use Better Auth's own encryption, keyed on `BETTER_AUTH_SECRET`. - Signing in, being refused, and being granted the administrator role by configuration each write an audit row. Without them nothing recorded that somebody who could edit `INITIAL_ADMIN_EMAILS` had promoted themselves, and revoking a person deleted the sessions that were the only evidence they had been here. - Removing somebody deletes their sessions and denies their address, because deleting the user row alone is not removal: the next sign-in through the provider recreates it. -- With no identity provider configured, the deployment refuses to start unless `OPENBOT_SINGLE_USER=true` says every request may be one fixed administrator. The lock is that flag and nothing else: it used to be `NODE_ENV`, which is unset by default and so admitted exactly the deployment it existed to catch. +- With no identity provider configured, the deployment refuses to start unless `OPENBOT_SINGLE_USER=true` says every request may be one fixed administrator. That flag is the only thing that permits it; `NODE_ENV` does not. - `KEY_ENCRYPTION_KEY` must be a base64-encoded 32-byte value. The example key is refused with `NODE_ENV=production`. - Credential plaintext is encrypted at rest, never returned by APIs, and redacted from audit events. - Browser navigation allows `http` and `https`; cloud metadata addresses are refused under every configuration. diff --git a/docs/configuration.md b/docs/configuration.md index 9e209563..4836f0d4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -112,10 +112,9 @@ Two things are worth knowing before pointing a deployment at any gateway. Not ev **With no provider at all, `OPENBOT_SINGLE_USER=true` is required.** A deployment that configures nothing to sign anybody in and does not say that was deliberate refuses to start, naming what to -configure. It used to come up open unless `NODE_ENV=production`, which is unset by default and so -missed exactly the deployment that needed catching: a container on a VM with a hand-written env -file served every visitor as an administrator and looked like it was working. `.env.example` ships -the line switched on, so a clone still runs with no configuration at all. +configure, because a public URL where every visitor is an administrator fails silently. `NODE_ENV` +does not enter into it. `.env.example` ships the line switched on, so a clone runs with no +configuration at all. **Any one provider turns sign-in on**, and several may be configured at once. Each provider's id and secret must be set together, Okta additionally needs its issuer, and any of them requires diff --git a/docs/deployment.md b/docs/deployment.md index 70d3c3ed..6dfc167f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -77,8 +77,7 @@ the container, so there is nothing to share it with. **Authentication is required.** With no identity provider configured the deployment refuses to start, because a public URL where every visitor is an administrator fails silently: it looks like it works. Configure Google, Microsoft or Okta, or set `OPENBOT_SINGLE_USER=true` to say you meant an open -deployment. This does not depend on `NODE_ENV`, which is unset unless something sets it and so used -to let a hand-written env file through. +deployment. `NODE_ENV` does not affect this. **Put TLS in front of it.** Not only for the cookies. A page served from `http://
` is not a secure context, which removes a set of browser APIs that are present on `http://localhost` and so diff --git a/docs/development.md b/docs/development.md index 3c5093cb..3c84c575 100644 --- a/docs/development.md +++ b/docs/development.md @@ -66,7 +66,7 @@ adds the column. A rolling deploy runs the migrations and then serves from old a once, and an old replica writes rows without the new column: under `NOT NULL` its writes start failing, so the release that added the column breaks for everybody who lands on a replica that has not been replaced yet. Ship the column nullable, let the fleet turn over, then tighten it. `issuer` -on `accounts` is the worked example, and the reason `0004` is not what it originally was. +on `accounts` is the worked example: the column is nullable and no migration tightens it. **A data step is its own migration**, created with the flag that exists for it: