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/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/app/src/components/computer/activity-log.tsx b/app/src/components/computer/activity-log.tsx new file mode 100644 index 00000000..33353c29 --- /dev/null +++ b/app/src/components/computer/activity-log.tsx @@ -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 ( +
  • +
    + + + {LABELS[entry.kind]} + + {/* + 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. + */} + + {entry.subject} + + + {timeOf(entry.at)} + +
    + + {entry.refused === true ? ( +

    + {entry.output || "A boundary refused it."} +

    + ) : ( +
    + +
    + )} +
  • + ); +} + +export function ActivityLog({ computerId }: { computerId: string }) { + const entries = useSyncExternalStore( + subscribeToActivity, + () => activityFor(computerId), + () => activityFor(computerId), + ); + + if (entries.length === 0) { + return ( +

    + Nothing yet. Commands the Bot runs, and files it reads, appear here as + they happen. +

    + ); + } + + // Newest first: what somebody watching wants is what just happened, without scrolling for it. + const newestFirst = [...entries].reverse(); + + return ( + + ); +} diff --git a/app/src/components/computer/command-output.tsx b/app/src/components/computer/command-output.tsx new file mode 100644 index 00000000..502c61a5 --- /dev/null +++ b/app/src/components/computer/command-output.tsx @@ -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 ( +
    + {truncated ? ( +

    + Output was cut short at the start. What follows is the end of it. +

    + ) : null} + {output ? ( +
    +          {output}
    +        
    + ) : ( +

    + It printed nothing. +

    + )} + {timedOut ? ( +

    + It ran too long and was stopped. +

    + ) : null} + {failed ? ( +

    + Exit code {exitCode}. +

    + ) : null} +
    + ); +} diff --git a/app/src/lib/computers/activity.ts b/app/src/lib/computers/activity.ts new file mode 100644 index 00000000..fb75f2f2 --- /dev/null +++ b/app/src/lib/computers/activity.ts @@ -0,0 +1,88 @@ +/** + * What a Bot has been doing on its computer, other than browsing. + * + * The screen answers "what is it looking at" and nothing answered "what is it doing". A shell + * command rendered in the transcript as one grey line, `Ran a command rg --version`, with the output + * nowhere: the model saw it, decided which part mattered, and the person watching had to take its + * word. The same was true of reading a file and listing a folder. That is a poor deal on a machine + * holding somebody's logins, and it is the thing an operator asks about first. + * + * A live view of this session rather than a record. The record is the audit trail, which is on the + * server, survives a reload and is what an investigation reads; this is the pane beside the screen, + * and it is allowed to be gone when the tab is closed. Keeping it in the browser means no new + * endpoint, no polling, and no second copy of command output in the database. + * + * Written from the tool handlers, which run exactly once per call. A tool's `render` runs on every + * re-render, so recording from there would append the same command repeatedly. + */ + +/** One thing a Bot did on its computer. */ +export type ComputerActivity = { + id: string; + /** When it happened, for the timestamp beside it. */ + at: number; + kind: "command" | "read_file" | "write_file" | "list_files"; + /** The command, or the path. What the line says it acted on. */ + subject: string; + /** What came back. Empty when a command printed nothing, which is worth showing as itself. */ + output: string; + /** Present for a command. Non-zero is drawn as a failure. */ + exitCode?: number; + /** A boundary refused it. Drawn differently from a command that ran and failed. */ + refused?: boolean; + /** The far side cut the output short, or stopped the command. Said out loud rather than implied. */ + truncated?: boolean; + timedOut?: boolean; +}; + +/** + * The most recent entries per computer. + * + * Bounded, because a Bot working through a large job runs a lot of commands and this is a pane, not + * an archive. The oldest go first: what somebody watching wants is what just happened. + */ +const LIMIT = 200; + +const byComputer = new Map(); +const listeners = new Set<() => void>(); + +/** A stable empty array, so `useSyncExternalStore` does not see a new value on every render. */ +const NONE: ComputerActivity[] = []; + +let counter = 0; + +export function recordActivity( + computerId: string, + entry: Omit, +): void { + counter += 1; + const existing = byComputer.get(computerId) ?? []; + const next = [ + ...existing, + { ...entry, id: `activity-${counter}`, at: Date.now() }, + ]; + byComputer.set(computerId, next.slice(-LIMIT)); + for (const listener of listeners) listener(); +} + +export function activityFor(computerId: string): ComputerActivity[] { + return byComputer.get(computerId) ?? NONE; +} + +export function subscribeToActivity(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** + * Forget one computer's activity. + * + * Wiping a computer deletes the machine those commands ran on, so leaving them on screen would + * describe something that no longer exists. + */ +export function clearActivity(computerId: string): void { + byComputer.delete(computerId); + for (const listener of listeners) listener(); +} diff --git a/app/src/lib/copilot/computer-tools.tsx b/app/src/lib/copilot/computer-tools.tsx index d72f35f9..ff12a34b 100644 --- a/app/src/lib/copilot/computer-tools.tsx +++ b/app/src/lib/copilot/computer-tools.tsx @@ -1,9 +1,11 @@ import { useFrontendTool } from "@copilotkit/react-core/v2"; import { z } from "zod"; -import { tryClient } from "@/lib/client"; import { ToolLine } from "@/components/channels/tool-line"; +import { CommandOutput } from "@/components/computer/command-output"; import { ComputerView } from "@/components/computer/computer-view"; -import { readControl, type ControlState } from "@/lib/computers/control"; +import { tryClient } from "@/lib/client"; +import { recordActivity } from "@/lib/computers/activity"; +import { type ControlState, readControl } from "@/lib/computers/control"; import { useActiveBotHolder } from "./active-bot"; import { reportComputerActivity } from "./computer-activity"; @@ -12,7 +14,7 @@ import { reportComputerActivity } from "./computer-activity"; */ /** What every computer call returns to the model: either the result, or a reason it did not happen. */ -type ToolOutcome = Record & { ok: boolean }; +export type ToolOutcome = Record & { ok: boolean }; /** * Human-assistance wait window. Long enough for a user to return, finite so the run can unblock. @@ -106,8 +108,59 @@ type ComputerOutcome = { staleRefs?: boolean; elements?: unknown[]; element?: { role?: string; name?: string }; + /** What a shell call reports back, so the line can show the output rather than only the command. */ + exitCode?: number; + stdout?: string; + stderr?: string; + /** The far side cut the output short, or stopped the command. */ + truncated?: boolean; + timedOut?: boolean; + /** A file write. The size, never what was written. */ + bytes?: number; + /** A file read. Named `text` on the way back and `contents` on the way in. */ + text?: string; }; +/** + * What a call printed, as text a person can read. + * + * One helper because the three surfaces this feeds all want the same thing and shape it differently: + * a command has `stdout` and `stderr`, a file read has `contents`, a listing has `entries`. A refusal + * carries only its reason, and that is the most useful thing on the line. + * + * Never guesses. Something with none of those fields gives an empty string, and the pane says the + * call printed nothing rather than inventing a summary. + */ +export function outputOf(result: ToolOutcome): string { + if (result.refused === true || result.ok === false) { + return typeof result.reason === "string" ? result.reason : ""; + } + + // `text`, which is what the read route answers with. Not `contents`: that is the name on the way + // in, and reading it back gave an empty pane for a file the Bot had just read out loud. + if (typeof result.text === "string") return result.text; + + if (Array.isArray(result.entries)) { + return result.entries + .map((entry) => { + if (!entry || typeof entry !== "object") return String(entry); + const { path, kind, bytes } = entry as Record; + const label = String(path ?? ""); + // A trailing slash for a folder, the way a terminal marks one, so a listing of a workspace + // full of folders does not read as a list of extensionless files. + if (kind === "folder") return `${label}/`; + return typeof bytes === "number" ? `${label} ${bytes} bytes` : label; + }) + .join("\n"); + } + + const stdout = typeof result.stdout === "string" ? result.stdout : ""; + const stderr = typeof result.stderr === "string" ? result.stderr : ""; + // Both, in the order a terminal shows them, and labelled only when there is something on stderr: + // most commands write nothing there and a permanent empty heading is noise. + return stderr ? `${stdout}${stdout ? "\n" : ""}${stderr}` : stdout; +} + /** * Parse the SDK-render result string so the transcript can distinguish success, refusal, and failure. */ @@ -550,11 +603,20 @@ export function ComputerTools() { .optional() .describe("Optional folder to list. Omit for the whole workspace."), }), - handler: async (input: { path?: string }) => - callComputer(bot.current, "/files/list", { + handler: async (input: { path?: string }) => { + const computerId = bot.current; + const result = await callComputer(computerId, "/files/list", { method: "POST", body: input ?? {}, - }), + }); + recordActivity(computerId, { + kind: "list_files", + subject: input?.path ?? "the workspace", + output: outputOf(result), + ...(result.refused === true ? { refused: true } : {}), + }); + return result; + }, render: ({ result, status }) => { const outcome = outcomeOf(result); const entries = Array.isArray(outcome.entries) ? outcome.entries : []; @@ -587,11 +649,20 @@ export function ComputerTools() { .string() .describe("Path relative to your workspace, such as notes.md"), }), - handler: async (input: { path: string }) => - callComputer(bot.current, "/files/read", { + handler: async (input: { path: string }) => { + const computerId = bot.current; + const result = await callComputer(computerId, "/files/read", { method: "POST", body: input, - }), + }); + recordActivity(computerId, { + kind: "read_file", + subject: input.path, + output: outputOf(result), + ...(result.refused === true ? { refused: true } : {}), + }); + return result; + }, render: ({ args, result, status }) => { const outcome = outcomeOf(result); return ( @@ -632,22 +703,46 @@ export function ComputerTools() { handler: async ( input: { command: string }, { signal }: { signal?: AbortSignal } = {}, - ) => - callComputer( - bot.current, + ) => { + const computerId = bot.current; + const result = await callComputer( + computerId, "/exec", { method: "POST", body: input }, signal, - ), + ); + /* + * Recorded here rather than in `render`, which runs again on every re-render and would append + * the same command each time. This is the only place that runs once per call and has both the + * command and what it printed. + */ + recordActivity(computerId, { + kind: "command", + subject: input.command, + output: outputOf(result), + ...(typeof result.exitCode === "number" + ? { exitCode: result.exitCode } + : {}), + ...(result.refused === true ? { refused: true } : {}), + ...(result.truncated === true ? { truncated: true } : {}), + ...(result.timedOut === true ? { timedOut: true } : {}), + }); + return result; + }, render: ({ args, result, status }) => { const outcome = outcomeOf(result); /* - * The command, not its output. A person watching wants to know what their Bot just ran on a - * machine holding their logins, and that is the command; the output belongs in the answer the - * Bot gives, where the model has already decided which part of it mattered. + * The command on the line, its output behind the chevron. + * + * The line stays one line, because a transcript of a Bot working through twenty commands is + * unreadable if each one dumps a screenful. But the output has to be reachable: this ran on a + * machine holding somebody's logins, and "take the model's word for what it printed" is not an + * answer. The pane beside the screen shows the same thing without expanding anything. */ + const printed = outputOf(outcome as ToolOutcome); + const exit = typeof outcome.exitCode === "number" ? outcome.exitCode : 0; return ( - + failed={didNotWork(outcome) || exit !== 0} + > + {status === "complete" ? ( + + ) : null} + ); }, }); @@ -686,11 +790,30 @@ export function ComputerTools() { path: string; contents: string; append?: boolean; - }) => - callComputer(bot.current, "/files/write", { + }) => { + const computerId = bot.current; + const result = await callComputer(computerId, "/files/write", { method: "POST", body: input, - }), + }); + /* + * The path and the size, never the contents. A Bot may well be saving something it was told in + * confidence, and the write route declines to echo it back for exactly that reason; putting it + * in a pane would undo that. + */ + recordActivity(computerId, { + kind: "write_file", + subject: input.path, + output: + result.refused === true + ? outputOf(result) + : typeof result.bytes === "number" + ? `${result.bytes} bytes${input.append === true ? ", appended" : ""}` + : "", + ...(result.refused === true ? { refused: true } : {}), + }); + return result; + }, render: ({ args, result, status }) => { const outcome = outcomeOf(result); return ( 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/_app/channel/$channelId.tsx b/app/src/routes/_authed/_app/channel/$channelId.tsx index 06541d0f..b2e00869 100644 --- a/app/src/routes/_authed/_app/channel/$channelId.tsx +++ b/app/src/routes/_authed/_app/channel/$channelId.tsx @@ -2,16 +2,18 @@ import { IconDeviceDesktop, IconSettings } from "@tabler/icons-react"; import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { motion, useReducedMotion } from "motion/react"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { z } from "zod"; import { AgentProfile } from "@/components/agents/agent-profile"; import { ChannelAvatar } from "@/components/channels/avatar"; import { ChannelChat } from "@/components/channels/channel-chat"; +import { ActivityLog } from "@/components/computer/activity-log"; import { ComputerView } from "@/components/computer/computer-view"; import { useNeedsYou } from "@/components/computer/needs-you"; import { DetailPanel } from "@/components/layout/detail-panel"; import { Button } from "@/components/ui/button"; import { type AgentChannel, channelQueryOptions } from "@/lib/channels/queries"; +import { activityFor, subscribeToActivity } from "@/lib/computers/activity"; import { onComputerActivity } from "@/lib/copilot/computer-activity"; const chatSearchSchema = z.object({ @@ -33,6 +35,17 @@ export const Route = createFileRoute("/_authed/_app/channel/$channelId")({ component: RouteComponent, }); +/** + * What the Bot is looking at, and what it is doing. + * + * Two surfaces rather than one. The screen was the only window into a Bot's computer, so a Bot that + * spent two minutes in a terminal showed a blank browser and nothing else: the honest answer to + * "what is it doing" was "something, on a machine holding your logins". The second tab is the shell + * and the workspace, and it fills up while the screen sits still. + * + * The screen stays the default, because most work is browsing and it is the surface somebody has to + * take the wheel on. The count on the other tab is what says the Bot is busy somewhere else. + */ function ComputerViewPanel({ agentId, name, @@ -40,13 +53,53 @@ function ComputerViewPanel({ agentId: string; name?: string; }) { + const [showing, setShowing] = useState<"screen" | "activity">("screen"); + const activity = useSyncExternalStore( + subscribeToActivity, + () => activityFor(agentId), + () => activityFor(agentId), + ); + return ( -
    +
    - - - {name || "Agent"}'s screen - +
    + + +
    + + {/* + 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/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}