diff --git a/CHANGELOG.md b/CHANGELOG.md index 65d99fecb..e356162de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/routes/_authed/admin/audit.tsx b/app/src/routes/_authed/admin/audit.tsx index 18b4d98e5..6bd2cc124 100644 --- a/app/src/routes/_authed/admin/audit.tsx +++ b/app/src/routes/_authed/admin/audit.tsx @@ -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; @@ -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() { @@ -109,6 +114,7 @@ function AuditPage() { What On Bot + Started by Decision @@ -125,6 +131,41 @@ function AuditPage() { ); } +function StartedBy({ + event, + nameFor, +}: { + event: AuditEvent; + nameFor: (botId: string) => string; +}) { + if (event.initiatorKind === "routine") { + return ( + + A routine + + ); + } + if (event.initiatorKind === "handoff") { + return ( + + {event.initiatorId + ? `Handed on by ${nameFor(event.initiatorId)}` + : "Handed on"} + + ); + } + if (event.initiatorKind === "deployment") { + return This deployment; + } + return A person; +} + function Row({ event, nameFor, @@ -247,6 +288,9 @@ function Row({ "-" )} + + + 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); \ No newline at end of file diff --git a/server/drizzle/meta/0028_snapshot.json b/server/drizzle/meta/0028_snapshot.json new file mode 100644 index 000000000..3d11a459a --- /dev/null +++ b/server/drizzle/meta/0028_snapshot.json @@ -0,0 +1,3141 @@ +{ + "id": "2dc825f1-9abb-47e2-9b47-9c6c941c557a", + "prevId": "c98643b6-c5da-420f-82b7-ea9b94eb0b06", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "initiator_id": { + "name": "initiator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_initiator_time_idx": { + "name": "audit_events_initiator_time_idx", + "columns": [ + { + "expression": "initiator_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_at": { + "name": "summary_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channels_awaiting_summary_idx": { + "name": "channels_awaiting_summary_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"channels\".\"summary\" is null and \"channels\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_instructions": { + "name": "user_instructions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_instructions_user_id_users_id_fk": { + "name": "user_instructions_user_id_users_id_fk", + "tableFrom": "user_instructions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": [ + "user_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": [ + "kind", + "ref", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 49201434b..040924c34 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -197,6 +197,13 @@ "when": 1788543624826, "tag": "0027_user_instructions", "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1788548093782, + "tag": "0028_audit_initiator", + "breakpoints": true } ] } diff --git a/server/src/agents/callback-token.ts b/server/src/agents/callback-token.ts index 717d04421..8f95f38cd 100644 --- a/server/src/agents/callback-token.ts +++ b/server/src/agents/callback-token.ts @@ -1,4 +1,5 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { type AuditInitiator, PERSON_INITIATOR } from "../audit"; import { sign, verify } from "../auth/signed-value"; /** @@ -102,6 +103,17 @@ export type RunAssertion = { * Optional on the way in so an assertion minted before this existed still reads, and read as zero. */ depth?: number; + /** + * What started this run, for the audit rows written by whoever holds the assertion. + * + * IT TRAVELS HERE FOR THE REASON `depth` DOES: a hop is A to B on up to two pods, and the thing + * that knows a routine began the run is the process that claimed the routine, not the one writing + * the row. Signed with the rest, so a Bot cannot relabel its own run as a person's. + * + * Optional on the way in so an assertion minted before this existed still reads, and read as a + * person, which is what those runs were. + */ + initiator?: AuditInitiator; }; type SignedRun = RunAssertion & { exp: number }; @@ -121,6 +133,7 @@ export function mintRunAssertion( const payload: SignedRun = { ...run, depth: run.depth ?? 0, + initiator: run.initiator ?? PERSON_INITIATOR, exp: now + RUN_TTL_MS, }; const value = Buffer.from(JSON.stringify(payload)).toString("base64url"); @@ -175,12 +188,30 @@ export function readRunAssertion( payload.depth >= 0 ? payload.depth : 0, + // Read as a person on anything unclear, for the reason depth reads as zero: an assertion + // minted before this existed carries none, and a person is what those runs were. + initiator: readInitiator(payload.initiator), }; } catch { return null; } } +/** + * The initiator a signed payload carries, narrowed back to the union. + * + * A kind that is not one this deployment writes is read as a person rather than kept, so a field + * from a future version cannot arrive as a string the Audit screen has no branch for. + */ +function readInitiator(value: unknown): AuditInitiator { + if (!value || typeof value !== "object") return PERSON_INITIATOR; + const kind = (value as { kind?: unknown }).kind; + if (kind === "person" || kind === "deployment") return { kind }; + if (kind !== "routine" && kind !== "handoff") return PERSON_INITIATOR; + const id = (value as { id?: unknown }).id; + return typeof id === "string" && id ? { kind, id } : PERSON_INITIATOR; +} + export type CallVerdict = | { ok: true; botId: string; actorId: string } | { ok: false; status: 401 | 403; reason: string }; diff --git a/server/src/agents/escalation.ts b/server/src/agents/escalation.ts index 8a00e1047..017c27d73 100644 --- a/server/src/agents/escalation.ts +++ b/server/src/agents/escalation.ts @@ -124,6 +124,7 @@ export function escalationTool(options: { targetType: "agent", targetId: from.botId, ...(from.actorId ? { actorUserId: from.actorId } : {}), + ...(from.initiator ? { initiator: from.initiator } : {}), payload: { bot: from.botId, run: from.runId, diff --git a/server/src/agents/handoff-delivery.ts b/server/src/agents/handoff-delivery.ts index 21259bd0d..28128032b 100644 --- a/server/src/agents/handoff-delivery.ts +++ b/server/src/agents/handoff-delivery.ts @@ -70,6 +70,8 @@ export function createHandoffDelivery(options: { agentFor: (input: { actorId: string; botId: string; + /** The Bot that handed the work on, so the trail says a hop ran this and not the person. */ + fromBotId: string; }) => Promise; /** * The conversation so far, so the addressed Bot is not answering out of context. @@ -161,6 +163,7 @@ export function createHandoffDelivery(options: { const agent = await agentFor({ actorId: work.actorId, botId: work.toBotId, + fromBotId: work.fromBotId, }); if (!agent) { /* diff --git a/server/src/agents/handoff-runner.ts b/server/src/agents/handoff-runner.ts index c59a3bc76..da3506209 100644 --- a/server/src/agents/handoff-runner.ts +++ b/server/src/agents/handoff-runner.ts @@ -299,6 +299,7 @@ export function createHandoffRunner(options: { targetType: "agent", targetId: work.toBotId, ...(work.actorId ? { actorUserId: work.actorId } : {}), + initiator: { kind: "handoff", id: work.fromBotId }, payload: { // See the same key on `agent.handoff_delivered` below: the Audit screen's Bot // column reads `payload.bot`, so a row without it names no Bot. @@ -376,6 +377,7 @@ export function createHandoffRunner(options: { targetType: "agent", targetId: work.toBotId, ...(work.actorId ? { actorUserId: work.actorId } : {}), + initiator: { kind: "handoff", id: work.fromBotId }, payload: { // See the same key on `agent.handoff_delivered` below. bot: work.fromBotId, @@ -410,6 +412,7 @@ export function createHandoffRunner(options: { targetType: "agent", targetId: work.toBotId, ...(work.actorId ? { actorUserId: work.actorId } : {}), + initiator: { kind: "handoff", id: work.fromBotId }, payload: { // See the same key on `agent.handoff_offered`: the Audit screen's Bot column reads // `payload.bot`, so a row without it names no Bot. @@ -460,6 +463,7 @@ export function createHandoffRunner(options: { targetType: "agent", targetId: work.toBotId, ...(work.actorId ? { actorUserId: work.actorId } : {}), + initiator: { kind: "handoff", id: work.fromBotId }, payload: { // See the same key on `agent.handoff_delivered` above. This row is the one a // person's unanswered question ends on, so a Bot column showing a dash on it is diff --git a/server/src/agents/handoff.ts b/server/src/agents/handoff.ts index 58c32848f..dad7c3d85 100644 --- a/server/src/agents/handoff.ts +++ b/server/src/agents/handoff.ts @@ -109,6 +109,7 @@ export function createHandoffDesk(options: { targetType: "agent", targetId: from.botId, ...(from.actorId ? { actorUserId: from.actorId } : {}), + ...(from.initiator ? { initiator: from.initiator } : {}), payload: { // The same key `agent.handoff_offered` sets below, and for the same reason: the Audit // screen renders `payload.bot` and nothing else in its Bot column, so a row without it @@ -388,6 +389,7 @@ export function createHandoffDesk(options: { targetType: "agent", targetId: found.id, ...(from.actorId ? { actorUserId: from.actorId } : {}), + ...(from.initiator ? { initiator: from.initiator } : {}), payload: { // The Bot that did this, under the key the Audit screen reads for its Bot column. `from` // below says the same thing and is what the payload is read by, but the screen renders diff --git a/server/src/app.ts b/server/src/app.ts index d0f38a312..09f2764bc 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -10,6 +10,7 @@ import { type AuditStore, AuditQueryError, auditQueryFromUrl, + DEPLOYMENT_INITIATOR, recordAuditEvent, } from "./audit"; import { createDevRequireUser } from "./auth/dev-actor"; @@ -836,6 +837,7 @@ export function createApp( await recordAuditEvent(auditStore, { eventType: "routines.dispatch_refused", targetType: "worker", + initiator: DEPLOYMENT_INITIATOR, payload: { reason: !expected ? "unconfigured" @@ -1107,6 +1109,7 @@ export function createApp( await recordAuditEvent(auditStore, { eventType: "mcp.callback_refused", targetType: "mcp_tool", + initiator: DEPLOYMENT_INITIATOR, targetId: typeof body?.name === "string" ? body.name.slice(0, 120) diff --git a/server/src/audit.ts b/server/src/audit.ts index e057138dc..b422ddc71 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -392,11 +392,40 @@ export const auditEventTypes = [ export type AuditEventType = (typeof auditEventTypes)[number]; +/** What caused a row, where `actorUserId` is only whose authority it borrowed. */ +export type AuditInitiator = + | { kind: "person" } + | { kind: "deployment" } + | { kind: "routine"; id: string } + | { kind: "handoff"; id: string }; + +export const PERSON_INITIATOR: AuditInitiator = { kind: "person" }; + +/** The deployment acting as itself: at start-up, or refusing a caller it could not identify. */ +export const DEPLOYMENT_INITIATOR: AuditInitiator = { kind: "deployment" }; + +export const auditInitiatorKinds = [ + "person", + "deployment", + "routine", + "handoff", +] as const; + +export type AuditInitiatorKind = (typeof auditInitiatorKinds)[number]; + +export function isAuditInitiatorKind( + value: string, +): value is AuditInitiatorKind { + return (auditInitiatorKinds as readonly string[]).includes(value); +} + export type AuditEventInput = { eventType: AuditEventType; targetType: string; targetId?: string; actorUserId?: string; + /** Omitted means a person did it. */ + initiator?: AuditInitiator; payload: Record; }; @@ -407,6 +436,9 @@ export type AuditStore = { export type AuditEvent = { id: string; actorUserId: string | null; + /** Read back as written, not narrowed to the union. */ + initiatorKind: string; + initiatorId: string | null; eventType: string; targetType: string; targetId: string | null; @@ -426,6 +458,8 @@ export type AuditEventQuery = { */ eventType?: string; actorUserId?: string; + /** One kind, or several separated by commas, the way `eventType` takes several. */ + initiatorKind?: string; targetType?: string; targetId?: string; from?: string; @@ -482,11 +516,21 @@ export async function recordAuditEvent( }); } +function initiatorColumns(initiator: AuditInitiator | undefined) { + if (!initiator) + return { initiatorKind: "person" as const, initiatorId: null }; + if (initiator.kind === "person" || initiator.kind === "deployment") { + return { initiatorKind: initiator.kind, initiatorId: null }; + } + return { initiatorKind: initiator.kind, initiatorId: initiator.id }; +} + export function createAuditStore(database: Database): AuditStore { return { - insert: async (event) => { + insert: async ({ initiator, ...event }) => { await database.insert(auditEvents).values({ ...event, + ...initiatorColumns(initiator), payload: redactAuditPayload(event.payload) as Record, }); }, @@ -519,6 +563,10 @@ export function createAuditReader(database: Database): AuditReader { .split(",") .map((type) => type.trim()) .filter(Boolean); + const requestedInitiators = (query.initiatorKind ?? "") + .split(",") + .map((kind) => kind.trim()) + .filter((kind) => isAuditInitiatorKind(kind)); const conditions = [ requestedTypes.length === 1 ? eq(auditEvents.eventType, requestedTypes[0] as string) @@ -528,6 +576,11 @@ export function createAuditReader(database: Database): AuditReader { query.actorUserId ? eq(auditEvents.actorUserId, query.actorUserId) : undefined, + requestedInitiators.length === 1 + ? eq(auditEvents.initiatorKind, requestedInitiators[0] as string) + : requestedInitiators.length > 1 + ? inArray(auditEvents.initiatorKind, requestedInitiators) + : undefined, query.targetType ? eq(auditEvents.targetType, query.targetType) : undefined, @@ -611,6 +664,7 @@ export function auditQueryFromUrl(url: URL): AuditEventQuery { limit, eventType: optional("eventType"), actorUserId: optional("actorUserId"), + initiatorKind: optional("initiatorKind"), targetType: optional("targetType"), targetId: optional("targetId"), from, diff --git a/server/src/channels/stall-guard.ts b/server/src/channels/stall-guard.ts index b6aca7104..6e69297e7 100644 --- a/server/src/channels/stall-guard.ts +++ b/server/src/channels/stall-guard.ts @@ -41,7 +41,11 @@ * RUN_ERROR at any point in a stream, including as the very first event, which is what a Bot that * never spoke produces. */ -import { type AuditStore, recordAuditEvent } from "../audit"; +import { + type AuditInitiator, + type AuditStore, + recordAuditEvent, +} from "../audit"; import { type StalledStream, TurnWatchdog } from "./turn-watchdog"; /** The fetch an `HttpAgent` uses, as @ag-ui/client 0.0.57 declares it. */ @@ -51,7 +55,12 @@ export type AgentFetch = ( ) => Promise; /** Which Bot a watched stream belongs to. The name is for the sentence a person reads. */ -export type WatchedBot = { id: string; name: string }; +export type WatchedBot = { + id: string; + name: string; + /** What started the run this stream belongs to, so a routine's stall is not filed as a person's. */ + initiator?: AuditInitiator; +}; export type StallGuardOptions = { /** Silence this long ends the turn. Zero or less leaves every stream untouched. */ @@ -226,6 +235,7 @@ export function createStallGuard(options: StallGuardOptions): StallGuard { eventType: "agent.stream_stalled", targetType: "agent", targetId: stream.bot.id, + ...(stream.bot.initiator ? { initiator: stream.bot.initiator } : {}), payload: { bot: stream.bot.id, silentForMs: stalled.silentForMs, diff --git a/server/src/copilot.ts b/server/src/copilot.ts index fb68b0e2d..ee46da013 100644 --- a/server/src/copilot.ts +++ b/server/src/copilot.ts @@ -15,6 +15,7 @@ import { PROVENANCE_GUIDANCE, } from "../../shared/bot-prompt"; import { sanitizeSeededHistory } from "./agents/history-sanitize"; +import type { AuditInitiator } from "./audit"; import type { AgentActor } from "./agents/profile-types"; import type { AgentFetch, StallGuard } from "./channels/stall-guard"; import type { DeploymentConfig } from "./config"; @@ -361,6 +362,7 @@ export async function buildAgents( * carry standing instructions, which is what every deployment did before they existed. */ loadInstructions?: LoadInstructions, + initiator?: AuditInitiator, ): Promise> { const vendors = await loadVendors().catch(() => [] as readonly string[]); /* @@ -392,6 +394,7 @@ export async function buildAgents( agentFetch, handoff, instructions ?? null, + initiator, ), ]), ), @@ -421,6 +424,7 @@ async function buildAgent( handoff?: HandoffForRun, /** Already resolved by {@link buildAgents}, so one roster costs one read. */ standingInstructions: string | null = null, + initiator?: AuditInitiator, ): Promise { if (agent.type === "unavailable") { return new UnavailableAgent(agent); @@ -495,6 +499,7 @@ async function buildAgent( connectedVendors, narrowing ? offeredFor : undefined, agentFetch, + initiator, ); } @@ -620,6 +625,7 @@ function remoteAgentWithStandingRole( narrow?: (input: RunAgentInput) => Promise, /** The fetch this agent is dialled with. See {@link buildAgents}. */ agentFetch?: AgentFetch, + initiator?: AuditInitiator, ) { const remote = new HttpAgent({ url: agent.endpoint, @@ -632,7 +638,11 @@ function remoteAgentWithStandingRole( ...(stallGuard ? { fetch: stallGuard.watch( - { id: agent.id, name: agent.name }, + { + id: agent.id, + name: agent.name, + ...(initiator ? { initiator } : {}), + }, agentFetch, ), } @@ -958,6 +968,8 @@ export async function resolveRuntimeAgents( * are positional and moving one shifts every existing call site by one. */ loadInstructions?: LoadInstructions, + /** Appended after `loadInstructions`, for the positional reason it gives. */ + initiator?: AuditInitiator, ): Promise> { const all = await loadAgents(); if (all.length === 0) { @@ -989,6 +1001,7 @@ export async function resolveRuntimeAgents( agentFetch, handoff, loadInstructions, + initiator, ); } @@ -1036,9 +1049,12 @@ export function createRequestAgents( */ stallGuard?: StallGuard, /** What each Bot may call, resolved for whoever is asking. Absent means no tools. */ - loadToolsForActor?: (actorId: string) => LoadToolsForBot, + loadToolsForActor?: ( + actorId: string, + initiator?: AuditInitiator, + ) => LoadToolsForBot, /** Resolved per request, because what it signs is who this request turned out to be. */ - signRunForActor?: (actorId: string) => SignRun, + signRunForActor?: (actorId: string, initiator?: AuditInitiator) => SignRun, /** What every built-in Bot is told about the computer. Absent means this deployment has none. */ computerGuidance?: string, /** Which vendors this deployment connects to, held by a Bot or not. Absent means none. */ @@ -1186,8 +1202,11 @@ export function mountCopilotRuntime( * there is no reason for a caller to have to say `undefined` here to reach `basePath`. */ stallGuard: StallGuard, - loadToolsForActor?: (actorId: string) => LoadToolsForBot, - signRunForActor?: (actorId: string) => SignRun, + loadToolsForActor?: ( + actorId: string, + initiator?: AuditInitiator, + ) => LoadToolsForBot, + signRunForActor?: (actorId: string, initiator?: AuditInitiator) => SignRun, basePath = "/api/copilotkit", loadVendors?: () => Promise, selectionForActor?: (actorId: string) => ToolSelection, @@ -1236,6 +1255,7 @@ export function mountCopilotRuntime( */ actor: AgentActor; botId: string; + initiator?: AuditInitiator; }): Promise => { const { actor } = input; const agents = await resolveRuntimeAgents( @@ -1243,8 +1263,8 @@ export function mountCopilotRuntime( model, resolveModelApiKey, stallGuard, - loadToolsForActor?.(actor.id), - signRunForActor?.(actor.id), + loadToolsForActor?.(actor.id, input.initiator), + signRunForActor?.(actor.id, input.initiator), config.computer ? COMPUTER_GUIDANCE : undefined, loadVendors, selectionForActor?.(actor.id), @@ -1255,6 +1275,7 @@ export function mountCopilotRuntime( // what each of them was granted, on every delivery and again on every retry. input.botId, loadInstructionsForActor?.(actor.id), + input.initiator, ); return agents[input.botId] ?? null; }; diff --git a/server/src/db/schema/core.ts b/server/src/db/schema/core.ts index e2bcdf80e..5387b54bd 100644 --- a/server/src/db/schema/core.ts +++ b/server/src/db/schema/core.ts @@ -419,6 +419,10 @@ export const auditEvents = pgTable( * user who had done anything could never be deleted. */ actorUserId: text("actor_user_id"), + /** Defaulted rather than nullable, because every row written before this was a person's. */ + initiatorKind: text("initiator_kind").notNull().default("person"), + /** Which routine, or which Bot handed the work on. Null when a person started it. */ + initiatorId: text("initiator_id"), eventType: text("event_type").notNull(), targetType: text("target_type").notNull(), targetId: text("target_id"), @@ -456,6 +460,11 @@ export const auditEvents = pgTable( table.createdAt.desc(), table.id.desc(), ), + index("audit_events_initiator_time_idx").on( + table.initiatorKind, + table.createdAt.desc(), + table.id.desc(), + ), ], ); diff --git a/server/src/index.ts b/server/src/index.ts index 667f05bdb..1218ebc0c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -17,7 +17,14 @@ import { createAgentProfileStore } from "./agents/profile-store"; import type { AgentActor } from "./agents/profile-types"; import { createRuntimeAgentLoader } from "./agents/runtime-agents"; import { createApp } from "./app"; -import { createAuditReader, createAuditStore, recordAuditEvent } from "./audit"; +import { + type AuditInitiator, + createAuditReader, + createAuditStore, + DEPLOYMENT_INITIATOR, + PERSON_INITIATOR, + recordAuditEvent, +} from "./audit"; import { startRetentionSweeps } from "./audit-retention"; import { createAuth } from "./auth"; import { DEV_ACTOR, initializeDevActorUser } from "./auth/dev-actor"; @@ -374,6 +381,7 @@ const handoffDesk = createHandoffDesk({ void recordAuditEvent(bootAuditStore, { eventType: "computer.policy_loaded", targetType: "policy", + initiator: DEPLOYMENT_INITIATOR, payload: { ...policyStore.get(), source: @@ -400,6 +408,7 @@ const isolation = describeComputerIsolation(computerProvider); void recordAuditEvent(bootAuditStore, { eventType: "computer.isolation_loaded", targetType: "computer", + initiator: DEPLOYMENT_INITIATOR, payload: { isolation: isolation.isolation, note: isolation.note, @@ -511,8 +520,10 @@ const resolveRuntimeModelApiKey = () => // Tools run here, not in the browser. Each one still executes through the plugin store, so the // grant, the policy and the audit row are exactly where they were. -const loadToolsForActor = (actorId: string) => (botId: string) => - grantedTools({ store: pluginStore, botId, actorId }); +const loadToolsForActor = + (actorId: string, initiator: AuditInitiator = PERSON_INITIATOR) => + (botId: string) => + grantedTools({ store: pluginStore, botId, actorId, initiator }); /** One person's standing instructions, for both the /api/settings routes and every run they start. */ const userInstructionsStore = createUserInstructionsStore(database); @@ -537,9 +548,10 @@ const loadInstructionsForActor = (actorId: string) => () => * neither is read out of the request body any more. */ const signRunForActor = - (actorId: string) => (botId: string, runId: string, threadId?: string) => + (actorId: string, initiator: AuditInitiator = PERSON_INITIATOR) => + (botId: string, runId: string, threadId?: string) => mintRunAssertion( - { botId, actorId, runId, threadId }, + { botId, actorId, runId, threadId, initiator }, config.keyEncryptionKey, ); @@ -661,9 +673,11 @@ const actorFor = async (ownerUserId: string): Promise => { const buildAgentFor = async ({ ownerUserId, agentId, + initiator, }: { ownerUserId: string; agentId: string; + initiator: AuditInitiator; }) => { const actor = await actorFor(ownerUserId); const agents = await resolveRuntimeAgents( @@ -671,8 +685,8 @@ const buildAgentFor = async ({ tenantPackage.model, resolveRuntimeModelApiKey, stallGuard, - loadToolsForActor(actor.id), - signRunForActor(actor.id), + loadToolsForActor(actor.id, initiator), + signRunForActor(actor.id, initiator), config.computer ? COMPUTER_GUIDANCE : undefined, loadVendors, selectionForActor(actor.id), @@ -685,6 +699,7 @@ const buildAgentFor = async ({ // The owner's own standing instructions. A routine is their work done while they are asleep, so // it is written the way they asked for it to be written, exactly as their chat turn would be. loadInstructionsForActor(actor.id), + initiator, ); const agent = agents[agentId]; if (!agent) { @@ -783,6 +798,9 @@ const copilotRuntime = mountCopilotRuntime( runId: input.runId, threadId: input.threadId, depth: from?.depth ?? 0, + // Read from the assertion for the reason `depth` is: the run is rebuilt from parts here, and + // a field left out of this object is a field the desk and the escalation never see. + initiator: from?.initiator ?? PERSON_INITIATOR, }; /* * The caps are checked BEFORE the grants query, not inside the tool that would discard it. @@ -898,14 +916,18 @@ if (config.handoff.maxDepth > 0 && config.handoff.maxPerRun > 0) { * delivery that then rebuilt them as an ordinary user could not find the Bot the desk had just * agreed to, and the person was told it never answered. */ - agentFor: async ({ actorId, botId }) => { + agentFor: async ({ actorId, botId, fromBotId }) => { const actor = await actorFor(actorId).catch(() => null); if (!actor) { throw new Error( "who this is for could not be confirmed, so the Bot was not run", ); } - return copilotRuntime.agentFor({ actor, botId }); + return copilotRuntime.agentFor({ + actor, + botId, + initiator: { kind: "handoff", id: fromBotId }, + }); }, history: copilotRuntime.history, lock: copilotRuntime.threadLock, diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 11867e4ce..4a65202a0 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -1,5 +1,9 @@ import { and, asc, eq, inArray, isNull, or, sql } from "drizzle-orm"; -import { type AuditStore, recordAuditEvent } from "../audit"; +import { + type AuditInitiator, + type AuditStore, + recordAuditEvent, +} from "../audit"; import { type ActionPolicy, evaluateActionPolicy, @@ -2787,6 +2791,7 @@ export function createPluginStore(options: PluginStoreOptions) { args: Record; botId: string; actorId: string; + initiator?: AuditInitiator; }): Promise<{ text: string; isError: boolean }> { const [serverId, ...rest] = input.ref.split("/"); const toolName = rest.join("/"); @@ -2800,6 +2805,7 @@ export function createPluginStore(options: PluginStoreOptions) { eventType: "mcp.call_rejected", targetType: "mcp_tool", targetId: input.ref, + ...(input.initiator ? { initiator: input.initiator } : {}), payload: { actor: input.actorId, bot: input.botId, @@ -2919,6 +2925,7 @@ export function createPluginStore(options: PluginStoreOptions) { eventType: "mcp.call_rejected", targetType: "mcp_tool", targetId: input.ref, + ...(input.initiator ? { initiator: input.initiator } : {}), payload: decided, }); } @@ -2957,6 +2964,7 @@ export function createPluginStore(options: PluginStoreOptions) { eventType: result.isError ? "mcp.call_failed" : "mcp.call_succeeded", targetType: "mcp_tool", targetId: input.ref, + ...(input.initiator ? { initiator: input.initiator } : {}), /* * The vendor's own words, when it is reporting a failure. * @@ -2992,6 +3000,7 @@ export function createPluginStore(options: PluginStoreOptions) { eventType: "mcp.call_failed", targetType: "mcp_tool", targetId: input.ref, + ...(input.initiator ? { initiator: input.initiator } : {}), payload: { ...decided, failure: (error instanceof Error diff --git a/server/src/plugins/tools.ts b/server/src/plugins/tools.ts index d4bb9a2fd..4e54bf20c 100644 --- a/server/src/plugins/tools.ts +++ b/server/src/plugins/tools.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import type { AuditInitiator } from "../audit"; import type { SelectableSkill } from "./selection"; import { PluginRefusedError, type PluginStore } from "./store"; @@ -158,8 +159,9 @@ export async function grantedTools(options: { store: PluginStore; botId: string; actorId: string; + initiator?: AuditInitiator; }): Promise { - const { store, botId, actorId } = options; + const { store, botId, actorId, initiator } = options; const granted = await store.listForAgent(botId); return granted.tools.map((tool) => ({ @@ -179,6 +181,7 @@ export async function grantedTools(options: { : {}, botId, actorId, + ...(initiator ? { initiator } : {}), }); /* * A vendor's error is named as one, not handed over as content. diff --git a/server/src/routines/run-turn.ts b/server/src/routines/run-turn.ts index e3d5929f9..aad4793ce 100644 --- a/server/src/routines/run-turn.ts +++ b/server/src/routines/run-turn.ts @@ -56,6 +56,7 @@ import type { } from "@ag-ui/client"; import { EventType } from "@ag-ui/client"; import { sanitizeSeededHistory } from "../agents/history-sanitize"; +import type { AuditInitiator } from "../audit"; import { historyOrEmpty } from "../copilot"; import type { TurnRunner } from "./runner"; @@ -260,6 +261,7 @@ export function createTurnRunner(options: { buildAgentFor: (input: { ownerUserId: string; agentId: string; + initiator: AuditInitiator; }) => Promise; /** How long one headless turn may take before it is stopped. */ turnTimeoutMs?: number; @@ -278,7 +280,7 @@ export function createTurnRunner(options: { abortGraceMs = DEFAULT_ABORT_GRACE_MS, } = options; - return async ({ ownerUserId, agentId, threadId, instruction }) => { + return async ({ ownerUserId, routineId, agentId, threadId, instruction }) => { /* * One id for this turn, minted once. * @@ -362,7 +364,11 @@ export function createTurnRunner(options: { * ownership on every event and pushes them to the gateway, which is the whole reason this file * exists rather than a bare `runAgent`. */ - const agent = await buildAgentFor({ ownerUserId, agentId }); + const agent = await buildAgentFor({ + ownerUserId, + agentId, + initiator: { kind: "routine", id: routineId }, + }); agent.threadId = threadId; agent.setMessages(messages); diff --git a/server/src/routines/runner.ts b/server/src/routines/runner.ts index 99775aa0e..3455ce5c9 100644 --- a/server/src/routines/runner.ts +++ b/server/src/routines/runner.ts @@ -28,6 +28,7 @@ import type { RoutineStore } from "./store"; /** Everything a headless turn needs, injectable so tests never dial a model. */ export type TurnRunner = (input: { ownerUserId: string; // the actor the run asserts — grants and connections resolve to them + routineId: string; // what the trail names as having started this turn, rather than the owner agentId: string; threadId: string; // the owner's thread for the routine's channel instruction: string; // the user message of this turn @@ -130,6 +131,7 @@ export function createRoutineRunner(options: { try { ({ replyText } = await runTurn({ ownerUserId, + routineId, agentId, threadId: channel.threadId, instruction, diff --git a/server/tests/agent-callback-token.test.ts b/server/tests/agent-callback-token.test.ts index dc5ad86e1..26aa0e595 100644 --- a/server/tests/agent-callback-token.test.ts +++ b/server/tests/agent-callback-token.test.ts @@ -43,7 +43,11 @@ describe("the run assertion", () => { test("survives a round trip", () => { const signed = mintRunAssertion(RUN, KEY); // A run that began with a person is depth zero, which is what an unstated depth means. - expect(readRunAssertion(signed, KEY)).toEqual({ ...RUN, depth: 0 }); + expect(readRunAssertion(signed, KEY)).toEqual({ + ...RUN, + depth: 0, + initiator: { kind: "person" }, + }); }); test("is refused when signed with another key", () => { @@ -71,6 +75,7 @@ describe("the run assertion", () => { expect(readRunAssertion(signed, KEY, 60 * 1000)).toEqual({ ...RUN, depth: 0, + initiator: { kind: "person" }, }); }); @@ -293,6 +298,47 @@ describe("how deep a run is", () => { } }); + test("what started the run survives a round trip", () => { + for (const initiator of [ + { kind: "person" } as const, + { kind: "deployment" } as const, + { kind: "routine", id: "routine_7" } as const, + { kind: "handoff", id: "research-assistant" } as const, + ]) { + const signed = mintRunAssertion({ ...RUN, initiator }, KEY); + expect(readRunAssertion(signed, KEY)?.initiator).toEqual(initiator); + } + }); + + test("a run that says nothing about what started it reads as a person", () => { + expect( + readRunAssertion(mintRunAssertion(RUN, KEY), KEY)?.initiator, + ).toEqual({ kind: "person" }); + }); + + /* + * The point of putting this inside the signature. A Bot cannot relabel its own run, and a kind + * this deployment does not write cannot arrive as a string the Audit screen has no branch for. + */ + test("an initiator that is not one reads as a person rather than being kept", () => { + for (const nonsense of [ + { kind: "administrator" }, + { kind: "routine" }, + { kind: "handoff", id: "" }, + { kind: "routine", id: 7 }, + "routine", + null, + ]) { + const signed = mintRunAssertion( + { ...RUN, initiator: nonsense as never }, + KEY, + ); + expect(readRunAssertion(signed, KEY)?.initiator).toEqual({ + kind: "person", + }); + } + }); + test("the conversation survives a round trip, and is absent when there is none", () => { expect( readRunAssertion(mintRunAssertion({ ...RUN, threadId: "t1" }, KEY), KEY) diff --git a/server/tests/agent-escalation.test.ts b/server/tests/agent-escalation.test.ts index 4b2b76ff3..d1c92eefe 100644 --- a/server/tests/agent-escalation.test.ts +++ b/server/tests/agent-escalation.test.ts @@ -48,6 +48,36 @@ describe("asking a person", () => { expect(said).toContain("the person in this conversation"); }); + /* + * A routine's Bot stopping to ask is the case worth finding: nobody is in the conversation to + * answer, so the row has to say the question was raised by a schedule rather than by a person. + */ + test("the row says what started the run, not only whose authority it had", async () => { + const { written, store } = recorder(); + const tool = escalationTool({ + from: { ...FROM, initiator: { kind: "routine", id: "routine_7" } }, + route: askTheirOwnPerson, + auditStore: store, + }); + + await tool.execute({ question: "which account?" }); + + expect(written[0]?.initiator).toEqual({ kind: "routine", id: "routine_7" }); + }); + + test("a run that says nothing leaves the row filed as a person's", async () => { + const { written, store } = recorder(); + const tool = escalationTool({ + from: FROM, + route: askTheirOwnPerson, + auditStore: store, + }); + + await tool.execute({ question: "which account?" }); + + expect(written[0]?.initiator).toBe(undefined); + }); + test("the question is on the record", async () => { const { written, store } = recorder(); const tool = escalationTool({ diff --git a/server/tests/agent-handoff-delivery.test.ts b/server/tests/agent-handoff-delivery.test.ts index 293f7fbc6..4dc63eea2 100644 --- a/server/tests/agent-handoff-delivery.test.ts +++ b/server/tests/agent-handoff-delivery.test.ts @@ -53,15 +53,20 @@ function delivery( }> = []; const lockCalls: string[] = []; const released: string[] = []; + const builtFor: { actorId: string; botId: string; fromBotId: string }[] = []; return { requests, lockCalls, released, + builtFor, delivery: createHandoffDelivery({ ...(options.deadlineMs === undefined ? {} : { deadlineMs: options.deadlineMs }), - agentFor: async () => agent, + agentFor: async (input) => { + builtFor.push(input); + return agent; + }, history: async () => options.history ?? PRIOR, newRunId: () => "run-2", mintThreadId: () => "scratch-thread", @@ -783,3 +788,20 @@ describe("a history read that fails", () => { expect(lockCalls).toEqual([]); }); }); + +describe("what the trail is told started the hop", () => { + test("the addressed Bot is built for the Bot that handed the work on", async () => { + const { delivery: deliver, builtFor } = delivery(FINISHED); + + await deliver.deliver({ + work: WORK, + message: "assistant has asked you to help", + shown: "Assistant asked Researcher for this on your behalf: find it", + assertion: "signed", + }); + + expect(builtFor).toEqual([ + { actorId: "user-1", botId: "researcher", fromBotId: "assistant" }, + ]); + }); +}); diff --git a/server/tests/agent-handoff.test.ts b/server/tests/agent-handoff.test.ts index 748e232b7..4a72a4df1 100644 --- a/server/tests/agent-handoff.test.ts +++ b/server/tests/agent-handoff.test.ts @@ -51,8 +51,11 @@ function desk(options?: { role?: "admin" | "user"; }) { const rows: Array<{ kind: string; key: string; payload: unknown }> = []; - const events: Array<{ eventType: string; payload: Record }> = - []; + const events: Array<{ + eventType: string; + payload: Record; + initiator?: { kind: string; id?: string }; + }> = []; const queue = { offer: async (item: { @@ -84,6 +87,7 @@ function desk(options?: { recorded.push({ eventType: event.eventType, payload: event.payload ?? {}, + ...(event.initiator ? { initiator: event.initiator } : {}), }); }, }; @@ -303,6 +307,51 @@ describe("handing work to another Bot", () => { * a hop that was refused is invisible everywhere else, and "why did it not ask the specialist" is * the question somebody asks about a thin answer. */ + /* + * The row that records a hop beginning. It asserts the person whose authority the run carries, so + * without this it reads as an action that person took, which is the whole reason the column exists. + */ + test("the offered row says what started the run, not only whose authority it had", async () => { + const started = desk(); + await started.desk.send({ + from: { ...FROM, initiator: { kind: "routine", id: "routine_7" } }, + target: "researcher", + envelope: { task: "t" }, + }); + + expect(started.events[0]?.eventType).toBe("agent.handoff_offered"); + expect(started.events[0]?.initiator).toEqual({ + kind: "routine", + id: "routine_7", + }); + }); + + test("a refusal says it too, so a refused hop is not filed as a person's", async () => { + const refused = desk({ granted: false }); + await refused.desk.send({ + from: { ...FROM, initiator: { kind: "handoff", id: "researcher" } }, + target: "researcher", + envelope: { task: "t" }, + }); + + expect(refused.events[0]?.eventType).toBe("agent.handoff_refused"); + expect(refused.events[0]?.initiator).toEqual({ + kind: "handoff", + id: "researcher", + }); + }); + + test("a run that says nothing leaves the row filed as a person's", async () => { + const plain = desk(); + await plain.desk.send({ + from: FROM, + target: "researcher", + envelope: { task: "t" }, + }); + + expect(plain.events[0]?.initiator).toBe(undefined); + }); + test("both outcomes leave a row naming the run and the reason", async () => { const allowed = desk(); await allowed.desk.send({ diff --git a/server/tests/audit-initiator.integration.test.ts b/server/tests/audit-initiator.integration.test.ts new file mode 100644 index 000000000..ba0ca3bb8 --- /dev/null +++ b/server/tests/audit-initiator.integration.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, test } from "bun:test"; +import { eq } from "drizzle-orm"; +import { + auditQueryFromUrl, + createAuditReader, + createAuditStore, +} from "../src/audit"; +import { createDatabase } from "../src/db/client"; +import { auditEvents } from "../src/db/schema"; +import { TEST_POOL } from "./support/database"; + +const databaseUrl = + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot"; +const database = createDatabase(databaseUrl, TEST_POOL); +const store = createAuditStore(database); +const reader = createAuditReader(database); + +// Nothing is cleaned up: the retention trigger refuses the delete, so each test takes its own target id. +const target = () => `initiator-test-${crypto.randomUUID()}`; + +async function rowsFor(targetId: string, search = "") { + const { events } = await reader.list( + auditQueryFromUrl(new URL(`https://openbot.test/audit${search}`)), + ); + return events.filter((event) => event.targetId === targetId); +} + +describe("what started a run", () => { + test("a person's action is the default, and needs nothing passed", async () => { + const TARGET = target(); + await store.insert({ + eventType: "mcp.call_succeeded", + targetType: "mcp_tool", + targetId: TARGET, + payload: { bot: "general-assistant" }, + }); + + const [row] = await rowsFor(TARGET); + + expect(row?.initiatorKind).toBe("person"); + expect(row?.initiatorId).toBeNull(); + }); + + test("a routine's action names the routine, not only the owner", async () => { + const TARGET = target(); + await store.insert({ + eventType: "mcp.call_succeeded", + targetType: "mcp_tool", + targetId: TARGET, + actorUserId: null as unknown as undefined, + initiator: { kind: "routine", id: "routine-7" }, + payload: { bot: "general-assistant" }, + }); + + const [row] = await rowsFor(TARGET); + + expect(row?.initiatorKind).toBe("routine"); + expect(row?.initiatorId).toBe("routine-7"); + }); + + test("a hop names the Bot that handed the work on", async () => { + const TARGET = target(); + await store.insert({ + eventType: "agent.handoff_delivered", + targetType: "agent", + targetId: TARGET, + initiator: { kind: "handoff", id: "research-assistant" }, + payload: { bot: "general-assistant" }, + }); + + const [row] = await rowsFor(TARGET); + + expect(row?.initiatorKind).toBe("handoff"); + expect(row?.initiatorId).toBe("research-assistant"); + }); + + test("the deployment acting as itself is not filed as a person", async () => { + const TARGET = target(); + await store.insert({ + eventType: "computer.policy_loaded", + targetType: "policy", + targetId: TARGET, + initiator: { kind: "deployment" }, + payload: { note: "read at start-up" }, + }); + + const [row] = await rowsFor(TARGET); + + expect(row?.initiatorKind).toBe("deployment"); + expect(row?.initiatorId).toBeNull(); + }); + + test("a boundary refusal is the deployment, and is not swept up by nobody watching", async () => { + const TARGET = target(); + await store.insert({ + eventType: "routines.dispatch_refused", + targetType: "worker", + targetId: TARGET, + initiator: { kind: "deployment" }, + payload: { marker: "by-deployment" }, + }); + await store.insert({ + eventType: "mcp.call_succeeded", + targetType: "mcp_tool", + targetId: TARGET, + initiator: { kind: "routine", id: "routine-7" }, + payload: { marker: "by-routine" }, + }); + + const unattended = await rowsFor(TARGET, "?initiatorKind=routine,handoff"); + const deployment = await rowsFor(TARGET, "?initiatorKind=deployment"); + + expect(unattended.map((event) => event.payload.marker)).toEqual([ + "by-routine", + ]); + expect(deployment.map((event) => event.payload.marker)).toEqual([ + "by-deployment", + ]); + }); + + test("one filter answers what ran with nobody watching", async () => { + const TARGET = target(); + await store.insert({ + eventType: "mcp.call_succeeded", + targetType: "mcp_tool", + targetId: TARGET, + payload: { marker: "by-hand" }, + }); + await store.insert({ + eventType: "mcp.call_succeeded", + targetType: "mcp_tool", + targetId: TARGET, + initiator: { kind: "routine", id: "routine-7" }, + payload: { marker: "by-routine" }, + }); + await store.insert({ + eventType: "agent.handoff_delivered", + targetType: "agent", + targetId: TARGET, + initiator: { kind: "handoff", id: "research-assistant" }, + payload: { marker: "by-hop" }, + }); + + const unattended = await rowsFor(TARGET, "?initiatorKind=routine,handoff"); + + expect(unattended.map((event) => event.payload.marker).sort()).toEqual([ + "by-hop", + "by-routine", + ]); + }); + + test("one kind on its own narrows to that kind", async () => { + const TARGET = target(); + await store.insert({ + eventType: "mcp.call_succeeded", + targetType: "mcp_tool", + targetId: TARGET, + initiator: { kind: "routine", id: "routine-7" }, + payload: { marker: "by-routine" }, + }); + await store.insert({ + eventType: "agent.handoff_delivered", + targetType: "agent", + targetId: TARGET, + initiator: { kind: "handoff", id: "research-assistant" }, + payload: { marker: "by-hop" }, + }); + + const routines = await rowsFor(TARGET, "?initiatorKind=routine"); + + expect(routines.map((event) => event.payload.marker)).toEqual([ + "by-routine", + ]); + }); + + test("a kind nothing writes is ignored rather than returning nothing", async () => { + const TARGET = target(); + await store.insert({ + eventType: "mcp.call_succeeded", + targetType: "mcp_tool", + targetId: TARGET, + payload: { marker: "by-hand" }, + }); + + const rows = await rowsFor(TARGET, "?initiatorKind=nonsense"); + + expect(rows.map((event) => event.payload.marker)).toEqual(["by-hand"]); + }); + + test("the trail stays append-only, so a row cannot be re-attributed later", async () => { + const TARGET = target(); + await store.insert({ + eventType: "mcp.call_succeeded", + targetType: "mcp_tool", + targetId: TARGET, + initiator: { kind: "routine", id: "routine-7" }, + payload: { bot: "general-assistant" }, + }); + + const reattribute = async () => { + await database + .update(auditEvents) + .set({ initiatorKind: "person", initiatorId: null }) + .where(eq(auditEvents.targetId, TARGET)); + }; + + expect(reattribute()).rejects.toThrow(); + }); +}); diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 89e2ef832..04edb7686 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -126,6 +126,8 @@ async function auditRowsFor(targetId: string) { .select({ eventType: auditEvents.eventType, payload: auditEvents.payload, + initiatorKind: auditEvents.initiatorKind, + initiatorId: auditEvents.initiatorId, }) .from(auditEvents) .where( @@ -267,6 +269,49 @@ describe("a grant is the permission", () => { ); }); + test("a refusal names the routine that asked, not only the person it ran as", async () => { + await expect( + store.callTool({ + ref, + args: {}, + botId: strangerId, + actorId: "someone@openbot.local", + initiator: { kind: "routine", id: "routine_standup" }, + }), + ).rejects.toBeInstanceOf(PluginRefusedError); + + const rows = await auditRowsFor(ref); + const rejected = rows.filter( + (row) => + row.eventType === "mcp.call_rejected" && + (row.payload as { bot?: string }).bot === strangerId && + row.initiatorKind === "routine", + ); + expect(rejected.length).toBeGreaterThan(0); + expect(rejected[0].initiatorId).toBe("routine_standup"); + }); + + test("a call nobody said anything about is still filed as a person's", async () => { + await expect( + store.callTool({ + ref, + args: {}, + botId: strangerId, + actorId: "someone@openbot.local", + }), + ).rejects.toBeInstanceOf(PluginRefusedError); + + const rows = await auditRowsFor(ref); + expect( + rows.some( + (row) => + row.eventType === "mcp.call_rejected" && + row.initiatorKind === "person" && + row.initiatorId === null, + ), + ).toBe(true); + }); + test("granting lets the same Bot past the grant check", async () => { await store.grant("mcp", ref, holderId, "admin@openbot.local"); const decision = await store.decide("mcp", ref, holderId); diff --git a/server/tests/routine-run-turn.test.ts b/server/tests/routine-run-turn.test.ts index 05ce5ca9b..07cdcde2e 100644 --- a/server/tests/routine-run-turn.test.ts +++ b/server/tests/routine-run-turn.test.ts @@ -24,6 +24,7 @@ import { */ const OWNER = "user_owner"; +const ROUTINE_ID = "routine_standup"; const AGENT_ID = "bot_helper"; const THREAD_ID = "thread_owner_channel_1"; const INSTRUCTION = "Post the standup summary."; @@ -195,12 +196,16 @@ function harness(options: { }, }; + const builtFor: { initiator: { kind: string; id?: string } }[] = []; const runTurn = createTurnRunner({ // biome-ignore lint/suspicious/noExplicitAny: narrow structural fakes, on purpose. intelligence: intelligence as any, // biome-ignore lint/suspicious/noExplicitAny: narrow structural fakes, on purpose. runner: runner as any, - buildAgentFor: async () => agent, + buildAgentFor: async (input) => { + builtFor.push(input); + return agent; + }, ...(options.turnTimeoutMs === undefined ? {} : { turnTimeoutMs: options.turnTimeoutMs }), @@ -218,12 +223,13 @@ function harness(options: { const run = () => runTurn({ ownerUserId: OWNER, + routineId: ROUTINE_ID, agentId: AGENT_ID, threadId: THREAD_ID, instruction: INSTRUCTION, }); - return { run, agent, calls, order }; + return { run, agent, calls, order, builtFor }; } const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -880,3 +886,17 @@ describe("a RUN_ERROR through next", () => { expect(calls.cleaned).toHaveLength(1); }); }); + +describe("what the trail is told started the turn", () => { + test("the Bot is built for the routine, not for the owner acting by hand", async () => { + const { run, builtFor } = harness({}); + + await run(); + + expect(builtFor).toHaveLength(1); + expect(builtFor[0]?.initiator).toEqual({ + kind: "routine", + id: ROUTINE_ID, + }); + }); +}); diff --git a/server/tests/routine-runner.test.ts b/server/tests/routine-runner.test.ts index 2013419ab..731bf54ff 100644 --- a/server/tests/routine-runner.test.ts +++ b/server/tests/routine-runner.test.ts @@ -150,6 +150,8 @@ describe("createRoutineRunner", () => { expect(recorded.turns).toEqual([ { ownerUserId: CONTEXT.ownerUserId, + // Carried so the turn's audit rows say a routine ran this, not that the owner did. + routineId: CONTEXT.routineId, agentId: CONTEXT.agentId, threadId: CHANNEL.threadId, instruction: CONTEXT.instruction, diff --git a/server/tests/stall-guard.test.ts b/server/tests/stall-guard.test.ts index 9148db18f..6c9f2d1a2 100644 --- a/server/tests/stall-guard.test.ts +++ b/server/tests/stall-guard.test.ts @@ -79,6 +79,24 @@ describe("a Bot that stops streaming", () => { expect(body.endsWith("\n\n")).toBe(true); }); + test("the row says what started the run, so a routine's stall is not a person's", async () => { + const audit = collecting(); + const guard = createStallGuard({ stallMs: 60, auditStore: audit.store }); + const watched = guard.watch( + { ...BOT, initiator: { kind: "routine", id: "routine_7" } }, + async () => sse(saysNothing()), + ); + + const response = await watched("http://bot.internal/ag-ui", RUN_REQUEST); + await new Response(response.body).text(); + guard.stop(); + + const row = audit.rows.find( + (event) => event.eventType === "agent.stream_stalled", + ); + expect(row?.initiator).toEqual({ kind: "routine", id: "routine_7" }); + }); + test("leaves a row naming the Bot, the turn and how long the silence was", async () => { const audit = collecting(); const guard = createStallGuard({ stallMs: 60, auditStore: audit.store });