Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,30 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

### The trail says what started a run, not only whose authority it had

A routine runs as the person who set it up, and a Bot handing work to another Bot runs as the person
who began the conversation. Both are correct, that is whose grants and whose connections are being
used, and both meant an action taken while somebody slept was written into the audit trail as though
they had taken it themselves. Telling the two apart meant correlating timestamps against
`routine_runs` by hand, and there was nothing at all to correlate a hop against.

Every audit row now also names what caused it: a person, a routine, another Bot handing work on, or
the deployment itself. It travels inside the signed run assertion, so a tool call, a hop, a Bot
stopping to ask its person and a stalled stream all say it, and a Bot cannot relabel its own run.
The Audit screen has a **Started by** column and a **Nobody watching** view that answers the
question directly. An unattended run is the one nobody is there to notice going
wrong, which is the reason it is worth being able to find.

The fourth of those exists so the column never overclaims. Two rows have no person behind them at
all: the boundary and isolation rows written at start-up, and the refusal written when a caller
cannot be identified at all. Those say the deployment, not a person, and they stay out of
**Nobody watching**, which asks what ran on somebody's authority rather than what the deployment did
by itself.

Nothing about existing rows changes. Every row already written, and every row a person's own click
writes from now on, reads as a person, because that is what it was.

### A bad `COMPUTER_MEMORY_BYTES` refuses to start the supervisor, instead of capping a computer at 512 bytes

`COMPUTER_MEMORY_BYTES=512m` used to parse as `512` via `parseInt`, which Docker accepts as a memory
Expand Down
44 changes: 44 additions & 0 deletions app/src/routes/_authed/admin/audit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export const Route = createFileRoute("/_authed/admin/audit")({
type AuditEvent = {
id: string;
actorUserId: string | null;
/** Absent on a deployment that has not migrated yet. */
initiatorKind?: string;
initiatorId?: string | null;
eventType: string;
targetType: string;
targetId: string | null;
Expand All @@ -52,6 +55,8 @@ const FILTERS = [
label: "Did not happen",
search: eventTypeFilter(DID_NOT_HAPPEN_EVENT_TYPES),
},
// Both unattended kinds: the question is whether anybody was watching, not which of the two.
{ label: "Nobody watching", search: "?initiatorKind=routine,handoff" },
] as const;

function AuditPage() {
Expand Down Expand Up @@ -109,6 +114,7 @@ function AuditPage() {
<th className="px-4 py-2 font-medium">What</th>
<th className="px-4 py-2 font-medium">On</th>
<th className="px-4 py-2 font-medium">Bot</th>
<th className="px-4 py-2 font-medium">Started by</th>
<th className="px-4 py-2 font-medium">Decision</th>
</tr>
</thead>
Expand All @@ -125,6 +131,41 @@ function AuditPage() {
);
}

function StartedBy({
event,
nameFor,
}: {
event: AuditEvent;
nameFor: (botId: string) => string;
}) {
if (event.initiatorKind === "routine") {
return (
<span
className="font-medium text-amber-600 dark:text-amber-500"
title={event.initiatorId ?? undefined}
>
A routine
</span>
);
}
if (event.initiatorKind === "handoff") {
return (
<span
className="font-medium text-amber-600 dark:text-amber-500"
title={event.initiatorId ?? undefined}
>
{event.initiatorId
? `Handed on by ${nameFor(event.initiatorId)}`
: "Handed on"}
</span>
);
}
if (event.initiatorKind === "deployment") {
return <span className="text-muted-foreground">This deployment</span>;
}
return <span className="text-muted-foreground">A person</span>;
}

function Row({
event,
nameFor,
Expand Down Expand Up @@ -247,6 +288,9 @@ function Row({
"-"
)}
</td>
<td className="px-4 py-2">
<StartedBy event={event} nameFor={nameFor} />
</td>
<td className="px-4 py-2">
<span
className={
Expand Down
37 changes: 37 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,43 @@ A command on the computer inherits PATH, locale and terminal names, and the prox

The supervisor exposes only ensure, stop, reset, and list operations. It holds the Docker socket, so do not expose it outside the deployment network: Docker Compose binds it to `127.0.0.1:4500`, and a deployment running the server inside the compose network reaches it as `supervisor:4300` and needs no published port at all. Set `COMPUTER_RUNTIME=runsc` to run computers under gVisor on hosts that support it.

## What started a run

Every audit row records on whose authority an action was taken. A routine asserts its owner, and a
hop between Bots asserts the person who began the conversation, so that column alone cannot say
whether anybody was there when it happened. An interactive run has somebody watching who will notice
a wrong tool call; an unattended one does not, which is the case worth being able to find.

Each row therefore also names what caused it:

| `initiator_kind` | `initiator_id` | What it means |
| ---------------- | ---------------------- | ------------------------------------------------------ |
| `person` | none | Somebody was in the room. The default. |
| `deployment` | none | The deployment itself, at start-up or refusing a caller it could not identify. |
| `routine` | the routine's id | A schedule fired it, as its owner, with nobody there. |
| `handoff` | the Bot that handed on | Another Bot asked for this, on the person's behalf. |

The Audit screen filters on it, and **Nobody watching** is `routine` and `handoff` together, which is
the question of what ran on somebody's authority while they were away. `deployment` is deliberately
outside that filter: a boundary held at start-up is not work done on anybody's behalf.

`deployment` exists so the column never overclaims. A row that says `person` is a row a person caused,
and the two places that have no person at all, the start-up rows and the two unauthenticated boundary
refusals, say so rather than borrowing the default. A deployment that has never run a routine or a hop
sees `A person` on every row a person made, which is what it was before this existed.

The value travels inside the signed run assertion, beside `depth`, for the reason `depth` does: a hop
is one run on one pod handing to another run on another, and the process that knows a routine began
it is the one that claimed the routine, not the one writing the row. Anything holding the assertion
holds the answer, so a tool call, a hop offered or refused at the desk, a Bot stopping to ask its
person, and a stream that stalls all say the same thing without each being told separately. A Bot
cannot relabel its own run, because the assertion is signed by the deployment, and a kind this
deployment does not write is read as a person rather than kept.

Computer actions are the one family that carries no initiator, and correctly so: the computer tools
are browser actions, executed by the person's own session, so a headless run has no way to drive the
computer at all today. A row there is a person's because a person's browser wrote it.

## Human control and secrets

Handovers are audited as control events:
Expand Down
8 changes: 5 additions & 3 deletions docs/routines.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,11 @@ separate, because as far as the channel is concerned, that is exactly what it is

This ships the core: creating, listing, changing and deleting routines from chat; the schedule, the
cap and the fatigue rule; the worker that fires them. Four follow-ups are tracked in
[#193](https://github.com/CopilotKit/OpenBot/issues/193) and deliberately not in this pass: audit rows
are not yet marked as unattended, so telling a routine's action apart from the same person's own is a
manual correlation against `routine_runs` timestamps rather than a flag; there is no admin view of
[#193](https://github.com/CopilotKit/OpenBot/issues/193) and deliberately not in this pass; the first
of them has since been closed. Audit rows now say what started the run they came out of, so a
routine's action is told apart from the same person's own by reading the row rather than by
correlating timestamps against `routine_runs`. See [Architecture](architecture.md#what-started-a-run). Still open:
there is no admin view of
other people's routines, only the owner-scoped page each person sees for their own; there is no
per-deployment or per-Bot cap on how many routines may be running at once beyond the sweep's own claim
limit; and a tenant package cannot yet ship routines the way it ships agents, channels or skills.
Expand Down
3 changes: 3 additions & 0 deletions server/drizzle/0028_audit_initiator.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE "audit_events" ADD COLUMN "initiator_kind" text DEFAULT 'person' NOT NULL;--> statement-breakpoint
ALTER TABLE "audit_events" ADD COLUMN "initiator_id" text;--> statement-breakpoint
CREATE INDEX "audit_events_initiator_time_idx" ON "audit_events" USING btree ("initiator_kind","created_at" DESC NULLS LAST,"id" DESC NULLS LAST);
Loading