From 59ce256da36e638e639990cfe7e494005c7052eb Mon Sep 17 00:00:00 2001 From: Vaibhav Zope Date: Sat, 5 Sep 2026 02:55:57 +0530 Subject: [PATCH 1/3] Say what started a run in the audit trail, not only whose authority it had --- CHANGELOG.md | 22 + app/src/routes/_authed/admin/audit.tsx | 44 + docs/architecture.md | 25 + docs/routines.md | 8 +- server/drizzle/0028_audit_initiator.sql | 3 + server/drizzle/meta/0028_snapshot.json | 3141 +++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/agents/handoff-delivery.ts | 3 + server/src/agents/handoff-runner.ts | 4 + server/src/app.ts | 3 + server/src/audit.ts | 56 +- server/src/copilot.ts | 14 +- server/src/db/schema/core.ts | 9 + server/src/index.ts | 29 +- server/src/plugins/store.ts | 11 +- server/src/plugins/tools.ts | 5 +- server/src/routines/run-turn.ts | 10 +- server/src/routines/runner.ts | 2 + server/tests/agent-handoff-delivery.test.ts | 24 +- .../tests/audit-initiator.integration.test.ts | 210 ++ server/tests/plugin-store.integration.test.ts | 45 + server/tests/routine-run-turn.test.ts | 24 +- server/tests/routine-runner.test.ts | 2 + 23 files changed, 3681 insertions(+), 20 deletions(-) create mode 100644 server/drizzle/0028_audit_initiator.sql create mode 100644 server/drizzle/meta/0028_snapshot.json create mode 100644 server/tests/audit-initiator.integration.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 65d99fecb..3d3a1e264 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,28 @@ 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. 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/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/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/copilot.ts b/server/src/copilot.ts index fb68b0e2d..6dddc9c0f 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"; @@ -1036,7 +1037,10 @@ 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, /** What every built-in Bot is told about the computer. Absent means this deployment has none. */ @@ -1186,7 +1190,10 @@ 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, + loadToolsForActor?: ( + actorId: string, + initiator?: AuditInitiator, + ) => LoadToolsForBot, signRunForActor?: (actorId: string) => SignRun, basePath = "/api/copilotkit", loadVendors?: () => Promise, @@ -1236,6 +1243,7 @@ export function mountCopilotRuntime( */ actor: AgentActor; botId: string; + initiator?: AuditInitiator; }): Promise => { const { actor } = input; const agents = await resolveRuntimeAgents( @@ -1243,7 +1251,7 @@ export function mountCopilotRuntime( model, resolveModelApiKey, stallGuard, - loadToolsForActor?.(actor.id), + loadToolsForActor?.(actor.id, input.initiator), signRunForActor?.(actor.id), config.computer ? COMPUTER_GUIDANCE : undefined, loadVendors, 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..88b1793b1 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); @@ -661,9 +672,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,7 +684,7 @@ const buildAgentFor = async ({ tenantPackage.model, resolveRuntimeModelApiKey, stallGuard, - loadToolsForActor(actor.id), + loadToolsForActor(actor.id, initiator), signRunForActor(actor.id), config.computer ? COMPUTER_GUIDANCE : undefined, loadVendors, @@ -898,14 +911,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-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/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, From 81ae1ab5af2a9c1211092fc8f3ec2a1279ed5bc4 Mon Sep 17 00:00:00 2001 From: Vaibhav Zope Date: Sat, 5 Sep 2026 23:29:56 +0530 Subject: [PATCH 2/3] Carry what started a run inside the signed assertion, so a hop and an escalation say it too --- server/src/agents/callback-token.ts | 31 +++++++++++++ server/src/agents/escalation.ts | 1 + server/src/agents/handoff.ts | 2 + server/src/copilot.ts | 6 +-- server/src/index.ts | 10 +++-- server/tests/agent-callback-token.test.ts | 48 +++++++++++++++++++- server/tests/agent-escalation.test.ts | 30 +++++++++++++ server/tests/agent-handoff.test.ts | 53 ++++++++++++++++++++++- 8 files changed, 172 insertions(+), 9 deletions(-) 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.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/copilot.ts b/server/src/copilot.ts index 6dddc9c0f..37eee3063 100644 --- a/server/src/copilot.ts +++ b/server/src/copilot.ts @@ -1042,7 +1042,7 @@ export function createRequestAgents( 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. */ @@ -1194,7 +1194,7 @@ export function mountCopilotRuntime( actorId: string, initiator?: AuditInitiator, ) => LoadToolsForBot, - signRunForActor?: (actorId: string) => SignRun, + signRunForActor?: (actorId: string, initiator?: AuditInitiator) => SignRun, basePath = "/api/copilotkit", loadVendors?: () => Promise, selectionForActor?: (actorId: string) => ToolSelection, @@ -1252,7 +1252,7 @@ export function mountCopilotRuntime( resolveModelApiKey, stallGuard, loadToolsForActor?.(actor.id, input.initiator), - signRunForActor?.(actor.id), + signRunForActor?.(actor.id, input.initiator), config.computer ? COMPUTER_GUIDANCE : undefined, loadVendors, selectionForActor?.(actor.id), diff --git a/server/src/index.ts b/server/src/index.ts index 88b1793b1..682a6f9b7 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -548,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, ); @@ -685,7 +686,7 @@ const buildAgentFor = async ({ resolveRuntimeModelApiKey, stallGuard, loadToolsForActor(actor.id, initiator), - signRunForActor(actor.id), + signRunForActor(actor.id, initiator), config.computer ? COMPUTER_GUIDANCE : undefined, loadVendors, selectionForActor(actor.id), @@ -796,6 +797,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. 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.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({ From f50a12467d44bb7b8308a49e39b8dc04972bb7b4 Mon Sep 17 00:00:00 2001 From: Vaibhav Zope Date: Sat, 5 Sep 2026 23:42:48 +0530 Subject: [PATCH 3/3] Carry what started a run to the stall guard, so a routine's stalled stream says so --- CHANGELOG.md | 6 ++++-- docs/architecture.md | 12 ++++++++++++ server/src/channels/stall-guard.ts | 14 ++++++++++++-- server/src/copilot.ts | 15 ++++++++++++++- server/src/index.ts | 1 + server/tests/stall-guard.test.ts | 18 ++++++++++++++++++ 6 files changed, 61 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d3a1e264..e356162de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,10 @@ they had taken it themselves. Telling the two apart meant correlating timestamps `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. 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 +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 diff --git a/docs/architecture.md b/docs/architecture.md index ed8032264..ccc57cf87 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -102,6 +102,18 @@ and the two places that have no person at all, the start-up rows and the two una refusals, say so rather than borrowing the default. A deployment that has never run a routine or a hop sees `A person` on every row a person made, which is what it was before this existed. +The value travels inside the signed run assertion, beside `depth`, for the reason `depth` does: a hop +is one run on one pod handing to another run on another, and the process that knows a routine began +it is the one that claimed the routine, not the one writing the row. Anything holding the assertion +holds the answer, so a tool call, a hop offered or refused at the desk, a Bot stopping to ask its +person, and a stream that stalls all say the same thing without each being told separately. A Bot +cannot relabel its own run, because the assertion is signed by the deployment, and a kind this +deployment does not write is read as a person rather than kept. + +Computer actions are the one family that carries no initiator, and correctly so: the computer tools +are browser actions, executed by the person's own session, so a headless run has no way to drive the +computer at all today. A row there is a person's because a person's browser wrote it. + ## Human control and secrets Handovers are audited as control events: 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 37eee3063..ee46da013 100644 --- a/server/src/copilot.ts +++ b/server/src/copilot.ts @@ -362,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[]); /* @@ -393,6 +394,7 @@ export async function buildAgents( agentFetch, handoff, instructions ?? null, + initiator, ), ]), ), @@ -422,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); @@ -496,6 +499,7 @@ async function buildAgent( connectedVendors, narrowing ? offeredFor : undefined, agentFetch, + initiator, ); } @@ -621,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, @@ -633,7 +638,11 @@ function remoteAgentWithStandingRole( ...(stallGuard ? { fetch: stallGuard.watch( - { id: agent.id, name: agent.name }, + { + id: agent.id, + name: agent.name, + ...(initiator ? { initiator } : {}), + }, agentFetch, ), } @@ -959,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) { @@ -990,6 +1001,7 @@ export async function resolveRuntimeAgents( agentFetch, handoff, loadInstructions, + initiator, ); } @@ -1263,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/index.ts b/server/src/index.ts index 682a6f9b7..1218ebc0c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -699,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) { 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 });