diff --git a/.changeset/tool-call-log.md b/.changeset/tool-call-log.md new file mode 100644 index 0000000000..e786b2419a --- /dev/null +++ b/.changeset/tool-call-log.md @@ -0,0 +1,15 @@ +--- +"executor": minor +--- + +**New: an audit trail of every tool call — which integration an agent used, when, and how it ended** + +Executor kept no record of tool usage. A run that called GitHub or Search Console left one HTTP line (`POST /mcp 200`) and nothing about which integration, which tool, or what came back; the analytics catalog is anonymous by construction and deliberately drops exactly those fields. That made two questions unanswerable after the fact: what did this agent touch, and what did my policies actually stop. + +Every call through `execute` now writes a row: the address as called, its integration/connection/tool, the outcome, the policy that governed it, and how long it took. The rows that matter most are the ones with no other trace — a call a `block` policy stopped, and an approval someone declined, both of which end before any request is made. Read them from `executor.toolCalls.list()`, from `GET /api/tool-calls` (filter by integration, connection, client, outcome or time), or on the new **Activity** page in the console. + +Each row also says **who** ran the call and **which client** it came in on: the member (kept even on org-owned connections, whose rows are otherwise shared org-wide), and the credential — which API key, which OAuth-connected MCP client by its registered name ("Claude Code", "Cursor"), the browser console, or a CLI login. Only the host sees the credential, so it names the client through a new optional `ExecutorConfig.caller`; self-host resolves it from whichever credential authenticated the request. `GET /api/tool-calls/clients` lists the clients seen, and the Activity page filters by them. A client names itself at OAuth registration, so its label is treated as untrusted text: control characters are stripped and it is bounded before it is stored. + +Arguments, results, and any text that came from outside are never stored: an argument can be a credential, and an upstream error message routinely echoes the request back. A failed call keeps its upstream `code`, never its message; a call keeps its top-level argument _names_, and only those that look like parameters rather than payloads. Writing a row can never fail a call: an insert failure is logged and swallowed. The write is awaited (a row exists before the call returns) and deliberately carries no timeout of its own — a finalizer runs uninterruptible, so a timeout there is decorative exactly when the database hangs; bounding a stalled driver is the driver's job. + +Retention is left to the host: `executor.toolCalls.prune({ before })` removes old rows, and nothing schedules it for you — an audit log that quietly deletes itself on a default nobody chose is worse than one that grows. diff --git a/apps/cloud/drizzle/0021_tool_call_log.sql b/apps/cloud/drizzle/0021_tool_call_log.sql new file mode 100644 index 0000000000..76eeda7a22 --- /dev/null +++ b/apps/cloud/drizzle/0021_tool_call_log.sql @@ -0,0 +1,26 @@ +CREATE TABLE "tool_call_log" ( + "id" varchar(255) NOT NULL, + "address" text NOT NULL, + "integration" varchar(255), + "connection" varchar(255), + "tool" text, + "outcome" varchar(255) NOT NULL, + "error_code" text, + "error_message" text, + "policy_action" text, + "policy_pattern" text, + "duration_ms" bigint NOT NULL, + "arg_keys" json, + "actor" varchar(255), + "actor_label" text, + "client_kind" varchar(255), + "client_id" varchar(255), + "client_name" text, + "created_at" timestamp NOT NULL, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL, + "owner" varchar(255) NOT NULL, + "subject" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "tool_call_log_uidx" ON "tool_call_log" USING btree ("tenant","owner","subject","id"); \ No newline at end of file diff --git a/apps/cloud/drizzle/meta/0021_snapshot.json b/apps/cloud/drizzle/meta/0021_snapshot.json new file mode 100644 index 0000000000..8bbb346c34 --- /dev/null +++ b/apps/cloud/drizzle/meta/0021_snapshot.json @@ -0,0 +1,1939 @@ +{ + "id": "9fe4b072-0e39-4a7c-903a-e7d44c64401d", + "prevId": "88f2845b-be28-4ad3-92b2-2cac819478e5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sign_in_at": { + "name": "last_sign_in_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": { + "accounts_email_lower_idx": { + "name": "accounts_email_lower_idx", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.membership_tombstones": { + "name": "membership_tombstones", + "schema": "", + "columns": { + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "membership_tombstones_organization_id_idx": { + "name": "membership_tombstones_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "membership_tombstones_account_id_accounts_id_fk": { + "name": "membership_tombstones_account_id_accounts_id_fk", + "tableFrom": "membership_tombstones", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "membership_tombstones_organization_id_organizations_id_fk": { + "name": "membership_tombstones_organization_id_organizations_id_fk", + "tableFrom": "membership_tombstones", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "workos_updated_at": { + "name": "workos_updated_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()" + } + }, + "indexes": { + "memberships_membership_id_unique": { + "name": "memberships_membership_id_unique", + "columns": [ + { + "expression": "membership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memberships_organization_id_idx": { + "name": "memberships_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backfilled_at": { + "name": "backfilled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "workos_updated_at": { + "name": "workos_updated_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": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workos_sync": { + "name": "workos_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "range_start": { + "name": "range_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "backfill_completed_at": { + "name": "backfill_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "drained_at": { + "name": "drained_at", + "type": "timestamp with time zone", + "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.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bindings": { + "name": "bindings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health": { + "name": "last_health", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "subject_uidx": { + "name": "subject_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_call_log": { + "name": "tool_call_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy_action": { + "name": "policy_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy_pattern": { + "name": "policy_pattern", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "arg_keys": { + "name": "arg_keys", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "actor": { + "name": "actor", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "actor_label": { + "name": "actor_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_kind": { + "name": "client_kind", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_call_log_uidx": { + "name": "tool_call_log_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 73842e4d59..ae4bd65195 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1789575639971, "tag": "0020_workos_sync_drained_at", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1789817175360, + "tag": "0021_tool_call_log", + "breakpoints": true } ] } diff --git a/apps/cloud/src/db/executor-schema.ts b/apps/cloud/src/db/executor-schema.ts index 0db709b884..f8513b1f17 100644 --- a/apps/cloud/src/db/executor-schema.ts +++ b/apps/cloud/src/db/executor-schema.ts @@ -229,6 +229,40 @@ export const tool_policy = pgTable( ], ); +export const tool_call_log = pgTable( + "tool_call_log", + { + id: varchar("id", { length: 255 }).notNull(), + address: text("address").notNull(), + integration: varchar("integration", { length: 255 }), + connection: varchar("connection", { length: 255 }), + tool: text("tool"), + outcome: varchar("outcome", { length: 255 }).notNull(), + error_code: text("error_code"), + error_message: text("error_message"), + policy_action: text("policy_action"), + policy_pattern: text("policy_pattern"), + duration_ms: bigint("duration_ms", { mode: "bigint" }).notNull(), + arg_keys: json("arg_keys"), + actor: varchar("actor", { length: 255 }), + actor_label: text("actor_label"), + client_kind: varchar("client_kind", { length: 255 }), + client_id: varchar("client_id", { length: 255 }), + client_name: text("client_name"), + created_at: timestamp("created_at").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + tenant: varchar("tenant", { length: 255 }).notNull(), + owner: varchar("owner", { length: 255 }).notNull(), + subject: varchar("subject", { length: 255 }).notNull(), + }, + (table) => [ + uniqueIndex("tool_call_log_uidx").on(table.tenant, table.owner, table.subject, table.id), + ], +); + export const artifact = pgTable( "artifact", { diff --git a/apps/cloud/src/db/org-deletion.test.ts b/apps/cloud/src/db/org-deletion.test.ts index a7268e2764..c459f252ab 100644 --- a/apps/cloud/src/db/org-deletion.test.ts +++ b/apps/cloud/src/db/org-deletion.test.ts @@ -39,6 +39,7 @@ import { plugin_storage, subject, tool, + tool_call_log, tool_policy, } from "./executor-schema"; @@ -159,6 +160,17 @@ const seedTenant = async (db: DrizzleDb, tenant: string, tag: string) => { subject: "s", }); + await db.insert(tool_call_log).values({ + id: `tcl-${tag}`, + address: `tools.int-${tag}.org.main.repos.get`, + outcome: "ok", + duration_ms: 1n, + created_at: now, + tenant, + owner: "o", + subject: "s", + }); + const orgNs = `o:${tenant}/plugin`; const userNs = `u:${tenant}:subject/plugin`; await db.insert(blob).values({ @@ -186,6 +198,7 @@ const TENANT_TABLES = [ plugin_storage, subject, artifact, + tool_call_log, ] as const; // Tables that are NOT purged by org id, each with the reason it is exempt. Any diff --git a/apps/cloud/src/db/org-deletion.ts b/apps/cloud/src/db/org-deletion.ts index abcff12f3b..1eb74d7116 100644 --- a/apps/cloud/src/db/org-deletion.ts +++ b/apps/cloud/src/db/org-deletion.ts @@ -36,6 +36,7 @@ import { plugin_storage, subject, tool, + tool_call_log, tool_policy, } from "./executor-schema"; @@ -68,6 +69,8 @@ export const purgeOrganizationData = ( await tx.delete(plugin_storage).where(eq(plugin_storage.tenant, organizationId)); await tx.delete(subject).where(eq(subject.tenant, organizationId)); await tx.delete(artifact).where(eq(artifact.tenant, organizationId)); + // The audit trail is org data like any other: a deleted org keeps no rows. + await tx.delete(tool_call_log).where(eq(tool_call_log.tenant, organizationId)); // Secrets, OAuth tokens, and cached specs live in `blob`, namespaced by // owner: `o:/` (org scope) and `u::/` diff --git a/apps/cloud/src/mcp/session-build-semaphore.ts b/apps/cloud/src/mcp/session-build-semaphore.ts index 4e778deb2f..baa501b429 100644 --- a/apps/cloud/src/mcp/session-build-semaphore.ts +++ b/apps/cloud/src/mcp/session-build-semaphore.ts @@ -125,7 +125,16 @@ export const acquireBuildSlot = (maxQueueWaitMs: number = MAX_QUEUE_WAIT_MS): Bu waiter.state = "timed-out"; const idx = waitQueue.indexOf(waiter); if (idx !== -1) waitQueue.splice(idx, 1); - resolvePromise({ acquired: false, waitMs: Date.now() - requestedAt, timedOut: true }); + // Floor the reported wait at the timeout that produced it. The timer runs + // on libuv's monotonic clock while this measures the wall clock, and the + // two disagree by up to a millisecond — `setTimeout(10)` routinely lands + // on a `Date.now()` delta of 9, which made "waited at least the timeout" + // a coin flip for anyone asserting on it (this test suite included). + resolvePromise({ + acquired: false, + waitMs: Math.max(maxQueueWaitMs, Date.now() - requestedAt), + timedOut: true, + }); }, maxQueueWaitMs); return { diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index d9641402b0..7629d454d8 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -22,6 +22,7 @@ import { Route as OrgRouteImport } from './routes/app/org' import { Route as BillingRouteImport } from './routes/app/billing' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' import { Route as ApiKeysRouteImport } from './routes/app/api-keys' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRouteImport } from './../../../packages/react/src/routes/activity' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse' @@ -102,6 +103,12 @@ const ApiKeysRoute = ApiKeysRouteImport.update({ path: '/{-$orgSlug}/api-keys', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRouteImport.update({ + id: '/{-$orgSlug}/activity', + path: '/{-$orgSlug}/activity', + getParentRoute: () => rootRouteImport, + } as any) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update( { @@ -167,6 +174,7 @@ export interface FileRoutesByFullPath { '/create-org': typeof CreateOrgRoute '/login': typeof LoginRoute '/setup-mcp': typeof SetupMcpRoute + '/{-$orgSlug}/activity': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/billing': typeof BillingRoute @@ -190,6 +198,7 @@ export interface FileRoutesByTo { '/create-org': typeof CreateOrgRoute '/login': typeof LoginRoute '/setup-mcp': typeof SetupMcpRoute + '/{-$orgSlug}/activity': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/billing': typeof BillingRoute @@ -214,6 +223,7 @@ export interface FileRoutesById { '/create-org': typeof CreateOrgRoute '/login': typeof LoginRoute '/setup-mcp': typeof SetupMcpRoute + '/{-$orgSlug}/activity': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/billing': typeof BillingRoute @@ -239,6 +249,7 @@ export interface FileRouteTypes { | '/create-org' | '/login' | '/setup-mcp' + | '/{-$orgSlug}/activity' | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/billing' @@ -262,6 +273,7 @@ export interface FileRouteTypes { | '/create-org' | '/login' | '/setup-mcp' + | '/{-$orgSlug}/activity' | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/billing' @@ -285,6 +297,7 @@ export interface FileRouteTypes { | '/create-org' | '/login' | '/setup-mcp' + | '/{-$orgSlug}/activity' | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/billing' @@ -309,6 +322,7 @@ export interface RootRouteChildren { CreateOrgRoute: typeof CreateOrgRoute LoginRoute: typeof LoginRoute SetupMcpRoute: typeof SetupMcpRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute ApiKeysRoute: typeof ApiKeysRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren BillingRoute: typeof BillingRoute @@ -420,6 +434,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiKeysRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/activity': { + id: '/{-$orgSlug}/activity' + path: '/{-$orgSlug}/activity' + fullPath: '/{-$orgSlug}/activity' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/toolkits/$toolkitSlug': { id: '/{-$orgSlug}/toolkits/$toolkitSlug' path: '/$toolkitSlug' @@ -513,6 +534,8 @@ const rootRouteChildren: RootRouteChildren = { CreateOrgRoute: CreateOrgRoute, LoginRoute: LoginRoute, SetupMcpRoute: SetupMcpRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute, ApiKeysRoute: ApiKeysRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren, diff --git a/apps/host-cloudflare/web/routeTree.gen.ts b/apps/host-cloudflare/web/routeTree.gen.ts index 8de64af12d..8ba133eb4a 100644 --- a/apps/host-cloudflare/web/routeTree.gen.ts +++ b/apps/host-cloudflare/web/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImp import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRouteImport } from './../../../packages/react/src/routes/activity' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse' @@ -60,6 +61,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute = path: '/{-$orgSlug}/artifacts', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRouteImport.update({ + id: '/{-$orgSlug}/activity', + path: '/{-$orgSlug}/activity', + getParentRoute: () => rootRouteImport, + } as any) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update( { @@ -128,6 +135,7 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginK ) export interface FileRoutesByFullPath { + '/{-$orgSlug}/activity': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute @@ -144,6 +152,7 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute } export interface FileRoutesByTo { + '/{-$orgSlug}/activity': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute @@ -161,6 +170,7 @@ export interface FileRoutesByTo { } export interface FileRoutesById { __root__: typeof rootRouteImport + '/{-$orgSlug}/activity': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute @@ -179,6 +189,7 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: + | '/{-$orgSlug}/activity' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' @@ -195,6 +206,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/plugins/$pluginId/$' fileRoutesByTo: FileRoutesByTo to: + | '/{-$orgSlug}/activity' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' @@ -211,6 +223,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/plugins/$pluginId/$' id: | '__root__' + | '/{-$orgSlug}/activity' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' @@ -228,6 +241,7 @@ export interface FileRouteTypes { fileRoutesById: FileRoutesById } export interface RootRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute @@ -286,6 +300,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/activity': { + id: '/{-$orgSlug}/activity' + path: '/{-$orgSlug}/activity' + fullPath: '/{-$orgSlug}/activity' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/toolkits/$toolkitSlug': { id: '/{-$orgSlug}/toolkits/$toolkitSlug' path: '/$toolkitSlug' @@ -376,6 +397,8 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren = ) const rootRouteChildren: RootRouteChildren = { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: diff --git a/apps/host-selfhost/src/auth/credential-names.ts b/apps/host-selfhost/src/auth/credential-names.ts new file mode 100644 index 0000000000..a4ffbdee75 --- /dev/null +++ b/apps/host-selfhost/src/auth/credential-names.ts @@ -0,0 +1,62 @@ +import { Effect, Option, Schema } from "effect"; + +import type { BetterAuthHandle } from "./better-auth"; + +// --------------------------------------------------------------------------- +// Human names for the credentials a request can arrive on — the API key's +// name, the MCP OAuth client's registered name — so the tool call log says +// "Claude Code" or "jean-mcp" rather than an opaque id. +// +// Looked up AFTER authentication succeeded, through Better Auth's own adapter +// (the same path `member-directory.ts` reads through), and only ever as audit +// metadata: a failed or slow lookup yields `null` and never fails the request. +// Cached briefly because an MCP client authenticates on every POST; a renamed +// key shows its new name within the TTL, which is all an audit label needs. +// --------------------------------------------------------------------------- + +const TTL_MS = 5 * 60_000; +/** Bounded: entries are only created for credentials that already + * authenticated, but a cache with no ceiling is still a cache that grows. */ +const MAX_ENTRIES = 500; + +const NamedRow = Schema.Struct({ name: Schema.NullishOr(Schema.String) }); +const decodeNamedRow = Schema.decodeUnknownOption(NamedRow); + +export interface CredentialNames { + /** The API key's name, by the key's own id. */ + readonly apiKeyName: (id: string) => Effect.Effect; + /** The OAuth client's registered `client_name`, by its client id. */ + readonly oauthClientName: (clientId: string) => Effect.Effect; +} + +export const makeCredentialNames = (auth: BetterAuthHandle["auth"]): CredentialNames => { + const cache = new Map(); + + const lookup = (model: string, field: string, value: string): Effect.Effect => + Effect.gen(function* () { + const key = `${model}:${value}`; + const hit = cache.get(key); + if (hit !== undefined && Date.now() - hit.at < TTL_MS) return hit.name; + const { adapter } = yield* Effect.promise(() => auth.$context); + const row = yield* Effect.tryPromise({ + try: () => adapter.findOne({ model, where: [{ field, value }] }), + catch: () => "credential name lookup failed", + }); + const name = Option.match(decodeNamedRow(row), { + onNone: () => null, + onSome: (decoded) => decoded.name ?? null, + }); + if (cache.size >= MAX_ENTRIES) cache.clear(); + cache.set(key, { name, at: Date.now() }); + return name; + }).pipe( + // Audit metadata only: a lookup that fails leaves the label empty and + // the request exactly as authenticated as it already was. + Effect.orElseSucceed(() => null), + ); + + return { + apiKeyName: (id) => lookup("apikey", "id", id), + oauthClientName: (clientId) => lookup("oauthApplication", "clientId", clientId), + }; +}; diff --git a/apps/host-selfhost/src/auth/identity.ts b/apps/host-selfhost/src/auth/identity.ts index a637b12a6a..52d1a636a1 100644 --- a/apps/host-selfhost/src/auth/identity.ts +++ b/apps/host-selfhost/src/auth/identity.ts @@ -1,9 +1,10 @@ import { Effect, Layer } from "effect"; -import { IdentityProvider, Unauthorized } from "@executor-js/api/server"; +import { IdentityProvider, Unauthorized, type PrincipalCredential } from "@executor-js/api/server"; import { isPrivileged } from "../admin/require-admin"; import { BetterAuth, type BetterAuthHandle } from "./better-auth"; +import { makeCredentialNames, type CredentialNames } from "./credential-names"; // --------------------------------------------------------------------------- // The self-host identity seam — the production implementation of the shared @@ -67,6 +68,7 @@ export const betterAuthIdentityLayer: Layer.Layer Effect.gen(function* () { @@ -76,6 +78,7 @@ export const betterAuthIdentityLayer: Layer.Layer = request.headers; + let resolvedByApiKey = false; if (!resolved) { const token = bearerToken(request.headers); if (token) { @@ -85,11 +88,18 @@ export const betterAuthIdentityLayer: Layer.Layer "api-key session lookup failed", }).pipe(Effect.orElseSucceed(() => null)); sessionHeaders = apiKeyHeaders; + resolvedByApiKey = resolved != null; } } // No session resolved from any credential shape -> unauthenticated. // The middleware's failure strategy renders this as a 401. if (!resolved) return yield* new Unauthorized(); + const credential = yield* credentialFor( + request.headers, + resolved.session, + resolvedByApiKey, + credentialNames, + ); // Single-org instance: every authenticated user belongs to the one // seeded org. Cookie/bearer-session logins are pinned to it by the // session hook; API-key-minted sessions carry no active org, so we @@ -120,8 +130,36 @@ export const betterAuthIdentityLayer: Layer.Layer role.length > 0), orgRoleModel: "organization", orgRole, + credential, }; }), }); }), ); + +/** + * Which credential resolved the session, for the tool call log. Three shapes + * reach here (the MCP OAuth bearer is resolved separately, in mcp/auth.ts): + * + * - an API key: the api-key plugin mints its session with `session.id` set + * to the key's own id (better-auth api-key 1.6.12), so the id names it; + * - a bearer SESSION token — the CLI's device login — recognised by the + * presented bearer being that session's token (signed or bare); + * - otherwise the browser's session cookie. + */ +const credentialFor = ( + headers: Headers, + session: { readonly id: string; readonly token: string }, + resolvedByApiKey: boolean, + names: CredentialNames, +): Effect.Effect => + Effect.gen(function* () { + if (resolvedByApiKey) { + return { kind: "api_key", id: session.id, name: yield* names.apiKeyName(session.id) }; + } + const bearer = bearerToken(headers); + if (bearer !== undefined && session.token.length > 0 && bearer.startsWith(session.token)) { + return { kind: "cli", id: null, name: "CLI login" }; + } + return { kind: "session", id: null, name: "Web console" }; + }); diff --git a/apps/host-selfhost/src/mcp/auth.ts b/apps/host-selfhost/src/mcp/auth.ts index 967d4255d3..ca37487b9c 100644 --- a/apps/host-selfhost/src/mcp/auth.ts +++ b/apps/host-selfhost/src/mcp/auth.ts @@ -13,6 +13,7 @@ import { import { isPrivileged } from "../admin/require-admin"; import { BetterAuth } from "../auth/better-auth"; +import { makeCredentialNames } from "../auth/credential-names"; import { MCP_ORIGINAL_PATH_HEADER, mcpResourcePathFromOriginalPath } from "./org-path"; // --------------------------------------------------------------------------- @@ -200,6 +201,7 @@ export const selfHostMcpAuth: Layer.Layer auth.$context); + const credentialNames = makeCredentialNames(auth); /** Enrich a bare OAuth `userId` into the full provider-neutral principal. */ const principalFromUserId = (userId: string): Effect.Effect => @@ -250,7 +252,19 @@ export const selfHostMcpAuth: Layer.Layer null)); /** (b) The existing cookie / bearer-session / x-api-key path. The fallback's diff --git a/apps/host-selfhost/src/mcp/mcp-oauth.test.ts b/apps/host-selfhost/src/mcp/mcp-oauth.test.ts index a66952a0e1..b30fe8008c 100644 --- a/apps/host-selfhost/src/mcp/mcp-oauth.test.ts +++ b/apps/host-selfhost/src/mcp/mcp-oauth.test.ts @@ -208,5 +208,45 @@ test("MCP OAuth opaque-bearer flow authenticates /mcp end-to-end", async () => { }), ); expect(init.status).toBe(200); - expect(init.headers.get("mcp-session-id")).not.toBe(null); + const sessionId = init.headers.get("mcp-session-id"); + expect(sessionId).not.toBe(null); + + // 5. A tool call over that session is recorded under the OAuth client that + // made it — by the name it registered — so the audit shows which agent ran + // it, not only which member. + const mcpPost = (body: unknown) => + handler( + new Request(`${BASE}/mcp`, { + method: "POST", + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + accept: "application/json, text/event-stream", + "mcp-session-id": sessionId ?? "", + "mcp-protocol-version": "2025-03-26", + }, + body: JSON.stringify(body), + }), + ); + await (await mcpPost({ jsonrpc: "2.0", method: "notifications/initialized" })).text(); + const call = await mcpPost({ + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "execute", + arguments: { code: "return await tools.executor.coreTools.policies.list({})" }, + }, + }); + expect(call.status).toBe(200); + await call.text(); + + const log = await handler(new Request(`${BASE}/api/tool-calls`, { headers: { cookie } })); + expect(log.status).toBe(200); + const rows = (await log.json()) as readonly { readonly client: unknown }[]; + expect(rows.map((row) => row.client)).toContainEqual({ + kind: "oauth_client", + id: clientId, + name: "test-client", + }); }); diff --git a/apps/host-selfhost/src/tool-call-attribution.test.ts b/apps/host-selfhost/src/tool-call-attribution.test.ts new file mode 100644 index 0000000000..9df252c550 --- /dev/null +++ b/apps/host-selfhost/src/tool-call-attribution.test.ts @@ -0,0 +1,141 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, beforeAll, expect, test } from "@effect/vitest"; + +// --------------------------------------------------------------------------- +// The tool call log names the CLIENT behind every call, not only the member: +// which API key, which CLI login, the browser console. Only the self-host +// identity layer sees the credential, so this boots the real app (real Better +// Auth, real API keys) and reads the rows back over the public API. +// --------------------------------------------------------------------------- + +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-attribution-")); +process.env.BETTER_AUTH_SECRET = "attribution-secret-0123456789-abcdefghij-klmn"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "owner@attribution.test"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "owner-pass-123456"; + +let handler!: (request: Request) => Promise; +let dispose: () => Promise = async () => {}; + +beforeAll(async () => { + const { makeSelfHostApiHandler } = await import("./app"); + const app = await makeSelfHostApiHandler(); + handler = app.handler; + dispose = app.dispose; +}); +afterAll(() => dispose()); + +const BASE = "http://localhost:4788"; + +interface ToolCallRow { + readonly address: string; + readonly actorLabel: string | null; + readonly client: { + readonly kind: string; + readonly id: string | null; + readonly name: string | null; + } | null; +} + +const signIn = async (): Promise<{ readonly token: string; readonly cookie: string }> => { + const res = await handler( + new Request(`${BASE}/api/auth/sign-in/email`, { + method: "POST", + headers: { "content-type": "application/json", origin: BASE }, + body: JSON.stringify({ + email: "owner@attribution.test", + password: "owner-pass-123456", + }), + }), + ); + expect(res.status).toBe(200); + const token = res.headers.get("set-auth-token") ?? ""; + const cookie = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; + expect(token).not.toBe(""); + expect(cookie).not.toBe(""); + return { token, cookie }; +}; + +const createApiKey = async ( + sessionToken: string, + name: string, +): Promise<{ readonly id: string; readonly key: string }> => { + const res = await handler( + new Request(`${BASE}/api/auth/api-key/create`, { + method: "POST", + headers: { + authorization: `Bearer ${sessionToken}`, + "content-type": "application/json", + origin: BASE, + }, + body: JSON.stringify({ name }), + }), + ); + expect(res.status).toBe(200); + return (await res.json()) as { id: string; key: string }; +}; + +// A core tool: no integration, no network, but a real trip through `execute`. +const CALL = "return await tools.executor.coreTools.policies.list({})"; + +const runCode = (headers: Record) => + handler( + new Request(`${BASE}/api/executions`, { + method: "POST", + headers: { ...headers, "content-type": "application/json", origin: BASE }, + body: JSON.stringify({ code: CALL }), + }), + ); + +const readToolCalls = async (sessionToken: string, query = ""): Promise => { + const res = await handler( + new Request(`${BASE}/api/tool-calls${query}`, { + headers: { authorization: `Bearer ${sessionToken}` }, + }), + ); + expect(res.status).toBe(200); + return (await res.json()) as ToolCallRow[]; +}; + +test("every tool call names the client it came in on", async () => { + const { token, cookie } = await signIn(); + const apiKey = await createApiKey(token, "jean-mcp"); + + // The same call three ways: an API key, the CLI's bearer session, the + // browser's cookie. + expect((await runCode({ authorization: `Bearer ${apiKey.key}` })).status).toBe(200); + expect((await runCode({ authorization: `Bearer ${token}` })).status).toBe(200); + expect((await runCode({ cookie })).status).toBe(200); + + const calls = await readToolCalls(token); + const clients = calls.map((call) => call.client); + expect(clients).toContainEqual({ kind: "api_key", id: apiKey.id, name: "jean-mcp" }); + expect(clients).toContainEqual({ kind: "cli", id: null, name: "CLI login" }); + expect(clients).toContainEqual({ kind: "session", id: null, name: "Web console" }); + // And WHO, on every one of them. + for (const call of calls) expect(call.actorLabel).toBe("owner@attribution.test"); + + // The API key's secret is never part of what is stored or served. + expect(JSON.stringify(calls)).not.toContain(apiKey.key); +}); + +test("the log filters by client and lists the clients it has seen", async () => { + const { token } = await signIn(); + + const onlyKey = await readToolCalls(token, "?client=jean-mcp"); + expect(onlyKey.length).toBeGreaterThan(0); + for (const call of onlyKey) expect(call.client?.name).toBe("jean-mcp"); + + const res = await handler( + new Request(`${BASE}/api/tool-calls/clients`, { + headers: { authorization: `Bearer ${token}` }, + }), + ); + expect(res.status).toBe(200); + const seen = (await res.json()) as readonly { readonly kind: string; readonly name: string }[]; + expect(seen.map((client) => client.name)).toEqual( + expect.arrayContaining(["jean-mcp", "CLI login", "Web console"]), + ); +}); diff --git a/apps/host-selfhost/web/routeTree.gen.ts b/apps/host-selfhost/web/routeTree.gen.ts index 4d09b63152..44a8aecdc1 100644 --- a/apps/host-selfhost/web/routeTree.gen.ts +++ b/apps/host-selfhost/web/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImp import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' import { Route as ApiKeysRouteImport } from './routes/app/api-keys' import { Route as AdminRouteImport } from './routes/app/admin' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRouteImport } from './../../../packages/react/src/routes/activity' import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' @@ -80,6 +81,12 @@ const AdminRoute = AdminRouteImport.update({ path: '/{-$orgSlug}/admin', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRouteImport.update({ + id: '/{-$orgSlug}/activity', + path: '/{-$orgSlug}/activity', + getParentRoute: () => rootRouteImport, + } as any) const JoinDotcodeRoute = JoinDotcodeRouteImport.update({ id: '/join/$code', path: '/join/$code', @@ -154,6 +161,7 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginK export interface FileRoutesByFullPath { '/join/$code': typeof JoinDotcodeRoute + '/{-$orgSlug}/activity': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute '/{-$orgSlug}/admin': typeof AdminRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren @@ -174,6 +182,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/join/$code': typeof JoinDotcodeRoute + '/{-$orgSlug}/activity': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute '/{-$orgSlug}/admin': typeof AdminRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren @@ -195,6 +204,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/join/$code': typeof JoinDotcodeRoute + '/{-$orgSlug}/activity': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute '/{-$orgSlug}/admin': typeof AdminRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren @@ -217,6 +227,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/join/$code' + | '/{-$orgSlug}/activity' | '/{-$orgSlug}/admin' | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' @@ -237,6 +248,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/join/$code' + | '/{-$orgSlug}/activity' | '/{-$orgSlug}/admin' | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' @@ -257,6 +269,7 @@ export interface FileRouteTypes { id: | '__root__' | '/join/$code' + | '/{-$orgSlug}/activity' | '/{-$orgSlug}/admin' | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' @@ -278,6 +291,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { JoinDotcodeRoute: typeof JoinDotcodeRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute AdminRoute: typeof AdminRoute ApiKeysRoute: typeof ApiKeysRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren @@ -360,6 +374,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/activity': { + id: '/{-$orgSlug}/activity' + path: '/{-$orgSlug}/activity' + fullPath: '/{-$orgSlug}/activity' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRouteImport + parentRoute: typeof rootRouteImport + } '/join/$code': { id: '/join/$code' path: '/join/$code' @@ -458,6 +479,8 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren = const rootRouteChildren: RootRouteChildren = { JoinDotcodeRoute: JoinDotcodeRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute, AdminRoute: AdminRoute, ApiKeysRoute: ApiKeysRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: diff --git a/packages/app/src/routeTree.gen.ts b/packages/app/src/routeTree.gen.ts index 649117c693..08228b94bd 100644 --- a/packages/app/src/routeTree.gen.ts +++ b/packages/app/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsRouteImport } from './ import { Route as SecretsRouteImport } from './routes/app/secrets' import { Route as DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport } from './../../react/src/routes/policies' import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsRouteImport } from './../../react/src/routes/artifacts' +import { Route as DotDotDotDotDotDotReactSrcRoutesActivityRouteImport } from './../../react/src/routes/activity' import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug' import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId' import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../react/src/routes/integrations.browse' @@ -59,6 +60,12 @@ const DotDotDotDotDotDotReactSrcRoutesArtifactsRoute = path: '/{-$orgSlug}/artifacts', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotReactSrcRoutesActivityRoute = + DotDotDotDotDotDotReactSrcRoutesActivityRouteImport.update({ + id: '/{-$orgSlug}/activity', + path: '/{-$orgSlug}/activity', + getParentRoute: () => rootRouteImport, + } as any) const DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute = DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update({ id: '/$toolkitSlug', @@ -111,6 +118,7 @@ const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute = ) export interface FileRoutesByFullPath { + '/{-$orgSlug}/activity': typeof DotDotDotDotDotDotReactSrcRoutesActivityRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute @@ -127,6 +135,7 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute } export interface FileRoutesByTo { + '/{-$orgSlug}/activity': typeof DotDotDotDotDotDotReactSrcRoutesActivityRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute @@ -144,6 +153,7 @@ export interface FileRoutesByTo { } export interface FileRoutesById { __root__: typeof rootRouteImport + '/{-$orgSlug}/activity': typeof DotDotDotDotDotDotReactSrcRoutesActivityRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute @@ -162,6 +172,7 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: + | '/{-$orgSlug}/activity' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' @@ -178,6 +189,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/plugins/$pluginId/$' fileRoutesByTo: FileRoutesByTo to: + | '/{-$orgSlug}/activity' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' @@ -194,6 +206,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/plugins/$pluginId/$' id: | '__root__' + | '/{-$orgSlug}/activity' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' @@ -211,6 +224,7 @@ export interface FileRouteTypes { fileRoutesById: FileRoutesById } export interface RootRouteChildren { + DotDotDotDotDotDotReactSrcRoutesActivityRoute: typeof DotDotDotDotDotDotReactSrcRoutesActivityRoute DotDotDotDotDotDotReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren DotDotDotDotDotDotReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute SecretsRoute: typeof SecretsRoute @@ -269,6 +283,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/activity': { + id: '/{-$orgSlug}/activity' + path: '/{-$orgSlug}/activity' + fullPath: '/{-$orgSlug}/activity' + preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesActivityRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/toolkits/$toolkitSlug': { id: '/{-$orgSlug}/toolkits/$toolkitSlug' path: '/$toolkitSlug' @@ -359,6 +380,8 @@ const DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren = ) const rootRouteChildren: RootRouteChildren = { + DotDotDotDotDotDotReactSrcRoutesActivityRoute: + DotDotDotDotDotDotReactSrcRoutesActivityRoute, DotDotDotDotDotDotReactSrcRoutesArtifactsRoute: DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren, DotDotDotDotDotDotReactSrcRoutesPoliciesRoute: diff --git a/packages/core/api/src/account/org-slug.ts b/packages/core/api/src/account/org-slug.ts index addd842831..4b70b7a1ec 100644 --- a/packages/core/api/src/account/org-slug.ts +++ b/packages/core/api/src/account/org-slug.ts @@ -46,6 +46,7 @@ export const RESERVED_ORG_SLUGS: ReadonlySet = new Set([ "connect", "integrations", "policies", + "activity", "secrets", "tools", "toolkits", diff --git a/packages/core/api/src/api.ts b/packages/core/api/src/api.ts index 4bbe145e23..ef01ed92c9 100644 --- a/packages/core/api/src/api.ts +++ b/packages/core/api/src/api.ts @@ -9,6 +9,7 @@ import { ExecutionsApi } from "./executions/api"; import { OAuthApi } from "./oauth/api"; import { PoliciesApi } from "./policies/api"; import { ArtifactsApi } from "./artifacts/api"; +import { ToolCallsApi } from "./tool-calls/api"; export const CoreExecutorApi = HttpApi.make("executor") .add(ToolsApi) @@ -19,6 +20,7 @@ export const CoreExecutorApi = HttpApi.make("executor") .add(OAuthApi) .add(PoliciesApi) .add(ArtifactsApi) + .add(ToolCallsApi) .annotateMerge( OpenApi.annotations({ title: "Executor API", diff --git a/packages/core/api/src/handlers/index.ts b/packages/core/api/src/handlers/index.ts index 360952bd7d..6e5528a4ac 100644 --- a/packages/core/api/src/handlers/index.ts +++ b/packages/core/api/src/handlers/index.ts @@ -8,6 +8,7 @@ import { ExecutionsHandlers } from "./executions"; import { OAuthHandlers } from "./oauth"; import { PoliciesHandlers } from "./policies"; import { ArtifactsHandlers } from "./artifacts"; +import { ToolCallsHandlers } from "./tool-calls"; export { ToolsHandlers } from "./tools"; export { IntegrationsHandlers } from "./integrations"; @@ -17,6 +18,7 @@ export { ExecutionsHandlers } from "./executions"; export { OAuthHandlers } from "./oauth"; export { PoliciesHandlers } from "./policies"; export { ArtifactsHandlers } from "./artifacts"; +export { ToolCallsHandlers } from "./tool-calls"; export const CoreHandlers = Layer.mergeAll( ToolsHandlers, @@ -27,4 +29,5 @@ export const CoreHandlers = Layer.mergeAll( OAuthHandlers, PoliciesHandlers, ArtifactsHandlers, + ToolCallsHandlers, ); diff --git a/packages/core/api/src/handlers/tool-calls.ts b/packages/core/api/src/handlers/tool-calls.ts new file mode 100644 index 0000000000..53b8033dc4 --- /dev/null +++ b/packages/core/api/src/handlers/tool-calls.ts @@ -0,0 +1,66 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { Effect } from "effect"; +import type { ToolCall, ToolCallClientSummary } from "@executor-js/sdk"; + +import { ExecutorApi } from "../api"; +import { ExecutorService } from "../services"; +import { capture } from "@executor-js/api"; + +const toResponse = (call: ToolCall) => ({ + id: call.id, + owner: call.owner, + address: call.address, + integration: call.integration, + connection: call.connection, + tool: call.tool, + outcome: call.outcome, + errorCode: call.errorCode, + errorMessage: call.errorMessage, + policyAction: call.policyAction, + policyPattern: call.policyPattern, + durationMs: call.durationMs, + argKeys: call.argKeys, + actor: call.actor, + actorLabel: call.actorLabel, + client: call.client, + createdAt: call.createdAt.getTime(), +}); + +const toClientResponse = (client: ToolCallClientSummary) => ({ + kind: client.kind, + name: client.name, + calls: client.calls, + lastCallAt: client.lastCallAt.getTime(), +}); + +export const ToolCallsHandlers = HttpApiBuilder.group(ExecutorApi, "toolCalls", (handlers) => + handlers + .handle("list", ({ query }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const calls = yield* executor.toolCalls.list({ + integration: query.integration, + connection: query.connection, + outcome: query.outcome, + // Epoch ms on the wire; the executor filters on a Date. + since: query.since === undefined ? undefined : new Date(query.since), + limit: query.limit, + offset: query.offset, + search: query.search, + clientName: query.client, + }); + return calls.map(toResponse); + }), + ), + ) + .handle("clients", () => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const clients = yield* executor.toolCalls.clients(); + return clients.map(toClientResponse); + }), + ), + ), +); diff --git a/packages/core/api/src/server.ts b/packages/core/api/src/server.ts index bba228e10f..f7a56f0f02 100644 --- a/packages/core/api/src/server.ts +++ b/packages/core/api/src/server.ts @@ -13,6 +13,7 @@ export { OAuthHandlers, PoliciesHandlers, ArtifactsHandlers, + ToolCallsHandlers, ExecutionsHandlers, } from "./handlers"; export { @@ -85,10 +86,14 @@ export { PluginsProvider, RequestWebOrigin, RequestOrgSlug, + RequestCaller, + provideRequestCaller, + type CallerSource, type HostConfigShape, type PluginsProviderShape, type RequestWebOriginShape, type RequestOrgSlugShape, + type RequestCallerShape, } from "./server/scoped-executor"; export { collectTables } from "@executor-js/sdk"; export { @@ -102,6 +107,7 @@ export { authContextFromPlatform, isPlatformPrincipal, type Principal, + type PrincipalCredential, type PlatformPrincipal, type ResolvedPrincipal, type IdentityProviderShape, diff --git a/packages/core/api/src/server/execution-stack-middleware.ts b/packages/core/api/src/server/execution-stack-middleware.ts index df3b0d9341..d1a7c84699 100644 --- a/packages/core/api/src/server/execution-stack-middleware.ts +++ b/packages/core/api/src/server/execution-stack-middleware.ts @@ -43,6 +43,7 @@ import type { ExecutionEngine } from "@executor-js/execution"; import type { DbProvider } from "./executor-fuma-db"; import { + provideRequestCaller, RequestOrgSlug, RequestWebOrigin, type HostConfig, @@ -257,6 +258,8 @@ export const makeExecutionStackMiddleware = < Effect.provideService(RequestWebOrigin, { origin: requestWebOriginFromRequest(webRequest), }), + // Which credential this request came in on, for the tool call log. + provideRequestCaller(resolved), ); // Pin browser-handoff URLs to the resolved org's slug when the identity // provider carried one. Absent slug -> the service stays unprovided and diff --git a/packages/core/api/src/server/identity.ts b/packages/core/api/src/server/identity.ts index 4c88040a96..82441b9544 100644 --- a/packages/core/api/src/server/identity.ts +++ b/packages/core/api/src/server/identity.ts @@ -21,6 +21,20 @@ // --------------------------------------------------------------------------- import { Context, Effect, Schema } from "effect"; +import type { ToolCallClientKind } from "@executor-js/sdk"; + +/** + * The credential a request authenticated with, when the provider can tell: + * which API key, which OAuth-connected MCP client, or the browser console. + * Recorded on the tool call log so an audit row names the agent and not only + * the member. NEVER an authorization input — two credentials of one member + * carry exactly the same rights. + */ +export interface PrincipalCredential { + readonly kind: ToolCallClientKind; + readonly id: string | null; + readonly name: string | null; +} /** * The provider-neutral resolved identity. Both self-host's AuthProvider impls @@ -48,6 +62,9 @@ interface PrincipalBase { readonly name: string | null; readonly avatarUrl: string | null; readonly roles: readonly string[]; + /** The credential the request came in on, when the provider can tell. See + * {@link PrincipalCredential}; absent on providers that do not say. */ + readonly credential?: PrincipalCredential; } /** diff --git a/packages/core/api/src/server/mcp-build.ts b/packages/core/api/src/server/mcp-build.ts index 3891f6cae3..666b7e71d6 100644 --- a/packages/core/api/src/server/mcp-build.ts +++ b/packages/core/api/src/server/mcp-build.ts @@ -15,7 +15,12 @@ import { import { ErrorCapture } from "../observability"; import { CodeExecutorProvider, EngineDecorator, makeExecutionStack } from "./execution-stack"; import { DbProvider } from "./executor-fuma-db"; -import { HostConfig, PluginsProvider, RequestOrgSlug } from "./scoped-executor"; +import { + HostConfig, + PluginsProvider, + provideRequestCaller, + RequestOrgSlug, +} from "./scoped-executor"; // --------------------------------------------------------------------------- // Shared in-process MCP host helpers. @@ -64,6 +69,10 @@ export const makeMcpBuildServer = principal.organizationSlug !== undefined ? Effect.provideService(RequestOrgSlug, { slug: principal.organizationSlug }) : (effect) => effect, + // The credential that opened this MCP session (an OAuth client, an API + // key), stamped on every tool call it makes. A session is built once per + // connection, so this is the client for the session's whole life. + provideRequestCaller(principal), Effect.provide(executionStack), Effect.mapError((cause) => new McpEngineBuildError({ cause })), Effect.flatMap(({ engine, executor, webBaseUrl }) => diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index 749839be3e..b8c44902ad 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -43,6 +43,8 @@ import { type ExecutorConfig, type FirstPartyOAuthClientConfig, type StorageFailure, + type ToolCallCaller, + type ToolCallClientKind, } from "@executor-js/sdk"; import { makeHostedFetch, @@ -180,6 +182,55 @@ export class RequestOrgSlug extends Context.Service()( + "@executor-js/api/RequestCaller", +) {} + +/** The subset of a principal the caller is derived from — satisfied by both + * the API `Principal` and host-mcp's Schema'd copy. */ +export interface CallerSource { + readonly email: string; + readonly name: string | null; + readonly credential?: { + readonly kind: ToolCallClientKind; + readonly id: string | null; + readonly name: string | null; + }; +} + +/** + * Provide {@link RequestCaller} from a resolved principal — or nothing, when + * the provider did not say which credential it saw. + */ +export const provideRequestCaller = + (source: CallerSource) => + (effect: Effect.Effect): Effect.Effect => + source.credential === undefined + ? effect + : Effect.provideService(effect, RequestCaller, { + caller: { + kind: source.credential.kind, + id: source.credential.id, + name: source.credential.name, + actorLabel: source.email !== "" ? source.email : source.name, + }, + }); + const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); const isLoopbackOrigin = (origin: string): boolean => { @@ -297,6 +348,12 @@ export const makeScopedExecutor = < onNone: () => undefined, onSome: (o) => o.slug, }); + // The credential behind this request, for the tool call log. Absent when + // the host's identity layer did not say. + const caller = Option.match(yield* Effect.serviceOption(RequestCaller), { + onNone: () => undefined, + onSome: (c) => c.caller, + }); // EXPLICIT OAuth wiring: the redirect callback the host serves and sends to // providers is `${webBaseUrl}${oauthCallbackPath}` — the host's API mount @@ -337,6 +394,7 @@ export const makeScopedExecutor = < ...(waitUntil !== undefined ? { waitUntil } : {}), onElicitation: "accept-all", ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }), + ...(caller === undefined ? {} : { caller }), redirectUri, oauthCallbackStateOrgSlug: orgSlug, firstPartyOAuthClients: config.firstPartyOAuthClients, diff --git a/packages/core/api/src/tool-calls/api.ts b/packages/core/api/src/tool-calls/api.ts new file mode 100644 index 0000000000..bfc635da36 --- /dev/null +++ b/packages/core/api/src/tool-calls/api.ts @@ -0,0 +1,110 @@ +// --------------------------------------------------------------------------- +// Tool call log HTTP API — the audit trail. +// +// One row per tool call that reached the executor, including the ones a policy +// blocked and the approvals a caller declined. Read-only by construction: a +// log a caller can edit is not evidence, so there is no write endpoint and no +// delete. Owner-scoped like the rest of the API, so no owner travels on the +// wire — a caller reads back exactly the calls its own scope may see. +// --------------------------------------------------------------------------- + +import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { Schema } from "effect"; +import { InternalError, Owner } from "@executor-js/sdk/shared"; +import { TOOL_CALL_LIST_MAX_LIMIT } from "@executor-js/sdk"; + +const ToolCallOutcome = Schema.Literals(["ok", "fail", "blocked", "declined", "error"]); + +/** How the caller authenticated — see `ToolCallClientKind`. */ +const ToolCallClientKind = Schema.Literals(["api_key", "oauth_client", "session", "cli"]); + +const ToolCallClientResponse = Schema.Struct({ + kind: ToolCallClientKind, + /** The credential's own id (API key id, OAuth client id), when it has one. */ + id: Schema.NullOr(Schema.String), + /** Its human label: the key's name, the OAuth client's registered name. */ + name: Schema.NullOr(Schema.String), +}); + +const ToolCallResponse = Schema.Struct({ + id: Schema.String, + owner: Owner, + /** The address as called, e.g. `github.org.main.repos.get`. */ + address: Schema.String, + /** Null for static tools, which have no connection behind them. */ + integration: Schema.NullOr(Schema.String), + connection: Schema.NullOr(Schema.String), + tool: Schema.NullOr(Schema.String), + outcome: ToolCallOutcome, + errorCode: Schema.NullOr(Schema.String), + errorMessage: Schema.NullOr(Schema.String), + /** The policy that governed the call, when a rule matched it. */ + policyAction: Schema.NullOr(Schema.String), + policyPattern: Schema.NullOr(Schema.String), + durationMs: Schema.Number, + /** Top-level argument names. Never their values — see `tool-call-log.ts`. */ + argKeys: Schema.NullOr(Schema.Array(Schema.String)), + /** The member who ran the call, even when the row is owned by the org. + * Null for calls recorded before this was kept. */ + actor: Schema.NullOr(Schema.String), + actorLabel: Schema.NullOr(Schema.String), + /** The credential the call came in on. Null when the host did not say. */ + client: Schema.NullOr(ToolCallClientResponse), + /** Epoch milliseconds, like every other timestamp on this API. */ + createdAt: Schema.Number, +}); + +/** One distinct client in the visible log. */ +const ToolCallClientSummaryResponse = Schema.Struct({ + kind: ToolCallClientKind, + name: Schema.NullOr(Schema.String), + calls: Schema.Number, + /** Epoch milliseconds. */ + lastCallAt: Schema.Number, +}); + +/** + * Query filters. + * + * `since` is epoch milliseconds rather than a date string: it is what the + * other endpoints already put on the wire, and it survives a round trip + * through a URL without a timezone argument. + */ +const ListToolCallsQuery = Schema.Struct({ + integration: Schema.optional(Schema.String), + connection: Schema.optional(Schema.String), + outcome: Schema.optional(ToolCallOutcome), + since: Schema.optional(Schema.FiniteFromString), + limit: Schema.optional( + Schema.FiniteFromString.check( + Schema.isBetween({ minimum: 1, maximum: TOOL_CALL_LIST_MAX_LIMIT }), + ), + ), + // Paging. Bounded like every other numeric input: an unbounded offset is a + // cheap way to make the database walk the whole partition. + offset: Schema.optional( + Schema.FiniteFromString.check(Schema.isBetween({ minimum: 0, maximum: 1_000_000 })), + ), + /** Substring match on the address. Bounded: nobody types 200 characters of + * tool address, and an unbounded pattern is free load on the database. */ + search: Schema.optional(Schema.String.check(Schema.isMaxLength(200))), + /** Exact client name ("Claude Code"). Bounded like `search`. */ + client: Schema.optional(Schema.String.check(Schema.isMaxLength(200))), +}); + +export const ToolCallsApi = HttpApiGroup.make("toolCalls") + .add( + HttpApiEndpoint.get("list", "/tool-calls", { + query: ListToolCallsQuery, + success: Schema.Array(ToolCallResponse), + error: InternalError, + }), + ) + .add( + // The clients seen in the caller's visible log, most recently active first + // — the choices for the Activity page's client filter. + HttpApiEndpoint.get("clients", "/tool-calls/clients", { + success: Schema.Array(ToolCallClientSummaryResponse), + error: InternalError, + }), + ); diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index 8014584695..795ba2c400 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -375,6 +375,76 @@ export const coreTables = defineTables({ ["tenant", "owner", "subject", "id"], ), + // One row per tool call that reached `execute`, written after the call + // settles. This is the audit trail: which connection an agent used, when, + // and how it ended — including the calls a policy blocked and the approvals + // a caller declined, which today leave no trace at all. + // + // Deliberately NOT the analytics catalog in `@executor-js/analytics`: that + // one is anonymous by construction and forbids exactly the fields an audit + // needs (tool address, integration, connection). Owner-scoped like every + // other personal row, so a subject reads back its own calls and nobody + // else's. + // + // Arguments and results are never stored. They carry the caller's data and, + // for a credential-shaped argument, the credential itself; `arg_keys` keeps + // the top-level parameter NAMES, which is what an audit needs to answer + // "what did it ask for" without the table becoming a secret store. + tool_call_log: ownedExecutorTable( + "tool_call_log", + { + id: keyColumn("id"), + // The address as called: `integration.owner.connection.tool`, or a + // static tool's fqid. Verbatim, so a row greps against what the agent + // actually wrote. + address: textColumn("address"), + // The parsed parts, so a report groups by integration or connection + // without re-parsing every address. Null for static tools (core-tools, + // plugin namespaces), which have no connection behind them. + integration: nullableKeyColumn("integration"), + connection: nullableKeyColumn("connection"), + tool: nullableTextColumn("tool"), + // ok | fail | blocked | declined | error — see ToolCallOutcome. + outcome: keyColumn("outcome"), + // For `fail` the upstream's own error code; otherwise the failure tag. + // Null when the call simply succeeded. + error_code: nullableTextColumn("error_code"), + // One line of human context, truncated. Never a response body. + error_message: nullableTextColumn("error_message"), + // The policy that governed this call, so the row shows both what + // happened and under which rule. Null when no rule matched. + policy_action: nullableTextColumn("policy_action"), + policy_pattern: nullableTextColumn("policy_pattern"), + // Wall-clock duration of the call, in milliseconds. + duration_ms: bigintColumn("duration_ms"), + // Top-level argument names only — never their values. + arg_keys: nullableJsonColumn("arg_keys"), + // WHO ran the call, independent of the tier the row files under. A call + // on an org connection is owned by the org (subject "") so the whole + // workspace reads it — which on its own loses the member behind it. + // `actor` keeps that member's subject id; `actor_label` the name the + // host resolved (email, else display name), so a report reads without a + // user lookup. Null for a subject-less (platform) executor. + actor: nullableKeyColumn("actor"), + actor_label: nullableTextColumn("actor_label"), + // WHICH credential the caller presented — see ToolCallClientKind. + // `client_id` is the credential's own id (the API key id, the OAuth + // client id), `client_name` its human label ("jean-mcp", "Claude + // Code"). Only the host sees the credential, so the host stamps these; + // null when it did not say (older rows, hosts without the seam). + client_kind: nullableKeyColumn("client_kind"), + client_id: nullableKeyColumn("client_id"), + client_name: nullableTextColumn("client_name"), + created_at: dateColumn("created_at"), + }, + // The conventional owned-table key. Note what it does NOT do: a subject's + // view spans two partitions (its own rows plus the org's), so a newest- + // first read across both still sorts. Serving that would take a + // `(tenant, created_at)` index, and the schema layer has no non-unique + // index yet — worth adding before this table gets large. + ["tenant", "owner", "subject", "id"], + ), + // A saved generative-UI artifact — the JSX source a model produced, kept so // it can be re-rendered later and matched by title/description from any MCP // client. Owner-scoped like every other personal row: artifacts are created @@ -470,6 +540,28 @@ export const TOOL_INVOCATION_COLUMNS = [ export type DefinitionRow = FumaRow; export type ToolPolicyRow = FumaRow; export type ArtifactRow = FumaRow; +export type ToolCallLogRow = FumaRow; + +/** + * How a tool call ended. + * + * `ok` and `fail` both mean the call reached the upstream service: `fail` is + * the tool's own error result (a 404 from the API, an expired credential), + * which travels the success channel and would otherwise read as healthy. + * `blocked` and `declined` mean it never left the gateway — a policy stopped + * it, or the human refused the approval. `error` is everything else: the tool + * or connection did not exist, the plugin could not be loaded, the transport + * broke. + */ +export type ToolCallOutcome = "ok" | "fail" | "blocked" | "declined" | "error"; + +export const TOOL_CALL_OUTCOMES = [ + "ok", + "fail", + "blocked", + "declined", + "error", +] as const satisfies readonly ToolCallOutcome[]; /** * The columns a list projects — everything except the JSX source, which only a * full read needs. diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index fdbc9b7671..7165cbcf3f 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -33,6 +33,20 @@ import { } from "./fuma-runtime"; import { makeFumaBlobStore, pluginBlobStore, type BlobStore, type OwnerPartitions } from "./blob"; import { makePendingApprovalStore, type PendingApprovalStore } from "./pending-approval"; +import { + clampToolCallLimit, + cleanToolCallLabel, + isToolCallClientKind, + TOOL_CALL_CLIENTS_WINDOW, + rowToToolCall, + toolCallArgKeys, + toolCallOutcome, + type ListToolCallsInput, + type PruneToolCallsInput, + type ToolCall, + type ToolCallCaller, + type ToolCallClientSummary, +} from "./tool-call-log"; import { coreToolsPlugin } from "./core-tools"; import type { Connection, @@ -502,6 +516,22 @@ export type Executor = { */ readonly admin?: ExecutorAdmin; /** Saved generative-UI artifacts, visible to the bound owner scope. */ + /** + * The audit trail: one row per tool call that reached `execute`, including + * the calls a policy blocked and the approvals a caller declined. + */ + readonly toolCalls: { + /** Newest first, filtered and capped — the log is unbounded. */ + readonly list: ( + input?: ListToolCallsInput, + ) => Effect.Effect; + /** Drop rows older than `before`. Retention is the host's policy to set. */ + readonly prune: (input: PruneToolCallsInput) => Effect.Effect; + /** The distinct clients seen in the visible log, most recently active + * first — the choices for a "which agent" filter. */ + readonly clients: () => Effect.Effect; + }; + readonly artifacts: { /** Newest first, without the JSX source — lists stay light. */ readonly list: () => Effect.Effect; @@ -853,6 +883,14 @@ export interface ExecutorConfig EffectivePolicy | null, + ) => { + const startedAt = Date.now(); + return (effect: Effect.Effect): Effect.Effect => + Effect.onExit(effect, (exit) => + writeToolCallRow({ + address, + args, + policy: policy(), + exit, + durationMs: Date.now() - startedAt, + }).pipe( + // The insert is awaited, deliberately unbounded. It used to carry + // an Effect.timeout, but an onExit finalizer runs uninterruptible, + // so the timeout's interrupt could never land — the cap was + // decorative in exactly the sick-database case it was written for, + // and its timer deadlocked the run loop under an adversarial + // scheduler budget (caught by the execute-read-concurrency + // tests). Bounding a stalled driver is the driver's job; what + // this wrapper owes the caller is that a row exists before the + // call returns, and that a failed write never changes the call. + // Never let the audit write decide the call's fate — including a + // defect. The row is the record OF the call, not part of it; a + // gap in the log beats taking the gateway down with it. + Effect.catchCause((cause) => + Effect.logWarning("tool call log: row not written", cause).pipe( + Effect.annotateLogs({ address: String(address) }), + ), + ), + ), + ); + }; + + const writeToolCallRow = ({ + address, + args, + policy, + exit, + durationMs, + }: { + readonly address: ToolAddress; + readonly args: unknown; + readonly policy: EffectivePolicy | null; + readonly exit: Exit.Exit; + readonly durationMs: number; + }): Effect.Effect => + Effect.gen(function* () { + const parsed = parseToolAddress(String(address)); + // A call is logged under the owner tier it targeted, so an org + // connection's calls stay visible org-wide and a user's stay personal. + // A static tool has no owner in its address; it belongs to whoever ran + // it, falling back to the org scope for a subject-less executor. + const tier: Owner = parsed ? parsed.owner : subject == null ? "org" : "user"; + const keys = yield* Effect.try({ + try: () => ownedKeys(tier), + catch: (cause) => storageFailureFromUnknown("invalid owner", cause), + }); + const outcome = toolCallOutcome(exit); + yield* core.create("tool_call_log", { + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + id: `tcl_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`, + address: String(address), + integration: parsed ? String(parsed.integration) : null, + connection: parsed ? String(parsed.connection) : null, + tool: parsed ? String(parsed.tool) : null, + outcome: outcome.outcome, + error_code: outcome.errorCode, + error_message: outcome.errorMessage, + policy_action: policy?.action ?? null, + policy_pattern: policy?.pattern ?? null, + duration_ms: durationMs, + arg_keys: toolCallArgKeys(args), + // The member who ran it, even when the row files under the org. + actor: subject, + actor_label: cleanToolCallLabel(caller?.actorLabel), + client_kind: caller?.kind ?? null, + client_id: caller ? cleanToolCallLabel(caller.id) : null, + client_name: caller ? cleanToolCallLabel(caller.name) : null, + created_at: new Date(), + }); + }); + + const toolCallLogList = ( + input?: ListToolCallsInput, + ): Effect.Effect => + core + .findMany("tool_call_log", { + where: toolCallLogWhere(input), + // Newest first; `id` breaks ties so two calls landing in the same + // millisecond keep a stable order between reads. + orderBy: [ + ["created_at", "desc"], + ["id", "desc"], + ], + limit: clampToolCallLimit(input?.limit), + ...(input?.offset && input.offset > 0 ? { offset: Math.floor(input.offset) } : {}), + }) + .pipe(Effect.map((rows) => rows.map(rowToToolCall))); + + /** + * Drop rows older than `before`. + * + * Retention is the host's call, not this package's: a self-hosted box and a + * regulated tenant want different windows, and an audit log that quietly + * deletes itself on a default nobody chose is worse than one that grows. + * So the executor exposes the operation and never schedules it. + */ + const toolCallLogPrune = (input: PruneToolCallsInput): Effect.Effect => + core.deleteMany("tool_call_log", { + where: (b: AnyCb) => b("created_at", "<", input.before), + }); + + /** + * The distinct clients behind the visible log, most recently active first. + * + * Grouped in memory over a bounded newest-first window rather than with a + * GROUP BY: the storage layer has no aggregate query, and a client that + * made no call in the last `TOOL_CALL_CLIENTS_WINDOW` calls is not one a + * filter needs to offer. Grouped by (kind, name), not id — one client + * re-registers under a new OAuth id, and the reader wants the agent. + */ + const toolCallLogClients = (): Effect.Effect< + readonly ToolCallClientSummary[], + StorageFailure + > => + core + .findMany("tool_call_log", { + where: (b: AnyCb) => b("client_kind", "is not", null), + orderBy: [["created_at", "desc"]], + limit: TOOL_CALL_CLIENTS_WINDOW, + select: ["client_kind", "client_name", "created_at"], + }) + .pipe( + Effect.map((rows) => { + const byClient = new Map(); + for (const row of rows) { + if (!isToolCallClientKind(row.client_kind)) continue; + const name = row.client_name == null ? null : String(row.client_name); + const key = `${row.client_kind}:${name ?? ""}`; + const at = + row.created_at instanceof Date ? row.created_at : new Date(String(row.created_at)); + const seen = byClient.get(key); + byClient.set( + key, + seen + ? { ...seen, calls: seen.calls + 1 } + : { kind: row.client_kind, name, calls: 1, lastCallAt: at }, + ); + } + return [...byClient.values()]; + }), + ); + + const toolCallLogWhere = (input?: ListToolCallsInput): CoreWhere | undefined => { + const clauses: readonly ((b: AnyCb) => Condition)[] = [ + ...(input?.integration ? [(b: AnyCb) => b("integration", "=", input.integration!)] : []), + // `%`/`_` in the query are LIKE wildcards; harmless here — the match + // never leaves the caller's own owner partition. + ...(input?.search ? [(b: AnyCb) => b("address", "contains", input.search!)] : []), + ...(input?.connection ? [(b: AnyCb) => b("connection", "=", input.connection!)] : []), + ...(input?.outcome ? [(b: AnyCb) => b("outcome", "=", input.outcome!)] : []), + ...(input?.since ? [(b: AnyCb) => b("created_at", ">=", input.since!)] : []), + ...(input?.clientName ? [(b: AnyCb) => b("client_name", "=", input.clientName!)] : []), + ]; + if (clauses.length === 0) return undefined; + return (b: AnyCb) => + clauses.length === 1 ? clauses[0]!(b) : b.and(...clauses.map((clause) => clause(b))); + }; + // ------------------------------------------------------------------ // Artifacts — saved generative-UI components, owner-scoped. // ------------------------------------------------------------------ @@ -6454,6 +6684,10 @@ export const createExecutor = => { const handler = pickHandler(options); + // The policy that governed this call, filled in as soon as it resolves. + // Per-call state: `execute` is re-entered for every invocation, so this + // binding is never shared between concurrent calls. + let governingPolicy: EffectivePolicy | null = null; return Effect.gen(function* () { // oxlint-disable executor/no-instanceof-error, executor/no-unknown-error-message, executor/no-manual-tag-check -- boundary: normalize arbitrary unknown plugin failures into a human-readable message for ToolInvocationError/telemetry const formatInvocationCauseMessage = (cause: unknown): string => { @@ -6494,6 +6728,7 @@ export const createExecutor = governingPolicy), ); }; @@ -7314,6 +7555,7 @@ export const createExecutor = ["orgWrites"]; readonly waitUntil?: ExecutorConfig["waitUntil"]; + /** The credential the executor acts for (see `ExecutorConfig.caller`). */ + readonly caller?: ExecutorConfig["caller"]; }; export const makeTestConfig = ( @@ -183,6 +185,7 @@ export const makeTestConfig = { + it("records a plain success as ok", () => { + expect(toolCallOutcome(Exit.succeed({ ran: true }))).toEqual({ + outcome: "ok", + errorCode: null, + errorMessage: null, + }); + }); + + it("records a ToolResult.fail as fail, not ok", () => { + // The case the span-only telemetry gets wrong: an expected tool failure + // travels the SUCCESS channel, so anything that looks at the Effect + // channel alone reports an upstream 404 as a healthy call. + const summary = toolCallOutcome( + Exit.succeed(ToolResult.fail({ code: "http_error", message: "404 Not Found", status: 404 })), + ); + expect(summary.outcome).toBe("fail"); + expect(summary.errorCode).toBe("http_error"); + }); + + it("keeps the upstream's code and never its message", () => { + // Plugins derive that message from the upstream response body, which + // routinely echoes the request back — including whatever was in it. + const summary = toolCallOutcome( + Exit.succeed( + ToolResult.fail({ + code: "http_error", + message: "invalid token ghp_averyrealsecrettoken for user@example.com", + }), + ), + ); + expect(summary.errorCode).toBe("http_error"); + expect(summary.errorMessage).toBeNull(); + expect(JSON.stringify(summary)).not.toContain("ghp_averyrealsecrettoken"); + }); + + it("records a decline a tool handler raised, not a generic error", () => { + // `execute` wraps any handler failure in ToolInvocationError on the way + // out, so the decline arrives one level down. + const declined = new ElicitationDeclinedError({ + address: ToolAddress.make("tools.github.org.main.delete"), + action: "decline", + }); + const summary = toolCallOutcome( + Exit.fail( + new ToolInvocationError({ + address: ToolAddress.make("tools.github.org.main.delete"), + message: "declined", + cause: declined, + }), + ), + ); + expect(summary.outcome).toBe("declined"); + }); + + it("drops an error code that is not shaped like one", () => { + // `ToolError.code` is typed as any string, so a plugin can forward an + // upstream body into it. The outcome already says what happened. + const summary = toolCallOutcome( + Exit.succeed( + ToolResult.fail({ + code: '{"error":"invalid_grant","token":"ghp_averyrealsecrettoken"}', + message: "upstream said no", + }), + ), + ); + expect(summary.outcome).toBe("fail"); + expect(summary.errorCode).toBeNull(); + expect(JSON.stringify(summary)).not.toContain("ghp_averyrealsecrettoken"); + }); + + it("records a defect as an error rather than losing the call", () => { + // The point of the test is an untyped throw from outside the Effect domain. + // oxlint-disable-next-line executor/no-error-constructor -- boundary: simulating a defect + const summary = toolCallOutcome(Exit.failCause(Cause.die(new Error("boom")))); + expect(summary.outcome).toBe("error"); + // The tag, never the message: a defect's text comes from outside. + expect(summary.errorMessage).toBeNull(); + }); +}); + +describe("toolCallArgKeys", () => { + it("keeps the names and never the values", () => { + const keys = toolCallArgKeys({ siteUrl: "sc-domain:example.com", token: "s3cr3t" }); + expect(keys).toEqual(["siteUrl", "token"]); + expect(JSON.stringify(keys)).not.toContain("s3cr3t"); + }); + + it("has nothing to say about absent or non-object arguments", () => { + expect(toolCallArgKeys(undefined)).toBeNull(); + expect(toolCallArgKeys("just a string")).toBeNull(); + expect(toolCallArgKeys([1, 2, 3])).toBeNull(); + expect(toolCallArgKeys({})).toBeNull(); + }); + + it("caps a pathological argument map", () => { + const args = Object.fromEntries(Array.from({ length: 500 }, (_, i) => [`k${i}`, i])); + expect(toolCallArgKeys(args)).toHaveLength(TOOL_CALL_ARG_KEY_LIMIT); + }); + + it("drops names that are payloads or credentials rather than parameters", () => { + // `execute` takes `unknown` arguments, so a KEY is caller-controlled too. + const keys = toolCallArgKeys({ + siteUrl: "ok", + ghp_averyrealsecrettoken: null, + ["x".repeat(500)]: 1, + eyJhbGciOiJIUzI1NiJ9: 1, + "0123456789abcdef0123456789abcdef": 1, + '{"nested":"json"}': 1, + }); + expect(keys).toEqual(["siteUrl"]); + }); +}); + +describe("cleanToolCallLabel", () => { + it("keeps an ordinary label as it is", () => { + expect(cleanToolCallLabel("Claude Code (executor)")).toBe("Claude Code (executor)"); + }); + + it("strips control characters and collapses whitespace", () => { + // An OAuth client names itself at registration: the label is whatever it + // sent, and it is rendered in the console. + expect(cleanToolCallLabel("Evil\x00Client\n\t name\x1b[31m")).toBe("Evil Client name [31m"); + }); + + it("bounds a label a client made too long", () => { + const label = cleanToolCallLabel("x".repeat(500)); + expect(label).toHaveLength(TOOL_CALL_LABEL_LIMIT + 1); + expect(label?.endsWith("…")).toBe(true); + }); + + it("has nothing to say about an absent or blank label", () => { + expect(cleanToolCallLabel(null)).toBeNull(); + expect(cleanToolCallLabel(undefined)).toBeNull(); + expect(cleanToolCallLabel(" \n\t ")).toBeNull(); + }); +}); + +describe("clampToolCallLimit", () => { + it("defaults, floors and caps", () => { + expect(clampToolCallLimit(undefined)).toBe(TOOL_CALL_LIST_DEFAULT_LIMIT); + expect(clampToolCallLimit(Number.NaN)).toBe(TOOL_CALL_LIST_DEFAULT_LIMIT); + expect(clampToolCallLimit(0)).toBe(1); + expect(clampToolCallLimit(25)).toBe(25); + expect(clampToolCallLimit(10_000)).toBe(TOOL_CALL_LIST_MAX_LIMIT); + }); +}); + +// --------------------------------------------------------------------------- +// Executor integration — every ending a call can have must leave a row. +// --------------------------------------------------------------------------- + +const memoryProvider = (): CredentialProvider => { + const store = new Map(); + return { + key: ProviderKey.make("memory"), + writable: true, + get: (id) => Effect.sync(() => store.get(String(id)) ?? null), + set: (id, value) => Effect.sync(() => void store.set(String(id), value)), + }; +}; + +const GITHUB = IntegrationSlug.make("github"); +const TEMPLATE = AuthTemplateSlug.make("apiKey"); +const CONN = ConnectionName.make("main"); + +const addr = (toolName: string): ToolAddress => + ToolAddress.make(`tools.${GITHUB}.org.${CONN}.${toolName}`); + +const logTestPlugin = definePlugin(() => ({ + id: "logtest" as const, + storage: () => ({}), + credentialProviders: [memoryProvider()], + resolveTools: () => + Effect.succeed({ + tools: [ + { name: ToolName.make("get"), description: "read a repo" }, + { name: ToolName.make("missing"), description: "always 404s upstream" }, + { + name: ToolName.make("delete"), + description: "delete a repo", + annotations: { requiresApproval: true }, + }, + ], + }), + invokeTool: ({ toolRow }) => + toolRow.name === "missing" + ? Effect.succeed(ToolResult.fail({ code: "http_error", message: "404", status: 404 })) + : Effect.succeed(ToolResult.ok({ ran: toolRow.name })), + extension: (ctx) => ({ + seed: () => ctx.core.integrations.register({ slug: GITHUB, description: "GitHub", config: {} }), + }), +})); + +const decliningHandler: ElicitationHandler = () => + Effect.succeed(ElicitationResponse.make({ action: "decline" })); + +const setupExecutor = (caller?: ToolCallCaller) => + makeTestExecutor({ + plugins: [logTestPlugin()] as const, + ...(caller === undefined ? {} : { caller }), + }).pipe( + Effect.tap((executor) => + Effect.gen(function* () { + yield* executor.logtest.seed(); + yield* executor.connections.create({ + owner: "org", + name: CONN, + integration: GITHUB, + template: TEMPLATE, + from: { provider: ProviderKey.make("memory"), id: ProviderItemId.make("g") }, + }); + }), + ), + ); + +describe("executor.toolCalls", () => { + it.effect("is empty before anything runs", () => + Effect.gen(function* () { + const executor = yield* setupExecutor(); + expect(yield* executor.toolCalls.list()).toEqual([]); + }), + ); + + it.effect("records a successful call with its address, policy and duration", () => + Effect.gen(function* () { + const executor = yield* setupExecutor(); + yield* executor.execute(addr("get"), { owner: "midego1", repo: "hermes-box" }); + + const calls = yield* executor.toolCalls.list(); + expect(calls).toHaveLength(1); + const [call] = calls; + expect(call?.address).toBe(String(addr("get"))); + expect(call?.integration).toBe("github"); + expect(call?.connection).toBe("main"); + expect(call?.tool).toBe("get"); + expect(call?.outcome).toBe("ok"); + expect(call?.errorCode).toBeNull(); + expect(call?.argKeys).toEqual(["owner", "repo"]); + expect(call?.durationMs).toBeGreaterThanOrEqual(0); + }), + ); + + it.effect("never stores argument values, only their names", () => + Effect.gen(function* () { + const executor = yield* setupExecutor(); + yield* executor.execute(addr("get"), { token: "ghp_averyrealsecrettoken" }); + + const [call] = yield* executor.toolCalls.list(); + expect(call?.argKeys).toEqual(["token"]); + expect(JSON.stringify(call)).not.toContain("ghp_averyrealsecrettoken"); + }), + ); + + it.effect("records an upstream failure as fail, with the upstream's own code", () => + Effect.gen(function* () { + const executor = yield* setupExecutor(); + yield* executor.execute(addr("missing"), {}); + + const [call] = yield* executor.toolCalls.list(); + expect(call?.outcome).toBe("fail"); + expect(call?.errorCode).toBe("http_error"); + }), + ); + + it.effect("records a call a policy blocked — the one that leaves no other trace", () => + Effect.gen(function* () { + const executor = yield* setupExecutor(); + yield* executor.policies.create({ owner: "org", pattern: "github.*.*.get", action: "block" }); + yield* Effect.result(executor.execute(addr("get"), {})); + + const [call] = yield* executor.toolCalls.list(); + expect(call?.outcome).toBe("blocked"); + expect(call?.errorCode).toBe("tool_blocked"); + expect(call?.policyAction).toBe("block"); + expect(call?.policyPattern).toBe("github.*.*.get"); + }), + ); + + it.effect("records an approval the caller declined", () => + Effect.gen(function* () { + const executor = yield* setupExecutor(); + yield* Effect.result( + executor.execute(addr("delete"), {}, { onElicitation: decliningHandler }), + ); + + const [call] = yield* executor.toolCalls.list(); + expect(call?.outcome).toBe("declined"); + expect(call?.errorCode).toBe("approval_declined"); + }), + ); + + it.effect("records which client made the call, as the host named it", () => + Effect.gen(function* () { + const executor = yield* setupExecutor({ + kind: "oauth_client", + id: "client_abc123", + name: "Claude Code (executor)", + actorLabel: "owner@example.com", + }); + yield* executor.execute(addr("get"), {}); + + const [call] = yield* executor.toolCalls.list(); + expect(call?.client).toEqual({ + kind: "oauth_client", + id: "client_abc123", + name: "Claude Code (executor)", + }); + expect(call?.actorLabel).toBe("owner@example.com"); + }), + ); + + it.effect("keeps the member behind a call on an org connection", () => + Effect.gen(function* () { + // The connection in `setupExecutor` is org-owned, so the row files under + // the org (subject "") — the tier alone no longer says who ran it. + const executor = yield* setupExecutor(); + yield* executor.execute(addr("get"), {}); + + const [call] = yield* executor.toolCalls.list(); + expect(call?.owner).toBe("org"); + expect(call?.actor).toBe("test-subject"); + }), + ); + + it.effect("records no client when the host did not say", () => + Effect.gen(function* () { + const executor = yield* setupExecutor(); + yield* executor.execute(addr("get"), {}); + + const [call] = yield* executor.toolCalls.list(); + expect(call?.client).toBeNull(); + expect(call?.actorLabel).toBeNull(); + }), + ); + + it.effect("cleans a client's self-chosen name before storing it", () => + Effect.gen(function* () { + const executor = yield* setupExecutor({ + kind: "oauth_client", + id: "client_x", + name: `