From 2567d788dd4be095022fb091028bf73e7a7fbb32 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:16:20 -0700 Subject: [PATCH 01/60] Add artifact table and core artifacts API --- .../cloud/drizzle/0013_curly_rocket_racer.sql | 15 + apps/cloud/drizzle/meta/0013_snapshot.json | 1474 +++++++++++++++++ apps/cloud/drizzle/meta/_journal.json | 7 + apps/cloud/src/db/executor-schema.ts | 20 + apps/cloud/src/db/org-deletion.test.ts | 13 + apps/cloud/src/db/org-deletion.ts | 2 + packages/core/api/src/api.ts | 2 + packages/core/api/src/artifacts/api.ts | 95 ++ packages/core/api/src/handlers/artifacts.ts | 78 + packages/core/api/src/handlers/index.ts | 3 + packages/core/api/src/index.ts | 1 + packages/core/api/src/server.ts | 1 + packages/core/sdk/src/artifact-table.test.ts | 131 ++ packages/core/sdk/src/artifact.ts | 63 + packages/core/sdk/src/artifacts.test.ts | 197 +++ packages/core/sdk/src/core-schema.ts | 34 + packages/core/sdk/src/errors.ts | 27 +- packages/core/sdk/src/executor.ts | 132 ++ packages/core/sdk/src/ids.ts | 4 + packages/core/sdk/src/index.ts | 14 + packages/core/sdk/src/shared.ts | 11 + 21 files changed, 2322 insertions(+), 2 deletions(-) create mode 100644 apps/cloud/drizzle/0013_curly_rocket_racer.sql create mode 100644 apps/cloud/drizzle/meta/0013_snapshot.json create mode 100644 packages/core/api/src/artifacts/api.ts create mode 100644 packages/core/api/src/handlers/artifacts.ts create mode 100644 packages/core/sdk/src/artifact-table.test.ts create mode 100644 packages/core/sdk/src/artifact.ts create mode 100644 packages/core/sdk/src/artifacts.test.ts diff --git a/apps/cloud/drizzle/0013_curly_rocket_racer.sql b/apps/cloud/drizzle/0013_curly_rocket_racer.sql new file mode 100644 index 000000000..022df47b9 --- /dev/null +++ b/apps/cloud/drizzle/0013_curly_rocket_racer.sql @@ -0,0 +1,15 @@ +CREATE TABLE "artifact" ( + "id" varchar(255) NOT NULL, + "title" text NOT NULL, + "description" text, + "code" text NOT NULL, + "created_at" timestamp NOT NULL, + "updated_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 +ALTER TABLE "definition" ALTER COLUMN "name" SET DATA TYPE varchar(255);--> statement-breakpoint +CREATE UNIQUE INDEX "artifact_uidx" ON "artifact" USING btree ("tenant","owner","subject","id"); \ No newline at end of file diff --git a/apps/cloud/drizzle/meta/0013_snapshot.json b/apps/cloud/drizzle/meta/0013_snapshot.json new file mode 100644 index 000000000..0947b138c --- /dev/null +++ b/apps/cloud/drizzle/meta/0013_snapshot.json @@ -0,0 +1,1474 @@ +{ + "id": "edc90067-822c-443a-8fcd-3bac77bca317", + "prevId": "97114994-f0fc-4419-80bf-21c529a6cf0b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "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 + }, + "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.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 + }, + "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 + }, + "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": "varchar(255)", + "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 + }, + "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_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 49c3e29fa..37775d78a 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1785193313601, "tag": "0012_woozy_shiva", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1785354087138, + "tag": "0013_curly_rocket_racer", + "breakpoints": true } ] } diff --git a/apps/cloud/src/db/executor-schema.ts b/apps/cloud/src/db/executor-schema.ts index 55db86471..520439a84 100644 --- a/apps/cloud/src/db/executor-schema.ts +++ b/apps/cloud/src/db/executor-schema.ts @@ -226,6 +226,26 @@ export const tool_policy = pgTable( ], ); +export const artifact = pgTable( + "artifact", + { + id: varchar("id", { length: 255 }).notNull(), + title: text("title").notNull(), + description: text("description"), + code: text("code").notNull(), + created_at: timestamp("created_at").notNull(), + updated_at: timestamp("updated_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("artifact_uidx").on(table.tenant, table.owner, table.subject, table.id)], +); + export const plugin_storage = pgTable( "plugin_storage", { diff --git a/apps/cloud/src/db/org-deletion.test.ts b/apps/cloud/src/db/org-deletion.test.ts index 748607825..86faa45bb 100644 --- a/apps/cloud/src/db/org-deletion.test.ts +++ b/apps/cloud/src/db/org-deletion.test.ts @@ -28,6 +28,7 @@ import * as cloudSchema from "./schema"; import * as executorSchema from "./executor-schema"; import { memberships, accounts } from "./schema"; import { + artifact, blob, connection, definition, @@ -146,6 +147,17 @@ const seedTenant = async (db: DrizzleDb, tenant: string, tag: string) => { tenant, }); + await db.insert(artifact).values({ + id: `art-${tag}`, + title: "Dashboard", + code: "export default function App() { return null; }", + created_at: now, + updated_at: now, + tenant, + owner: "o", + subject: "s", + }); + const orgNs = `o:${tenant}/plugin`; const userNs = `u:${tenant}:subject/plugin`; await db.insert(blob).values({ @@ -172,6 +184,7 @@ const TENANT_TABLES = [ tool_policy, plugin_storage, subject, + artifact, ] 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 07c4b807c..b2a922a3f 100644 --- a/apps/cloud/src/db/org-deletion.ts +++ b/apps/cloud/src/db/org-deletion.ts @@ -16,6 +16,7 @@ import { eq, or, sql } from "drizzle-orm"; import type { DrizzleDb } from "./db"; import { organizations } from "./schema"; import { + artifact, blob, connection, definition, @@ -50,6 +51,7 @@ export const purgeOrganizationData = (db: DrizzleDb, organizationId: string): Pr await tx.delete(tool_policy).where(eq(tool_policy.tenant, organizationId)); 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)); // Secrets, OAuth tokens, and cached specs live in `blob`, namespaced by // owner: `o:/` (org scope) and `u::/` diff --git a/packages/core/api/src/api.ts b/packages/core/api/src/api.ts index 22d531bc8..4bbe145e2 100644 --- a/packages/core/api/src/api.ts +++ b/packages/core/api/src/api.ts @@ -8,6 +8,7 @@ import { ProvidersApi } from "./providers/api"; import { ExecutionsApi } from "./executions/api"; import { OAuthApi } from "./oauth/api"; import { PoliciesApi } from "./policies/api"; +import { ArtifactsApi } from "./artifacts/api"; export const CoreExecutorApi = HttpApi.make("executor") .add(ToolsApi) @@ -17,6 +18,7 @@ export const CoreExecutorApi = HttpApi.make("executor") .add(ExecutionsApi) .add(OAuthApi) .add(PoliciesApi) + .add(ArtifactsApi) .annotateMerge( OpenApi.annotations({ title: "Executor API", diff --git a/packages/core/api/src/artifacts/api.ts b/packages/core/api/src/artifacts/api.ts new file mode 100644 index 000000000..9234f9018 --- /dev/null +++ b/packages/core/api/src/artifacts/api.ts @@ -0,0 +1,95 @@ +// --------------------------------------------------------------------------- +// Artifacts HTTP API — saved generative-UI components. +// +// An artifact is the JSX source a model produced plus the title/description an +// agent matches against. Owner-scoped: every endpoint reads and writes only +// what the bound owner scope may see, so no owner travels on the wire. +// --------------------------------------------------------------------------- + +import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { Schema } from "effect"; +import { ArtifactId, ArtifactNotFoundError, InternalError, Owner } from "@executor-js/sdk/shared"; + +// --------------------------------------------------------------------------- +// Params +// --------------------------------------------------------------------------- + +const ArtifactParams = { artifactId: ArtifactId }; + +// --------------------------------------------------------------------------- +// Response / payload schemas +// --------------------------------------------------------------------------- + +/** What a list returns — no `code`, so a long list stays cheap. */ +const ArtifactSummaryResponse = Schema.Struct({ + id: ArtifactId, + owner: Owner, + title: Schema.String, + description: Schema.NullOr(Schema.String), + createdAt: Schema.Number, + updatedAt: Schema.Number, +}); + +const ArtifactResponse = Schema.Struct({ + ...ArtifactSummaryResponse.fields, + code: Schema.String, +}); + +/** Omit `id` to create; pass one to overwrite that artifact in place. */ +const SaveArtifactPayload = Schema.Struct({ + id: Schema.optional(ArtifactId), + title: Schema.String, + description: Schema.optional(Schema.NullOr(Schema.String)), + code: Schema.String, +}); + +const RenameArtifactPayload = Schema.Struct({ + title: Schema.String, +}); + +// --------------------------------------------------------------------------- +// Error schemas with HTTP status annotations +// --------------------------------------------------------------------------- + +const ArtifactNotFound = ArtifactNotFoundError.annotate({ httpApiStatus: 404 }); + +// --------------------------------------------------------------------------- +// Group +// --------------------------------------------------------------------------- + +export const ArtifactsApi = HttpApiGroup.make("artifacts") + .add( + HttpApiEndpoint.get("list", "/artifacts", { + success: Schema.Array(ArtifactSummaryResponse), + error: InternalError, + }), + ) + .add( + HttpApiEndpoint.get("get", "/artifacts/:artifactId", { + params: ArtifactParams, + success: ArtifactResponse, + error: [InternalError, ArtifactNotFound], + }), + ) + .add( + HttpApiEndpoint.post("save", "/artifacts", { + payload: SaveArtifactPayload, + success: ArtifactResponse, + error: [InternalError, ArtifactNotFound], + }), + ) + .add( + HttpApiEndpoint.patch("rename", "/artifacts/:artifactId", { + params: ArtifactParams, + payload: RenameArtifactPayload, + success: ArtifactResponse, + error: [InternalError, ArtifactNotFound], + }), + ) + .add( + HttpApiEndpoint.delete("remove", "/artifacts/:artifactId", { + params: ArtifactParams, + success: Schema.Struct({ removed: Schema.Boolean }), + error: InternalError, + }), + ); diff --git a/packages/core/api/src/handlers/artifacts.ts b/packages/core/api/src/handlers/artifacts.ts new file mode 100644 index 000000000..983b15dfc --- /dev/null +++ b/packages/core/api/src/handlers/artifacts.ts @@ -0,0 +1,78 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { Effect } from "effect"; +import type { Artifact, ArtifactSummary } from "@executor-js/sdk"; + +import { ExecutorApi } from "../api"; +import { ExecutorService } from "../services"; +import { capture } from "@executor-js/api"; + +const summaryToResponse = (a: ArtifactSummary) => ({ + id: a.id, + owner: a.owner, + title: a.title, + description: a.description, + createdAt: a.createdAt.getTime(), + updatedAt: a.updatedAt.getTime(), +}); + +const artifactToResponse = (a: Artifact) => ({ + ...summaryToResponse(a), + code: a.code, +}); + +export const ArtifactsHandlers = HttpApiBuilder.group(ExecutorApi, "artifacts", (handlers) => + handlers + .handle("list", () => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const artifacts = yield* executor.artifacts.list(); + return artifacts.map(summaryToResponse); + }), + ), + ) + .handle("get", ({ params: path }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const artifact = yield* executor.artifacts.get(path.artifactId); + return artifactToResponse(artifact); + }), + ), + ) + .handle("save", ({ payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const saved = yield* executor.artifacts.save({ + id: payload.id, + title: payload.title, + description: payload.description, + code: payload.code, + }); + return artifactToResponse(saved); + }), + ), + ) + .handle("rename", ({ params: path, payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const renamed = yield* executor.artifacts.rename({ + id: path.artifactId, + title: payload.title, + }); + return artifactToResponse(renamed); + }), + ), + ) + .handle("remove", ({ params: path }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + yield* executor.artifacts.remove({ id: path.artifactId }); + return { removed: true }; + }), + ), + ), +); diff --git a/packages/core/api/src/handlers/index.ts b/packages/core/api/src/handlers/index.ts index 6a21313bf..360952bd7 100644 --- a/packages/core/api/src/handlers/index.ts +++ b/packages/core/api/src/handlers/index.ts @@ -7,6 +7,7 @@ import { ProvidersHandlers } from "./providers"; import { ExecutionsHandlers } from "./executions"; import { OAuthHandlers } from "./oauth"; import { PoliciesHandlers } from "./policies"; +import { ArtifactsHandlers } from "./artifacts"; export { ToolsHandlers } from "./tools"; export { IntegrationsHandlers } from "./integrations"; @@ -15,6 +16,7 @@ export { ProvidersHandlers } from "./providers"; export { ExecutionsHandlers } from "./executions"; export { OAuthHandlers } from "./oauth"; export { PoliciesHandlers } from "./policies"; +export { ArtifactsHandlers } from "./artifacts"; export const CoreHandlers = Layer.mergeAll( ToolsHandlers, @@ -24,4 +26,5 @@ export const CoreHandlers = Layer.mergeAll( ExecutionsHandlers, OAuthHandlers, PoliciesHandlers, + ArtifactsHandlers, ); diff --git a/packages/core/api/src/index.ts b/packages/core/api/src/index.ts index 271cf7ff7..6ba0b8b15 100644 --- a/packages/core/api/src/index.ts +++ b/packages/core/api/src/index.ts @@ -34,6 +34,7 @@ export { type RunOAuthCallbackInput, } from "./oauth-popup"; export { PoliciesApi } from "./policies/api"; +export { ArtifactsApi } from "./artifacts/api"; export { AccountApi, AccountHttpApi, diff --git a/packages/core/api/src/server.ts b/packages/core/api/src/server.ts index 1467234b2..9f8cecfe3 100644 --- a/packages/core/api/src/server.ts +++ b/packages/core/api/src/server.ts @@ -7,6 +7,7 @@ export { ProvidersHandlers, OAuthHandlers, PoliciesHandlers, + ArtifactsHandlers, ExecutionsHandlers, } from "./handlers"; export { diff --git a/packages/core/sdk/src/artifact-table.test.ts b/packages/core/sdk/src/artifact-table.test.ts new file mode 100644 index 000000000..0e4f52520 --- /dev/null +++ b/packages/core/sdk/src/artifact-table.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { withQueryContext } from "@executor-js/fumadb/query"; + +import { collectTables } from "./executor"; +import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db"; + +// The artifact table is OWNER-scoped: a saved artifact belongs to the subject +// who generated it, and nobody else in the tenant may read it (org-tier +// sharing is a later change that reuses the `owner` column already here). +// Written against the real SQLite bring-up (`createSqliteTestFumaDb` runs the +// same statements the local/self-host/D1 hosts boot with) instead of asserting +// on the table definition object. + +const TENANT = "t1"; +const SUBJECT = "user_a"; +const OTHER_SUBJECT = "user_b"; + +const withDb = (body: (db: SqliteTestFumaDb) => Promise): Promise => + Effect.runPromise( + Effect.acquireUseRelease( + Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })), + (db) => Effect.promise(() => body(db)), + (db) => Effect.promise(() => db.close()), + ), + ); + +const insertArtifact = ( + db: SqliteTestFumaDb, + row: { + readonly rowId: string; + readonly tenant: string; + readonly subject: string; + readonly id: string; + readonly title?: string; + }, +): Promise => + db.client.execute({ + sql: `INSERT INTO artifact (row_id, tenant, owner, subject, id, title, description, code, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + row.rowId, + row.tenant, + "user", + row.subject, + row.id, + row.title ?? "Dashboard", + null, + "export default function App() { return null; }", + Date.now(), + Date.now(), + ], + }); + +describe("artifact table", () => { + it.effect("is created by the runtime schema bring-up", () => + Effect.promise(() => + withDb(async (db) => { + const info = await db.client.execute("PRAGMA table_info('artifact')"); + const columns = info.rows.map((row) => String(row["name"])); + expect(columns).toEqual( + expect.arrayContaining([ + "row_id", + "tenant", + "owner", + "subject", + "id", + "title", + "description", + "code", + "created_at", + "updated_at", + ]), + ); + }), + ), + ); + + it.effect("keys one row per (tenant, owner, subject, id)", () => + Effect.promise(() => + withDb(async (db) => { + await insertArtifact(db, { rowId: "a1", tenant: TENANT, subject: SUBJECT, id: "art_1" }); + + // The same artifact id under a DIFFERENT subject is a different row — + // ids are only unique within one owner partition. + await insertArtifact(db, { + rowId: "a2", + tenant: TENANT, + subject: OTHER_SUBJECT, + id: "art_1", + }); + + await expect( + insertArtifact(db, { rowId: "a3", tenant: TENANT, subject: SUBJECT, id: "art_1" }), + ).rejects.toThrow(); + + const rows = await db.client.execute("SELECT row_id FROM artifact ORDER BY row_id"); + expect(rows.rows.map((row) => String(row["row_id"]))).toEqual(["a1", "a2"]); + }), + ), + ); + + it.effect("hides another subject's artifacts from a bound executor", () => + Effect.promise(() => + withDb(async (db) => { + await insertArtifact(db, { + rowId: "a1", + tenant: TENANT, + subject: SUBJECT, + id: "art_mine", + title: "Mine", + }); + await insertArtifact(db, { + rowId: "a2", + tenant: TENANT, + subject: OTHER_SUBJECT, + id: "art_theirs", + title: "Theirs", + }); + + const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT }); + const visible = await scoped.findMany("artifact", {}); + expect(visible.map((row) => row.id)).toEqual(["art_mine"]); + + const otherScoped = withQueryContext(db.db, { tenant: TENANT, subject: OTHER_SUBJECT }); + const otherVisible = await otherScoped.findMany("artifact", {}); + expect(otherVisible.map((row) => row.id)).toEqual(["art_theirs"]); + }), + ), + ); +}); diff --git a/packages/core/sdk/src/artifact.ts b/packages/core/sdk/src/artifact.ts new file mode 100644 index 000000000..981d9837b --- /dev/null +++ b/packages/core/sdk/src/artifact.ts @@ -0,0 +1,63 @@ +// --------------------------------------------------------------------------- +// Artifacts — saved generative-UI components. Row → public projection plus the +// operation inputs; the executor stitches these into `executor.artifacts` and +// the core `artifacts` HTTP group. +// +// An artifact IS the stored JSX source plus the title/description an agent +// matches against ("show me my active users dashboard"). Owner-scoped like a +// connection: created at the `user` tier, readable through the same owner +// policy, so org-tier sharing later needs no new machinery. +// --------------------------------------------------------------------------- + +import type { ArtifactRow, ArtifactSummaryRow } from "./core-schema"; +import { ArtifactId, type Owner } from "./ids"; + +export interface Artifact { + readonly id: ArtifactId; + readonly owner: Owner; + readonly title: string; + /** Model-supplied prose used for agent matching. Null when none was given. */ + readonly description: string | null; + /** The JSX source. Only the full read carries it — lists stay light. */ + readonly code: string; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +/** What a list returns: everything except the (potentially large) source. */ +export type ArtifactSummary = Omit; + +/** Create a new artifact, or overwrite an existing one in place when `id` names + * one. v1 has no version history: a save replaces the stored source. */ +export interface SaveArtifactInput { + readonly id?: string; + readonly title: string; + readonly description?: string | null; + readonly code: string; +} + +export interface RenameArtifactInput { + readonly id: string; + readonly title: string; +} + +export interface RemoveArtifactInput { + readonly id: string; +} + +const asDate = (value: Date | number | string): Date => + value instanceof Date ? value : new Date(value); + +export const rowToArtifactSummary = (row: ArtifactSummaryRow): ArtifactSummary => ({ + id: ArtifactId.make(row.id), + owner: row.owner as Owner, + title: row.title, + description: row.description ?? null, + createdAt: asDate(row.created_at), + updatedAt: asDate(row.updated_at), +}); + +export const rowToArtifact = (row: ArtifactRow): Artifact => ({ + ...rowToArtifactSummary(row), + code: row.code, +}); diff --git a/packages/core/sdk/src/artifacts.test.ts b/packages/core/sdk/src/artifacts.test.ts new file mode 100644 index 000000000..11208fc04 --- /dev/null +++ b/packages/core/sdk/src/artifacts.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Predicate, Result } from "effect"; + +import { makeTestExecutor } from "./testing"; + +// The `executor.artifacts` surface against the real SQLite test db. Artifacts +// are owner-scoped rows with no plugin involvement, so the default test +// executor (one tenant + one bound subject) is the whole fixture. + +const CODE = "export default function App() { return
hi
; }"; + +describe("executor.artifacts", () => { + it.effect("list is empty when nothing is saved", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + expect(yield* executor.artifacts.list()).toEqual([]); + }), + ); + + it.effect("save mints an id and stores the source", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const saved = yield* executor.artifacts.save({ + title: "Active users", + description: "Daily active users over time", + code: CODE, + }); + expect(saved.id).not.toBe(""); + expect(saved.owner).toBe("user"); + expect(saved.title).toBe("Active users"); + expect(saved.description).toBe("Daily active users over time"); + expect(saved.code).toBe(CODE); + + const fetched = yield* executor.artifacts.get(saved.id); + expect(fetched).toEqual(saved); + }), + ); + + it.effect("save defaults a missing description to null", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const saved = yield* executor.artifacts.save({ title: "Untitled", code: CODE }); + expect(saved.description).toBe(null); + }), + ); + + it.effect("list omits the source", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const saved = yield* executor.artifacts.save({ + title: "Active users", + description: "described", + code: CODE, + }); + + const listed = yield* executor.artifacts.list(); + // The list projection is deliberately code-free: a saved component can be + // large and a picker only needs title/description to match on. + expect(listed).toEqual([ + { + id: saved.id, + owner: "user", + title: "Active users", + description: "described", + createdAt: saved.createdAt, + updatedAt: saved.updatedAt, + }, + ]); + }), + ); + + // `it.live` (real clock) with a full-second gap: `dateColumn` lands in SQLite + // as a SECOND-resolution integer, so two artifacts saved inside one second + // carry the same `updated_at` and only the id tiebreak separates them. This + // pins the ordering the picker actually depends on, at the resolution the + // storage layer really has. + it.live("list returns the most recently updated artifact first", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const first = yield* executor.artifacts.save({ title: "First", code: CODE }); + yield* Effect.sleep("1100 millis"); + const second = yield* executor.artifacts.save({ title: "Second", code: CODE }); + expect(second.updatedAt.getTime()).toBeGreaterThan(first.updatedAt.getTime()); + + expect((yield* executor.artifacts.list()).map((a) => a.id)).toEqual([second.id, first.id]); + + // Touching the OLDER artifact floats it back to the top — the list is + // keyed on `updated_at`, not on creation order. + yield* Effect.sleep("1100 millis"); + yield* executor.artifacts.rename({ id: first.id, title: "First, touched" }); + expect((yield* executor.artifacts.list()).map((a) => a.id)).toEqual([first.id, second.id]); + }), + ); + + it.effect("save with an id overwrites in place", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const saved = yield* executor.artifacts.save({ title: "Draft", code: CODE }); + const updated = yield* executor.artifacts.save({ + id: saved.id, + title: "Final", + description: "now described", + code: "export default function App() { return null; }", + }); + + expect(updated.id).toBe(saved.id); + expect(updated.title).toBe("Final"); + expect(updated.description).toBe("now described"); + expect(updated.code).toBe("export default function App() { return null; }"); + expect(updated.createdAt.getTime()).toBe(saved.createdAt.getTime()); + + // Overwrite, not append: still exactly one row. + const listed = yield* executor.artifacts.list(); + expect(listed.map((a) => a.id)).toEqual([saved.id]); + }), + ); + + it.effect("save fails for an id that names no visible artifact", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const result = yield* Effect.result( + executor.artifacts.save({ id: "art_missing", title: "Ghost", code: CODE }), + ); + expect(Result.isFailure(result)).toBe(true); + if (!Result.isFailure(result)) return; + expect(Predicate.isTagged("ArtifactNotFoundError")(result.failure)).toBe(true); + }), + ); + + it.effect("get fails for an unknown id", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const result = yield* Effect.result(executor.artifacts.get("art_missing")); + expect(Result.isFailure(result)).toBe(true); + if (!Result.isFailure(result)) return; + expect(Predicate.isTagged("ArtifactNotFoundError")(result.failure)).toBe(true); + }), + ); + + it.effect("rename changes only the title", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const saved = yield* executor.artifacts.save({ + title: "Old name", + description: "kept", + code: CODE, + }); + const renamed = yield* executor.artifacts.rename({ id: saved.id, title: "New name" }); + expect(renamed.title).toBe("New name"); + expect(renamed.description).toBe("kept"); + expect(renamed.code).toBe(CODE); + }), + ); + + it.effect("rename fails for an unknown id", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const result = yield* Effect.result( + executor.artifacts.rename({ id: "art_missing", title: "Nope" }), + ); + expect(Result.isFailure(result)).toBe(true); + if (!Result.isFailure(result)) return; + expect(Predicate.isTagged("ArtifactNotFoundError")(result.failure)).toBe(true); + }), + ); + + it.effect("remove hard-deletes the artifact", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const saved = yield* executor.artifacts.save({ title: "Temp", code: CODE }); + yield* executor.artifacts.remove({ id: saved.id }); + + expect(yield* executor.artifacts.list()).toEqual([]); + const result = yield* Effect.result(executor.artifacts.get(saved.id)); + expect(Result.isFailure(result)).toBe(true); + if (!Result.isFailure(result)) return; + expect(Predicate.isTagged("ArtifactNotFoundError")(result.failure)).toBe(true); + }), + ); + + it.effect("remove is idempotent", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + yield* executor.artifacts.remove({ id: "art_missing" }); + }), + ); + + it.effect("an executor with no subject cannot save a user artifact", () => + Effect.gen(function* () { + // Pure-org executors (host request paths with no member bound) have no + // user partition to file a personal artifact into. + const executor = yield* makeTestExecutor({ subject: null }); + const result = yield* Effect.result(executor.artifacts.save({ title: "Orphan", code: CODE })); + expect(Result.isFailure(result)).toBe(true); + }), + ); +}); diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index fd174b4dc..b8022f975 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -357,6 +357,27 @@ export const coreTables = defineTables({ ["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 + // at the `user` tier, and the `org` tier the table already carries is what + // sharing will use later without new machinery. + artifact: ownedExecutorTable( + "artifact", + { + id: keyColumn("id"), + title: textColumn("title"), + // Model-supplied prose the agent matches against ("my active users + // dashboard"). Nullable: the title alone is enough to save one. + description: nullableTextColumn("description"), + // The JSX source. Free-form TEXT, never indexed. + code: textColumn("code"), + created_at: dateColumn("created_at"), + updated_at: dateColumn("updated_at"), + }, + ["tenant", "owner", "subject", "id"], + ), + // Host-owned plugin storage (shared `plugin_storage` table, owner-scoped). plugin_storage: ownedExecutorTable( "plugin_storage", @@ -410,6 +431,19 @@ export const TOOL_INVOCATION_COLUMNS = [ ] as const satisfies readonly (keyof ToolRow)[]; export type DefinitionRow = FumaRow; export type ToolPolicyRow = FumaRow; +export type ArtifactRow = FumaRow; +/** The columns a list projects — everything except the JSX source, which only + * a full read needs. */ +export const ARTIFACT_SUMMARY_COLUMNS = [ + "owner", + "id", + "title", + "description", + "created_at", + "updated_at", +] as const satisfies readonly (keyof ArtifactRow)[]; +/** The artifact-row projection {@link ARTIFACT_SUMMARY_COLUMNS} selects. */ +export type ArtifactSummaryRow = Pick; export type PluginStorageRow = FumaRow; export type BlobRow = FumaRow; diff --git a/packages/core/sdk/src/errors.ts b/packages/core/sdk/src/errors.ts index 078aeaca4..7f28ab58e 100644 --- a/packages/core/sdk/src/errors.ts +++ b/packages/core/sdk/src/errors.ts @@ -2,7 +2,14 @@ import { Schema } from "effect"; import { ElicitationDeclinedError } from "./elicitation"; import type { StorageFailure } from "./fuma-runtime"; -import { ConnectionName, IntegrationSlug, Owner, ProviderKey, ToolAddress } from "./ids"; +import { + ArtifactId, + ConnectionName, + IntegrationSlug, + Owner, + ProviderKey, + ToolAddress, +} from "./ids"; export interface UserActionableError { readonly __executorUserActionable: true; @@ -191,6 +198,21 @@ export class CredentialResolutionError extends Schema.TaggedErrorClass()( + "ArtifactNotFoundError", + { id: ArtifactId }, +) { + override get message(): string { + return `Artifact not found: ${this.id}`; + } +} + // --------------------------------------------------------------------------- // Union — the failure channel of `execute`. // --------------------------------------------------------------------------- @@ -211,4 +233,5 @@ export type ExecuteError = export type ExecutorError = | ExecuteError | IntegrationNotFoundError - | IntegrationRemovalNotAllowedError; + | IntegrationRemovalNotAllowedError + | ArtifactNotFoundError; diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 7ba62b5bb..d9e14015d 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -28,6 +28,7 @@ import type { import { HealthCheckResult, HealthCheckSpec } from "./health-check"; import type { HealthCheckCandidate } from "./health-check"; import { + ARTIFACT_SUMMARY_COLUMNS, coreSchema, isToolPolicyAction, TOOL_INVOCATION_COLUMNS, @@ -51,6 +52,16 @@ import { export type { OnElicitation, InvokeOptions } from "./elicitation"; import { + rowToArtifact, + rowToArtifactSummary, + type Artifact, + type ArtifactSummary, + type RemoveArtifactInput, + type RenameArtifactInput, + type SaveArtifactInput, +} from "./artifact"; +import { + ArtifactNotFoundError, ConnectionNotFoundError, CredentialProviderNotRegisteredError, CredentialResolutionError, @@ -65,6 +76,7 @@ import { type ExecuteError, } from "./errors"; import { + ArtifactId, AuthTemplateSlug, ConnectionAddress, ConnectionName, @@ -377,6 +389,20 @@ export type Executor = { * Internal admin surface; the public HTTP shape is a separate concern. */ readonly admin?: ExecutorAdmin; + /** Saved generative-UI artifacts, visible to the bound owner scope. */ + readonly artifacts: { + /** Newest first, without the JSX source — lists stay light. */ + readonly list: () => Effect.Effect; + readonly get: (id: string) => Effect.Effect; + /** Create, or overwrite an existing artifact in place when `id` is given. */ + readonly save: ( + input: SaveArtifactInput, + ) => Effect.Effect; + readonly rename: ( + input: RenameArtifactInput, + ) => Effect.Effect; + readonly remove: (input: RemoveArtifactInput) => Effect.Effect; + }; readonly execute: ( address: ToolAddress, @@ -3854,6 +3880,105 @@ export const createExecutor = + (b: AnyCb) => + b("id", "=", id); + + const artifactsList = (): Effect.Effect => + core + .findMany("artifact", { + // Newest first. `id` breaks ties so two artifacts sharing an + // `updated_at` millisecond list in a repeatable order rather than + // whatever the storage engine happens to return; which of the two + // comes first is arbitrary, only its stability is guaranteed. + orderBy: [ + ["updated_at", "desc"], + ["id", "desc"], + ], + select: ARTIFACT_SUMMARY_COLUMNS, + }) + .pipe(Effect.map((rows) => rows.map(rowToArtifactSummary))); + + const artifactsGet = ( + id: string, + ): Effect.Effect => + Effect.gen(function* () { + const row = yield* core.findFirst("artifact", { where: artifactById(id) }); + if (!row) return yield* new ArtifactNotFoundError({ id: ArtifactId.make(id) }); + return rowToArtifact(row); + }); + + const artifactsSave = ( + input: SaveArtifactInput, + ): Effect.Effect => + Effect.gen(function* () { + const now = new Date(); + const description = input.description ?? null; + // An explicit id overwrites in place (v1 keeps no version history). It + // must already resolve to a visible row: minting a caller-chosen id + // would let a stale client resurrect a deleted artifact silently. + if (input.id !== undefined) { + const where = artifactById(input.id); + const existing = yield* core.findFirst("artifact", { where }); + if (!existing) { + return yield* new ArtifactNotFoundError({ id: ArtifactId.make(input.id) }); + } + const set = { + title: input.title, + description, + code: input.code, + updated_at: now, + }; + yield* core.updateMany("artifact", { where, set }); + return rowToArtifact({ ...existing, ...set }); + } + yield* requireUserSubject("user"); + const keys = yield* Effect.try({ + try: () => ownedKeys("user"), + catch: (cause) => storageFailureFromUnknown("invalid owner", cause), + }); + const created = yield* core.create("artifact", { + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + id: `art_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`, + title: input.title, + description, + code: input.code, + created_at: now, + updated_at: now, + }); + return rowToArtifact(created); + }); + + const artifactsRename = ( + input: RenameArtifactInput, + ): Effect.Effect => + Effect.gen(function* () { + const where = artifactById(input.id); + const existing = yield* core.findFirst("artifact", { where }); + if (!existing) { + return yield* new ArtifactNotFoundError({ id: ArtifactId.make(input.id) }); + } + const set = { title: input.title, updated_at: new Date() }; + yield* core.updateMany("artifact", { where, set }); + return rowToArtifact({ ...existing, ...set }); + }); + + // Hard delete: an artifact is not a connection, so there is no disabled or + // archived state to fall back to. + const artifactsRemove = (input: RemoveArtifactInput): Effect.Effect => + core.deleteMany("artifact", { where: artifactById(input.id) }); + // ------------------------------------------------------------------ // Elicitation // ------------------------------------------------------------------ @@ -4614,6 +4739,13 @@ export const createExecutor = Date: Mon, 27 Jul 2026 22:37:46 -0700 Subject: [PATCH 02/60] Add MCP-Apps shell package and first-party render-ui tools --- apps/cloud/package.json | 1 + apps/cloud/src/mcp/session-durable-object.ts | 5 + apps/host-cloudflare/package.json | 1 + .../src/mcp/session-durable-object.ts | 11 +- apps/host-selfhost/package.json | 1 + apps/host-selfhost/src/mcp/session-store.ts | 3 + apps/local/package.json | 1 + apps/local/src/executor.ts | 7 +- apps/local/src/main.ts | 23 +- bun.lock | 51 ++ packages/core/api/src/server/mcp-build.ts | 40 +- packages/core/execution/src/index.ts | 10 +- .../core/execution/src/provided-globals.ts | 270 ++++++++ packages/core/execution/src/skills.ts | 89 ++- packages/hosts/mcp-apps-shell/.gitignore | 1 + packages/hosts/mcp-apps-shell/package.json | 74 +++ .../scripts/gen-provided-globals.ts | 23 + packages/hosts/mcp-apps-shell/src/index.ts | 11 + .../hosts/mcp-apps-shell/src/shell-html.ts | 49 ++ .../src/shell/component-runtime.test.ts | 53 ++ .../src/shell/component-runtime.ts | 185 ++++++ .../mcp-apps-shell/src/shell/components.ts | 277 ++++++++ .../mcp-apps-shell/src/shell/globals.css | 147 +++++ .../hosts/mcp-apps-shell/src/shell/hooks.ts | 114 ++++ .../src/shell/inner-renderer-source.d.ts | 4 + .../src/shell/inner-renderer.tsx | 309 +++++++++ .../mcp-apps-shell/src/shell/mcp-app.html | 12 + .../mcp-apps-shell/src/shell/mcp-app.tsx | 35 + .../src/shell/provided-globals.pin.test.ts | 22 + .../hosts/mcp-apps-shell/src/shell/proxy.ts | 154 +++++ .../mcp-apps-shell/src/shell/shell-app.tsx | 623 ++++++++++++++++++ packages/hosts/mcp-apps-shell/tsconfig.json | 15 + .../hosts/mcp-apps-shell/vite.config.shell.ts | 59 ++ .../hosts/mcp-apps-shell/vitest.config.ts | 12 + packages/hosts/mcp/package.json | 5 + packages/hosts/mcp/src/render-ui.ts | 96 +++ packages/hosts/mcp/src/tool-server.ts | 503 +++++++++++++- 37 files changed, 3276 insertions(+), 20 deletions(-) create mode 100644 packages/core/execution/src/provided-globals.ts create mode 100644 packages/hosts/mcp-apps-shell/.gitignore create mode 100644 packages/hosts/mcp-apps-shell/package.json create mode 100644 packages/hosts/mcp-apps-shell/scripts/gen-provided-globals.ts create mode 100644 packages/hosts/mcp-apps-shell/src/index.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell-html.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/component-runtime.test.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/component-runtime.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/components.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/globals.css create mode 100644 packages/hosts/mcp-apps-shell/src/shell/hooks.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/inner-renderer-source.d.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/inner-renderer.tsx create mode 100644 packages/hosts/mcp-apps-shell/src/shell/mcp-app.html create mode 100644 packages/hosts/mcp-apps-shell/src/shell/mcp-app.tsx create mode 100644 packages/hosts/mcp-apps-shell/src/shell/provided-globals.pin.test.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/proxy.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx create mode 100644 packages/hosts/mcp-apps-shell/tsconfig.json create mode 100644 packages/hosts/mcp-apps-shell/vite.config.shell.ts create mode 100644 packages/hosts/mcp-apps-shell/vitest.config.ts create mode 100644 packages/hosts/mcp/src/render-ui.ts diff --git a/apps/cloud/package.json b/apps/cloud/package.json index 6409dccb9..f19f06a02 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -43,6 +43,7 @@ "@executor-js/execution": "workspace:*", "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", + "@executor-js/mcp-apps-shell": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index c2d249aef..b11ca8e76 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -25,6 +25,8 @@ import { createExecutorMcpServer, } from "@executor-js/host-mcp/tool-server"; import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; +import { artifactUrlFor } from "@executor-js/host-mcp/render-ui"; +import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; import { McpAgentSessionDOBase, type BuiltMcpServer, @@ -250,6 +252,9 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase self.currentParentSpan(), debug: env.EXECUTOR_MCP_DEBUG === "true", browserApprovalStore: self.browserApprovalStore, diff --git a/apps/host-cloudflare/package.json b/apps/host-cloudflare/package.json index d7159620d..55a9ff19b 100644 --- a/apps/host-cloudflare/package.json +++ b/apps/host-cloudflare/package.json @@ -23,6 +23,7 @@ "@executor-js/execution": "workspace:*", "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", + "@executor-js/mcp-apps-shell": "workspace:*", "@executor-js/plugin-encrypted-secrets": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", diff --git a/apps/host-cloudflare/src/mcp/session-durable-object.ts b/apps/host-cloudflare/src/mcp/session-durable-object.ts index 95b366b7e..194bfbbd2 100644 --- a/apps/host-cloudflare/src/mcp/session-durable-object.ts +++ b/apps/host-cloudflare/src/mcp/session-durable-object.ts @@ -5,6 +5,8 @@ import { createExecutorMcpServer, } from "@executor-js/host-mcp/tool-server"; import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; +import { artifactUrlFor } from "@executor-js/host-mcp/render-ui"; +import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; import type { ExecutorDbHandle } from "@executor-js/api/server"; import { McpAgentSessionDOBase, @@ -113,7 +115,7 @@ export class McpSessionDO extends McpAgentSessionDOBase preloadQuickJs()); - const { engine } = yield* makeExecutionStack( + const { engine, executor } = yield* makeExecutionStack( sessionMeta.userId, sessionMeta.organizationId, sessionMeta.organizationName, @@ -124,8 +126,15 @@ export class McpSessionDO extends McpAgentSessionDOBase interface LocalExecutorBundle { readonly executor: Executor; readonly plugins: LocalPlugins; + /** Where this daemon's web UI is reachable, resolved once at boot. Surfaced + * so callers building user-facing links (MCP artifact deep links) use the + * same origin the executor itself was configured with. */ + readonly webBaseUrl: string; } class LocalExecutorTag extends Context.Service()( @@ -233,7 +237,7 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { ); } - return { executor, plugins }; + return { executor, plugins, webBaseUrl }; }), ); }; @@ -246,6 +250,7 @@ export const createExecutorHandle = async (options: LocalExecutorOptions = {}) = return { executor: bundle.executor, plugins: bundle.plugins, + webBaseUrl: bundle.webBaseUrl, dispose: async () => { await Effect.runPromise(Effect.ignore(bundle.executor.close())); await ignorePromiseFailure("disposeRuntime", () => runtime.dispose()); diff --git a/apps/local/src/main.ts b/apps/local/src/main.ts index 750cd72f6..3daff99c5 100644 --- a/apps/local/src/main.ts +++ b/apps/local/src/main.ts @@ -1,6 +1,8 @@ import { Context, Data, Effect, Layer, ManagedRuntime } from "effect"; import { createExecutionEngine } from "@executor-js/execution"; +import { artifactUrlFor } from "@executor-js/host-mcp/render-ui"; +import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import { makeLocalApiHandler } from "./app"; import { createExecutorHandle, disposeExecutor, getExecutorBundle } from "./executor"; @@ -81,15 +83,24 @@ export const createServerHandlers = async (token: string): Promise { - if (resource.kind === "default") return { config: { engine } }; + if (resource.kind === "default") { + return { config: { engine, artifacts: executor.artifacts, ...appsConfig } }; + } const handle = await createExecutorHandle({ activeToolkitSlug: resource.slug, }); @@ -98,7 +109,11 @@ export const createServerHandlers = async (token: string): Promise=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="], + "vite-plugin-singlefile": ["vite-plugin-singlefile@2.3.3", "", { "dependencies": { "micromatch": "^4.0.8" }, "peerDependencies": { "rollup": "^4.59.0", "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["rollup"] }, "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg=="], + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], "vitest": ["vitest@4.1.5", "", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.5", "@vitest/browser-preview": "4.1.5", "@vitest/browser-webdriverio": "4.1.5", "@vitest/coverage-istanbul": "4.1.5", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="], diff --git a/packages/core/api/src/server/mcp-build.ts b/packages/core/api/src/server/mcp-build.ts index 275185754..0374bd42f 100644 --- a/packages/core/api/src/server/mcp-build.ts +++ b/packages/core/api/src/server/mcp-build.ts @@ -7,6 +7,7 @@ import { type McpBuildServerOptions, } from "@executor-js/host-mcp/in-memory-session-store"; import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; +import { artifactUrlFor } from "@executor-js/host-mcp/render-ui"; import { ErrorCapture } from "../observability"; import { CodeExecutorProvider, EngineDecorator, makeExecutionStack } from "./execution-stack"; @@ -37,13 +38,21 @@ export type McpExecutionStackLayer = Layer.Layer< * in the injected stack layer (libSQL vs D1, etc.). */ export const makeMcpBuildServer = - (executionStack: McpExecutionStackLayer): McpBuildServer => + (executionStack: McpExecutionStackLayer, hostOptions?: McpBuildHostOptions): McpBuildServer => (principal: Principal, options?: McpBuildServerOptions) => - makeExecutionStack(principal.accountId, principal.organizationId, principal.organizationName, { - mcpResource: options?.resource, + Effect.gen(function* () { + const { engine, executor } = yield* makeExecutionStack( + principal.accountId, + principal.organizationId, + principal.organizationName, + { mcpResource: options?.resource }, + ).pipe(Effect.withSpan("mcp.execution_stack.build")); + // Read inside the provided boundary: `webBaseUrl` is a host seam, and + // hosts that can't know their public URL at boot leave it unset — in + // which case artifacts still persist but carry no deep link. + const hostConfig = yield* HostConfig; + return { engine, executor, webBaseUrl: hostConfig.webBaseUrl }; }).pipe( - Effect.withSpan("mcp.execution_stack.build"), - Effect.map(({ engine }) => engine), // Pin browser-handoff URLs to the principal's org slug when present; // absent slug leaves the service unprovided and the URL stays bare. principal.organizationSlug !== undefined @@ -51,14 +60,31 @@ export const makeMcpBuildServer = : (effect) => effect, Effect.provide(executionStack), Effect.mapError((cause) => new McpEngineBuildError({ cause })), - Effect.flatMap((engine) => - createExecutorMcpServer({ engine, ...(options ?? {}) }).pipe( + Effect.flatMap(({ engine, executor, webBaseUrl }) => + createExecutorMcpServer({ + engine, + artifacts: executor.artifacts, + ...(hostOptions?.loadAppShellHtml + ? { loadAppShellHtml: hostOptions.loadAppShellHtml } + : {}), + ...(webBaseUrl ? { artifactUrl: artifactUrlFor(webBaseUrl) } : {}), + ...(options ?? {}), + }).pipe( Effect.withSpan("mcp.server.create"), Effect.map((mcpServer) => ({ mcpServer, engine })), ), ), ); +/** Per-host (not per-session) MCP wiring. Kept separate from + * `McpBuildServerOptions`, which the session store fills in per request. */ +export interface McpBuildHostOptions { + /** Serves the MCP-Apps shell resource. Hosts that can render generative UI + * pass `loadMcpAppsShellHtml` from `@executor-js/mcp-apps-shell`; omitting it + * leaves the ui-bearing tools unregistered. */ + readonly loadAppShellHtml?: () => Promise; +} + /** * The standard console `McpErrorReporter` seam: route an orchestration defect * the MCP envelope would otherwise swallow into a 500 through the host's diff --git a/packages/core/execution/src/index.ts b/packages/core/execution/src/index.ts index 5a384c36b..dc9113fc7 100644 --- a/packages/core/execution/src/index.ts +++ b/packages/core/execution/src/index.ts @@ -12,7 +12,15 @@ export { } from "./engine"; export { buildExecuteDescription, INTEGRATION_INVENTORY_HEADER } from "./description"; -export { EXECUTE_SKILL, SKILLS, findSkill, renderSkillsIndex, type Skill } from "./skills"; +export { + EXECUTE_SKILL, + RENDER_UI_SKILL, + SKILLS, + findSkill, + renderSkillsIndex, + type Skill, +} from "./skills"; +export { PROVIDED_GLOBAL_NAMES } from "./provided-globals"; export { ExecutionToolError } from "./errors"; export { defaultToolDiscoveryProvider, diff --git a/packages/core/execution/src/provided-globals.ts b/packages/core/execution/src/provided-globals.ts new file mode 100644 index 000000000..e4225fadd --- /dev/null +++ b/packages/core/execution/src/provided-globals.ts @@ -0,0 +1,270 @@ +/** + * Every name model-generated `render-ui` code may reference without declaring + * it — React hooks, TanStack Query, the `tools` proxy, and every shadcn, + * Recharts and Lucide export the shell binds into scope. + * + * GENERATED from the shell's real evaluation scope + * (`@executor-js/mcp-apps-shell` `PROVIDED_SCOPE_NAMES`). Do not hand-edit: + * `provided-globals.pin.test.ts` in the shell package fails if this drifts from + * what the iframe actually binds. Regenerate by running that package's + * `bun run gen:provided-globals`. + * + * It lives here rather than in the shell package because the MCP host must + * validate generated code without importing React. + */ + +export const PROVIDED_GLOBAL_NAMES: ReadonlySet = new Set([ + "React", + "useState", + "useEffect", + "useRef", + "useCallback", + "useMemo", + "useContext", + "Fragment", + "createContext", + "module", + "exports", + "require", + "fetch", + "XMLHttpRequest", + "WebSocket", + "EventSource", + "Worker", + "SharedWorker", + "useQuery", + "useMutation", + "useQueryClient", + "queryOptions", + "mutationOptions", + "skipToken", + "tools", + "run", + "Accordion", + "AccordionContent", + "AccordionItem", + "AccordionTrigger", + "Activity", + "Alert", + "AlertCircle", + "AlertDescription", + "AlertTitle", + "AlertTriangle", + "Area", + "AreaChart", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "ArrowUpRight", + "AtSign", + "Avatar", + "AvatarFallback", + "AvatarImage", + "Badge", + "Bar", + "BarChart", + "BarChart3", + "Battery", + "Bookmark", + "Box", + "Brush", + "Button", + "Calendar", + "Card", + "CardAction", + "CardContent", + "CardDescription", + "CardFooter", + "CardHeader", + "CardTitle", + "CartesianGrid", + "Cell", + "ChartContainer", + "ChartLegend", + "ChartLegendContent", + "ChartStyle", + "ChartTooltip", + "ChartTooltipContent", + "Check", + "Checkbox", + "ChevronDown", + "ChevronLeft", + "ChevronRight", + "ChevronUp", + "ChevronsUpDown", + "Circle", + "Clock", + "CloudRain", + "Code", + "ComposedChart", + "Copy", + "Cpu", + "Database", + "Dialog", + "DialogClose", + "DialogContent", + "DialogDescription", + "DialogFooter", + "DialogHeader", + "DialogTitle", + "DialogTrigger", + "Download", + "DropdownMenu", + "DropdownMenuCheckboxItem", + "DropdownMenuContent", + "DropdownMenuGroup", + "DropdownMenuItem", + "DropdownMenuLabel", + "DropdownMenuRadioGroup", + "DropdownMenuRadioItem", + "DropdownMenuSeparator", + "DropdownMenuSub", + "DropdownMenuSubContent", + "DropdownMenuSubTrigger", + "DropdownMenuTrigger", + "Edit", + "ExternalLink", + "Eye", + "EyeOff", + "File", + "FileText", + "Filter", + "Folder", + "FolderOpen", + "Funnel", + "FunnelChart", + "GitBranch", + "GitCommit", + "GitPullRequest", + "Globe", + "Grip", + "GripVertical", + "Hash", + "Heart", + "Hexagon", + "Home", + "Image", + "Info", + "Input", + "Key", + "Label", + "Legend", + "Line", + "LineChart", + "Link", + "Loader2", + "Lock", + "Mail", + "MapPin", + "Menu", + "MessageSquare", + "Mic", + "Minus", + "Moon", + "MoreHorizontal", + "MoreVertical", + "Package", + "Paperclip", + "Pause", + "Phone", + "Pie", + "PieChart", + "PieChartIcon", + "Play", + "Plus", + "PolarAngleAxis", + "PolarGrid", + "PolarRadiusAxis", + "Popover", + "PopoverAnchor", + "PopoverContent", + "PopoverTrigger", + "Progress", + "Radar", + "RadarChart", + "RadialBar", + "RadialBarChart", + "RadioGroup", + "RadioGroupItem", + "ReferenceArea", + "ReferenceLine", + "RefreshCw", + "ResponsiveContainer", + "RotateCcw", + "Scatter", + "ScatterChart", + "ScrollArea", + "ScrollBar", + "Search", + "Select", + "SelectContent", + "SelectGroup", + "SelectItem", + "SelectLabel", + "SelectTrigger", + "SelectValue", + "Send", + "Separator", + "Server", + "Settings", + "Sheet", + "SheetClose", + "SheetContent", + "SheetDescription", + "SheetFooter", + "SheetHeader", + "SheetTitle", + "SheetTrigger", + "Shield", + "Skeleton", + "Slider", + "SortAsc", + "SortDesc", + "Square", + "Star", + "Sun", + "Switch", + "Table", + "TableBody", + "TableCaption", + "TableCell", + "TableFooter", + "TableHead", + "TableHeader", + "TableRow", + "Tabs", + "TabsContent", + "TabsList", + "TabsTrigger", + "Tag", + "Terminal", + "Textarea", + "Thermometer", + "Toggle", + "ToggleGroup", + "ToggleGroupItem", + "Tooltip", + "TooltipContent", + "TooltipProvider", + "TooltipTrigger", + "Trash2", + "Treemap", + "TrendingDown", + "TrendingUp", + "Triangle", + "Unlock", + "Upload", + "User", + "Users", + "Video", + "Volume2", + "VolumeX", + "Wifi", + "WifiOff", + "X", + "XAxis", + "YAxis", + "Zap", + "cn", +]); diff --git a/packages/core/execution/src/skills.ts b/packages/core/execution/src/skills.ts index 849562da9..527acf135 100644 --- a/packages/core/execution/src/skills.ts +++ b/packages/core/execution/src/skills.ts @@ -70,8 +70,95 @@ export const EXECUTE_SKILL: Skill = { body: EXECUTE_SKILL_BODY, }; +// The `render-ui` how-to. Same reasoning as `execute`: the discovery-vs-render +// protocol, the TanStack rules and the component inventory are a page of prose +// that only matters once a model decides to build a UI, so the tool description +// stays short and points here. +const SHADCN_COMPONENTS = + "Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, Button, Input, Textarea, Label, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Checkbox, Switch, Slider, Toggle, Tabs, TabsList, TabsTrigger, TabsContent, Table, TableHeader, TableBody, TableRow, TableHead, TableCell, Badge, Avatar, AvatarFallback, Alert, AlertTitle, AlertDescription, Dialog, Sheet, Popover, Tooltip, Separator, ScrollArea, Skeleton, Progress, Accordion, AccordionItem, AccordionTrigger, AccordionContent, DropdownMenu + sub-components"; + +const RECHARTS_COMPONENTS = + "BarChart, Bar, LineChart, Line, AreaChart, Area, PieChart, Pie, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Legend, ChartContainer, ChartTooltip, ChartTooltipContent"; + +const LUCIDE_ICONS = + "Plus, Minus, Check, X, Search, Loader2, AlertCircle, ExternalLink, Copy, Trash2, Edit, Settings, User, Globe, Star, TrendingUp, Activity, Database, Shield, Package, and more"; + +const RENDER_UI_SKILL_BODY = [ + "# render-ui", + "", + "Render an interactive React UI component as an MCP app, and save it as an artifact.", + "", + "Every successful render is persisted under the `title` you supply, so the user can", + "reopen it later and you can find it again with `list-artifacts` / `show-artifact`.", + "Give it a short human-readable name (`Active users dashboard`), and a `description`", + "describing what it shows — that description is what a later request like", + '"show me my active users dashboard" is matched against.', + "", + "## Workflow", + "", + "1. If you need to understand tool names, query syntax, required arguments, response shapes, IDs, or mutation inputs, first use the regular `execute` tool to inspect them.", + "2. Then call `render-ui` with a component named `App` in the `code` parameter.", + "3. Recreate every read from the discovery step inside `App` with `useQuery(tools...queryOptions(args))` so the UI stays live.", + "4. Use `useMutation(tools...mutationOptions({ onSuccess }))` for user-triggered writes or actions.", + "5. Return only the component code.", + "", + "## Using Execute For Discovery", + "", + "- `execute` is for exploration: list datasets, inspect schemas, test a query, fetch one small sample row, or learn the exact mutation input shape.", + "- `render-ui` is for the final interactive surface. Do not paste discovery results into JSX as literal rows, cards, summaries, metrics, or chart series.", + "- After discovering an API call with `execute`, put the same call in TanStack Query options inside the generated component.", + "- Example discovery: call `execute` with `return await tools.axiom_mcp.querydataset({ ... })` to confirm columns, then call `render-ui` with `useQuery(tools.axiom_mcp.querydataset.queryOptions({ ... }))`.", + "- Use discovered result shapes exactly. If a sample or schema returns `{ renew, expiresAt }`, read `data?.renew`, not `data?.domain?.renew`.", + "- Keep discovery small. Use limits, narrow time ranges, or schema/list tools when possible.", + "", + "## TanStack Query State", + "", + "- The component is already wrapped in a `QueryClientProvider`; do not create your own.", + "- Use `const queryClient = useQueryClient()` when a mutation changes data shown by a query.", + "- For simple writes, invalidate with `queryClient.invalidateQueries(tools...queryFilter(args))` in `onSuccess` or `onSettled`.", + "- For toggles and switches, pass the new checked value into `mutate`: `onCheckedChange={(checked) => mutation.mutate({ body: { enabled: checked } })}`.", + "- For optimistic UI, use `onMutate` to `cancelQueries`, snapshot `getQueryData`, and `setQueryData`; return the snapshot, restore it in `onError`, and invalidate in `onSettled`.", + "- Tool proxy helpers are TanStack-native: `.queryOptions(args, options)`, `.mutationOptions(options)`, `.queryKey(args)`, `.queryFilter(args, filters)`, `.pathKey()`, `.pathFilter(filters)`, and `.mutationKey()`.", + "", + "## What Is Already In Scope", + "", + "**Write no imports.** Everything below is bound before your code runs:", + "", + "- React: `useState`, `useEffect`, `useRef`, `useCallback`, `useMemo`, `useContext`, `createContext`, `Fragment`.", + "- TanStack Query v5: `useQuery`, `useMutation`, `useQueryClient`, `queryOptions`, `mutationOptions`, `skipToken`.", + "- The tool proxy `tools` and the multi-step escape hatch `run(code)`.", + `- shadcn/ui components available by name: ${SHADCN_COMPONENTS}`, + `- Recharts components available by name: ${RECHARTS_COMPONENTS}`, + `- Lucide icons available by name: ${LUCIDE_ICONS}`, + "- The class-name helper `cn`.", + "", + "## Rules", + "", + "- Use this tool instead of `execute` whenever the output should be an interactive UI.", + "- Export a component named `App`. A top-level `const config = { maxHeight }` sets the frame height.", + "- Do not call API tools first and paste returned data into JSX.", + "- Do not embed tool response rows, API results, summaries, dashboard data, or copied query output as literals. Fetch them with `useQuery` so the UI stays live; only hardcode display constants like labels, colors, tab names, and chart configuration.", + "- Always render the loading and error states from `useQuery` / `useMutation`; do not replace them with hardcoded fallback data.", + "- Do not redeclare or destructure provided globals. `const { useState } = React` and `const Card = ...` are rejected by the server before the UI reaches the iframe — use them directly.", + "- `fetch`, `XMLHttpRequest`, `WebSocket`, `EventSource` and workers are blocked inside the frame. Every read and write goes through `tools`.", + "- Give `title` a short human-readable name and `description` enough detail that you can find the artifact again later.", + "", + "## Retrieving Saved Artifacts", + "", + "- `list-artifacts` returns every saved artifact's id, title, description and last-updated time.", + "- `show-artifact({ id })` re-renders one. Match the user's phrasing against the titles and descriptions from `list-artifacts` rather than guessing an id.", + "- Clients that cannot display MCP apps get a link to the artifact in the web app instead; pass that URL on to the user verbatim.", +].join("\n"); + +export const RENDER_UI_SKILL: Skill = { + name: "render-ui", + summary: + "How to write a React component for the render-ui tool: discover data with execute, keep it live with TanStack Query, and what is already in scope.", + body: RENDER_UI_SKILL_BODY, +}; + /** The full skill catalog. Hand-curated; keep it small. */ -export const SKILLS: readonly Skill[] = [EXECUTE_SKILL]; +export const SKILLS: readonly Skill[] = [EXECUTE_SKILL, RENDER_UI_SKILL]; /** Look up a skill by its exact name. */ export const findSkill = (name: string): Skill | undefined => diff --git a/packages/hosts/mcp-apps-shell/.gitignore b/packages/hosts/mcp-apps-shell/.gitignore new file mode 100644 index 000000000..849ddff3b --- /dev/null +++ b/packages/hosts/mcp-apps-shell/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/hosts/mcp-apps-shell/package.json b/packages/hosts/mcp-apps-shell/package.json new file mode 100644 index 000000000..ac2d8453d --- /dev/null +++ b/packages/hosts/mcp-apps-shell/package.json @@ -0,0 +1,74 @@ +{ + "name": "@executor-js/mcp-apps-shell", + "version": "1.4.4", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./shell-html": { + "types": "./src/shell-html.ts", + "default": "./src/shell-html.ts" + }, + "./shell/components": { + "types": "./src/shell/components.ts", + "default": "./src/shell/components.ts" + }, + "./shell/shell-app": { + "types": "./src/shell/shell-app.tsx", + "default": "./src/shell/shell-app.tsx" + }, + "./shell/proxy": { + "types": "./src/shell/proxy.ts", + "default": "./src/shell/proxy.ts" + }, + "./shell/component-runtime": { + "types": "./src/shell/component-runtime.ts", + "default": "./src/shell/component-runtime.ts" + }, + "./globals.css": "./src/shell/globals.css" + }, + "scripts": { + "build:shell": "vite build --config vite.config.shell.ts", + "typecheck": "tsgo --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@executor-js/react": "workspace:*", + "@modelcontextprotocol/ext-apps": "^1.7.4", + "@modelcontextprotocol/sdk": "^1.12.1", + "@tanstack/react-query": "^5.99.0", + "esbuild": "^0.27.7", + "sucrase": "^3.35.1" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@executor-js/execution": "workspace:*", + "@executor-js/host-mcp": "workspace:*", + "@executor-js/runtime-quickjs": "workspace:*", + "@executor-js/sdk": "workspace:*", + "@tailwindcss/browser": "^4.2.2", + "@tailwindcss/vite": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "bun-types": "catalog:", + "effect": "catalog:", + "lucide-react": "^1.7.0", + "playwright-core": "^1.59.1", + "react": "catalog:", + "react-dom": "catalog:", + "recharts": "3.8.0", + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-singlefile": "^2.3.2", + "vitest": "catalog:", + "zod": "4.3.6" + } +} diff --git a/packages/hosts/mcp-apps-shell/scripts/gen-provided-globals.ts b/packages/hosts/mcp-apps-shell/scripts/gen-provided-globals.ts new file mode 100644 index 000000000..43b25c011 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/scripts/gen-provided-globals.ts @@ -0,0 +1,23 @@ +import { PROVIDED_SCOPE_NAMES } from "../src/shell/component-runtime"; +const header = `/** + * Every name model-generated \`render-ui\` code may reference without declaring + * it — React hooks, TanStack Query, the \`tools\` proxy, and every shadcn, + * Recharts and Lucide export the shell binds into scope. + * + * GENERATED from the shell's real evaluation scope + * (\`@executor-js/mcp-apps-shell\` \`PROVIDED_SCOPE_NAMES\`). Do not hand-edit: + * \`provided-globals.pin.test.ts\` in the shell package fails if this drifts from + * what the iframe actually binds. Regenerate by running that package's + * \`bun run gen:provided-globals\`. + * + * It lives here rather than in the shell package because the MCP host must + * validate generated code without importing React. + */ + +export const PROVIDED_GLOBAL_NAMES: ReadonlySet = new Set([ +`; +const body = PROVIDED_SCOPE_NAMES.map((n) => ` ${JSON.stringify(n)},`).join("\n"); +await Bun.write( + new URL("../../../core/execution/src/provided-globals.ts", import.meta.url).pathname, + `${header}${body}\n]);\n`, +); diff --git a/packages/hosts/mcp-apps-shell/src/index.ts b/packages/hosts/mcp-apps-shell/src/index.ts new file mode 100644 index 000000000..e73575819 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/index.ts @@ -0,0 +1,11 @@ +/** + * The MCP-Apps shell: the trusted iframe host that renders model-written JSX. + * + * The shell reaches MCP clients as a single self-contained HTML document (the + * `ui://executor/shell.html` resource). Hosts read it with + * `loadMcpAppsShellHtml` and hand it to the MCP host through the + * `loadAppShellHtml` config seam — the MCP host never imports this package + * directly, so React/Recharts/Tailwind stay out of the Workers graph. + */ + +export { loadMcpAppsShellHtml, MCP_APPS_SHELL_NOT_BUILT_HTML } from "./shell-html"; diff --git a/packages/hosts/mcp-apps-shell/src/shell-html.ts b/packages/hosts/mcp-apps-shell/src/shell-html.ts new file mode 100644 index 000000000..04c21f4a0 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell-html.ts @@ -0,0 +1,49 @@ +let shellHtmlCache: string | undefined; + +/** + * The self-contained shell HTML served as the MCP-Apps `ui://` resource. + * + * Hosts inject this through `SharedMcpServerConfig.loadAppShellHtml` rather + * than the MCP host package importing it directly: the shell drags React, + * Recharts and Tailwind into whatever graph imports it, and the MCP host also + * runs on Workers where none of that belongs. + */ +export const loadMcpAppsShellHtml = async (): Promise => { + if (shellHtmlCache) return shellHtmlCache; + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: optional prebuilt shell asset is loaded from local filesystem when present + try { + const fs = await import("node:fs/promises"); + const path = await import("node:path"); + const candidates = [ + // `bun build --compile` can't bundle this runtime `fs.readFile`, so the + // binary build (apps/cli/src/build.ts) copies `mcp-app.html` next to the + // executable. We find it via `process.execPath`, the same colocation + // trick native-bindings.ts uses for `libsql.node` / `keyring.node`. + path.join(path.dirname(process.execPath), "mcp-app.html"), + // Dev / package-resolved (`bun run`, vitest): the package's own dist. + path.join(import.meta.dirname, "../dist/mcp-app.html"), + path.join(import.meta.dirname, "../../dist/mcp-app.html"), + ]; + + for (const candidate of candidates) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: try each possible emitted shell path before falling back + try { + shellHtmlCache = await fs.readFile(candidate, "utf-8"); + return shellHtmlCache; + } catch { + // Try the next candidate path. + } + } + } catch { + // Fall through to the development fallback below. + } + + shellHtmlCache = MCP_APPS_SHELL_NOT_BUILT_HTML; + return shellHtmlCache; +}; + +/** What the loader serves when no built shell is on disk. Exported so tests can + * assert a host is serving the real thing rather than this placeholder. */ +export const MCP_APPS_SHELL_NOT_BUILT_HTML = + "

Shell not built. Run: bun run --cwd packages/hosts/mcp-apps-shell build:shell

"; diff --git a/packages/hosts/mcp-apps-shell/src/shell/component-runtime.test.ts b/packages/hosts/mcp-apps-shell/src/shell/component-runtime.test.ts new file mode 100644 index 000000000..56c96fc58 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/component-runtime.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { compileJsx, evaluateComponent } from "./component-runtime"; + +describe("generated UI component runtime", () => { + it("accepts export default function components", () => { + const compiled = compileJsx(` + const config = { maxHeight: 500 }; + + export default function App() { + return ok; + } + `); + + const result = evaluateComponent(compiled, {}, () => Promise.resolve(null)); + + expect(compiled).not.toContain("export default"); + expect(result).not.toHaveProperty("error"); + if ("error" in result) return; + expect(typeof result.component).toBe("function"); + expect(result.config).toEqual({ maxHeight: 500 }); + }); + + it("accepts anonymous default exports", () => { + const compiled = compileJsx(` + export default function() { + return ok; + } + `); + + const result = evaluateComponent(compiled, {}, () => Promise.resolve(null)); + + expect(result).not.toHaveProperty("error"); + if ("error" in result) return; + expect(typeof result.component).toBe("function"); + }); + + it("shadows direct fetch attempts with a clear error", () => { + const compiled = compileJsx(` + fetch("https://example.com"); + function App() { + return ; + } + `); + + const result = evaluateComponent(compiled, {}, () => Promise.resolve(null)); + + expect(result).toEqual({ + error: + "Evaluation error: fetch is disabled in generated UI. Use tools.* via useQuery/useMutation.", + }); + }); +}); diff --git a/packages/hosts/mcp-apps-shell/src/shell/component-runtime.ts b/packages/hosts/mcp-apps-shell/src/shell/component-runtime.ts new file mode 100644 index 000000000..37d85dd0a --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/component-runtime.ts @@ -0,0 +1,185 @@ +import React, { + useState, + useEffect, + useRef, + useCallback, + useMemo, + useContext, + Fragment, + createContext, +} from "react"; +import { transform } from "sucrase"; + +import { + mutationOptions, + queryOptions, + skipToken, + useMutation, + useQuery, + useQueryClient, +} from "./hooks"; +import * as Components from "./components"; +import * as QueryHooks from "./hooks"; + +export type EvaluatedComponent = + | { component: React.ComponentType; config: Record } + | { error: string }; + +const createGeneratedCodeRequire = + () => + (specifier: string): unknown => { + if (specifier === "react") return React; + if (specifier === "@tanstack/react-query") return QueryHooks; + if (specifier === "recharts" || specifier === "lucide-react" || specifier === "./components") { + return Components; + } + throw new Error( + `Generated UI cannot import "${specifier}". Everything needed is already in scope; remove the import.`, + ); + }; + +const blockedNetworkPrimitive = (name: string) => + function blockedNetworkPrimitive() { + throw new Error(`${name} is disabled in generated UI. Use tools.* via useQuery/useMutation.`); + }; + +/** Compile JSX source to plain JS using Sucrase. */ +export function compileJsx(code: string): string { + const result = transform(code, { + transforms: ["jsx", "typescript", "imports"], + jsxRuntime: "classic", + production: true, + }); + return result.code; +} + +/** + * Evaluate compiled JS in a scoped context providing React, hooks, + * components, tools proxy, useQuery/useMutation, and Lucide icons. + */ +export function evaluateComponent( + compiled: string, + tools: Record, + run: (code: string) => Promise, +): EvaluatedComponent { + const module = { exports: {} as Record | React.ComponentType }; + const exports = module.exports; + + const scope: Record = buildGeneratedCodeScope({ module, exports, tools, run }); + + const scopeKeys = Object.keys(scope); + const scopeValues = scopeKeys.map((k) => scope[k]); + + // Execute the compiled code and look for a component + optional config. + // We check well-known names and common module export shapes so generated + // code stays resilient to `export default function App()` and friends. + const wrappedCode = ` + "use strict"; + ${compiled} + var __moduleExports = module && module.exports; + var __defaultExport = + __moduleExports && typeof __moduleExports === "object" && "default" in __moduleExports + ? __moduleExports.default + : exports && exports.default; + var __comp = null; + if (typeof App === "function") __comp = App; + else if (typeof Component === "function") __comp = Component; + else if (typeof Main === "function") __comp = Main; + else if (typeof __defaultExport === "function") __comp = __defaultExport; + else if (typeof __moduleExports === "function") __comp = __moduleExports; + else if (exports && typeof exports.App === "function") __comp = exports.App; + else if (exports && typeof exports.Component === "function") __comp = exports.Component; + else if (exports && typeof exports.Main === "function") __comp = exports.Main; + var __cfg = typeof config === "object" && config !== null ? config : {}; + return { component: __comp, config: __cfg }; + `; + + try { + // eslint-disable-next-line no-new-func + const factory = new Function(...scopeKeys, wrappedCode); + const result = factory(...scopeValues) as { + component: React.ComponentType | null; + config: Record; + }; + if (!result.component) { + return { error: "No component found. Export a function named App." }; + } + return { component: result.component, config: result.config }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error("[executor-shell] Failed to evaluate component:", err); + return { error: `Evaluation error: ${msg}` }; + } +} + +/** + * The single source of truth for what generated code may reference without + * declaring it. `evaluateComponent` passes these as the `new Function` + * parameter list, so a name here IS in scope in the iframe and a name missing + * here is NOT — which is why `render-ui`'s redeclaration validator is pinned + * against `PROVIDED_SCOPE_NAMES` by test rather than hand-maintained twice. + */ +function buildGeneratedCodeScope(bindings: { + module: unknown; + exports: unknown; + tools: Record; + run: (code: string) => Promise; +}): Record { + const { module, exports, tools, run } = bindings; + return { + // React core + React, + useState, + useEffect, + useRef, + useCallback, + useMemo, + useContext, + Fragment, + createContext, + + // Common module globals for resilient generated code evaluation. + module, + exports, + require: createGeneratedCodeRequire(), + + // Defense in depth for direct network attempts. The inner iframe CSP is + // the browser boundary; these shadows produce clearer runtime errors. + fetch: blockedNetworkPrimitive("fetch"), + XMLHttpRequest: blockedNetworkPrimitive("XMLHttpRequest"), + WebSocket: blockedNetworkPrimitive("WebSocket"), + EventSource: blockedNetworkPrimitive("EventSource"), + Worker: blockedNetworkPrimitive("Worker"), + SharedWorker: blockedNetworkPrimitive("SharedWorker"), + + // Data fetching + useQuery, + useMutation, + useQueryClient, + queryOptions, + mutationOptions, + skipToken, + + // Tools proxy + escape hatch + tools, + run, + + // All UI components, icons, chart primitives + ...Components, + }; +} + +/** + * Every name generated code may use without declaring it, derived from the + * real evaluation scope rather than re-listed by hand. `render-ui`'s + * redeclaration guard consumes this, so adding a component to `components.ts` + * automatically protects it from being shadowed. + */ +export const PROVIDED_SCOPE_NAMES: readonly string[] = Object.keys( + buildGeneratedCodeScope({ + module: { exports: {} }, + exports: {}, + tools: {}, + run: () => Promise.resolve(undefined), + }), +); diff --git a/packages/hosts/mcp-apps-shell/src/shell/components.ts b/packages/hosts/mcp-apps-shell/src/shell/components.ts new file mode 100644 index 000000000..d1d4fb192 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/components.ts @@ -0,0 +1,277 @@ +/** + * Barrel export of all UI components available to model-generated React code. + * These are re-exports of the existing shadcn components from @executor-js/react, + * plus Recharts primitives and Lucide icons. + */ + +// --------------------------------------------------------------------------- +// shadcn/ui components +// --------------------------------------------------------------------------- + +// Layout +export { + Card, + CardHeader, + CardTitle, + CardDescription, + CardAction, + CardContent, + CardFooter, +} from "@executor-js/react/components/card"; +export { Separator } from "@executor-js/react/components/separator"; +export { Tabs, TabsList, TabsTrigger, TabsContent } from "@executor-js/react/components/tabs"; +export { + Accordion, + AccordionItem, + AccordionTrigger, + AccordionContent, +} from "@executor-js/react/components/accordion"; +export { ScrollArea, ScrollBar } from "@executor-js/react/components/scroll-area"; + +// Overlay +export { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, + DialogClose, +} from "@executor-js/react/components/dialog"; +export { + Sheet, + SheetTrigger, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, + SheetClose, +} from "@executor-js/react/components/sheet"; +export { + Popover, + PopoverTrigger, + PopoverContent, + PopoverAnchor, +} from "@executor-js/react/components/popover"; +export { + Tooltip, + TooltipTrigger, + TooltipContent, + TooltipProvider, +} from "@executor-js/react/components/tooltip"; +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} from "@executor-js/react/components/dropdown-menu"; + +// Input +export { Button } from "@executor-js/react/components/button"; +export { Input } from "@executor-js/react/components/input"; +export { Textarea } from "@executor-js/react/components/textarea"; +export { Label } from "@executor-js/react/components/label"; +export { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, +} from "@executor-js/react/components/select"; +export { Checkbox } from "@executor-js/react/components/checkbox"; +export { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group"; +export { Switch } from "@executor-js/react/components/switch"; +export { Slider } from "@executor-js/react/components/slider"; +export { Toggle } from "@executor-js/react/components/toggle"; +export { ToggleGroup, ToggleGroupItem } from "@executor-js/react/components/toggle-group"; + +// Data display +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableRow, + TableHead, + TableCell, + TableCaption, +} from "@executor-js/react/components/table"; +export { Badge } from "@executor-js/react/components/badge"; +export { Avatar, AvatarImage, AvatarFallback } from "@executor-js/react/components/avatar"; +export { Progress } from "@executor-js/react/components/progress"; +export { Skeleton } from "@executor-js/react/components/skeleton"; + +// Feedback +export { Alert, AlertTitle, AlertDescription } from "@executor-js/react/components/alert"; + +// Charts (shadcn wrappers around Recharts) +export { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + ChartStyle, +} from "@executor-js/react/components/chart"; + +// --------------------------------------------------------------------------- +// Recharts primitives (exposed directly for model use) +// --------------------------------------------------------------------------- + +export { + BarChart, + Bar, + LineChart, + Line, + AreaChart, + Area, + PieChart, + Pie, + Cell, + RadarChart, + Radar, + PolarGrid, + PolarAngleAxis, + PolarRadiusAxis, + RadialBarChart, + RadialBar, + ScatterChart, + Scatter, + ComposedChart, + XAxis, + YAxis, + CartesianGrid, + ResponsiveContainer, + Legend, + ReferenceLine, + ReferenceArea, + Brush, + Funnel, + FunnelChart, + Treemap, +} from "recharts"; + +// --------------------------------------------------------------------------- +// Lucide icons (common subset) +// --------------------------------------------------------------------------- + +export { + Plus, + Minus, + Check, + X, + ChevronDown, + ChevronUp, + ChevronLeft, + ChevronRight, + ChevronsUpDown, + Search, + Loader2, + AlertCircle, + AlertTriangle, + Info, + ExternalLink, + Copy, + Trash2, + Edit, + Settings, + User, + Users, + Mail, + Calendar, + Clock, + ArrowUp, + ArrowDown, + ArrowLeft, + ArrowRight, + ArrowUpRight, + Download, + Upload, + File, + FileText, + Folder, + FolderOpen, + Image, + Link, + Globe, + Home, + Star, + Heart, + Eye, + EyeOff, + Lock, + Unlock, + RefreshCw, + RotateCcw, + Filter, + SortAsc, + SortDesc, + MoreHorizontal, + MoreVertical, + Menu, + Grip, + GripVertical, + Code, + Terminal, + Database, + Server, + Cpu, + Zap, + Activity, + TrendingUp, + TrendingDown, + BarChart3, + PieChart as PieChartIcon, + GitBranch, + GitCommit, + GitPullRequest, + MessageSquare, + Send, + Bookmark, + Tag, + Hash, + AtSign, + Paperclip, + MapPin, + Phone, + Video, + Mic, + Volume2, + VolumeX, + Play, + Pause, + Square, + Circle, + Triangle, + Hexagon, + Box, + Package, + Shield, + Key, + Wifi, + WifiOff, + Battery, + Sun, + Moon, + CloudRain, + Thermometer, +} from "lucide-react"; + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- + +export { cn } from "@executor-js/react/lib/utils"; diff --git a/packages/hosts/mcp-apps-shell/src/shell/globals.css b/packages/hosts/mcp-apps-shell/src/shell/globals.css new file mode 100644 index 000000000..9fb13129a --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/globals.css @@ -0,0 +1,147 @@ +@import "tailwindcss"; + +@custom-variant dark (&:is(.dark *)); + +@source "./**/*.{ts,tsx}"; +@source "../../../../react/src/components/**/*.{ts,tsx}"; + +@theme inline { + --font-sans: ui-sans-serif, system-ui, -apple-system, sans-serif; + --font-mono: ui-monospace, monospace; + + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); +} + +/* ——— Light theme ——— */ +:root { + color-scheme: light dark; + --radius: 0.625rem; + + --background: oklch(0.985 0.002 260); + --foreground: oklch(0.145 0.005 260); + --card: oklch(0.99 0.001 260); + --card-foreground: oklch(0.145 0.005 260); + --popover: oklch(0.99 0.001 260); + --popover-foreground: oklch(0.145 0.005 260); + --primary: oklch(0.55 0.17 175); + --primary-foreground: oklch(0.98 0.005 175); + --secondary: oklch(0.94 0.005 260); + --secondary-foreground: oklch(0.32 0.008 260); + --muted: oklch(0.955 0.004 260); + --muted-foreground: oklch(0.5 0.01 250); + --accent: oklch(0.94 0.006 260); + --accent-foreground: oklch(0.145 0.005 260); + --destructive: oklch(0.55 0.22 25); + --border: oklch(0.88 0.006 260); + --input: oklch(0.88 0.006 260); + --ring: oklch(0.55 0.17 175); + + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.714); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); +} + +/* ——— Dark theme ——— */ +@media (prefers-color-scheme: dark) { + :root { + --background: oklch(0.115 0.005 260); + --foreground: oklch(0.9 0.008 250); + --card: oklch(0.145 0.005 260); + --card-foreground: oklch(0.9 0.008 250); + --popover: oklch(0.145 0.005 260); + --popover-foreground: oklch(0.9 0.008 250); + --primary: oklch(0.72 0.15 175); + --primary-foreground: oklch(0.12 0.01 175); + --secondary: oklch(0.195 0.007 260); + --secondary-foreground: oklch(0.78 0.008 250); + --muted: oklch(0.175 0.005 260); + --muted-foreground: oklch(0.65 0.01 250); + --accent: oklch(0.195 0.008 260); + --accent-foreground: oklch(0.9 0.008 250); + --destructive: oklch(0.62 0.22 25); + --border: oklch(0.28 0.007 260); + --input: oklch(0.195 0.007 260); + --ring: oklch(0.72 0.15 175); + + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + } +} + +.dark { + --background: oklch(0.115 0.005 260); + --foreground: oklch(0.9 0.008 250); + --card: oklch(0.145 0.005 260); + --card-foreground: oklch(0.9 0.008 250); + --popover: oklch(0.145 0.005 260); + --popover-foreground: oklch(0.9 0.008 250); + --primary: oklch(0.72 0.15 175); + --primary-foreground: oklch(0.12 0.01 175); + --secondary: oklch(0.195 0.007 260); + --secondary-foreground: oklch(0.78 0.008 250); + --muted: oklch(0.175 0.005 260); + --muted-foreground: oklch(0.65 0.01 250); + --accent: oklch(0.195 0.008 260); + --accent-foreground: oklch(0.9 0.008 250); + --destructive: oklch(0.62 0.22 25); + --border: oklch(0.28 0.007 260); + --input: oklch(0.195 0.007 260); + --ring: oklch(0.72 0.15 175); + + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); +} + +@layer base { + * { + @apply border-border; + } + + html, + body, + #root { + min-height: 100%; + margin: 0; + padding: 0; + } + + body { + @apply bg-background font-sans text-foreground antialiased; + } +} diff --git a/packages/hosts/mcp-apps-shell/src/shell/hooks.ts b/packages/hosts/mcp-apps-shell/src/shell/hooks.ts new file mode 100644 index 000000000..873a64e9f --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/hooks.ts @@ -0,0 +1,114 @@ +import { useRef } from "react"; +import { + QueryClient, + QueryClientProvider, + mutationOptions, + queryOptions, + skipToken, + useMutation as useTanStackMutation, + useQuery as useTanStackQuery, + useQueryClient as useTanStackQueryClient, + type QueryClient as QueryClientType, + type UseMutationOptions, + type UseMutationResult, + type UseQueryOptions, + type UseQueryResult, +} from "@tanstack/react-query"; + +export { QueryClient, QueryClientProvider, mutationOptions, queryOptions, skipToken }; + +const invalidationScopes: Array>> = []; + +const trackInvalidation = (promise: Promise) => { + for (const scope of invalidationScopes) { + scope.push(promise); + } + return promise; +}; + +const trackMutationCallback = async (callback: () => T | Promise): Promise => { + const scope: Array> = []; + invalidationScopes.push(scope); + try { + const result = await callback(); + await Promise.allSettled(scope); + return result; + } finally { + invalidationScopes.pop(); + } +}; + +const wrapQueryClient = (client: QueryClientType): QueryClientType => + new Proxy(client, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver) as unknown; + if (prop === "invalidateQueries" && typeof value === "function") { + return (...args: unknown[]) => + trackInvalidation( + (value as (...args: unknown[]) => Promise).apply(target, args), + ); + } + return typeof value === "function" ? value.bind(target) : value; + }, + }) as QueryClientType; + +export const useQueryClient = (queryClient?: QueryClientType): QueryClientType => { + const client = useTanStackQueryClient(queryClient); + const wrappedRef = useRef<{ client: QueryClientType; wrapped: QueryClientType } | null>(null); + + if (wrappedRef.current?.client !== client) { + wrappedRef.current = { client, wrapped: wrapQueryClient(client) }; + } + + return wrappedRef.current.wrapped; +}; + +const wrapMutationOptions = ( + options: UseMutationOptions, +): UseMutationOptions => ({ + ...options, + onSuccess: options.onSuccess + ? (...args: Parameters>) => + trackMutationCallback(() => options.onSuccess?.(...args)) + : undefined, + onError: options.onError + ? (...args: Parameters>) => + trackMutationCallback(() => options.onError?.(...args)) + : undefined, + onSettled: options.onSettled + ? (...args: Parameters>) => + trackMutationCallback(() => options.onSettled?.(...args)) + : undefined, +}); + +export function useQuery< + TQueryFnData, + TError = Error, + TData = TQueryFnData, + TQueryKey extends readonly unknown[] = readonly unknown[], +>( + options: UseQueryOptions, + queryClient?: QueryClientType, +): UseQueryResult; +export function useQuery< + TQueryFnData, + TError = Error, + TData = TQueryFnData, + TQueryKey extends readonly unknown[] = readonly unknown[], +>( + options: UseQueryOptions, + queryClient?: QueryClientType, +): UseQueryResult { + return useTanStackQuery(options, queryClient); +} + +export function useMutation( + options: UseMutationOptions, + queryClient?: QueryClientType, +): UseMutationResult; +export function useMutation( + options: UseMutationOptions, + queryClient?: QueryClientType, +): UseMutationResult { + return useTanStackMutation(wrapMutationOptions(options), queryClient); +} diff --git a/packages/hosts/mcp-apps-shell/src/shell/inner-renderer-source.d.ts b/packages/hosts/mcp-apps-shell/src/shell/inner-renderer-source.d.ts new file mode 100644 index 000000000..8dce7ba81 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/inner-renderer-source.d.ts @@ -0,0 +1,4 @@ +declare module "virtual:executor-inner-renderer" { + const source: string; + export default source; +} diff --git a/packages/hosts/mcp-apps-shell/src/shell/inner-renderer.tsx b/packages/hosts/mcp-apps-shell/src/shell/inner-renderer.tsx new file mode 100644 index 000000000..37c5c3a15 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/inner-renderer.tsx @@ -0,0 +1,309 @@ +import React, { type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { + QueryClient, + QueryClientProvider, + mutationOptions, + queryOptions, + skipToken, + type MutationKey, + type QueryFilters, + type QueryKey, + type UseMutationOptions, + type UseQueryOptions, +} from "@tanstack/react-query"; + +import { compileJsx, evaluateComponent } from "./component-runtime"; +import * as Components from "./components"; + +type ParentRequestPayload = + | { type: "executor.toolCall"; path: string[]; args: unknown[] } + | { type: "executor.run"; code: string }; + +type ParentResponse = { + type: "executor.response"; + requestId: number; + token: string; + ok: boolean; + value?: unknown; + error?: string; +}; + +type RenderMessage = { + type: "executor.render"; + token: string; + code: string; + theme?: unknown; +}; + +type ThemeMessage = { + type: "executor.theme"; + token: string; + theme?: unknown; +}; + +type InboundMessage = ParentResponse | RenderMessage | ThemeMessage; + +const token = document.querySelector( + "meta[name='executor-render-token']", +)?.content; + +if (!token) { + throw new Error("Missing renderer token."); +} + +const pending = new Map< + number, + { + resolve: (value: unknown) => void; + reject: (reason: Error) => void; + } +>(); + +let nextRequestId = 0; +let root: ReturnType | null = null; + +const blockedNetwork = (name: string) => () => { + throw new Error(`${name} is disabled in generated UI. Use tools.* via useQuery/useMutation.`); +}; + +Object.assign(globalThis, { + fetch: blockedNetwork("fetch"), + XMLHttpRequest: blockedNetwork("XMLHttpRequest"), + WebSocket: blockedNetwork("WebSocket"), + EventSource: blockedNetwork("EventSource"), + Worker: blockedNetwork("Worker"), + SharedWorker: blockedNetwork("SharedWorker"), +}); + +const sendParent = (message: Record) => { + window.parent.postMessage({ ...message, token }, "*"); +}; + +const requestParent = (message: ParentRequestPayload): Promise => { + const requestId = ++nextRequestId; + return new Promise((resolve, reject) => { + pending.set(requestId, { resolve, reject }); + sendParent({ ...message, requestId }); + }); +}; + +function makeQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + }, + mutations: { + retry: false, + }, + }, + }); +} + +let queryClient: QueryClient = makeQueryClient(); + +const toolQueryKey = (path: readonly string[], input?: unknown): QueryKey => [ + "executor-tool", + path, + { input, type: "query" }, +]; + +const toolPathKey = (path: readonly string[]): QueryKey => ["executor-tool", path]; + +const toolMutationKey = (path: readonly string[]): MutationKey => [ + "executor-tool", + path, + { type: "mutation" }, +]; + +const queryFilter = ( + path: readonly string[], + input?: unknown, + filters?: Omit, +): QueryFilters => ({ + ...filters, + queryKey: toolQueryKey(path, input), +}); + +const pathFilter = ( + path: readonly string[], + filters?: Omit, +): QueryFilters => ({ + ...filters, + queryKey: toolPathKey(path), +}); + +const createToolsProxy = (): Record => { + const nest = (path: string[]): unknown => + new Proxy(function () {}, { + get(_target, key: string | symbol) { + if (key === "then" || key === "toJSON" || key === Symbol.toPrimitive) return undefined; + if (typeof key !== "string") return undefined; + if (key === "queryOptions") { + return (input?: unknown, options?: Omit) => + queryOptions({ + ...options, + queryKey: toolQueryKey(path, input === skipToken ? undefined : input), + queryFn: + input === skipToken + ? skipToken + : () => requestParent({ type: "executor.toolCall", path, args: [input ?? {}] }), + }); + } + if (key === "queryKey") { + return (input?: unknown) => toolQueryKey(path, input); + } + if (key === "queryFilter") { + return (input?: unknown, filters?: Omit) => + queryFilter(path, input, filters); + } + if (key === "pathKey") { + return () => toolPathKey(path); + } + if (key === "pathFilter") { + return (filters?: Omit) => pathFilter(path, filters); + } + if (key === "mutationOptions") { + return (options?: Omit) => + mutationOptions({ + ...options, + mutationKey: toolMutationKey(path), + mutationFn: (input?: unknown) => + requestParent({ type: "executor.toolCall", path, args: [input ?? {}] }), + }); + } + if (key === "mutationKey") { + return () => toolMutationKey(path); + } + return nest([...path, key]); + }, + apply(_target, _thisArg, args: unknown[]) { + return requestParent({ type: "executor.toolCall", path, args }); + }, + }); + + return nest([]) as Record; +}; + +const run = (code: string): Promise => requestParent({ type: "executor.run", code }); + +const applyTheme = (theme: unknown) => { + if (theme === "dark" || theme === "light") { + document.documentElement.classList.toggle("dark", theme === "dark"); + } +}; + +const renderNode = (node: ReactNode) => { + const mount = document.getElementById("root"); + if (!mount) return; + root ??= createRoot(mount); + root.render({node}); +}; + +const renderError = (title: string, message: string) => { + renderNode( +
+ + + {title} + + {message} + + +
, + ); + sendParent({ type: "executor.renderer.error", message }); +}; + +class ErrorBoundary extends React.Component<{ children: ReactNode }, { error: Error | null }> { + constructor(props: { children: ReactNode }) { + super(props); + this.state = { error: null }; + } + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + override render() { + if (this.state.error) { + return ( + + + Runtime Error + + {this.state.error.message} + {this.state.error.stack && ( +
{this.state.error.stack}
+ )} +
+
+ ); + } + return this.props.children; + } +} + +const renderGeneratedCode = (code: string) => { + try { + const compiled = compileJsx(code); + const evalResult = evaluateComponent(compiled, createToolsProxy(), run); + + if ("error" in evalResult) { + renderError("Error", evalResult.error); + return; + } + + sendParent({ type: "executor.renderer.config", config: evalResult.config }); + const Component = evalResult.component; + queryClient = makeQueryClient(); + renderNode( + + + + + , + ); + } catch (err) { + renderError("Compilation Error", err instanceof Error ? err.message : String(err)); + } +}; + +window.addEventListener("message", (event: MessageEvent) => { + const data = event.data; + if (!data || typeof data !== "object" || data.token !== token) return; + + if (data.type === "executor.response") { + const entry = pending.get(data.requestId); + if (!entry) return; + pending.delete(data.requestId); + if (data.ok) { + entry.resolve(data.value); + } else { + entry.reject(new Error(data.error ?? "Renderer request failed")); + } + return; + } + + if (data.type === "executor.theme") { + applyTheme(data.theme); + return; + } + + if (data.type === "executor.render") { + applyTheme(data.theme); + renderGeneratedCode(data.code); + } +}); + +const resizeObserver = new ResizeObserver(([entry]) => { + sendParent({ + type: "executor.renderer.size", + height: Math.ceil(entry.contentRect.height), + }); +}); + +resizeObserver.observe(document.body); +sendParent({ type: "executor.renderer.ready" }); diff --git a/packages/hosts/mcp-apps-shell/src/shell/mcp-app.html b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.html new file mode 100644 index 000000000..7d347f683 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.html @@ -0,0 +1,12 @@ + + + + + + Executor Shell + + +
+ + + diff --git a/packages/hosts/mcp-apps-shell/src/shell/mcp-app.tsx b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.tsx new file mode 100644 index 000000000..da9d82fc9 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { useApp } from "@modelcontextprotocol/ext-apps/react"; +import { McpAppsShell } from "./shell-app"; + +function ConnectedShellApp() { + const { app, error: connectionError } = useApp({ + appInfo: { name: "Executor Shell", version: "1.0.0" }, + capabilities: {}, + }); + + if (connectionError) { + return ( +
+
Connection error: {connectionError.message}
+
+ ); + } + + if (!app) { + return ( +
+
Connecting
+
+ ); + } + + return ; +} + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/packages/hosts/mcp-apps-shell/src/shell/provided-globals.pin.test.ts b/packages/hosts/mcp-apps-shell/src/shell/provided-globals.pin.test.ts new file mode 100644 index 000000000..33fdc0141 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/provided-globals.pin.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "@effect/vitest"; +import { PROVIDED_GLOBAL_NAMES } from "@executor-js/execution"; + +import { PROVIDED_SCOPE_NAMES } from "./component-runtime"; + +// The `render-ui` tool rejects code that redeclares a name the shell already +// binds. That guard lives in the MCP host (which must not import React), so the +// name list is generated into the execution package by +// `scripts/gen-provided-globals.ts`. This test is the pin: add a component to +// `components.ts` without regenerating and it fails here rather than silently +// letting a model shadow a real binding at render time. +describe("provided globals", () => { + it("matches the scope the iframe actually binds", () => { + expect([...PROVIDED_GLOBAL_NAMES].sort()).toEqual([...PROVIDED_SCOPE_NAMES].sort()); + }); + + it("covers the names the shell is built around", () => { + for (const name of ["React", "useState", "useQuery", "tools", "run", "Card", "cn"]) { + expect(PROVIDED_SCOPE_NAMES).toContain(name); + } + }); +}); diff --git a/packages/hosts/mcp-apps-shell/src/shell/proxy.ts b/packages/hosts/mcp-apps-shell/src/shell/proxy.ts new file mode 100644 index 000000000..794759aa6 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/proxy.ts @@ -0,0 +1,154 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export type ToolCallHost = { + readonly callServerTool: (params: { + name: string; + arguments?: Record; + }) => Promise; +}; + +export type TrustedInteraction = { + executionId: string; + interaction: { + kind?: unknown; + message?: unknown; + url?: unknown; + requestedSchema?: unknown; + }; +}; + +export type TrustedInteractionResponse = { + action: "accept" | "decline" | "cancel"; + content?: Record; +}; + +export type RequestTrustedInteraction = ( + interaction: TrustedInteraction, +) => Promise; + +/** + * Creates a tRPC-style recursive proxy that maps dotted tool paths + * to execute-action calls through the MCP Apps bridge. + * + * Usage: tools.github.issues.create({ title: "Bug" }) + * becomes: app.callServerTool("execute-action", { code: "return await tools.github.issues.create({\"title\":\"Bug\"})" }) + */ +export function createToolsProxy( + app: ToolCallHost, + requestTrustedInteraction: RequestTrustedInteraction, +): Record { + function nest(path: string[]): unknown { + return new Proxy(function () {}, { + get(_target, key: string) { + if (key === "then" || key === "toJSON" || key === (Symbol.toPrimitive as unknown)) { + return undefined; + } + return nest([...path, key]); + }, + apply(_target, _thisArg, args: unknown[]) { + const toolPath = path.join("."); + const serializedArgs = args.length > 0 ? JSON.stringify(args[0]) : "{}"; + const code = `return await tools.${toolPath}(${serializedArgs})`; + + return app + .callServerTool({ + name: "execute-action", + arguments: { code }, + }) + .then((r) => resolveToolResult(app, r, requestTrustedInteraction)); + }, + }); + } + + return nest([]) as Record; +} + +/** + * Creates the `run()` escape hatch for multi-step tool composition. + * + * Usage: const result = await run(` + * const me = await tools.github.users.me() + * return await tools.github.issues.create({ assignee: me.login, ... }) + * `) + */ +export function createRunFn( + app: ToolCallHost, + requestTrustedInteraction: RequestTrustedInteraction, +): (code: string) => Promise { + return (code: string) => + app + .callServerTool({ + name: "execute-action", + arguments: { code }, + }) + .then((r) => resolveToolResult(app, r, requestTrustedInteraction)); +} + +async function resolveToolResult( + app: ToolCallHost, + result: CallToolResult, + requestTrustedInteraction: RequestTrustedInteraction, +): Promise { + if (result.isError) { + const msg = result.content?.find((c) => c.type === "text")?.text ?? "Tool call failed"; + throw new Error(msg); + } + + const structured = result.structuredContent as Record | undefined; + const pending = parseTrustedInteraction(structured); + if (pending) { + const response = await requestTrustedInteraction(pending); + const resumed = await app.callServerTool({ + name: "execute-action-resume", + arguments: { + executionId: pending.executionId, + action: response.action, + content: JSON.stringify(response.content ?? {}), + }, + }); + return resolveToolResult(app, resumed, requestTrustedInteraction); + } + + return unwrapResult(structured) ?? parseTextContent(result); +} + +function parseTrustedInteraction( + structured: Record | undefined, +): TrustedInteraction | null { + if (!structured || structured.status !== "waiting_for_interaction") return null; + if (typeof structured.executionId !== "string") return null; + const interaction = + typeof structured.interaction === "object" && + structured.interaction !== null && + !Array.isArray(structured.interaction) + ? (structured.interaction as TrustedInteraction["interaction"]) + : {}; + return { executionId: structured.executionId, interaction }; +} + +/** + * Unwrap execution result. The kernel wraps results as + * `{ status: "completed", result: , logs: [...] }`. + * Return just the inner result value. + */ +function unwrapResult(structured: Record | undefined | null): unknown { + if ( + structured && + typeof structured === "object" && + "status" in structured && + "result" in structured + ) { + return structured.result; + } + return structured; +} + +function parseTextContent(r: { content?: Array<{ type: string; text?: string }> }): unknown { + const text = r.content?.find((c) => c.type === "text")?.text; + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return text; + } +} diff --git a/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx b/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx new file mode 100644 index 000000000..470f90d28 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx @@ -0,0 +1,623 @@ +import "./globals.css"; +import "@tailwindcss/browser"; + +import React, { useState, useEffect, useRef, useCallback, type ReactNode } from "react"; +import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { useElicitationApproval } from "@executor-js/react/components/elicitation-approval"; + +import { + createToolsProxy, + createRunFn, + type ToolCallHost, + type TrustedInteraction, + type TrustedInteractionResponse, +} from "./proxy"; +import * as Components from "./components"; +import innerRendererScript from "virtual:executor-inner-renderer"; + +type PendingInteraction = TrustedInteraction & { + resolve: (response: TrustedInteractionResponse) => void; +}; + +export type McpAppsShellHost = ToolCallHost & { + readonly getHostContext: () => McpUiHostContext | undefined; + readonly openLink: (params: { url: string }) => Promise; + ontoolinput?: (params: { arguments?: Record }) => void; + ontoolresult?: (result: CallToolResult) => void; + onerror?: (err: Error) => void; + onhostcontextchanged?: (ctx: McpUiHostContext) => void; +}; + +type RendererState = { + token: string; + code: string; + srcDoc: string; + config: Record; + height: number; +}; + +type RendererRequest = + | { + type: "executor.toolCall"; + requestId: number; + token: string; + path: unknown; + args: unknown; + } + | { type: "executor.run"; requestId: number; token: string; code: unknown } + | { type: "executor.renderer.ready"; token: string } + | { type: "executor.renderer.config"; token: string; config: unknown } + | { type: "executor.renderer.size"; token: string; height: unknown } + | { type: "executor.renderer.error"; token: string; message: unknown }; + +// --------------------------------------------------------------------------- +// Theme application from MCP Apps host context +// --------------------------------------------------------------------------- + +function applyTheme(ctx: McpUiHostContext) { + if (ctx.theme) { + document.documentElement.classList.toggle("dark", ctx.theme === "dark"); + } +} + +const createRendererToken = (): string => { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + return `renderer_${Date.now()}_${Math.random().toString(36).slice(2)}`; +}; + +const escapeInlineHtml = (value: string): string => + value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +const escapeStyleContent = (value: string): string => value.replace(/<\/style/gi, "<\\/style"); + +const escapeScriptContent = (value: string): string => value.replace(/<\/script/gi, "<\\/script"); + +const collectShellCss = (): string => + Array.from(document.styleSheets) + .map((sheet) => { + try { + return Array.from(sheet.cssRules) + .map((rule) => rule.cssText) + .join("\n"); + } catch { + return ""; + } + }) + .filter((css) => css.length > 0) + .join("\n"); + +const buildRendererSrcDoc = (token: string): string => { + const css = collectShellCss(); + return ` + + + + + + + + +
+ + +`; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const TOOL_PATH_SEGMENT = /^[A-Za-z_$][\w$]*$/; + +const toolPathToCode = (path: unknown, args: unknown): string => { + if (!Array.isArray(path) || path.length === 0) { + throw new Error("Invalid tool path."); + } + const parts = path.map((part) => { + if (typeof part !== "string" || !TOOL_PATH_SEGMENT.test(part)) { + throw new Error("Invalid tool path."); + } + return part; + }); + const argList = Array.isArray(args) ? args : []; + const serializedArgs = JSON.stringify(argList[0] ?? {}); + return `return await tools.${parts.join(".")}(${serializedArgs})`; +}; + +// --------------------------------------------------------------------------- +// Shell App — connects to MCP host, receives code, renders components +// --------------------------------------------------------------------------- + +export function McpAppsShell({ + app, + initialCode, +}: { + app: McpAppsShellHost; + initialCode?: string | undefined; +}) { + const [component, setComponent] = useState(null); + const [renderer, setRenderer] = useState(null); + const [error, setError] = useState(null); + const [hostContext, setHostContext] = useState(); + const [pendingInteraction, setPendingInteraction] = useState(null); + const toolsRef = useRef>({}); + const runRef = useRef<(code: string) => Promise>(() => Promise.resolve(null)); + const pendingInteractionRef = useRef(null); + const rendererFrameRef = useRef(null); + const rendererRef = useRef(null); + + useEffect(() => { + rendererRef.current = renderer; + }, [renderer]); + + const requestTrustedInteraction = useCallback( + (interaction: TrustedInteraction): Promise => + new Promise((resolve) => { + if (pendingInteractionRef.current) { + resolve({ action: "cancel" }); + return; + } + + const pending = { ...interaction, resolve }; + pendingInteractionRef.current = pending; + setPendingInteraction(pending); + }), + [], + ); + + const completeTrustedInteraction = useCallback((response: TrustedInteractionResponse) => { + const pending = pendingInteractionRef.current; + pendingInteractionRef.current = null; + setPendingInteraction(null); + pending?.resolve(response); + }, []); + + const postToRenderer = useCallback((message: Record) => { + const current = rendererRef.current; + const target = rendererFrameRef.current?.contentWindow; + if (!current || !target) return; + target.postMessage({ ...message, token: current.token }, "*"); + }, []); + + useEffect(() => { + const handleRendererMessage = (event: MessageEvent) => { + const current = rendererRef.current; + if (!current || event.source !== rendererFrameRef.current?.contentWindow) return; + const data = event.data; + if (!isRecord(data) || data.token !== current.token) return; + const source = event.source; + if (!source || typeof source.postMessage !== "function") return; + const respond = (requestId: number, ok: boolean, value?: unknown, error?: string) => { + source.postMessage( + { + type: "executor.response", + requestId, + token: current.token, + ok, + value, + error, + }, + "*", + ); + }; + + if (data.type === "executor.renderer.ready") { + postToRenderer({ + type: "executor.render", + code: current.code, + theme: hostContext?.theme, + }); + return; + } + + if (data.type === "executor.renderer.config") { + setRenderer((prev) => + prev && prev.token === current.token + ? { ...prev, config: isRecord(data.config) ? data.config : {} } + : prev, + ); + return; + } + + if (data.type === "executor.renderer.size") { + const height = typeof data.height === "number" ? Math.ceil(data.height) : current.height; + setRenderer((prev) => + prev && prev.token === current.token + ? { ...prev, height: Math.max(120, Math.min(4000, height)) } + : prev, + ); + return; + } + + if (data.type === "executor.renderer.error") { + if (typeof data.message === "string") { + console.error("[executor-shell] Renderer error:", data.message); + } + return; + } + + if (data.type === "executor.run") { + if (typeof data.code !== "string") { + respond(data.requestId, false, undefined, "Invalid run payload."); + return; + } + runRef + .current(data.code) + .then((value) => respond(data.requestId, true, value)) + .catch((err: unknown) => + respond( + data.requestId, + false, + undefined, + err instanceof Error ? err.message : String(err), + ), + ); + return; + } + + if (data.type === "executor.toolCall") { + let code: string; + try { + code = toolPathToCode(data.path, data.args); + } catch (err) { + respond( + data.requestId, + false, + undefined, + err instanceof Error ? err.message : String(err), + ); + return; + } + runRef + .current(code) + .then((value) => respond(data.requestId, true, value)) + .catch((err: unknown) => + respond( + data.requestId, + false, + undefined, + err instanceof Error ? err.message : String(err), + ), + ); + } + }; + + window.addEventListener("message", handleRendererMessage); + return () => window.removeEventListener("message", handleRendererMessage); + }, [hostContext?.theme, postToRenderer]); + + useEffect(() => { + if (renderer) { + postToRenderer({ type: "executor.theme", theme: hostContext?.theme }); + } + }, [hostContext?.theme, postToRenderer, renderer]); + + /** Render a JSX code string in the sandboxed inner iframe. */ + const renderCode = useCallback((code: string) => { + try { + const token = createRendererToken(); + const nextRenderer = { + token, + code, + srcDoc: buildRendererSrcDoc(token), + config: {}, + height: 240, + }; + rendererRef.current = nextRenderer; + setRenderer(nextRenderer); + setComponent(null); + setError(null); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(`Compilation error: ${msg}`); + setComponent(null); + rendererRef.current = null; + setRenderer(null); + } + }, []); + + useEffect(() => { + toolsRef.current = createToolsProxy(app, requestTrustedInteraction); + runRef.current = createRunFn(app, requestTrustedInteraction); + + // Handle tool input — fires on init (including page reload) with + // the tool arguments. For generative UI the arguments contain { code }. + app.ontoolinput = (params: { arguments?: Record }) => { + const code = params.arguments?.code; + if (code && typeof code === "string") { + renderCode(code); + } + }; + + app.ontoolresult = (result: CallToolResult) => { + const structured = result.structuredContent as Record | undefined; + const code = structured?.code; + + if (code && typeof code === "string") { + renderCode(code); + return; + } + + // Not a generative UI result — render a data view + const DataView = () => { + const text = result.content?.find((c) => c.type === "text")?.text; + const isError = (result as { isError?: boolean }).isError; + const data = structured as Record | undefined; + + return ( + + + {isError ? ( + + + Error + + {text ?? "Unknown error"} + + + ) : ( +
+                  {data ? JSON.stringify(data, null, 2) : (text ?? "(no result)")}
+                
+ )} +
+
+ ); + }; + setComponent(() => DataView); + rendererRef.current = null; + setRenderer(null); + setError(null); + }; + + app.onerror = (err) => { + console.error("[executor-shell] App error:", err); + }; + + app.onhostcontextchanged = (ctx: McpUiHostContext) => { + setHostContext((prev) => ({ ...prev, ...ctx })); + applyTheme(ctx); + }; + + (app as { onteardown?: () => Promise> }).onteardown = async () => { + return {}; + }; + }, [app, renderCode, requestTrustedInteraction]); + + // Apply initial host context + useEffect(() => { + const ctx = app.getHostContext(); + if (ctx) { + setHostContext(ctx); + applyTheme(ctx); + } + }, [app]); + + useEffect(() => { + if (initialCode) renderCode(initialCode); + }, [initialCode, renderCode]); + + if (error) { + return ( +
+ + + Error + + {error} + + +
+ ); + } + + if (!component && !renderer) { + return ( +
+ +
+ ); + } + + const Component = component; + const config = renderer?.config ?? {}; + const maxHeight = typeof config.maxHeight === "number" ? config.maxHeight : 800; + const rendererHeight = renderer ? Math.min(renderer.height, maxHeight) : undefined; + + return ( + +
+ {renderer ? ( + + + +`; + +const startShellServer = async (): Promise => { + const server = await createViteServer({ + configFile: resolve(packageRoot, "vite.config.shell.ts"), + clearScreen: false, + logLevel: "error", + server: { + host: "127.0.0.1", + port: 0, + }, + }); + + await server.listen(); + const url = server.resolvedUrls?.local[0]; + if (!url) { + throw new Error("Vite did not report a local shell URL."); + } + + return { + url: url.replace(/\/$/, ""), + close: () => server.close(), + }; +}; + +const readBody = (request: IncomingMessage): Promise => + new Promise((resolveBody, rejectBody) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer | string) => { + chunks.push(Buffer.from(chunk)); + }); + request.on("end", () => resolveBody(Buffer.concat(chunks).toString("utf8"))); + request.on("error", rejectBody); + }); + +const startOpenApiServer = (): Promise => + new Promise((resolveServer, rejectServer) => { + let baseUrl = ""; + let domainAutoRenew = false; + let nextDomainGetDelayMs = 0; + const postRequests: string[] = []; + + const server: Server = createServer(async (request, response) => { + if (request.method === "GET" && request.url === "/openapi.json") { + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + openapi: "3.0.0", + info: { title: "Inventory", version: "1.0.0" }, + servers: [{ url: baseUrl }], + paths: { + "/items": { + get: { + operationId: "listItems", + responses: { + "200": { + description: "Inventory items", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/Item" }, + }, + }, + }, + }, + }, + }, + post: { + operationId: "createItem", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CreateItem" }, + }, + }, + }, + responses: { + "200": { + description: "Created item", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CreatedItem" }, + }, + }, + }, + }, + }, + }, + "/domains/{domain}": { + get: { + operationId: "getDomain", + parameters: [ + { + name: "domain", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "Domain", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Domain" }, + }, + }, + }, + }, + }, + }, + "/domains/{domain}/auto-renew": { + post: { + operationId: "updateDomainAutoRenew", + parameters: [ + { + name: "domain", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/AutoRenewInput" }, + }, + }, + }, + responses: { + "200": { + description: "Updated domain", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Domain" }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + AutoRenewInput: { + type: "object", + required: ["autoRenew"], + properties: { + autoRenew: { type: "boolean" }, + }, + }, + Domain: { + type: "object", + required: ["domain", "renew"], + properties: { + domain: { type: "string" }, + renew: { type: "boolean" }, + }, + }, + Item: { + type: "object", + required: ["id", "name"], + properties: { + id: { type: "integer" }, + name: { type: "string" }, + }, + }, + CreateItem: { + type: "object", + required: ["name"], + properties: { + name: { type: "string" }, + }, + }, + CreatedItem: { + type: "object", + required: ["id", "name", "created"], + properties: { + id: { type: "integer" }, + name: { type: "string" }, + created: { type: "boolean" }, + }, + }, + }, + }, + }), + ); + return; + } + + if (request.method === "GET" && request.url === "/items") { + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify([{ id: 1, name: "Seed Widget" }])); + return; + } + + if (request.method === "GET" && request.url?.startsWith("/domains/")) { + const domain = decodeURIComponent(request.url.slice("/domains/".length)); + if (!domain.includes("/")) { + const delayMs = nextDomainGetDelayMs; + nextDomainGetDelayMs = 0; + if (delayMs > 0) { + await new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs)); + } + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ domain, renew: domainAutoRenew })); + return; + } + } + + if (request.method === "POST" && request.url === "/items") { + const body = await readBody(request); + postRequests.push(body); + const parsed = JSON.parse(body) as { name?: unknown }; + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + id: 2, + name: typeof parsed.name === "string" ? parsed.name : "Unnamed", + created: true, + }), + ); + return; + } + + if ( + request.method === "POST" && + request.url?.startsWith("/domains/") && + request.url.endsWith("/auto-renew") + ) { + const body = await readBody(request); + postRequests.push(body); + const parsed = JSON.parse(body) as { autoRenew?: unknown }; + domainAutoRenew = parsed.autoRenew === true; + nextDomainGetDelayMs = 300; + const domainPath = request.url.slice( + "/domains/".length, + request.url.length - "/auto-renew".length, + ); + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + domain: decodeURIComponent(domainPath), + renew: domainAutoRenew, + }), + ); + return; + } + + response.statusCode = 404; + response.end("not found"); + }); + + server.once("error", rejectServer); + server.listen(0, "127.0.0.1", () => { + server.off("error", rejectServer); + const address = server.address(); + if (!address || typeof address === "string") { + rejectServer(new Error("Failed to resolve OpenAPI server address.")); + return; + } + + const { port } = address as AddressInfo; + baseUrl = `http://127.0.0.1:${port}`; + resolveServer({ + specUrl: `${baseUrl}/openapi.json`, + postRequests, + close: () => + new Promise((resolveClose, rejectClose) => { + server.close((err) => (err ? rejectClose(err) : resolveClose())); + }), + }); + }); + }); + +const makePausedResult = ( + id: string, + request: ReturnType, +): ExecutionResult => ({ + status: "paused", + execution: { id, elicitationContext: { address: formToolId, args: {}, request } }, +}); + +/** + * These tests drive the shell directly over postMessage rather than through + * `render-ui`, so persistence is out of scope here — but the ui tools only + * register when an artifacts port is present, and `execute-action` is what the + * shell actually calls. A trivial in-memory port is enough to bring them up. + */ +const makeInMemoryArtifacts = () => { + const rows = new Map(); + let seq = 0; + return { + list: (): Effect.Effect => Effect.sync(() => [...rows.values()]), + get: (id: string): Effect.Effect => + Effect.suspend(() => { + const row = rows.get(id); + return row ? Effect.succeed(row) : Effect.fail(new Error(`no artifact ${id}`)); + }), + save: (input: { + readonly title: string; + readonly description?: string | null; + readonly code: string; + }): Effect.Effect => + Effect.sync(() => { + seq += 1; + const artifact = { + id: ArtifactId.make(`art_${seq}`), + owner: "user" as const, + title: input.title, + description: input.description ?? null, + code: input.code, + createdAt: new Date(seq * 1000), + updatedAt: new Date(seq * 1000), + }; + rows.set(artifact.id, artifact); + return artifact; + }), + }; +}; + +const startMcpHarnessForEngine = async ( + engine: ExecutionEngine, +): Promise => { + const mcpServer = await Effect.runPromise( + createExecutorMcpServer({ + engine, + loadAppShellHtml: loadMcpAppsShellHtml, + artifacts: makeInMemoryArtifacts(), + }), + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client( + { name: "browser-harness", version: "1.0.0" }, + { capabilities: appsWithoutElicitationCapabilities }, + ); + + await mcpServer.connect(serverTransport); + await client.connect(clientTransport); + + return { + callTool: async (params) => { + if (!params.name) { + throw new Error("Missing MCP tool name."); + } + return client.callTool({ + name: params.name, + arguments: params.arguments ?? {}, + }); + }, + close: async () => { + await clientTransport.close(); + await serverTransport.close(); + }, + }; +}; + +const startMcpHarness = async (openApi: OpenApiServer): Promise => { + const executor = await Effect.runPromise( + Effect.gen(function* () { + const built = yield* createExecutor( + makeTestConfig({ plugins: [inventoryPlugin(openApi.postRequests)] }), + ); + // Tools only exist per connection, so register the integration and open + // one org `main` connection — that is what makes + // `tools.inventory.org.main.*` resolvable from generated code. + yield* built["inventory-test"].seed(); + yield* built.connections.create({ + owner: "org", + name: INVENTORY_CONNECTION, + integration: INVENTORY_SLUG, + template: INVENTORY_TEMPLATE, + value: "token", + }); + return built; + }), + ); + + const engine = createExecutionEngine({ + executor, + codeExecutor: makeQuickJsExecutor({ + timeoutMs: 5_000, + memoryLimitBytes: 32 * 1024 * 1024, + }), + }); + + return startMcpHarnessForEngine(engine); +}; + +const startSchemaElicitationMcpHarness = (): Promise => + startMcpHarnessForEngine({ + getDescription: Effect.succeed("schema elicitation test executor"), + execute: () => Effect.succeed({ result: null }), + executeWithPause: () => + Effect.succeed( + makePausedResult( + "schema_exec", + FormElicitation.make({ + message: "Provide approval details", + requestedSchema: { + type: "object", + required: ["name", "count", "priority", "tags"], + properties: { + name: { + type: "string", + title: "Display name", + minLength: 2, + description: "Human-readable name to submit.", + }, + count: { + type: "integer", + title: "Count", + minimum: 1, + default: 2, + }, + priority: { + type: "string", + title: "Priority", + enum: ["low", "high"], + enumNames: ["Low", "High"], + }, + notify: { + type: "boolean", + title: "Notify", + default: false, + }, + tags: { + type: "array", + title: "Tags", + minItems: 1, + items: { + enum: ["alpha", "beta"], + enumNames: ["Alpha", "Beta"], + }, + }, + }, + }, + }), + ), + ), + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + resume: (_executionId, response) => + Effect.succeed({ + status: "completed", + result: { result: response.content ?? {} }, + }), + }); + +const startHostServer = (shellUrl: string, mcp: McpHarness): Promise => + new Promise((resolveServer, rejectServer) => { + const html = createHostHtml(shellUrl); + const server: Server = createServer(async (request, response) => { + if (request.method === "POST" && request.url === "/tools/call") { + try { + const body = await readBody(request); + const params = JSON.parse(body) as HostToolCall; + const result = await mcp.callTool(params); + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify(result)); + } catch (err) { + response.statusCode = 500; + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + content: [ + { + type: "text", + text: err instanceof Error ? err.message : String(err), + }, + ], + isError: true, + }), + ); + } + return; + } + + if (request.method !== "GET" || request.url !== "/") { + response.statusCode = 404; + response.end("not found"); + return; + } + + response.statusCode = 200; + response.setHeader("content-type", "text/html; charset=utf-8"); + response.end(html); + }); + + server.once("error", rejectServer); + server.listen(0, "127.0.0.1", () => { + server.off("error", rejectServer); + const address = server.address(); + if (!address || typeof address === "string") { + rejectServer(new Error("Failed to resolve host server address.")); + return; + } + + const { port } = address as AddressInfo; + resolveServer({ + url: `http://127.0.0.1:${port}`, + close: () => + new Promise((resolveClose, rejectClose) => { + server.close((err) => (err ? rejectClose(err) : resolveClose())); + }), + }); + }); + }); + +const waitForValue = async ( + page: Page, + read: () => T | undefined, + label: string, +): Promise => { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const value = read(); + if (value !== undefined) return value; + await page.waitForTimeout(50); + } + throw new Error(`Timed out waiting for ${label}.`); +}; + +const waitForShellFrame = (page: Page): Promise => + waitForValue( + page, + () => page.frames().find((frame) => frame.url().includes("/mcp-app.html")), + "MCP app shell iframe", + ); + +const waitForInnerFrame = async (page: Page, shellFrame: Frame): Promise => { + const locator = shellFrame.locator('iframe[title="Generated UI"]'); + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const handle = await locator.elementHandle(); + const frame = await handle?.contentFrame(); + await handle?.dispose(); + if (frame) return frame; + await page.waitForTimeout(50); + } + throw new Error("Timed out waiting for generated UI iframe."); +}; + +const waitForHostInitialized = (page: Page) => + page.waitForFunction(() => + Boolean((window as unknown as BrowserHostWindow).__mcpHostState.initialized), + ); + +const getHostState = (page: Page): Promise => + page.evaluate(() => (window as unknown as BrowserHostWindow).__mcpHostState); + +const openHarness = async (browser: Browser, hostUrl: string) => { + const page = await browser.newPage(); + await page.goto(hostUrl, { waitUntil: "domcontentloaded" }); + const shellFrame = await waitForShellFrame(page); + await waitForHostInitialized(page); + await shellFrame.locator('[data-testid="shell-loading-state"]').waitFor({ timeout: 10_000 }); + return { page, shellFrame }; +}; + +const renderGeneratedUi = async (page: Page, shellFrame: Frame, code: string): Promise => { + await page.evaluate( + (value) => (window as unknown as BrowserHostWindow).__sendGeneratedUi(value), + code, + ); + await shellFrame.locator('iframe[title="Generated UI"]').waitFor({ timeout: 10_000 }); + return waitForInnerFrame(page, shellFrame); +}; + +describe("MCP app generated UI browser isolation", () => { + let openApiServer: OpenApiServer | undefined; + let mcpHarness: McpHarness | undefined; + let shellServer: ShellServer | undefined; + let hostServer: HostServer | undefined; + let browser: Browser | undefined; + + beforeAll(async () => { + openApiServer = await startOpenApiServer(); + mcpHarness = await startMcpHarness(openApiServer); + shellServer = await startShellServer(); + hostServer = await startHostServer(shellServer.url, mcpHarness); + browser = await chromium.launch({ + executablePath: chromeExecutablePath, + headless: process.env.PLAYWRIGHT_HEADLESS !== "0", + slowMo: process.env.PLAYWRIGHT_SLOWMO ? Number(process.env.PLAYWRIGHT_SLOWMO) : undefined, + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }); + }, 60_000); + + afterAll(async () => { + await browser?.close(); + await hostServer?.close(); + await shellServer?.close(); + await mcpHarness?.close(); + await openApiServer?.close(); + }, 30_000); + + it("runs generated UI in a sandboxed browser iframe and proxies live tool calls", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedDataCode); + await innerFrame.waitForFunction( + () => document.querySelector("#status")?.textContent === "Widget", + undefined, + { timeout: 10_000 }, + ); + + const rendererAttributes = await shellFrame + .locator('iframe[title="Generated UI"]') + .evaluate((element) => ({ + sandbox: element.getAttribute("sandbox"), + srcDoc: element.getAttribute("srcdoc") ?? "", + })); + + expect(rendererAttributes.sandbox).toBe("allow-scripts"); + expect(rendererAttributes.srcDoc).toContain('meta name="executor-render-token"'); + expect(rendererAttributes.srcDoc).toContain("default-src 'none'"); + expect(rendererAttributes.srcDoc).toContain("connect-src 'none'"); + expect(rendererAttributes.srcDoc).toContain("form-action 'none'"); + expect(rendererAttributes.srcDoc).toContain("frame-src 'none'"); + expect(rendererAttributes.srcDoc).toContain("worker-src 'none'"); + + const parentAccess = await innerFrame.evaluate(() => { + try { + void window.parent.document.body; + return "allowed"; + } catch (err) { + return err instanceof DOMException ? err.name : String(err); + } + }); + expect(parentAccess).toBe("SecurityError"); + + const blockedText = (await innerFrame.locator("#blocked").textContent()) ?? ""; + for (const name of networkPrimitives) { + expect(blockedText).toContain( + `${name} is disabled in generated UI. Use tools.* via useQuery/useMutation.`, + ); + } + + const hostState = await getHostState(page); + expect(hostState.toolCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "execute-action", + arguments: { + code: "return await tools.inventory.org.main.listItems({})", + }, + }), + ]), + ); + } finally { + await page.close(); + } + }, 30_000); + + it("blocks popup, form, and nested-frame escape attempts from generated UI", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedEscapeAttemptCode); + await innerFrame.locator("#escape-results").waitFor({ timeout: 10_000 }); + + const escapeText = (await innerFrame.locator("#escape-results").textContent()) ?? ""; + expect(escapeText).toContain("popup:true"); + expect(escapeText).toContain("form:"); + expect(escapeText).toContain("iframe:"); + + const pages = browser.contexts().flatMap((context) => context.pages()); + expect(pages).toHaveLength(1); + + const hostState = await getHostState(page); + expect(hostState.toolCalls).toHaveLength(0); + } finally { + await page.close(); + } + }, 30_000); + + it("rejects spoofed renderer messages unless the iframe window and token match", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedStaticCode); + await innerFrame.locator("#ready").waitFor({ timeout: 10_000 }); + + await shellFrame.evaluate(() => { + const iframe = document.querySelector('iframe[title="Generated UI"]'); + const srcDoc = iframe?.getAttribute("srcdoc") ?? ""; + const token = //.exec(srcDoc)?.[1]; + if (!iframe?.contentWindow || !token) { + throw new Error("Generated UI iframe is missing a token."); + } + + window.dispatchEvent( + new MessageEvent("message", { + source: window, + data: { type: "executor.run", requestId: 1, token, code: "return 42" }, + }), + ); + window.dispatchEvent( + new MessageEvent("message", { + source: iframe.contentWindow, + data: { type: "executor.run", requestId: 2, token: "wrong", code: "return 42" }, + }), + ); + }); + + await page.waitForTimeout(100); + expect((await getHostState(page)).toolCalls).toHaveLength(0); + + await shellFrame.evaluate(() => { + const iframe = document.querySelector('iframe[title="Generated UI"]'); + const srcDoc = iframe?.getAttribute("srcdoc") ?? ""; + const token = //.exec(srcDoc)?.[1]; + if (!iframe?.contentWindow || !token) { + throw new Error("Generated UI iframe is missing a token."); + } + + window.dispatchEvent( + new MessageEvent("message", { + source: iframe.contentWindow, + data: { type: "executor.run", requestId: 3, token, code: "return 42" }, + }), + ); + }); + + await page.waitForFunction( + () => (window as unknown as BrowserHostWindow).__mcpHostState.toolCalls.length === 1, + ); + expect((await getHostState(page)).toolCalls[0]).toEqual( + expect.objectContaining({ + name: "execute-action", + arguments: { code: "return 42" }, + }), + ); + } finally { + await page.close(); + } + }, 30_000); + + it("handles elicitations in the trusted shell instead of the generated iframe", async () => { + if (!browser || !hostServer || !openApiServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedApprovalCode); + await innerFrame.locator("#ask").waitFor({ timeout: 10_000 }); + await innerFrame.locator("#ask").click({ timeout: 10_000 }); + + await shellFrame.locator("text=Approve action").waitFor({ timeout: 10_000 }); + expect(await innerFrame.locator("text=Approve action").count()).toBe(0); + expect(await shellFrame.locator('[data-testid="trusted-interaction-fields"]').count()).toBe( + 0, + ); + expect(await shellFrame.locator("text=Response content").count()).toBe(0); + expect(openApiServer.postRequests).toHaveLength(0); + + await shellFrame.getByRole("button", { name: "Approve" }).click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => document.querySelector("#approval-status")?.textContent === "Approved Widget:true", + undefined, + { timeout: 10_000 }, + ); + expect(openApiServer.postRequests).toEqual([JSON.stringify({ name: "Approved Widget" })]); + + const hostState = await getHostState(page); + expect(hostState.resumeCalls).toEqual([ + expect.objectContaining({ + name: "execute-action-resume", + arguments: { + executionId: expect.any(String), + action: "accept", + content: "{}", + }, + }), + ]); + } finally { + await page.close(); + } + }, 30_000); + + it("resumes declined and canceled approvals without performing the mutation", async () => { + if (!browser || !hostServer || !openApiServer) { + throw new Error("Browser harness did not start."); + } + + for (const action of ["Decline", "Cancel"] as const) { + const { page, shellFrame } = await openHarness(browser, hostServer.url); + const initialPostCount = openApiServer.postRequests.length; + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedApprovalCode); + await innerFrame.locator("#ask").click({ timeout: 10_000 }); + await shellFrame.locator("text=Approve action").waitFor({ timeout: 10_000 }); + + await shellFrame.getByRole("button", { name: action }).click({ timeout: 10_000 }); + await page.waitForFunction( + () => (window as unknown as BrowserHostWindow).__mcpHostState.resumeCalls.length === 1, + undefined, + { timeout: 10_000 }, + ); + + expect(openApiServer.postRequests).toHaveLength(initialPostCount); + const hostState = await getHostState(page); + expect(hostState.resumeCalls).toEqual([ + expect.objectContaining({ + name: "execute-action-resume", + arguments: { + executionId: expect.any(String), + action: action === "Decline" ? "decline" : "cancel", + content: "{}", + }, + }), + ]); + } finally { + await page.close(); + } + } + }, 30_000); + + it("blocks generated UI mutations that run on mount until trusted approval", async () => { + if (!browser || !hostServer || !openApiServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const initialPostCount = openApiServer.postRequests.length; + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedAutoMutationCode); + await innerFrame.locator("#auto-mutation-status").waitFor({ timeout: 10_000 }); + await shellFrame.locator("text=Approve action").waitFor({ timeout: 10_000 }); + + expect(openApiServer.postRequests).toHaveLength(initialPostCount); + const hostStateBeforeApproval = await getHostState(page); + expect(hostStateBeforeApproval.toolCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "execute-action", + arguments: { + code: 'return await tools.inventory.org.main.createItem({"body":{"name":"Mount Widget"}})', + }, + }), + ]), + ); + + await shellFrame.getByRole("button", { name: "Approve" }).click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => document.querySelector("#auto-mutation-status")?.textContent === "Mount Widget:true", + undefined, + { timeout: 10_000 }, + ); + expect(openApiServer.postRequests).toHaveLength(initialPostCount + 1); + expect(openApiServer.postRequests.at(-1)).toBe(JSON.stringify({ name: "Mount Widget" })); + } finally { + await page.close(); + } + }, 30_000); + + it("updates live query state after an approved TanStack Query mutation", async () => { + if (!browser || !hostServer || !openApiServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const initialPostCount = openApiServer.postRequests.length; + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedTanstackQueryCode); + await innerFrame.waitForFunction( + () => document.querySelector("#auto-renew-state")?.textContent === "false", + undefined, + { timeout: 10_000 }, + ); + + await innerFrame.locator("#auto-renew-toggle").click({ timeout: 10_000 }); + await shellFrame.locator("text=Approve action").waitFor({ timeout: 10_000 }); + expect(openApiServer.postRequests).toHaveLength(initialPostCount); + + await shellFrame.getByRole("button", { name: "Approve" }).click({ timeout: 10_000 }); + + await innerFrame.waitForFunction( + () => document.querySelector("#auto-renew-state")?.textContent === "true", + undefined, + { timeout: 10_000 }, + ); + await innerFrame.waitForFunction( + () => + document.querySelector("#auto-renew-success")?.textContent === + "Auto-renew enabled successfully", + undefined, + { timeout: 10_000 }, + ); + + expect(openApiServer.postRequests).toHaveLength(initialPostCount + 1); + expect(openApiServer.postRequests.at(-1)).toBe(JSON.stringify({ autoRenew: true })); + + const hostState = await getHostState(page); + expect(hostState.toolCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "execute-action", + arguments: { + code: 'return await tools.inventory.org.main.getDomain({"domain":"openexecutor.com"})', + }, + }), + expect.objectContaining({ + name: "execute-action", + arguments: { + code: 'return await tools.inventory.org.main.updateDomainAutoRenew({"domain":"openexecutor.com","body":{"autoRenew":true}})', + }, + }), + ]), + ); + } finally { + await page.close(); + } + }, 30_000); + + it("renders form elicitations as typed approval fields", async () => { + if (!browser || !shellServer) { + throw new Error("Browser harness did not start."); + } + + const schemaMcpHarness = await startSchemaElicitationMcpHarness(); + const schemaHostServer = await startHostServer(shellServer.url, schemaMcpHarness); + const { page, shellFrame } = await openHarness(browser, schemaHostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedSchemaApprovalCode); + await innerFrame.locator("#ask-schema").click({ timeout: 10_000 }); + await shellFrame.locator("text=Provide approval details").waitFor({ timeout: 10_000 }); + + await shellFrame.getByLabel("Display name").fill("Rhea"); + await shellFrame.getByLabel("Count").fill("3"); + await shellFrame.getByLabel("Priority").selectOption("high"); + await shellFrame.getByLabel("Notify").click(); + await shellFrame.getByLabel("Beta").click(); + await shellFrame.getByRole("button", { name: "Approve" }).click({ timeout: 10_000 }); + + await innerFrame.waitForFunction( + () => + document.querySelector("#schema-status")?.textContent === + JSON.stringify({ + name: "Rhea", + count: 3, + priority: "high", + notify: true, + tags: ["beta"], + }), + undefined, + { timeout: 10_000 }, + ); + + const hostState = await getHostState(page); + expect(hostState.resumeCalls).toEqual([ + expect.objectContaining({ + name: "execute-action-resume", + arguments: { + executionId: "schema_exec", + action: "accept", + content: JSON.stringify({ + name: "Rhea", + count: 3, + priority: "high", + notify: true, + tags: ["beta"], + }), + }, + }), + ]); + } finally { + await page.close(); + await schemaHostServer.close(); + await schemaMcpHarness.close(); + } + }, 30_000); + + it("keeps trusted approval controls visible in a short host iframe", async () => { + if (!browser || !hostServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + await page.evaluate(() => { + const appFrame = document.getElementById("app") as HTMLIFrameElement | null; + if (!appFrame) throw new Error("Missing app iframe."); + appFrame.style.height = "180px"; + }); + await shellFrame.waitForFunction(() => document.documentElement.clientHeight <= 180); + + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedApprovalCode); + await innerFrame.locator("#ask").click({ timeout: 10_000 }); + await shellFrame.locator("text=Approve action").waitFor({ timeout: 10_000 }); + + const metrics = await shellFrame + .locator('[data-testid="trusted-interaction-modal"]') + .evaluate((modal) => { + const card = modal.querySelector('[data-testid="trusted-interaction-card"]'); + const body = modal.querySelector('[data-testid="trusted-interaction-body"]'); + const footer = modal.querySelector( + '[data-testid="trusted-interaction-footer"]', + ); + if (!card || !body || !footer) throw new Error("Missing trusted modal element."); + + const cardRect = card.getBoundingClientRect(); + const footerRect = footer.getBoundingClientRect(); + return { + bodyOverflowY: getComputedStyle(body).overflowY, + cardBottom: cardRect.bottom, + cardTop: cardRect.top, + footerBottom: footerRect.bottom, + footerTop: footerRect.top, + viewportHeight: document.documentElement.clientHeight, + }; + }); + + expect(metrics.bodyOverflowY).toBe("auto"); + expect(metrics.cardTop).toBeGreaterThanOrEqual(0); + expect(metrics.cardBottom).toBeLessThanOrEqual(metrics.viewportHeight + 1); + expect(metrics.footerTop).toBeGreaterThanOrEqual(0); + expect(metrics.footerBottom).toBeLessThanOrEqual(metrics.viewportHeight + 1); + } finally { + await page.close(); + } + }, 30_000); +}); diff --git a/packages/hosts/mcp/src/artifacts-tools.test.ts b/packages/hosts/mcp/src/artifacts-tools.test.ts new file mode 100644 index 000000000..06007a24e --- /dev/null +++ b/packages/hosts/mcp/src/artifacts-tools.test.ts @@ -0,0 +1,527 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Data, Effect } from "effect"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; +import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; +import type * as Cause from "effect/Cause"; + +import { ArtifactId, FormElicitation, type Artifact, type ArtifactSummary } from "@executor-js/sdk"; +import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; + +import { MCP_APPS_SHELL_RESOURCE_URI, artifactUrlFor } from "./render-ui"; +import { createExecutorMcpServer, type ExecutorMcpServerConfig } from "./tool-server"; + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +class TestArtifactError extends Data.TaggedError("TestArtifactError")<{ + readonly message: string; +}> {} + +const makeStubEngine = (overrides: { + executeWithPause?: ExecutionEngine["executeWithPause"]; + resume?: ExecutionEngine["resume"]; +}): ExecutionEngine => ({ + execute: () => Effect.succeed({ result: "default" }), + executeWithPause: + overrides.executeWithPause ?? + (() => Effect.succeed({ status: "completed", result: { result: "default" } })), + resume: overrides.resume ?? (() => Effect.succeed(null)), + isExecutionSettled: undefined, + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + getDescription: Effect.succeed("test executor"), +}); + +/** + * An in-memory stand-in for `executor.artifacts` with the same observable + * behavior the real owner-scoped store has: save mints an id (or overwrites in + * place), get fails when nothing matches, list is newest-first without code. + */ +const makeArtifactStore = () => { + const rows = new Map(); + let seq = 0; + const calls: Array<{ title: string; description: string | null; code: string }> = []; + return { + calls, + rows, + port: { + list: (): Effect.Effect => + Effect.sync(() => + [...rows.values()] + .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()) + .map(({ code: _code, ...summary }) => summary), + ), + get: (id: string): Effect.Effect => + Effect.suspend(() => { + const row = rows.get(id); + return row + ? Effect.succeed(row) + : Effect.fail(new TestArtifactError({ message: `no artifact ${id}` })); + }), + save: (input: { + readonly id?: string; + readonly title: string; + readonly description?: string | null; + readonly code: string; + }): Effect.Effect => + Effect.suspend(() => { + calls.push({ + title: input.title, + description: input.description ?? null, + code: input.code, + }); + const existing = input.id === undefined ? undefined : rows.get(input.id); + if (input.id !== undefined && !existing) { + return Effect.fail(new TestArtifactError({ message: `no artifact ${input.id}` })); + } + seq += 1; + const artifact: Artifact = { + id: ArtifactId.make(existing?.id ?? `art_${seq}`), + owner: "user", + title: input.title, + description: input.description ?? null, + code: input.code, + createdAt: existing?.createdAt ?? new Date(seq * 1000), + updatedAt: new Date(seq * 1000), + }; + rows.set(artifact.id, artifact); + return Effect.succeed(artifact); + }), + }, + }; +}; + +const withClient = async ( + engine: ExecutionEngine, + capabilities: ClientCapabilities, + fn: (client: Client) => Promise, + config?: Partial>, +) => { + const mcpServer = await Effect.runPromise( + createExecutorMcpServer({ + engine, + loadAppShellHtml: () => Promise.resolve(SHELL_HTML), + ...config, + } as ExecutorMcpServerConfig), + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities }); + await mcpServer.connect(serverTransport); + await client.connect(clientTransport); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test helper must close MCP transports after async client assertions + try { + await fn(client); + } finally { + await clientTransport.close(); + await serverTransport.close(); + } +}; + +const SHELL_HTML = "
"; + +// What a client that renders MCP Apps advertises at `initialize`. +const APPS_CAPS = { + extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } }, +} as unknown as ClientCapabilities; + +const NO_APPS_CAPS: ClientCapabilities = {}; + +const structuredOf = (result: Awaited>): Record => + (result.structuredContent ?? {}) as Record; + +const textOf = (result: Awaited>): string => + (result.content as Array<{ type: string; text: string }>)[0].text; + +const toolNames = async (client: Client): Promise => + (await client.listTools()).tools.map((tool) => tool.name); + +const COUNTER_CODE = "function App() { return
hi
; }"; + +const makePausedResult = (id: string, message: string): ExecutionResult => ({ + status: "paused", + execution: { + id, + elicitationContext: { + request: FormElicitation.make({ message, requestedSchema: {} }), + }, + } as ExecutionResult extends { status: "paused"; execution: infer P } ? P : never, +}); + +// --------------------------------------------------------------------------- +// Capability-gated visibility +// --------------------------------------------------------------------------- + +describe("MCP host — artifact tool visibility", () => { + it("hides the app-only tools from clients that cannot render MCP Apps", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + NO_APPS_CAPS, + async (client) => { + const names = await toolNames(client); + // The model-facing three are always advertised — they degrade to a + // deep link rather than disappearing. + expect(names).toContain("render-ui"); + expect(names).toContain("list-artifacts"); + expect(names).toContain("show-artifact"); + // `execute-action` is only callable from inside a rendered app. + expect(names).not.toContain("execute-action"); + expect(names).not.toContain("execute-action-resume"); + }, + { artifacts: store.port }, + ); + }); + + it("exposes the app-only tools to clients that advertise the shell mime type", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + const names = await toolNames(client); + expect(names).toContain("render-ui"); + expect(names).toContain("execute-action"); + expect(names).toContain("execute-action-resume"); + }, + { artifacts: store.port }, + ); + }); + + it("registers no ui tools at all when no shell loader is configured", async () => { + const store = makeArtifactStore(); + const mcpServer = await Effect.runPromise( + createExecutorMcpServer({ engine: makeStubEngine({}), artifacts: store.port }), + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} }); + await mcpServer.connect(serverTransport); + await client.connect(clientTransport); + const names = (await client.listTools()).tools.map((tool) => tool.name); + expect(names).toContain("execute"); + expect(names).not.toContain("render-ui"); + expect(names).not.toContain("list-artifacts"); + await clientTransport.close(); + await serverTransport.close(); + }); + + it("serves the shell as an MCP-Apps resource with a zero-domain CSP", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + const read = await client.readResource({ uri: MCP_APPS_SHELL_RESOURCE_URI }); + const [content] = read.contents; + expect(content.mimeType).toBe(RESOURCE_MIME_TYPE); + expect(content.text).toBe(SHELL_HTML); + // The shell may open no network connection of its own; everything + // routes back over the MCP bridge. + expect(content._meta).toMatchObject({ + ui: { csp: { connectDomains: [], resourceDomains: [] } }, + }); + }, + { artifacts: store.port }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// render-ui delivery +// --------------------------------------------------------------------------- + +describe("MCP host — render-ui", () => { + it("returns the code inline and persists it when the client renders apps", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + const result = await client.callTool({ + name: "render-ui", + arguments: { + code: COUNTER_CODE, + title: "Active users dashboard", + description: "Daily active users over time", + }, + }); + expect(structuredOf(result)).toEqual({ code: COUNTER_CODE, artifactId: "art_1" }); + expect(result.isError).toBeFalsy(); + expect(store.calls).toEqual([ + { + title: "Active users dashboard", + description: "Daily active users over time", + code: COUNTER_CODE, + }, + ]); + }, + { artifacts: store.port, artifactUrl: artifactUrlFor("https://executor.test") }, + ); + }); + + it("returns a deep link and still persists when the client cannot render apps", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + NO_APPS_CAPS, + async (client) => { + const result = await client.callTool({ + name: "render-ui", + arguments: { code: COUNTER_CODE, title: "Active users dashboard" }, + }); + expect(structuredOf(result)).toEqual({ + status: "fallback_url", + url: "https://executor.test/artifacts/art_1", + artifactId: "art_1", + }); + // The model needs to be told to hand the URL over. + expect(textOf(result)).toContain("https://executor.test/artifacts/art_1"); + // Persistence is what makes the fallback possible at all. + expect(store.calls).toHaveLength(1); + expect(store.rows.get("art_1")?.code).toBe(COUNTER_CODE); + }, + { artifacts: store.port, artifactUrl: artifactUrlFor("https://executor.test") }, + ); + }); + + it("still persists and reports the id when no web UI is configured", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + NO_APPS_CAPS, + async (client) => { + const result = await client.callTool({ + name: "render-ui", + arguments: { code: COUNTER_CODE, title: "Orphan dashboard" }, + }); + expect(structuredOf(result)).toEqual({ + status: "fallback_unavailable", + reason: "mcp_apps_unsupported", + artifactId: "art_1", + }); + expect(store.rows.get("art_1")?.title).toBe("Orphan dashboard"); + }, + { artifacts: store.port }, + ); + }); + + it("rejects redeclared provided globals before the code reaches the iframe", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + const destructured = await client.callTool({ + name: "render-ui", + arguments: { + code: "const { useState } = React; function App(){ return null; }", + title: "Bad", + }, + }); + expect(destructured.isError).toBe(true); + expect(textOf(destructured)).toContain("Do not destructure React"); + + const shadowed = await client.callTool({ + name: "render-ui", + arguments: { code: "const Card = 1; function App(){ return null; }", title: "Bad" }, + }); + expect(shadowed.isError).toBe(true); + expect(textOf(shadowed)).toContain('Provided global "Card"'); + + // Nothing rejected is ever persisted. + expect(store.calls).toHaveLength(0); + }, + { artifacts: store.port }, + ); + }); + + it("allows display constants that the dropped data heuristic used to reject", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + // The donor branch rejected this on the variable name alone. Legit + // chart configuration is not a hardcoded live-data snapshot. + const result = await client.callTool({ + name: "render-ui", + arguments: { + code: "const series = [{ key: 'a', color: '#111' }, { key: 'b', color: '#222' }]; function App(){ return null; }", + title: "Chart", + }, + }); + expect(result.isError).toBeFalsy(); + expect(store.calls).toHaveLength(1); + }, + { artifacts: store.port }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Retrieval +// --------------------------------------------------------------------------- + +describe("MCP host — artifact retrieval", () => { + it("round-trips: render-ui saves, list-artifacts finds it, show-artifact returns the code", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + await client.callTool({ + name: "render-ui", + arguments: { + code: COUNTER_CODE, + title: "Active users dashboard", + description: "DAU over time", + }, + }); + + const listed = await client.callTool({ name: "list-artifacts", arguments: {} }); + expect(structuredOf(listed)).toMatchObject({ + artifacts: [ + { id: "art_1", title: "Active users dashboard", description: "DAU over time" }, + ], + }); + // The text form is what a model without structured-output support reads. + expect(textOf(listed)).toContain("Active users dashboard"); + + const shown = await client.callTool({ + name: "show-artifact", + arguments: { id: "art_1" }, + }); + expect(structuredOf(shown)).toEqual({ code: COUNTER_CODE, artifactId: "art_1" }); + }, + { artifacts: store.port }, + ); + }); + + it("delivers a saved artifact as a deep link to clients without apps support", async () => { + const store = makeArtifactStore(); + await Effect.runPromise( + store.port.save({ title: "Saved earlier", description: null, code: COUNTER_CODE }), + ); + await withClient( + makeStubEngine({}), + NO_APPS_CAPS, + async (client) => { + const shown = await client.callTool({ name: "show-artifact", arguments: { id: "art_1" } }); + expect(structuredOf(shown)).toEqual({ + status: "fallback_url", + url: "https://executor.test/artifacts/art_1", + artifactId: "art_1", + }); + }, + { artifacts: store.port, artifactUrl: artifactUrlFor("https://executor.test") }, + ); + }); + + it("reports a miss as an error result rather than failing the tool call", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + const shown = await client.callTool({ + name: "show-artifact", + arguments: { id: "art_nope" }, + }); + expect(shown.isError).toBe(true); + expect(structuredOf(shown)).toMatchObject({ error: "artifact_not_found", id: "art_nope" }); + // The model is told how to recover. + expect(textOf(shown)).toContain("list-artifacts"); + }, + { artifacts: store.port }, + ); + }); + + it("lists nothing, without erroring, before anything is saved", async () => { + const store = makeArtifactStore(); + await withClient( + makeStubEngine({}), + APPS_CAPS, + async (client) => { + const listed = await client.callTool({ name: "list-artifacts", arguments: {} }); + expect(listed.isError).toBeFalsy(); + expect(structuredOf(listed)).toEqual({ artifacts: [] }); + expect(textOf(listed)).toContain("No saved artifacts"); + }, + { artifacts: store.port }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// execute-action — shell-owned approval +// --------------------------------------------------------------------------- + +describe("MCP host — execute-action", () => { + // The pin that matters: the shell renders the approval modal itself, in its + // trusted outer frame. A browser approval URL would be unusable from inside a + // widget, so `execute-action` must return the resolvable pause payload even + // when the session's elicitation mode is `browser`. + it("pauses for shell-owned approval instead of a browser approval URL", async () => { + const store = makeArtifactStore(); + const engine = makeStubEngine({ + executeWithPause: () => Effect.succeed(makePausedResult("exec_app", "Approve UI action?")), + resume: (executionId, response) => + Effect.succeed( + executionId === "exec_app" + ? { status: "completed", result: { result: `action:${response.action}` } } + : null, + ), + }); + + await withClient( + engine, + APPS_CAPS, + async (client) => { + const paused = await client.callTool({ + name: "execute-action", + arguments: { code: "return await tools.github.issues.create({})" }, + }); + expect(paused.structuredContent).toMatchObject({ + status: "waiting_for_interaction", + executionId: "exec_app", + interaction: { message: "Approve UI action?" }, + }); + expect(textOf(paused)).not.toContain("executor.test/resume"); + + const resumed = await client.callTool({ + name: "execute-action-resume", + arguments: { executionId: "exec_app", action: "accept", content: "{}" }, + }); + expect(resumed.content).toEqual([{ type: "text", text: "action:accept" }]); + }, + { + artifacts: store.port, + elicitationMode: { + mode: "browser", + approvalUrl: (executionId) => `https://executor.test/resume/${executionId}`, + }, + }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Deep-link shape +// --------------------------------------------------------------------------- + +describe("artifactUrlFor", () => { + it("builds /artifacts/:id against the host's origin", () => { + expect(artifactUrlFor("https://executor.sh")("art_123")).toBe( + "https://executor.sh/artifacts/art_123", + ); + }); + + it("ignores any path on the configured base and escapes the id", () => { + expect(artifactUrlFor("http://localhost:4788/")("art/../x")).toBe( + "http://localhost:4788/artifacts/art%2F..%2Fx", + ); + }); +}); From 0bd7cdb4d4b9e459f8a1badf0db4a2af365a8ea4 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:57:41 -0700 Subject: [PATCH 04/60] Ship the MCP-Apps shell in binaries and revive the sunpeak e2e harness --- .oxlintrc.jsonc | 25 ++++++ apps/cli/src/build.ts | 31 +++++++ e2e/mcp-apps/.gitignore | 5 ++ e2e/mcp-apps/README.md | 67 ++++++++++++++ e2e/mcp-apps/package.json | 17 ++++ e2e/mcp-apps/playwright.config.ts | 28 ++++++ e2e/mcp-apps/scripts/check-sunpeak.mjs | 45 ++++++++++ e2e/mcp-apps/tests/render-ui.spec.ts | 87 +++++++++++++++++++ packages/hosts/mcp-apps-shell/CHANGELOG.md | 1 + .../src/shell-resource.smoke.test.ts | 1 + .../hosts/mcp/src/artifacts-tools.test.ts | 9 +- 11 files changed, 314 insertions(+), 2 deletions(-) create mode 100644 e2e/mcp-apps/.gitignore create mode 100644 e2e/mcp-apps/README.md create mode 100644 e2e/mcp-apps/package.json create mode 100644 e2e/mcp-apps/playwright.config.ts create mode 100644 e2e/mcp-apps/scripts/check-sunpeak.mjs create mode 100644 e2e/mcp-apps/tests/render-ui.spec.ts create mode 100644 packages/hosts/mcp-apps-shell/CHANGELOG.md diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index 9c7d8caf1..da06c5351 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -119,6 +119,31 @@ "executor/no-try-catch-or-throw": "off", }, }, + { + // boundary: the MCP-Apps shell is browser code, not Effect domain code. + // It runs inside a sandboxed iframe and talks to the host over + // postMessage and the MCP bridge — both of which are throw/Promise APIs + // with no Effect runtime present. `new Function` evaluation of generated + // JSX, CSS harvesting off `document.styleSheets`, and JSON on the wire + // all surface untyped JS errors that must be caught and rendered rather + // than modelled. Scoped to the iframe payload plus its build config; the + // package's Effect-facing loader (`src/shell-html.ts`) is deliberately + // NOT listed and uses inline suppressions instead. + "files": [ + "packages/hosts/mcp-apps-shell/src/shell/**/*.{ts,tsx}", + "packages/hosts/mcp-apps-shell/vite.config.shell.ts", + ], + "rules": { + "executor/no-double-cast": "off", + "executor/no-error-constructor": "off", + "executor/no-instanceof-error": "off", + "executor/no-json-parse": "off", + "executor/no-promise-catch": "off", + "executor/no-promise-reject": "off", + "executor/no-try-catch-or-throw": "off", + "executor/no-unknown-error-message": "off", + }, + }, { "files": ["packages/kernel/core/src/code-recovery.ts"], "rules": { diff --git a/apps/cli/src/build.ts b/apps/cli/src/build.ts index 64117dc86..1fc7ad937 100644 --- a/apps/cli/src/build.ts +++ b/apps/cli/src/build.ts @@ -25,6 +25,32 @@ const resolveQuickJsWasmPath = (): string => { return wasmPath; }; +// --------------------------------------------------------------------------- +// MCP-Apps shell (mcp-app.html) +// +// The MCP host serves the shell as the `ui://executor/shell.html` resource, and +// `@executor-js/mcp-apps-shell` reads it from disk at runtime. That runtime +// `fs.readFile` is invisible to `bun build --compile`, so without this the +// packaged binary would serve the "Shell not built" placeholder and every +// generated UI would render blank. Build it if missing and copy it next to the +// executable, where the loader finds it via `process.execPath` — the same +// colocation trick used for `libsql.node` / `emscripten-module.wasm`. +// --------------------------------------------------------------------------- + +const mcpAppsShellDir = resolve(repoRoot, "packages/hosts/mcp-apps-shell"); +const mcpAppsShellHtml = join(mcpAppsShellDir, "dist/mcp-app.html"); + +const ensureMcpAppsShell = async (): Promise => { + if (!existsSync(mcpAppsShellHtml)) { + console.log("Building MCP-Apps shell (mcp-app.html)..."); + await $`bun run --cwd ${mcpAppsShellDir} build:shell`.quiet(); + } + if (!existsSync(mcpAppsShellHtml)) { + throw new Error(`MCP-Apps shell missing at ${mcpAppsShellHtml} after build:shell`); + } + return mcpAppsShellHtml; +}; + const resolveOnePasswordCoreWasmPath = (): string => { const req = createRequire(join(repoRoot, "packages/plugins/onepassword/package.json")); const sdkPkg = req.resolve("@1password/sdk/package.json"); @@ -398,6 +424,7 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { await writeFile(embeddedMigrationsPath, `${embeddedMigrations}\n`); const quickJsWasmPath = resolveQuickJsWasmPath(); + const mcpAppsShellPath = await ensureMcpAppsShell(); const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath(); const workerBundlerDistPath = resolveWorkerBundlerDistPath(); const packedWorkerBundlerSource = await createPackedWorkerBundlerSource(workerBundlerDistPath); @@ -423,6 +450,10 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { // Copy QuickJS WASM next to binary — loaded at runtime by the server await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm")); + // Copy the MCP-Apps shell next to the binary. Platform-independent HTML, + // read from execDir at runtime by @executor-js/mcp-apps-shell. + await cp(mcpAppsShellPath, join(binDir, "mcp-app.html")); + // Copy 1Password's sdk-core WASM next to the binary. The patched // sdk-core loader falls back to this sibling file when bun --compile // bakes a build-machine node_modules path into __dirname. diff --git a/e2e/mcp-apps/.gitignore b/e2e/mcp-apps/.gitignore new file mode 100644 index 000000000..cb5522839 --- /dev/null +++ b/e2e/mcp-apps/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +package-lock.json +test-results/ +playwright-report/ +.exdata/ diff --git a/e2e/mcp-apps/README.md b/e2e/mcp-apps/README.md new file mode 100644 index 000000000..a81ca3576 --- /dev/null +++ b/e2e/mcp-apps/README.md @@ -0,0 +1,67 @@ +# MCP Apps tests (sunpeak) + +Render and test executor's generative-UI MCP Apps (the `render-ui` tool from the +`mcp-apps-shell` plugin) in a **real MCP-Apps host**, headlessly, in CI, with **no +VM and no Claude/ChatGPT account**. + +[sunpeak](https://github.com/Sunpeak-AI/sunpeak) locally replicates the Claude +and ChatGPT app runtimes: it connects to an MCP server, calls a UI tool, mounts +the returned `ui://` resource in a sandboxed iframe with the host bridge, and +hands the test a frame-scoped handle to the rendered component. Every test runs +against both host simulations. This is the lightweight successor to the earlier +MCPJam Playwright harness (which we drove by hand via fragile UI selectors). + +## Run + +```bash +cd e2e/mcp-apps +npm install # also patches sunpeak (see below) and is isolated from the bun workspace +npm test # builds the shell, starts sunpeak + `executor mcp`, runs the specs +``` + +`playwright.config.ts` starts `executor mcp` over **stdio** (from source, so the +shell resource is served), so there is no daemon or HTTP token to manage. Specs +live in `tests/`; `npm run test:headed` and `npm run report` help when debugging. + +A spec is tiny: + +```ts +import { test, expect } from "sunpeak/test"; +test("widget mounts", async ({ inspector }) => { + const result = await inspector.renderTool("render-ui", { code: APP_SRC }); + const app = result.app().frameLocator("iframe"); // see "extra iframe" below + await expect(app.locator('button:has-text("Increment")')).toBeVisible(); +}); +``` + +## Two interop notes (both handled here) + +1. **sunpeak doesn't advertise the MCP-Apps UI _client_ capability.** Its + inspector connects with `new Client({ name, version })` and never declares + `capabilities.extensions["io.modelcontextprotocol/ui"]`. executor's + `render-ui` (correctly, per the MCP-Apps spec) only mounts the widget inline + when the host advertises it can render `text/html;profile=mcp-app`; otherwise + it returns its browser **fallback URL** and nothing renders. `scripts/patch-sunpeak.mjs` + (a postinstall) adds that capability to sunpeak's client. This is + upstream-worthy: sunpeak should advertise it itself. + +2. **executor's shell nests one extra iframe.** The shell mounts the generated + component in a nested `srcdoc` iframe (`shell-app` -> `inner-renderer`, the + double-iframe sandbox), one level below sunpeak's `result.app()`. So tests use + `result.app().frameLocator("iframe")` to reach the component. + +## Why stdio + from source + +`render-ui`'s shell resource (`ui://executor/shell-tanstack-query.html`) is +`packages/hosts/mcp-apps-shell/dist/mcp-app.html`. `pretest` builds it. Running +`executor mcp` from source serves it directly. (The compiled binary now ships +the shell too — see `apps/cli/src/build.ts` — but source is the cheaper path +for a test.) + +## Scope + +sunpeak is a host _simulation_: it covers the protocol contract, the rendered +UI, and tool/theme/display-mode behavior. It does not catch real-host quirks +(OAuth, production CSP). For the shell component in isolation there is also the +faster in-repo unit test at +`packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts`. diff --git a/e2e/mcp-apps/package.json b/e2e/mcp-apps/package.json new file mode 100644 index 000000000..d38201777 --- /dev/null +++ b/e2e/mcp-apps/package.json @@ -0,0 +1,17 @@ +{ + "name": "@executor-js/e2e-mcp-apps", + "private": true, + "description": "Render + test executor's generative-UI MCP Apps in a real MCP-Apps host (sunpeak), headless, no VM.", + "type": "module", + "scripts": { + "postinstall": "node scripts/check-sunpeak.mjs", + "pretest": "bun run --cwd ../../packages/hosts/mcp-apps-shell build:shell", + "test": "playwright test", + "test:headed": "playwright test --headed", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.56.0", + "sunpeak": "^0.20.56" + } +} diff --git a/e2e/mcp-apps/playwright.config.ts b/e2e/mcp-apps/playwright.config.ts new file mode 100644 index 000000000..fa6b97480 --- /dev/null +++ b/e2e/mcp-apps/playwright.config.ts @@ -0,0 +1,28 @@ +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { defineConfig } from "sunpeak/test/config"; + +// sunpeak runs `sunpeak inspect` (a real MCP-Apps host that mounts ui:// +// resources in a sandboxed iframe) as the Playwright web server, connects it to +// the MCP server below, and gives tests a frame-scoped handle to the rendered +// app. We point it at executor over stdio (`executor mcp`) so the whole thing is +// self-contained: no daemon to manage, no HTTP token. +// +// Run the daemon FROM SOURCE (bun apps/cli/bin/executor.ts) so the shell +// resource is served from packages/hosts/mcp-apps-shell/dist/mcp-app.html +// (`pretest` builds it). The compiled binary also ships the shell, but source +// is the cheapest path for a test. +const here = dirname(fileURLToPath(import.meta.url)); +const repo = resolve(here, "../.."); + +export default defineConfig({ + testDir: "tests", + server: { + command: "bun", + args: [resolve(repo, "apps/cli/bin/executor.ts"), "mcp"], + env: { + EXECUTOR_DATA_DIR: resolve(here, ".exdata"), + }, + cwd: repo, + }, +}); diff --git a/e2e/mcp-apps/scripts/check-sunpeak.mjs b/e2e/mcp-apps/scripts/check-sunpeak.mjs new file mode 100644 index 000000000..5b4283e1d --- /dev/null +++ b/e2e/mcp-apps/scripts/check-sunpeak.mjs @@ -0,0 +1,45 @@ +// Verify sunpeak's inspector advertises the MCP-Apps UI *client* capability to +// the upstream MCP server. +// +// executor gates inline UI rendering on that advertisement (per the MCP-Apps +// spec: a server mounts inline only when the host says it can render +// `text/html;profile=mcp-app`). A host that doesn't advertise it gets the +// deep-link fallback instead of the widget — which would make this whole suite +// pass while testing the wrong delivery path. +// +// sunpeak used to construct its Client with no capabilities at all and we +// patched the shipped file. As of 0.20.69 it declares the capability itself, so +// this is now a CHECK rather than a patch. It stays fatal: if a future sunpeak +// drops the capability, the harness must fail loudly at install time rather +// than silently degrade. +import { readFileSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const target = resolve(here, "../node_modules/sunpeak/bin/commands/inspect.mjs"); + +if (!existsSync(target)) { + console.error(`[check-sunpeak] ${target} not found. Is sunpeak installed?`); + process.exit(1); +} + +const src = readFileSync(target, "utf8"); +const EXTENSION_ID = "io.modelcontextprotocol/ui"; +const MIME_TYPE = "text/html;profile=mcp-app"; + +if (!src.includes(EXTENSION_ID) || !src.includes(MIME_TYPE)) { + console.error( + [ + "[check-sunpeak] the inspector no longer advertises MCP-Apps client support.", + ` expected both ${EXTENSION_ID} and ${MIME_TYPE}`, + ` in: ${target}`, + "Without it executor returns its deep-link fallback and these tests would pass", + "while testing nothing. Pin a sunpeak version that advertises the capability,", + "or restore a patch here that injects it into the Client constructor.", + ].join("\n"), + ); + process.exit(1); +} + +console.log("[check-sunpeak] inspector advertises MCP-Apps UI client capability"); diff --git a/e2e/mcp-apps/tests/render-ui.spec.ts b/e2e/mcp-apps/tests/render-ui.spec.ts new file mode 100644 index 000000000..33c4c75c2 --- /dev/null +++ b/e2e/mcp-apps/tests/render-ui.spec.ts @@ -0,0 +1,87 @@ +import { test, expect } from "sunpeak/test"; + +// executor's render-ui shell mounts the generated component in a *nested* srcdoc +// iframe (shell-app -> inner-renderer, the MCP-Apps double-iframe sandbox), one +// level below sunpeak's own `result.app()`. Descend that extra level. +const appBody = (result: { app: () => ReturnType["app"]> } | any) => + result.app().frameLocator("iframe"); + +const COUNTER = `function App() { + const [count, setCount] = useState(0); + return ( +
+

MCP App counter

+ {count} + +
+ ); +}`; + +// sunpeak runs every test against both the Claude and ChatGPT host simulations. + +test("render-ui mounts an interactive React widget", async ({ inspector }) => { + const result = await inspector.renderTool("render-ui", { + code: COUNTER, + title: "MCP App counter", + }); + const app = appBody(result); + + await expect(app.locator("text=MCP App counter")).toBeVisible({ timeout: 15000 }); + const inc = app.locator('button:has-text("Increment")'); + await expect(inc).toBeVisible(); + + // state is live: clicking updates the rendered output + await inc.click(); + await expect(app.locator("text=1")).toBeVisible(); +}); + +test("render-ui renders in dark theme", async ({ inspector }) => { + const result = await inspector.renderTool( + "render-ui", + { code: COUNTER, title: "MCP App counter" }, + { theme: "dark" }, + ); + await expect(appBody(result).locator("text=MCP App counter")).toBeVisible({ timeout: 15000 }); +}); + +// The whole point of patching sunpeak to advertise MCP-Apps support is that +// executor returns the inline widget rather than its deep-link fallback. Assert +// that directly, so a future sunpeak change that drops the capability fails +// here instead of quietly testing the wrong delivery path. +// Protocol-level assertions go through the `mcp` fixture: `inspector` models +// what a *host* shows the user and does not surface the raw tool result. +const structuredOf = (result: { structuredContent?: unknown }): Record => + (result.structuredContent ?? {}) as Record; + +test("render-ui returns an inline widget, not the deep-link fallback", async ({ mcp }) => { + const result = await mcp.callTool("render-ui", { + code: COUNTER, + title: "MCP App counter", + description: "A counter used by the MCP Apps e2e harness", + }); + const structured = structuredOf(result); + // The whole point of the inspector advertising MCP-Apps support: executor + // must hand back the component, not a URL to go look at it elsewhere. + expect(structured.status).not.toBe("fallback_url"); + expect(structured.code).toContain("MCP App counter"); + // Every successful render is persisted, so the artifact can be reopened. + expect(structured.artifactId).toBeTruthy(); +}); + +test("a rendered artifact is listed and can be shown again", async ({ inspector, mcp }) => { + const rendered = await mcp.callTool("render-ui", { + code: COUNTER, + title: "Retrievable counter", + description: "Saved so list-artifacts can find it", + }); + const artifactId = structuredOf(rendered).artifactId as string; + expect(artifactId).toBeTruthy(); + + const listed = await mcp.callTool("list-artifacts", {}); + const artifacts = structuredOf(listed).artifacts as Array<{ id: string }> | undefined; + expect(artifacts?.some((artifact) => artifact.id === artifactId)).toBe(true); + + // …and the stored source really does render, through the same shell. + const shown = await inspector.renderTool("show-artifact", { id: artifactId }); + await expect(appBody(shown).locator("text=MCP App counter")).toBeVisible({ timeout: 15000 }); +}); diff --git a/packages/hosts/mcp-apps-shell/CHANGELOG.md b/packages/hosts/mcp-apps-shell/CHANGELOG.md new file mode 100644 index 000000000..33433e4c6 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/CHANGELOG.md @@ -0,0 +1 @@ +# @executor-js/mcp-apps-shell diff --git a/packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts b/packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts index 09d7f7e7e..2c4e94639 100644 --- a/packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts +++ b/packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts @@ -28,6 +28,7 @@ const stubEngine: ExecutionEngine = { getDescription: Effect.succeed("smoke"), }; +// oxlint-disable-next-line executor/no-double-cast -- boundary: MCP SDK ClientCapabilities predates the ext-apps `extensions` field const APPS_CAPS = { extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } }, } as unknown as ClientCapabilities; diff --git a/packages/hosts/mcp/src/artifacts-tools.test.ts b/packages/hosts/mcp/src/artifacts-tools.test.ts index 06007a24e..11fc5835f 100644 --- a/packages/hosts/mcp/src/artifacts-tools.test.ts +++ b/packages/hosts/mcp/src/artifacts-tools.test.ts @@ -123,7 +123,10 @@ const withClient = async ( const SHELL_HTML = "
"; -// What a client that renders MCP Apps advertises at `initialize`. +// What a client that renders MCP Apps advertises at `initialize`. The SDK's +// `ClientCapabilities` has no `extensions` field yet (pending SEP-1724), which +// is exactly why ext-apps ships `getUiCapability` to read it. +// oxlint-disable-next-line executor/no-double-cast -- boundary: MCP SDK ClientCapabilities predates the ext-apps `extensions` field const APPS_CAPS = { extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } }, } as unknown as ClientCapabilities; @@ -217,7 +220,9 @@ describe("MCP host — artifact tool visibility", () => { const read = await client.readResource({ uri: MCP_APPS_SHELL_RESOURCE_URI }); const [content] = read.contents; expect(content.mimeType).toBe(RESOURCE_MIME_TYPE); - expect(content.text).toBe(SHELL_HTML); + // Served as text, never as a blob. + expect("text" in content).toBe(true); + expect("text" in content ? content.text : "").toBe(SHELL_HTML); // The shell may open no network connection of its own; everything // routes back over the MCP bridge. expect(content._meta).toMatchObject({ From 6858efb7d6fe7a7bde10130713b753a2aa3a1810 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:49:10 -0700 Subject: [PATCH 05/60] Add the Artifacts console pages and embed the MCP-Apps shell --- apps/cloud/src/routeTree.gen.ts | 61 +++++ apps/cloud/src/routes/__root.tsx | 5 +- apps/cloud/vite.config.ts | 4 + apps/host-cloudflare/vite.config.ts | 4 + apps/host-cloudflare/web/routeTree.gen.ts | 61 +++++ apps/host-cloudflare/web/routes/__root.tsx | 9 +- apps/host-selfhost/vite.config.ts | 4 + apps/host-selfhost/web/routeTree.gen.ts | 61 +++++ apps/host-selfhost/web/routes/__root.tsx | 9 +- bun.lock | 1 + packages/app/package.json | 1 + packages/app/src/routeTree.gen.ts | 58 +++++ packages/app/src/routes/__root.tsx | 9 +- packages/app/src/web/shell.tsx | 7 + packages/app/vite.ts | 4 + packages/core/api/src/account/org-slug.ts | 6 +- packages/hosts/mcp-apps-shell/package.json | 8 + .../src/shell/artifact-renderer.tsx | 35 +++ .../mcp-apps-shell/src/shell/shell-app.tsx | 6 + packages/hosts/mcp-apps-shell/src/vite.ts | 77 +++++++ .../hosts/mcp-apps-shell/vite.config.shell.ts | 37 +-- packages/react/package.json | 1 + packages/react/src/api/analytics.tsx | 5 + packages/react/src/api/artifact-renderer.tsx | 56 +++++ packages/react/src/api/atoms.tsx | 59 +++++ packages/react/src/api/reactivity-keys.tsx | 6 + packages/react/src/api/shell-host.ts | 167 ++++++++++++++ packages/react/src/console-routes.ts | 4 + packages/react/src/lib/relative-time.test.ts | 37 +++ packages/react/src/lib/relative-time.ts | 19 ++ packages/react/src/multiplayer/shell.tsx | 1 + packages/react/src/pages/artifact-detail.tsx | 190 ++++++++++++++++ .../src/pages/artifact-rename-dialog.tsx | 109 +++++++++ packages/react/src/pages/artifacts.tsx | 212 ++++++++++++++++++ packages/react/src/routes/artifacts-route.tsx | 15 ++ .../src/routes/artifacts.$artifactId.tsx | 12 + packages/react/src/routes/artifacts.tsx | 15 ++ packages/react/src/routes/routeTree.gen.ts | 53 +++++ packages/react/src/styles/globals.css | 5 + 39 files changed, 1393 insertions(+), 40 deletions(-) create mode 100644 packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx create mode 100644 packages/hosts/mcp-apps-shell/src/vite.ts create mode 100644 packages/react/src/api/artifact-renderer.tsx create mode 100644 packages/react/src/api/shell-host.ts create mode 100644 packages/react/src/lib/relative-time.test.ts create mode 100644 packages/react/src/lib/relative-time.ts create mode 100644 packages/react/src/pages/artifact-detail.tsx create mode 100644 packages/react/src/pages/artifact-rename-dialog.tsx create mode 100644 packages/react/src/pages/artifacts.tsx create mode 100644 packages/react/src/routes/artifacts-route.tsx create mode 100644 packages/react/src/routes/artifacts.$artifactId.tsx create mode 100644 packages/react/src/routes/artifacts.tsx diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index e57f3de44..37493600a 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -20,12 +20,14 @@ import { Route as SecretsRouteImport } from './routes/app/secrets' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' 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 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey' const SetupMcpRoute = SetupMcpRouteImport.update({ @@ -88,6 +90,12 @@ const BillingRoute = BillingRouteImport.update({ path: '/{-$orgSlug}/billing', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport.update({ + id: '/{-$orgSlug}/artifacts', + path: '/{-$orgSlug}/artifacts', + getParentRoute: () => rootRouteImport, + } as any) const ApiKeysRoute = ApiKeysRouteImport.update({ id: '/{-$orgSlug}/api-keys', path: '/{-$orgSlug}/api-keys', @@ -128,6 +136,15 @@ const Billing_DotplansRoute = Billing_DotplansRouteImport.update({ path: '/{-$orgSlug}/billing/plans', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport.update( + { + id: '/$artifactId', + path: '/$artifactId', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport.update( { @@ -142,6 +159,7 @@ export interface FileRoutesByFullPath { '/login': typeof LoginRoute '/setup-mcp': typeof SetupMcpRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/billing': typeof BillingRoute '/{-$orgSlug}/org': typeof OrgRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute @@ -150,6 +168,7 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute @@ -162,6 +181,7 @@ export interface FileRoutesByTo { '/login': typeof LoginRoute '/setup-mcp': typeof SetupMcpRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/billing': typeof BillingRoute '/{-$orgSlug}/org': typeof OrgRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute @@ -170,6 +190,7 @@ export interface FileRoutesByTo { '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute @@ -183,6 +204,7 @@ export interface FileRoutesById { '/login': typeof LoginRoute '/setup-mcp': typeof SetupMcpRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/billing': typeof BillingRoute '/{-$orgSlug}/org': typeof OrgRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute @@ -191,6 +213,7 @@ export interface FileRoutesById { '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute @@ -205,6 +228,7 @@ export interface FileRouteTypes { | '/login' | '/setup-mcp' | '/{-$orgSlug}/api-keys' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/billing' | '/{-$orgSlug}/org' | '/{-$orgSlug}/policies' @@ -213,6 +237,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/billing/plans' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' @@ -225,6 +250,7 @@ export interface FileRouteTypes { | '/login' | '/setup-mcp' | '/{-$orgSlug}/api-keys' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/billing' | '/{-$orgSlug}/org' | '/{-$orgSlug}/policies' @@ -233,6 +259,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' | '/{-$orgSlug}' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/billing/plans' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' @@ -245,6 +272,7 @@ export interface FileRouteTypes { | '/login' | '/setup-mcp' | '/{-$orgSlug}/api-keys' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/billing' | '/{-$orgSlug}/org' | '/{-$orgSlug}/policies' @@ -253,6 +281,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/billing_/plans' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' @@ -266,6 +295,7 @@ export interface RootRouteChildren { LoginRoute: typeof LoginRoute SetupMcpRoute: typeof SetupMcpRoute ApiKeysRoute: typeof ApiKeysRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren BillingRoute: typeof BillingRoute OrgRoute: typeof OrgRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute @@ -360,6 +390,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof BillingRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts': { + id: '/{-$orgSlug}/artifacts' + path: '/{-$orgSlug}/artifacts' + fullPath: '/{-$orgSlug}/artifacts' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/api-keys': { id: '/{-$orgSlug}/api-keys' path: '/{-$orgSlug}/api-keys' @@ -402,6 +439,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof Billing_DotplansRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts/$artifactId': { + id: '/{-$orgSlug}/artifacts/$artifactId' + path: '/$artifactId' + fullPath: '/{-$orgSlug}/artifacts/$artifactId' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute + } '/{-$orgSlug}/integrations/add/$pluginKey': { id: '/{-$orgSlug}/integrations/add/$pluginKey' path: '/{-$orgSlug}/integrations/add/$pluginKey' @@ -412,6 +456,21 @@ declare module '@tanstack/react-router' { } } +interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute +} + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren = + { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute, + } + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute._addFileChildren( + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren, + ) + interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute } @@ -432,6 +491,8 @@ const rootRouteChildren: RootRouteChildren = { LoginRoute: LoginRoute, SetupMcpRoute: SetupMcpRoute, ApiKeysRoute: ApiKeysRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren, BillingRoute: BillingRoute, OrgRoute: OrgRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: diff --git a/apps/cloud/src/routes/__root.tsx b/apps/cloud/src/routes/__root.tsx index 2ae3e48cf..32cc11b8a 100644 --- a/apps/cloud/src/routes/__root.tsx +++ b/apps/cloud/src/routes/__root.tsx @@ -21,6 +21,7 @@ import { EXECUTOR_ORG_HEADER } from "@executor-js/react/api/server-connection"; import { OrgSlugGate } from "@executor-js/react/multiplayer/org-slug-gate"; import { Toaster } from "@executor-js/react/components/sonner"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; +import { ArtifactShellProvider } from "@executor-js/mcp-apps-shell/shell/artifact-renderer"; import { plugins as clientPlugins } from "virtual:executor/plugins-client"; import type { AuthHint } from "@executor-js/react/multiplayer/auth-hint"; import { AuthProvider, useAuth } from "../web/auth"; @@ -327,7 +328,9 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { a bare URL gets rewritten — onto the auth org, the one thing it can mean. */} - + + + diff --git a/apps/cloud/vite.config.ts b/apps/cloud/vite.config.ts index 4cdbd7cf4..477782b9e 100644 --- a/apps/cloud/vite.config.ts +++ b/apps/cloud/vite.config.ts @@ -5,6 +5,7 @@ import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import executorVitePlugin from "@executor-js/vite-plugin"; +import { innerRendererPlugin } from "@executor-js/mcp-apps-shell/vite"; import { unstable_readConfig } from "wrangler"; import { routes } from "./tsr.routes"; @@ -126,6 +127,9 @@ export default defineConfig(({ command, mode }) => { plugins: [ devCrashGuard(), tailwindcss(), + // The artifact page embeds the MCP-Apps shell, whose sandboxed inner frame + // is inlined from `virtual:executor-inner-renderer`. + innerRendererPlugin(), executorVitePlugin(), cloudflare({ viteEnvironment: { name: "ssr" }, inspectorPort: false }), tanstackStart({ diff --git a/apps/host-cloudflare/vite.config.ts b/apps/host-cloudflare/vite.config.ts index eff4d12c4..baaacbcd7 100644 --- a/apps/host-cloudflare/vite.config.ts +++ b/apps/host-cloudflare/vite.config.ts @@ -6,6 +6,7 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; import executorVitePlugin from "@executor-js/vite-plugin"; +import { innerRendererPlugin } from "@executor-js/mcp-apps-shell/vite"; import { routes } from "./tsr.routes"; @@ -60,6 +61,9 @@ export default defineConfig({ }, plugins: [ tailwindcss(), + // The artifact page embeds the MCP-Apps shell, whose sandboxed inner frame + // is inlined from `virtual:executor-inner-renderer`. + innerRendererPlugin(), executorVitePlugin({ configPath: fileURLToPath(new URL("./executor.config.ts", import.meta.url)), }), diff --git a/apps/host-cloudflare/web/routeTree.gen.ts b/apps/host-cloudflare/web/routeTree.gen.ts index 700382410..2acc6bbf2 100644 --- a/apps/host-cloudflare/web/routeTree.gen.ts +++ b/apps/host-cloudflare/web/routeTree.gen.ts @@ -14,10 +14,12 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' 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 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey' @@ -51,6 +53,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute = path: '/{-$orgSlug}/policies', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport.update({ + id: '/{-$orgSlug}/artifacts', + path: '/{-$orgSlug}/artifacts', + getParentRoute: () => rootRouteImport, + } as any) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update( { @@ -84,6 +92,15 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRou getParentRoute: () => rootRouteImport, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport.update( + { + id: '/$artifactId', + path: '/$artifactId', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update( { @@ -102,11 +119,13 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginK ) export interface FileRoutesByFullPath { + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute @@ -115,11 +134,13 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute } export interface FileRoutesByTo { + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute @@ -129,11 +150,13 @@ export interface FileRoutesByTo { } export interface FileRoutesById { __root__: typeof rootRouteImport + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute @@ -144,11 +167,13 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' @@ -157,11 +182,13 @@ export interface FileRouteTypes { | '/{-$orgSlug}/plugins/$pluginId/$' fileRoutesByTo: FileRoutesByTo to: + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' @@ -170,11 +197,13 @@ export interface FileRouteTypes { | '/{-$orgSlug}/plugins/$pluginId/$' id: | '__root__' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' @@ -184,6 +213,7 @@ export interface FileRouteTypes { fileRoutesById: FileRoutesById } export interface RootRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren @@ -233,6 +263,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts': { + id: '/{-$orgSlug}/artifacts' + path: '/{-$orgSlug}/artifacts' + fullPath: '/{-$orgSlug}/artifacts' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/toolkits/$toolkitSlug': { id: '/{-$orgSlug}/toolkits/$toolkitSlug' path: '/$toolkitSlug' @@ -261,6 +298,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts/$artifactId': { + id: '/{-$orgSlug}/artifacts/$artifactId' + path: '/$artifactId' + fullPath: '/{-$orgSlug}/artifacts/$artifactId' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute + } '/{-$orgSlug}/plugins/$pluginId/$': { id: '/{-$orgSlug}/plugins/$pluginId/$' path: '/{-$orgSlug}/plugins/$pluginId/$' @@ -278,6 +322,21 @@ declare module '@tanstack/react-router' { } } +interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute +} + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren = + { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute, + } + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute._addFileChildren( + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren, + ) + interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute } @@ -294,6 +353,8 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren = ) const rootRouteChildren: RootRouteChildren = { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: diff --git a/apps/host-cloudflare/web/routes/__root.tsx b/apps/host-cloudflare/web/routes/__root.tsx index eaf4e356a..cad2fd5ec 100644 --- a/apps/host-cloudflare/web/routes/__root.tsx +++ b/apps/host-cloudflare/web/routes/__root.tsx @@ -3,6 +3,7 @@ import { useEffect, type ReactNode } from "react"; import { ExecutorProvider } from "@executor-js/react/api/provider"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; +import { ArtifactShellProvider } from "@executor-js/mcp-apps-shell/shell/artifact-renderer"; import { OrganizationProvider } from "@executor-js/react/api/organization-context"; import { OrgSlugGate } from "@executor-js/react/multiplayer/org-slug-gate"; import { Toaster } from "@executor-js/react/components/sonner"; @@ -78,7 +79,13 @@ function AuthenticatedApp() { wrangler.jsonc run_worker_first), so a slug-pinned URL would fall through to the SPA. */} - {organization ? {gated} : gated} + + {organization ? ( + {gated} + ) : ( + gated + )} + diff --git a/apps/host-selfhost/vite.config.ts b/apps/host-selfhost/vite.config.ts index 9cf280edd..4949aabc3 100644 --- a/apps/host-selfhost/vite.config.ts +++ b/apps/host-selfhost/vite.config.ts @@ -7,6 +7,7 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; import executorVitePlugin from "@executor-js/vite-plugin"; +import { innerRendererPlugin } from "@executor-js/mcp-apps-shell/vite"; import { routes } from "./tsr.routes"; import { MCP_ORIGINAL_PATH_HEADER, stripMcpOrgSegment } from "./src/mcp/org-path"; @@ -230,6 +231,9 @@ export default defineConfig({ plugins: [ executorApiPlugin(), tailwindcss(), + // The artifact page embeds the MCP-Apps shell, whose sandboxed inner frame + // is inlined from `virtual:executor-inner-renderer`. + innerRendererPlugin(), executorVitePlugin({ configPath: fileURLToPath(new URL("./executor.config.ts", import.meta.url)), }), diff --git a/apps/host-selfhost/web/routeTree.gen.ts b/apps/host-selfhost/web/routeTree.gen.ts index 1369bda98..e2d2df624 100644 --- a/apps/host-selfhost/web/routeTree.gen.ts +++ b/apps/host-selfhost/web/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' 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 ApiKeysRouteImport } from './routes/app/api-keys' import { Route as AdminRouteImport } from './routes/app/admin' import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code' @@ -22,6 +23,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolk import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey' @@ -61,6 +63,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute = path: '/{-$orgSlug}/policies', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport.update({ + id: '/{-$orgSlug}/artifacts', + path: '/{-$orgSlug}/artifacts', + getParentRoute: () => rootRouteImport, + } as any) const ApiKeysRoute = ApiKeysRouteImport.update({ id: '/{-$orgSlug}/api-keys', path: '/{-$orgSlug}/api-keys', @@ -109,6 +117,15 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRou getParentRoute: () => rootRouteImport, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport.update( + { + id: '/$artifactId', + path: '/$artifactId', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update( { @@ -130,12 +147,14 @@ export interface FileRoutesByFullPath { '/join/$code': typeof JoinDotcodeRoute '/{-$orgSlug}/admin': typeof AdminRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute @@ -147,12 +166,14 @@ export interface FileRoutesByTo { '/join/$code': typeof JoinDotcodeRoute '/{-$orgSlug}/admin': typeof AdminRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute @@ -165,12 +186,14 @@ export interface FileRoutesById { '/join/$code': typeof JoinDotcodeRoute '/{-$orgSlug}/admin': typeof AdminRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute @@ -184,12 +207,14 @@ export interface FileRouteTypes { | '/join/$code' | '/{-$orgSlug}/admin' | '/{-$orgSlug}/api-keys' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' @@ -201,12 +226,14 @@ export interface FileRouteTypes { | '/join/$code' | '/{-$orgSlug}/admin' | '/{-$orgSlug}/api-keys' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' | '/{-$orgSlug}' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' @@ -218,12 +245,14 @@ export interface FileRouteTypes { | '/join/$code' | '/{-$orgSlug}/admin' | '/{-$orgSlug}/api-keys' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' @@ -236,6 +265,7 @@ export interface RootRouteChildren { JoinDotcodeRoute: typeof JoinDotcodeRoute AdminRoute: typeof AdminRoute ApiKeysRoute: typeof ApiKeysRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren @@ -293,6 +323,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts': { + id: '/{-$orgSlug}/artifacts' + path: '/{-$orgSlug}/artifacts' + fullPath: '/{-$orgSlug}/artifacts' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/api-keys': { id: '/{-$orgSlug}/api-keys' path: '/{-$orgSlug}/api-keys' @@ -342,6 +379,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts/$artifactId': { + id: '/{-$orgSlug}/artifacts/$artifactId' + path: '/$artifactId' + fullPath: '/{-$orgSlug}/artifacts/$artifactId' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute + } '/{-$orgSlug}/plugins/$pluginId/$': { id: '/{-$orgSlug}/plugins/$pluginId/$' path: '/{-$orgSlug}/plugins/$pluginId/$' @@ -359,6 +403,21 @@ declare module '@tanstack/react-router' { } } +interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute +} + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren = + { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute, + } + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute._addFileChildren( + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren, + ) + interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute } @@ -378,6 +437,8 @@ const rootRouteChildren: RootRouteChildren = { JoinDotcodeRoute: JoinDotcodeRoute, AdminRoute: AdminRoute, ApiKeysRoute: ApiKeysRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: diff --git a/apps/host-selfhost/web/routes/__root.tsx b/apps/host-selfhost/web/routes/__root.tsx index 26260c664..15c455228 100644 --- a/apps/host-selfhost/web/routes/__root.tsx +++ b/apps/host-selfhost/web/routes/__root.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, type ReactNode } from "react"; import { ExecutorProvider } from "@executor-js/react/api/provider"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; +import { ArtifactShellProvider } from "@executor-js/mcp-apps-shell/shell/artifact-renderer"; import { OrganizationProvider } from "@executor-js/react/api/organization-context"; import { OrgSlugGate } from "@executor-js/react/multiplayer/org-slug-gate"; import { Toaster } from "@executor-js/react/components/sonner"; @@ -176,7 +177,13 @@ function AuthenticatedApp() { a slug-pinned URL would 404, and a single-org instance has nothing to select anyway. */} - {organization ? {gated} : gated} + + {organization ? ( + {gated} + ) : ( + gated + )} + diff --git a/bun.lock b/bun.lock index f702999d6..adf11fdfb 100644 --- a/bun.lock +++ b/bun.lock @@ -430,6 +430,7 @@ "version": "1.4.4", "dependencies": { "@effect/atom-react": "catalog:", + "@executor-js/mcp-apps-shell": "workspace:*", "@executor-js/react": "workspace:*", "@executor-js/sdk": "workspace:*", "@executor-js/vite-plugin": "workspace:*", diff --git a/packages/app/package.json b/packages/app/package.json index a0cc4ad00..b8936d7e6 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@effect/atom-react": "catalog:", + "@executor-js/mcp-apps-shell": "workspace:*", "@executor-js/react": "workspace:*", "@executor-js/sdk": "workspace:*", "@executor-js/vite-plugin": "workspace:*", diff --git a/packages/app/src/routeTree.gen.ts b/packages/app/src/routeTree.gen.ts index 537a66eb6..ab013d687 100644 --- a/packages/app/src/routeTree.gen.ts +++ b/packages/app/src/routeTree.gen.ts @@ -14,10 +14,12 @@ import { Route as DotDotDotDotDotDotReactSrcRoutesToolsRouteImport } from './../ import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsRouteImport } from './../../react/src/routes/toolkits' 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 DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug' import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId' import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace' import { Route as DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../react/src/routes/connect.$integrationSlug' +import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../react/src/routes/artifacts.$artifactId' import { Route as DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../react/src/routes/plugins.$pluginId.$' import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../react/src/routes/integrations.add.$pluginKey' @@ -50,6 +52,12 @@ const DotDotDotDotDotDotReactSrcRoutesPoliciesRoute = path: '/{-$orgSlug}/policies', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotReactSrcRoutesArtifactsRoute = + DotDotDotDotDotDotReactSrcRoutesArtifactsRouteImport.update({ + id: '/{-$orgSlug}/artifacts', + path: '/{-$orgSlug}/artifacts', + getParentRoute: () => rootRouteImport, + } as any) const DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute = DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update({ id: '/$toolkitSlug', @@ -74,6 +82,12 @@ const DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute = path: '/{-$orgSlug}/connect/$integrationSlug', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute = + DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRouteImport.update({ + id: '/$artifactId', + path: '/$artifactId', + getParentRoute: () => DotDotDotDotDotDotReactSrcRoutesArtifactsRoute, + } as any) const DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute = DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update({ id: '/{-$orgSlug}/plugins/$pluginId/$', @@ -90,11 +104,13 @@ const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute = ) export interface FileRoutesByFullPath { + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute @@ -103,11 +119,13 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute } export interface FileRoutesByTo { + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute '/{-$orgSlug}': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute @@ -117,11 +135,13 @@ export interface FileRoutesByTo { } export interface FileRoutesById { __root__: typeof rootRouteImport + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute @@ -132,11 +152,13 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' @@ -145,11 +167,13 @@ export interface FileRouteTypes { | '/{-$orgSlug}/plugins/$pluginId/$' fileRoutesByTo: FileRoutesByTo to: + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' @@ -158,11 +182,13 @@ export interface FileRouteTypes { | '/{-$orgSlug}/plugins/$pluginId/$' id: | '__root__' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' @@ -172,6 +198,7 @@ export interface FileRouteTypes { fileRoutesById: FileRoutesById } export interface RootRouteChildren { + DotDotDotDotDotDotReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren DotDotDotDotDotDotReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute SecretsRoute: typeof SecretsRoute DotDotDotDotDotDotReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren @@ -221,6 +248,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts': { + id: '/{-$orgSlug}/artifacts' + path: '/{-$orgSlug}/artifacts' + fullPath: '/{-$orgSlug}/artifacts' + preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/toolkits/$toolkitSlug': { id: '/{-$orgSlug}/toolkits/$toolkitSlug' path: '/$toolkitSlug' @@ -249,6 +283,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts/$artifactId': { + id: '/{-$orgSlug}/artifacts/$artifactId' + path: '/$artifactId' + fullPath: '/{-$orgSlug}/artifacts/$artifactId' + preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRouteImport + parentRoute: typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRoute + } '/{-$orgSlug}/plugins/$pluginId/$': { id: '/{-$orgSlug}/plugins/$pluginId/$' path: '/{-$orgSlug}/plugins/$pluginId/$' @@ -266,6 +307,21 @@ declare module '@tanstack/react-router' { } } +interface DotDotDotDotDotDotReactSrcRoutesArtifactsRouteChildren { + DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute: typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute +} + +const DotDotDotDotDotDotReactSrcRoutesArtifactsRouteChildren: DotDotDotDotDotDotReactSrcRoutesArtifactsRouteChildren = + { + DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute: + DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute, + } + +const DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren = + DotDotDotDotDotDotReactSrcRoutesArtifactsRoute._addFileChildren( + DotDotDotDotDotDotReactSrcRoutesArtifactsRouteChildren, + ) + interface DotDotDotDotDotDotReactSrcRoutesToolkitsRouteChildren { DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute } @@ -282,6 +338,8 @@ const DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren = ) const rootRouteChildren: RootRouteChildren = { + DotDotDotDotDotDotReactSrcRoutesArtifactsRoute: + DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren, DotDotDotDotDotDotReactSrcRoutesPoliciesRoute: DotDotDotDotDotDotReactSrcRoutesPoliciesRoute, SecretsRoute: SecretsRoute, diff --git a/packages/app/src/routes/__root.tsx b/packages/app/src/routes/__root.tsx index b1995ac3a..020053557 100644 --- a/packages/app/src/routes/__root.tsx +++ b/packages/app/src/routes/__root.tsx @@ -3,6 +3,7 @@ import { ExecutorProvider } from "@executor-js/react/api/provider"; import { LocalAuthGate } from "@executor-js/react/api/local-auth"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; import { Toaster } from "@executor-js/react/components/sonner"; +import { ArtifactShellProvider } from "@executor-js/mcp-apps-shell/shell/artifact-renderer"; import { plugins as clientPlugins } from "virtual:executor/plugins-client"; import { Shell } from "../web/shell"; @@ -35,9 +36,11 @@ function RootComponent() { return ( - - - + + + + + diff --git a/packages/app/src/web/shell.tsx b/packages/app/src/web/shell.tsx index 37740ecc4..4085432e1 100644 --- a/packages/app/src/web/shell.tsx +++ b/packages/app/src/web/shell.tsx @@ -162,6 +162,7 @@ function SidebarContent(props: { const isSecrets = props.pathname === "/secrets"; const isPolicies = props.pathname === "/policies"; const isToolkits = props.pathname === "/toolkits" || props.pathname.startsWith("/toolkits/"); + const isArtifacts = props.pathname === "/artifacts" || props.pathname.startsWith("/artifacts/"); return ( <> @@ -201,6 +202,12 @@ function SidebarContent(props: { active={isToolkits} onNavigate={props.onNavigate} /> + diff --git a/packages/app/vite.ts b/packages/app/vite.ts index 246fe513e..09f5fcad5 100644 --- a/packages/app/vite.ts +++ b/packages/app/vite.ts @@ -4,6 +4,7 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; import executorVitePlugin from "@executor-js/vite-plugin"; +import { innerRendererPlugin } from "@executor-js/mcp-apps-shell/vite"; import { routes } from "./tsr.routes.ts"; @@ -51,6 +52,9 @@ export default function appPlugin(options: AppPluginOptions = {}): PluginOption[ }, }, tailwindcss(), + // The artifact page embeds the MCP-Apps shell, whose sandboxed inner frame + // is inlined from `virtual:executor-inner-renderer`. + innerRendererPlugin() as PluginOption, executorVitePlugin({ ...(options.executorConfigPath ? { configPath: options.executorConfigPath } : {}), ...(options.executorJsoncPath ? { jsoncPath: options.executorJsoncPath } : {}), diff --git a/packages/core/api/src/account/org-slug.ts b/packages/core/api/src/account/org-slug.ts index 71f63ce9b..addd84283 100644 --- a/packages/core/api/src/account/org-slug.ts +++ b/packages/core/api/src/account/org-slug.ts @@ -18,8 +18,9 @@ const ORG_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){1,47}$/; * - App planes: api, mcp, .well-known (cloud `app-paths.ts`, selfhost * envelope, cloudflare `run_worker_first`) * - Console routes: connect, integrations, policies, secrets, tools, users, - * toolkits, resume, plugins (the shared contract, i.e. - * every entry in `CONSOLE_ROUTE_PATHS`), plus host extras: + * toolkits, artifacts, resume, plugins (the shared + * contract, i.e. every entry in `CONSOLE_ROUTE_PATHS`), + * plus host extras: * api-keys, org, billing, create-org, setup-mcp (cloud), * admin, join, docs (selfhost), login (Better Auth * `mcp({ loginPage })` + cloud/selfhost login UX) @@ -48,6 +49,7 @@ export const RESERVED_ORG_SLUGS: ReadonlySet = new Set([ "secrets", "tools", "toolkits", + "artifacts", "resume", "plugins", "api-keys", diff --git a/packages/hosts/mcp-apps-shell/package.json b/packages/hosts/mcp-apps-shell/package.json index ac2d8453d..867d84d43 100644 --- a/packages/hosts/mcp-apps-shell/package.json +++ b/packages/hosts/mcp-apps-shell/package.json @@ -12,6 +12,10 @@ "types": "./src/shell-html.ts", "default": "./src/shell-html.ts" }, + "./vite": { + "types": "./src/vite.ts", + "default": "./src/vite.ts" + }, "./shell/components": { "types": "./src/shell/components.ts", "default": "./src/shell/components.ts" @@ -20,6 +24,10 @@ "types": "./src/shell/shell-app.tsx", "default": "./src/shell/shell-app.tsx" }, + "./shell/artifact-renderer": { + "types": "./src/shell/artifact-renderer.tsx", + "default": "./src/shell/artifact-renderer.tsx" + }, "./shell/proxy": { "types": "./src/shell/proxy.ts", "default": "./src/shell/proxy.ts" diff --git a/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx b/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx new file mode 100644 index 000000000..308011713 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx @@ -0,0 +1,35 @@ +import { ArtifactRendererProvider } from "@executor-js/react/api/artifact-renderer"; +import type { ArtifactRendererProps } from "@executor-js/react/api/artifact-renderer"; + +import { McpAppsShell, type McpAppsShellHost } from "./shell-app"; + +/** + * Binds the MCP-Apps shell to the console's artifact page. + * + * The console declares an artifact-renderer seam it cannot fill itself: this + * package depends on `@executor-js/react` for its component barrel, so the + * reverse import would close a package cycle. App composition roots — which + * already depend on both — mount this provider to complete the wiring. + * + * ```tsx + * import { ArtifactShellProvider } from "@executor-js/mcp-apps-shell/shell/artifact-renderer"; + * + * + * + * + * ``` + */ +export function ArtifactShellProvider(props: { readonly children: React.ReactNode }) { + return ( + {props.children} + ); +} + +/** + * The stored source is passed as `initialCode` rather than replayed through a + * synthetic `ontoolresult`: on this page there is no MCP client to deliver a + * tool result, and the shell already treats `initialCode` as the render trigger. + */ +function ArtifactShell(props: ArtifactRendererProps) { + return ; +} diff --git a/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx b/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx index 470f90d28..0f3180c26 100644 --- a/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx +++ b/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx @@ -1,3 +1,9 @@ +// `virtual:executor-inner-renderer` is supplied by `innerRendererPlugin` +// (`@executor-js/mcp-apps-shell/vite`). Referenced explicitly — and first, as +// triple-slash directives are only honored above the imports — so the ambient +// declaration travels with this file into consumers that bundle the shell; +// a consumer's tsconfig `include` covers only its own sources. +/// import "./globals.css"; import "@tailwindcss/browser"; diff --git a/packages/hosts/mcp-apps-shell/src/vite.ts b/packages/hosts/mcp-apps-shell/src/vite.ts new file mode 100644 index 000000000..de0aedb29 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/vite.ts @@ -0,0 +1,77 @@ +/** + * The Vite plugin that supplies `virtual:executor-inner-renderer`. + * + * The shell renders model-written JSX inside a nested `srcDoc` iframe whose + * whole program is inlined as a string. That string is the inner renderer, + * bundled to an IIFE by esbuild — it cannot be a normal import, because it must + * exist as *source text* to be embedded in the sandboxed frame. + * + * It lives here rather than in `@executor-js/vite-plugin` because that package + * is on the plugin-system kill list, and because esbuild and the renderer entry + * are both this package's own — resolving them from here needs no cross-package + * path guessing. Apps that bundle the shell into their SPA add this plugin to + * their vite config; the standalone shell build uses it too, so there is one + * definition of how the inner renderer is built. + */ + +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +/** The structural shape of the Vite plugin object, declared locally so this + * module imports no Vite types (apps pin their own Vite version). */ +export interface InnerRendererVitePlugin { + readonly name: string; + readonly enforce?: "pre"; + readonly resolveId: (id: string) => string | undefined; + readonly load: (id: string) => Promise; +} + +const VIRTUAL_ID = "virtual:executor-inner-renderer"; +const RESOLVED_ID = `\0${VIRTUAL_ID}`; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +export const innerRendererEntry = (): string => + path.resolve(packageRoot, "src/shell/inner-renderer.tsx"); + +/** + * Bundle the inner renderer to a self-contained IIFE string. + * + * Exported on its own so a build script or test can produce the same bytes the + * plugin serves without standing up Vite. + */ +export const bundleInnerRenderer = async (): Promise => { + const result = await build({ + entryPoints: [innerRendererEntry()], + absWorkingDir: packageRoot, + bundle: true, + write: false, + format: "iife", + platform: "browser", + target: "es2022", + jsx: "automatic", + define: { + "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"), + }, + }); + + const js = result.outputFiles[0]; + if (!js) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: Vite plugin hooks report build failures by throwing + throw new Error("Failed to bundle the MCP-Apps inner renderer."); + } + return js.text; +}; + +/** Add to an app's `plugins` array to make the shell bundleable in that app. */ +export const innerRendererPlugin = (): InnerRendererVitePlugin => ({ + name: "executor-inner-renderer-source", + // `pre` so the virtual id resolves before any generic resolver claims it. + enforce: "pre", + resolveId: (id) => (id === VIRTUAL_ID ? RESOLVED_ID : undefined), + load: async (id) => { + if (id !== RESOLVED_ID) return undefined; + return `export default ${JSON.stringify(await bundleInnerRenderer())};`; + }, +}); diff --git a/packages/hosts/mcp-apps-shell/vite.config.shell.ts b/packages/hosts/mcp-apps-shell/vite.config.shell.ts index 9b26a140b..856f79149 100644 --- a/packages/hosts/mcp-apps-shell/vite.config.shell.ts +++ b/packages/hosts/mcp-apps-shell/vite.config.shell.ts @@ -4,40 +4,13 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { viteSingleFile } from "vite-plugin-singlefile"; import path from "node:path"; -import { build } from "esbuild"; -function innerRendererSourcePlugin(): Plugin { - const publicId = "virtual:executor-inner-renderer"; - const resolvedId = `\0${publicId}`; +import { innerRendererPlugin } from "./src/vite"; - return { - name: "executor-inner-renderer-source", - resolveId(id) { - return id === publicId ? resolvedId : undefined; - }, - async load(id) { - if (id !== resolvedId) return undefined; - - const result = await build({ - entryPoints: [path.resolve(__dirname, "src/shell/inner-renderer.tsx")], - absWorkingDir: __dirname, - bundle: true, - write: false, - format: "iife", - platform: "browser", - target: "es2022", - jsx: "automatic", - define: { - "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"), - }, - }); - - const js = result.outputFiles[0]; - if (!js) throw new Error("Failed to bundle inner renderer."); - return `export default ${JSON.stringify(js.text)};`; - }, - }; -} +// The standalone shell build and the app builds that embed the shell must +// produce the SAME inner-renderer bytes, so both go through the one plugin +// exported from `./src/vite` (`@executor-js/mcp-apps-shell/vite`). +const innerRendererSourcePlugin = (): Plugin => innerRendererPlugin() as Plugin; export default defineConfig({ plugins: [innerRendererSourcePlugin(), react(), tailwindcss(), viteSingleFile()], diff --git a/packages/react/package.json b/packages/react/package.json index 569b8dc30..9d31c8736 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -10,6 +10,7 @@ "default": "./dist/console-routes.js" }, "./api/oauth-popup": "./src/api/oauth-popup.ts", + "./api/shell-host": "./src/api/shell-host.ts", "./api/*": "./src/api/*.tsx", "./plugins/*": "./src/plugins/*.tsx", "./pages/*": "./src/pages/*.tsx", diff --git a/packages/react/src/api/analytics.tsx b/packages/react/src/api/analytics.tsx index 6addc8482..57933903d 100644 --- a/packages/react/src/api/analytics.tsx +++ b/packages/react/src/api/analytics.tsx @@ -120,6 +120,11 @@ export interface AnalyticsEvents { success: boolean; }; + // ── Artifacts page ─────────────────────────────────────────────────────── + artifact_opened: { surface: "list" | "deep_link" }; + artifact_renamed: { success: boolean }; + artifact_removed: { success: boolean }; + // ── API keys ───────────────────────────────────────────────────────────── api_key_created: { success: boolean }; api_key_revoked: { success: boolean }; diff --git a/packages/react/src/api/artifact-renderer.tsx b/packages/react/src/api/artifact-renderer.tsx new file mode 100644 index 000000000..68fa17342 --- /dev/null +++ b/packages/react/src/api/artifact-renderer.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; + +// --------------------------------------------------------------------------- +// The seam between the artifact page and the MCP-Apps shell that renders it. +// +// The shell (`@executor-js/mcp-apps-shell`) compiles model-written JSX and runs +// it inside a sandboxed iframe. It cannot be imported here: the shell already +// depends on THIS package for its shadcn component barrel, and turbo rejects +// the resulting package cycle outright (it errors, it does not warn). So the +// dependency is inverted the way the rest of the console does it — this package +// declares the contract, and the app composition roots (`apps/*`, which already +// depend on both) provide the implementation at startup. +// +// A host that never registers a renderer still gets a working artifacts list +// and a detail page that explains the artifact cannot be rendered here, rather +// than a crash — the same graceful-degradation rule the shared console follows +// for any host-specific capability. +// --------------------------------------------------------------------------- + +/** What the page hands the shell: the stored source, and the live host. */ +export interface ArtifactRendererProps { + /** The artifact's stored JSX source. */ + readonly code: string; + /** HTTP-backed MCP host — see `createHttpShellHost` in `./shell-host`. */ + readonly host: unknown; +} + +export type ArtifactRenderer = React.ComponentType; + +const ArtifactRendererContext = React.createContext(null); + +/** + * Provide the shell implementation. Apps mount this above the console shell: + * + * ```tsx + * import { McpAppsShell } from "@executor-js/mcp-apps-shell/shell/shell-app"; + * + * } + * > + * ``` + */ +export function ArtifactRendererProvider( + props: React.PropsWithChildren<{ readonly renderer: ArtifactRenderer }>, +) { + return ( + + {props.children} + + ); +} + +/** The registered renderer, or `null` on a host that provides none. */ +export function useArtifactRenderer(): ArtifactRenderer | null { + return React.useContext(ArtifactRendererContext); +} diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index 0f8ff57f9..cdd8c667a 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -2,6 +2,7 @@ import { ConnectionAddress, PolicyId, ProviderKey, + type ArtifactId, type AuthTemplateSlug, type Connection, type ConnectionName, @@ -150,6 +151,24 @@ export const pausedExecutionAtom = (executionId: string) => timeToLive: "5 seconds", }); +export const artifactsAtom = ExecutorApiClient.query("artifacts", "list", { + timeToLive: "30 seconds", + reactivityKeys: [ReactivityKey.artifacts], +}); + +/** + * One artifact, with its `code`. `artifacts.list` deliberately omits the source + * so a long list stays cheap, so the detail page must fetch the row itself — + * it cannot derive from the list atom the way `integrationAtom` does. + */ +export const artifactAtom = Atom.family((artifactId: ArtifactId) => + ExecutorApiClient.query("artifacts", "get", { + params: { artifactId }, + timeToLive: "30 seconds", + reactivityKeys: [ReactivityKey.artifacts], + }), +); + // --------------------------------------------------------------------------- // Mutation atoms — reactivityKeys must be passed at call site (effect-atom // does not accept them at definition time). See `reactivity-keys.tsx` for the @@ -245,6 +264,10 @@ export const updatePolicy = ExecutorApiClient.mutation("policies", "update"); export const removePolicy = ExecutorApiClient.mutation("policies", "remove"); +export const renameArtifact = ExecutorApiClient.mutation("artifacts", "rename"); + +export const removeArtifact = ExecutorApiClient.mutation("artifacts", "remove"); + export const resumeExecution = ExecutorApiClient.mutation("executions", "resume"); /** Run codemode source (`POST /executions`). Used by the per-tool Run/Test panel @@ -455,6 +478,42 @@ export const removePolicyOptimistic = policiesOptimisticAtom.pipe( }), ); +// --------------------------------------------------------------------------- +// Artifacts — optimistic surface. Rename and delete are the only writes the +// console makes (artifacts are created by the model through `render-ui`), so +// the list is the single optimistic surface both mutations reduce over. +// --------------------------------------------------------------------------- + +export const artifactsOptimisticAtom = Atom.optimistic(artifactsAtom); + +export const renameArtifactOptimistic = artifactsOptimisticAtom.pipe( + Atom.optimisticFn({ + reducer: ( + current, + arg: { + readonly params: { readonly artifactId: ArtifactId }; + readonly payload: { readonly title: string }; + }, + ) => + AsyncResult.map(current, (rows) => + rows.map((row) => + row.id === arg.params.artifactId + ? { ...row, title: arg.payload.title, updatedAt: Date.now() } + : row, + ), + ), + fn: renameArtifact, + }), +); + +export const removeArtifactOptimistic = artifactsOptimisticAtom.pipe( + Atom.optimisticFn({ + reducer: (current, arg: { readonly params: { readonly artifactId: ArtifactId } }) => + AsyncResult.map(current, (rows) => rows.filter((row) => row.id !== arg.params.artifactId)), + fn: removeArtifact, + }), +); + // --------------------------------------------------------------------------- // OAuth clients (apps) — optimistic surface. The list reads through // `oauthClientsOptimisticAtom`; the remove mutation drops the matching diff --git a/packages/react/src/api/reactivity-keys.tsx b/packages/react/src/api/reactivity-keys.tsx index bca06b19b..a6a06da41 100644 --- a/packages/react/src/api/reactivity-keys.tsx +++ b/packages/react/src/api/reactivity-keys.tsx @@ -26,6 +26,8 @@ export const ReactivityKey = { /** Credential-provider discovery. */ providers: "providers", policies: "policies", + /** Saved generative-UI artifacts. */ + artifacts: "artifacts", /** Registered OAuth clients (apps). */ oauthClients: "oauth-clients", /** An integration's declared health check (the operation/identity-field spec). */ @@ -71,6 +73,10 @@ export const healthCheckWriteKeys = [ * policy changes what the tools page shows. */ export const policyWriteKeys = [ReactivityKey.policies, ReactivityKey.tools] as const; +/** Mutations that rename or delete a saved artifact. Artifacts are a leaf + * resource — nothing else reads them — so they invalidate only themselves. */ +export const artifactWriteKeys = [ReactivityKey.artifacts] as const; + /** Cloud-only: org membership mutations. */ export const orgMemberWriteKeys = [ReactivityKey.orgMembers] as const; diff --git a/packages/react/src/api/shell-host.ts b/packages/react/src/api/shell-host.ts new file mode 100644 index 000000000..1a49e8f18 --- /dev/null +++ b/packages/react/src/api/shell-host.ts @@ -0,0 +1,167 @@ +/** + * The HTTP-backed host an embedded MCP-Apps shell talks to. + * + * A shell normally runs inside an MCP client, where `callServerTool` is the + * MCP bridge. On the artifact page there is no MCP client — the console renders + * the artifact itself — so this adapter answers the same two tool calls over + * the ordinary executions HTTP API instead: + * + * execute-action -> POST /executions + * execute-action-resume -> POST /executions/:id/resume + * + * The shell's `proxy.ts` already turns `tools.a.b.c(...)` into `execute-action` + * and recurses through `waiting_for_interaction` -> trusted modal -> resume, so + * elicitation approvals work unchanged; this layer only moves bytes. + * + * The type is declared structurally rather than imported from + * `@executor-js/mcp-apps-shell`: that package depends on THIS one for its + * component barrel, so a type import here would close a package cycle that + * turbo rejects outright. The shell's `McpAppsShellHost` is deliberately + * structural for exactly this reason — see its declaration. + */ + +import { getExecutorApiBaseUrl, getExecutorServerAuthorizationHeader } from "./server-connection"; + +/** The wire shape of `POST /executions` and `POST /executions/:id/resume`. */ +type ExecutionResponse = + | { + readonly status: "completed"; + readonly text: string; + readonly structured: unknown; + readonly isError: boolean; + } + | { readonly status: "paused"; readonly text: string; readonly structured: unknown }; + +/** The subset of `CallToolResult` the shell reads back. */ +export interface ShellToolResult { + readonly content: ReadonlyArray<{ readonly type: "text"; readonly text: string }>; + readonly structuredContent?: Record; + readonly isError?: boolean | undefined; +} + +/** Structurally compatible with the shell package's `McpAppsShellHost`. */ +export interface HttpShellHost { + readonly callServerTool: (params: { + name: string; + arguments?: Record; + }) => Promise; + readonly getHostContext: () => { readonly theme: "light" | "dark" } | undefined; + readonly openLink: (params: { url: string }) => Promise; +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * The kernel's envelope travels in `structured`; the shell unwraps it itself. + * A completed-and-failed execution maps to `isError` so the shell's proxy + * throws inside the generated component instead of handing it a bogus value. + */ +const toShellToolResult = (response: ExecutionResponse): ShellToolResult => ({ + content: [{ type: "text", text: response.text }], + structuredContent: isRecord(response.structured) + ? response.structured + : { result: response.structured }, + isError: response.status === "completed" && response.isError ? true : undefined, +}); + +/** The resume action the shell sends back after the user answers the modal. */ +const isResumeAction = (value: unknown): value is "accept" | "decline" | "cancel" => + value === "accept" || value === "decline" || value === "cancel"; + +/** + * `execute-action-resume` carries the elicitation answer as a JSON *string* + * (the MCP tool contract is string-typed), so it is parsed back here. An + * unparseable or non-object body becomes `undefined` rather than throwing: the + * user's decision (accept/decline/cancel) still deserves to reach the server. + */ +const parseResumeContent = (raw: unknown): Record | undefined => { + if (typeof raw !== "string" || raw === "{}") return undefined; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: JSON.parse over a value the shell built; a malformed body must not lose the user's answer + try { + // oxlint-disable-next-line executor/no-json-parse -- boundary: the resume content arrives as an opaque JSON string across the shell's postMessage bridge + const parsed: unknown = JSON.parse(raw); + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +}; + +const prefersDark = (): boolean => + typeof globalThis.window !== "undefined" && + typeof globalThis.window.matchMedia === "function" && + globalThis.window.matchMedia("(prefers-color-scheme: dark)").matches; + +/** + * Build the host. `fetch` is injectable so the seam can be tested without a + * server; everything else is read at call time from the active server + * connection, matching how the typed API client resolves its base URL and + * bearer (a desktop connection carries no auth and sends none). + */ +export const createHttpShellHost = (options?: { + readonly fetch?: typeof globalThis.fetch; +}): HttpShellHost => { + const doFetch = options?.fetch ?? globalThis.fetch.bind(globalThis); + + const post = async (path: string, payload: Record): Promise => { + const headers: Record = { "content-type": "application/json" }; + const authorization = getExecutorServerAuthorizationHeader(); + if (authorization) headers.authorization = authorization; + + const response = await doFetch(`${getExecutorApiBaseUrl()}${path}`, { + method: "POST", + headers, + body: JSON.stringify(payload), + }); + if (!response.ok) { + const text = await response.text(); + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: the shell's tool proxy is Promise-based and surfaces rejections as component errors + throw new Error(text || `Executor API request failed with ${response.status}`); + } + return response.json(); + }; + + return { + getHostContext: () => ({ theme: prefersDark() ? "dark" : "light" }), + + openLink: async ({ url }) => { + globalThis.window?.open(url, "_blank", "noopener,noreferrer"); + return {}; + }, + + callServerTool: async ({ name, arguments: args }) => { + const input = args ?? {}; + + if (name === "execute-action") { + const code = input.code; + if (typeof code !== "string") { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: see above + throw new Error("Missing execute-action code."); + } + return toShellToolResult((await post("/executions", { code })) as ExecutionResponse); + } + + if (name === "execute-action-resume") { + const executionId = input.executionId; + const action = input.action; + if (typeof executionId !== "string") { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: see above + throw new Error("Missing execution id."); + } + if (!isResumeAction(action)) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: see above + throw new Error("Invalid resume action."); + } + return toShellToolResult( + (await post(`/executions/${encodeURIComponent(executionId)}/resume`, { + action, + content: parseResumeContent(input.content), + })) as ExecutionResponse, + ); + } + + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: see above + throw new Error(`Unsupported shell tool: ${name}`); + }, + }; +}; diff --git a/packages/react/src/console-routes.ts b/packages/react/src/console-routes.ts index 6d940b7b5..4d32b876e 100644 --- a/packages/react/src/console-routes.ts +++ b/packages/react/src/console-routes.ts @@ -43,6 +43,8 @@ export const CONSOLE_ROUTE_PATHS = [ "/users", "/toolkits", "/toolkits/$toolkitSlug", + "/artifacts", + "/artifacts/$artifactId", "/resume/$executionId", "/plugins/$pluginId/$", ] as const; @@ -85,6 +87,8 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array now - ms; + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +describe("formatRelativeTime", () => { + it("collapses anything under a minute to 'just now'", () => { + expect(formatRelativeTime(now, now)).toBe("just now"); + expect(formatRelativeTime(ago(59_000), now)).toBe("just now"); + }); + + it("counts minutes, then hours, then days", () => { + expect(formatRelativeTime(ago(MINUTE), now)).toBe("1m ago"); + expect(formatRelativeTime(ago(59 * MINUTE), now)).toBe("59m ago"); + expect(formatRelativeTime(ago(HOUR), now)).toBe("1h ago"); + expect(formatRelativeTime(ago(23 * HOUR), now)).toBe("23h ago"); + expect(formatRelativeTime(ago(DAY), now)).toBe("1d ago"); + expect(formatRelativeTime(ago(6 * DAY), now)).toBe("6d ago"); + }); + + it("falls back to an absolute date once a week has passed", () => { + // Beyond a week the relative figure stops being useful, so the exact date + // is shown instead of an ever-growing day count. + expect(formatRelativeTime(ago(7 * DAY), now)).not.toContain("ago"); + }); + + it("never renders a future timestamp as negative", () => { + // Clock skew between server and browser must not produce "-3m ago". + expect(formatRelativeTime(now + HOUR, now)).toBe("just now"); + }); +}); diff --git a/packages/react/src/lib/relative-time.ts b/packages/react/src/lib/relative-time.ts new file mode 100644 index 000000000..13dd033b7 --- /dev/null +++ b/packages/react/src/lib/relative-time.ts @@ -0,0 +1,19 @@ +/** + * Compact relative timestamps for list metadata ("5m ago", "3d ago"). + * + * The house format is compact rather than prose ("2h ago", not "2 hours ago"), + * and falls back to an absolute date once the distance stops being useful as a + * relative figure. `now` is injectable so callers can test without freezing the + * clock. + */ +export const formatRelativeTime = (timestamp: number, now = Date.now()): string => { + const diffMs = Math.max(0, now - timestamp); + const minutes = Math.floor(diffMs / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d ago`; + return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" }); +}; diff --git a/packages/react/src/multiplayer/shell.tsx b/packages/react/src/multiplayer/shell.tsx index dfc525116..9a92cd0ff 100644 --- a/packages/react/src/multiplayer/shell.tsx +++ b/packages/react/src/multiplayer/shell.tsx @@ -52,6 +52,7 @@ export const defaultShellNavItems: ReadonlyArray = [ { to: "/secrets", label: "Providers" }, { to: "/policies", label: "Policies" }, { to: "/toolkits", label: "Toolkits" }, + { to: "/artifacts", label: "Artifacts" }, ]; /** Canonical public docs (Mintlify). Same-origin on cloud (executor.sh proxies diff --git a/packages/react/src/pages/artifact-detail.tsx b/packages/react/src/pages/artifact-detail.tsx new file mode 100644 index 000000000..bd8d2b1c2 --- /dev/null +++ b/packages/react/src/pages/artifact-detail.tsx @@ -0,0 +1,190 @@ +import { useMemo } from "react"; +import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; +import { useNavigate } from "@tanstack/react-router"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import * as Exit from "effect/Exit"; +import { toast } from "sonner"; +import type { ArtifactId } from "@executor-js/sdk/shared"; + +import { trackEvent } from "../api/analytics"; +import { useArtifactRenderer } from "../api/artifact-renderer"; +import { artifactAtom, removeArtifactOptimistic, renameArtifactOptimistic } from "../api/atoms"; +import { artifactWriteKeys } from "../api/reactivity-keys"; +import { createHttpShellHost } from "../api/shell-host"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "../components/alert-dialog"; +import { Button } from "../components/button"; +import { ErrorState } from "../components/error-state"; +import { isAsyncResultLoading } from "../lib/async-result"; +import { useExecutorDocumentTitle } from "../lib/document-title"; +import { formatRelativeTime } from "../lib/relative-time"; +import { RenameArtifactDialog } from "./artifact-rename-dialog"; + +/** + * The artifact detail page — also the deep-link target `render-ui` hands to MCP + * clients that cannot display MCP Apps, so it must work as a first landing URL. + * Auth is handled by the surrounding console gate, exactly like /policies. + */ +export function ArtifactDetailPage(props: { readonly artifactId: ArtifactId }) { + const artifact = useAtomValue(artifactAtom(props.artifactId)); + const refresh = useAtomRefresh(artifactAtom(props.artifactId)); + const doRename = useAtomSet(renameArtifactOptimistic, { mode: "promiseExit" }); + const doRemove = useAtomSet(removeArtifactOptimistic, { mode: "promiseExit" }); + const navigate = useNavigate(); + + const title = AsyncResult.isSuccess(artifact) ? artifact.value.title : "Artifact"; + useExecutorDocumentTitle(title); + + const handleRename = async (nextTitle: string) => { + const exit = await doRename({ + params: { artifactId: props.artifactId }, + payload: { title: nextTitle }, + reactivityKeys: artifactWriteKeys, + }); + trackEvent("artifact_renamed", { success: Exit.isSuccess(exit) }); + if (Exit.isFailure(exit)) toast.error("Couldn't rename the artifact. Try again."); + }; + + const handleRemove = async () => { + const exit = await doRemove({ + params: { artifactId: props.artifactId }, + reactivityKeys: artifactWriteKeys, + }); + trackEvent("artifact_removed", { success: Exit.isSuccess(exit) }); + if (Exit.isFailure(exit)) { + toast.error("Couldn't delete the artifact. Try again."); + return; + } + // The row this page reads is gone; the list is the only sensible landing + // place. `params` is omitted so the router keeps the active org slug. + await navigate({ to: "/{-$orgSlug}/artifacts" }); + }; + + return ( +
+
+
+ +

{title}

+ {AsyncResult.isSuccess(artifact) ? ( + + {formatRelativeTime(artifact.value.updatedAt)} + + ) : null} +
+ {AsyncResult.isSuccess(artifact) ? ( +
+ void handleRename(next)} + trigger={ + + } + /> + + + + + + + Delete {artifact.value.title}? + + This removes the artifact for good. Agents will no longer find it by name. + + + + Cancel + void handleRemove()}> + Delete Artifact + + + + +
+ ) : null} +
+ +
+ {isAsyncResultLoading(artifact) ? ( +
+
+

Loading artifact…

+
+ ) : ( + AsyncResult.match(artifact, { + onInitial: () => ( +
+
+

Loading artifact…

+
+ ), + // A deleted or foreign id lands here. The message says which of the + // two it is as far as this viewer can tell, and offers the way back. + onFailure: () => ( +
+ +
+ ), + onSuccess: ({ value }) => , + }) + )} +
+
+ ); +} + +/** + * Mounts the registered MCP-Apps shell around the artifact's stored source. + * + * The shell itself is supplied by the app composition root (see + * `ArtifactRendererProvider`) because it depends on this package and cannot be + * imported back into it. A host that registers none still renders a page that + * explains itself instead of crashing. + */ +function ArtifactStage(props: { readonly code: string }) { + const Renderer = useArtifactRenderer(); + // One host per mount: it holds no state, but a new identity each render would + // retrigger the shell's host effects. + const host = useMemo(() => createHttpShellHost(), []); + + if (!Renderer) { + return ( +
+

+ This build can't render artifacts. Open it in the Executor app to view it. +

+
+ ); + } + + return ; +} diff --git a/packages/react/src/pages/artifact-rename-dialog.tsx b/packages/react/src/pages/artifact-rename-dialog.tsx new file mode 100644 index 000000000..1df46ae4b --- /dev/null +++ b/packages/react/src/pages/artifact-rename-dialog.tsx @@ -0,0 +1,109 @@ +import { useId, useState } from "react"; + +import { Button } from "../components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../components/dialog"; +import { Input } from "../components/input"; +import { Label } from "../components/label"; + +/** + * Rename affordance shared by the artifacts list and the artifact detail page. + * + * The draft title lives inside the dialog, so closing it discards the edit and + * reopening always starts from the artifact's current title — no stale draft + * survives a cancel. The parent owns the write and the failure toast. + */ +export function RenameArtifactDialog(props: { + readonly currentTitle: string; + readonly onRename: (title: string) => void; + /** Rendered as the trigger; defaults to the list row's hover-revealed button. */ + readonly trigger?: React.ReactNode; +}) { + const [open, setOpen] = useState(false); + + return ( + + + {props.trigger ?? ( + + )} + + {/* Remounted per open so the draft always starts from the current title. */} + {open ? ( + setOpen(false)} + /> + ) : null} + + ); +} + +function RenameArtifactForm(props: { + readonly currentTitle: string; + readonly onRename: (title: string) => void; + readonly onClose: () => void; +}) { + const titleId = useId(); + const [draft, setDraft] = useState(props.currentTitle); + const trimmed = draft.trim(); + const unchanged = trimmed === props.currentTitle; + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + if (!trimmed || unchanged) { + props.onClose(); + return; + } + props.onRename(trimmed); + props.onClose(); + }; + + return ( + +
+ + Rename Artifact + + Agents find an artifact by its title, so name it the way you would ask for it. + + +
+ + setDraft((event.target as HTMLInputElement).value)} + className="mt-1.5 h-9 text-sm" + /> +
+ + + + +
+
+ ); +} diff --git a/packages/react/src/pages/artifacts.tsx b/packages/react/src/pages/artifacts.tsx new file mode 100644 index 000000000..26b18d936 --- /dev/null +++ b/packages/react/src/pages/artifacts.tsx @@ -0,0 +1,212 @@ +import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; +import { Link } from "@tanstack/react-router"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import * as Exit from "effect/Exit"; +import { toast } from "sonner"; +import type { ArtifactId } from "@executor-js/sdk/shared"; + +import { trackEvent } from "../api/analytics"; +import { + artifactsOptimisticAtom, + removeArtifactOptimistic, + renameArtifactOptimistic, +} from "../api/atoms"; +import { artifactWriteKeys } from "../api/reactivity-keys"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "../components/alert-dialog"; +import { Button } from "../components/button"; +import { + CardStack, + CardStackContent, + CardStackEntry, + CardStackEntryActions, + CardStackEntryContent, + CardStackEntryDescription, + CardStackEntryTitle, + CardStackHeader, +} from "../components/card-stack"; +import { ErrorState } from "../components/error-state"; +import { PageContainer, PageHeader } from "../components/page"; +import { isAsyncResultLoading } from "../lib/async-result"; +import { useExecutorDocumentTitle } from "../lib/document-title"; +import { formatRelativeTime } from "../lib/relative-time"; +import { RenameArtifactDialog } from "./artifact-rename-dialog"; + +/** The wire row `artifacts.list` returns — no `code`, so the list stays cheap. */ +interface ArtifactSummary { + readonly id: ArtifactId; + readonly title: string; + readonly description: string | null; + readonly updatedAt: number; +} + +const LoadingState = () => ( +
+
+

Loading artifacts…

+
+); + +/** + * Artifacts are never created here — a model makes them by calling `render-ui` + * over MCP — so the empty state teaches that path rather than offering a button + * that cannot exist. + */ +const EmptyState = () => ( + + + + No artifacts yet. Ask an agent to render a UI and it appears here, ready to reopen. + + + +); + +function ArtifactRow(props: { + readonly artifact: ArtifactSummary; + readonly onRename: (title: string) => void; + readonly onRemove: () => void; +}) { + const { artifact } = props; + return ( + + + + trackEvent("artifact_opened", { surface: "list" })} + > + {artifact.title} + + + {artifact.description ? ( + {artifact.description} + ) : null} + + + + {formatRelativeTime(artifact.updatedAt)} + + + + + + + + + Delete {artifact.title}? + + This removes the artifact for good. Agents will no longer find it by name. + + + + Cancel + + Delete Artifact + + + + + + + ); +} + +export function ArtifactsPage() { + useExecutorDocumentTitle("Artifacts"); + const artifacts = useAtomValue(artifactsOptimisticAtom); + const refreshArtifacts = useAtomRefresh(artifactsOptimisticAtom); + const doRename = useAtomSet(renameArtifactOptimistic, { mode: "promiseExit" }); + const doRemove = useAtomSet(removeArtifactOptimistic, { mode: "promiseExit" }); + + const handleRename = async (artifactId: ArtifactId, title: string) => { + const exit = await doRename({ + params: { artifactId }, + payload: { title }, + reactivityKeys: artifactWriteKeys, + }); + trackEvent("artifact_renamed", { success: Exit.isSuccess(exit) }); + if (Exit.isFailure(exit)) toast.error("Couldn't rename the artifact. Try again."); + }; + + const handleRemove = async (artifactId: ArtifactId) => { + const exit = await doRemove({ + params: { artifactId }, + reactivityKeys: artifactWriteKeys, + }); + trackEvent("artifact_removed", { success: Exit.isSuccess(exit) }); + if (Exit.isFailure(exit)) toast.error("Couldn't delete the artifact. Try again."); + }; + + return ( + + + + {isAsyncResultLoading(artifacts) ? ( + + ) : ( + AsyncResult.match(artifacts, { + onInitial: () => , + onFailure: () => ( + + ), + onSuccess: ({ value }) => { + // Most-recently-updated first: an artifact just generated by an + // agent is the one the user is most likely coming here to open. + const rows = [...value].sort((a, b) => b.updatedAt - a.updatedAt); + return ( + + + Saved artifacts + {rows.length > 0 ? ( + + {rows.length} + + ) : null} + + + {rows.length === 0 ? ( + + ) : ( + rows.map((artifact) => ( + void handleRename(artifact.id, title)} + onRemove={() => void handleRemove(artifact.id)} + /> + )) + )} + + + ); + }, + }) + )} + + ); +} + +export type { ArtifactSummary }; diff --git a/packages/react/src/routes/artifacts-route.tsx b/packages/react/src/routes/artifacts-route.tsx new file mode 100644 index 000000000..7919e3a5a --- /dev/null +++ b/packages/react/src/routes/artifacts-route.tsx @@ -0,0 +1,15 @@ +import { ArtifactId } from "@executor-js/sdk/shared"; + +import { ArtifactDetailPage } from "../pages/artifact-detail"; +import { ArtifactsPage } from "../pages/artifacts"; + +/** + * The one component both artifact routes render. `/artifacts/$artifactId` is a + * child of `/artifacts` in the generated tree and the parent renders no + * ``, so the parent has to resolve which view to show — the shape + * `toolkits-route.tsx` established. + */ +export function ArtifactsRoute(props: { readonly artifactId?: string | undefined }) { + if (props.artifactId === undefined) return ; + return ; +} diff --git a/packages/react/src/routes/artifacts.$artifactId.tsx b/packages/react/src/routes/artifacts.$artifactId.tsx new file mode 100644 index 000000000..b52093059 --- /dev/null +++ b/packages/react/src/routes/artifacts.$artifactId.tsx @@ -0,0 +1,12 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ArtifactsRoute } from "./artifacts-route"; + +export const Route = createFileRoute("/{-$orgSlug}/artifacts/$artifactId")({ + component: ArtifactDetailRouteComponent, +}); + +function ArtifactDetailRouteComponent() { + const { artifactId } = Route.useParams(); + return ; +} diff --git a/packages/react/src/routes/artifacts.tsx b/packages/react/src/routes/artifacts.tsx new file mode 100644 index 000000000..b8372ddf1 --- /dev/null +++ b/packages/react/src/routes/artifacts.tsx @@ -0,0 +1,15 @@ +import { createFileRoute, useParams } from "@tanstack/react-router"; + +import { ArtifactsRoute } from "./artifacts-route"; + +export const Route = createFileRoute("/{-$orgSlug}/artifacts")({ + component: ArtifactsRouteComponent, +}); + +function ArtifactsRouteComponent() { + // `/artifacts/$artifactId` generates as a CHILD of this route, and this + // component renders no , so the parent must serve both URLs — the + // same shape `toolkits.tsx` uses for `/toolkits/$toolkitSlug`. + const { artifactId } = useParams({ strict: false }) as { artifactId?: string }; + return ; +} diff --git a/packages/react/src/routes/routeTree.gen.ts b/packages/react/src/routes/routeTree.gen.ts index a636a8d51..f4934a198 100644 --- a/packages/react/src/routes/routeTree.gen.ts +++ b/packages/react/src/routes/routeTree.gen.ts @@ -15,10 +15,12 @@ import { Route as DotToolsRouteImport } from './tools' import { Route as DotToolkitsRouteImport } from './toolkits' import { Route as DotSecretsRouteImport } from './secrets' import { Route as DotPoliciesRouteImport } from './policies' +import { Route as DotArtifactsRouteImport } from './artifacts' import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug' import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId' import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace' import { Route as DotConnectDotintegrationSlugRouteImport } from './connect.$integrationSlug' +import { Route as DotArtifactsDotartifactIdRouteImport } from './artifacts.$artifactId' import { Route as DotPluginsDotpluginIdDotsplatRouteImport } from './plugins.$pluginId.$' import { Route as DotIntegrationsDotaddDotpluginKeyRouteImport } from './integrations.add.$pluginKey' @@ -52,6 +54,11 @@ const DotPoliciesRoute = DotPoliciesRouteImport.update({ path: '/{-$orgSlug}/policies', getParentRoute: () => rootRouteImport, } as any) +const DotArtifactsRoute = DotArtifactsRouteImport.update({ + id: '/{-$orgSlug}/artifacts', + path: '/{-$orgSlug}/artifacts', + getParentRoute: () => rootRouteImport, +} as any) const DotToolkitsDottoolkitSlugRoute = DotToolkitsDottoolkitSlugRouteImport.update({ id: '/$toolkitSlug', @@ -75,6 +82,12 @@ const DotConnectDotintegrationSlugRoute = path: '/{-$orgSlug}/connect/$integrationSlug', getParentRoute: () => rootRouteImport, } as any) +const DotArtifactsDotartifactIdRoute = + DotArtifactsDotartifactIdRouteImport.update({ + id: '/$artifactId', + path: '/$artifactId', + getParentRoute: () => DotArtifactsRoute, + } as any) const DotPluginsDotpluginIdDotsplatRoute = DotPluginsDotpluginIdDotsplatRouteImport.update({ id: '/{-$orgSlug}/plugins/$pluginId/$', @@ -89,12 +102,14 @@ const DotIntegrationsDotaddDotpluginKeyRoute = } as any) export interface FileRoutesByFullPath { + '/{-$orgSlug}/artifacts': typeof DotArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotPoliciesRoute '/{-$orgSlug}/secrets': typeof DotSecretsRoute '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotToolsRoute '/{-$orgSlug}/users': typeof DotUsersRoute '/{-$orgSlug}/': typeof DotIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute @@ -103,12 +118,14 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute } export interface FileRoutesByTo { + '/{-$orgSlug}/artifacts': typeof DotArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotPoliciesRoute '/{-$orgSlug}/secrets': typeof DotSecretsRoute '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotToolsRoute '/{-$orgSlug}/users': typeof DotUsersRoute '/{-$orgSlug}': typeof DotIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute @@ -118,12 +135,14 @@ export interface FileRoutesByTo { } export interface FileRoutesById { __root__: typeof rootRouteImport + '/{-$orgSlug}/artifacts': typeof DotArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotPoliciesRoute '/{-$orgSlug}/secrets': typeof DotSecretsRoute '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotToolsRoute '/{-$orgSlug}/users': typeof DotUsersRoute '/{-$orgSlug}/': typeof DotIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute @@ -134,12 +153,14 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' @@ -148,12 +169,14 @@ export interface FileRouteTypes { | '/{-$orgSlug}/plugins/$pluginId/$' fileRoutesByTo: FileRoutesByTo to: + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' | '/{-$orgSlug}' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' @@ -162,12 +185,14 @@ export interface FileRouteTypes { | '/{-$orgSlug}/plugins/$pluginId/$' id: | '__root__' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' @@ -177,6 +202,7 @@ export interface FileRouteTypes { fileRoutesById: FileRoutesById } export interface RootRouteChildren { + DotArtifactsRoute: typeof DotArtifactsRouteWithChildren DotPoliciesRoute: typeof DotPoliciesRoute DotSecretsRoute: typeof DotSecretsRoute DotToolkitsRoute: typeof DotToolkitsRouteWithChildren @@ -234,6 +260,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotPoliciesRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts': { + id: '/{-$orgSlug}/artifacts' + path: '/{-$orgSlug}/artifacts' + fullPath: '/{-$orgSlug}/artifacts' + preLoaderRoute: typeof DotArtifactsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/toolkits/$toolkitSlug': { id: '/{-$orgSlug}/toolkits/$toolkitSlug' path: '/$toolkitSlug' @@ -262,6 +295,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotConnectDotintegrationSlugRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts/$artifactId': { + id: '/{-$orgSlug}/artifacts/$artifactId' + path: '/$artifactId' + fullPath: '/{-$orgSlug}/artifacts/$artifactId' + preLoaderRoute: typeof DotArtifactsDotartifactIdRouteImport + parentRoute: typeof DotArtifactsRoute + } '/{-$orgSlug}/plugins/$pluginId/$': { id: '/{-$orgSlug}/plugins/$pluginId/$' path: '/{-$orgSlug}/plugins/$pluginId/$' @@ -279,6 +319,18 @@ declare module '@tanstack/react-router' { } } +interface DotArtifactsRouteChildren { + DotArtifactsDotartifactIdRoute: typeof DotArtifactsDotartifactIdRoute +} + +const DotArtifactsRouteChildren: DotArtifactsRouteChildren = { + DotArtifactsDotartifactIdRoute: DotArtifactsDotartifactIdRoute, +} + +const DotArtifactsRouteWithChildren = DotArtifactsRoute._addFileChildren( + DotArtifactsRouteChildren, +) + interface DotToolkitsRouteChildren { DotToolkitsDottoolkitSlugRoute: typeof DotToolkitsDottoolkitSlugRoute } @@ -292,6 +344,7 @@ const DotToolkitsRouteWithChildren = DotToolkitsRoute._addFileChildren( ) const rootRouteChildren: RootRouteChildren = { + DotArtifactsRoute: DotArtifactsRouteWithChildren, DotPoliciesRoute: DotPoliciesRoute, DotSecretsRoute: DotSecretsRoute, DotToolkitsRoute: DotToolkitsRouteWithChildren, diff --git a/packages/react/src/styles/globals.css b/packages/react/src/styles/globals.css index 2fe3bcad0..ffb91e305 100644 --- a/packages/react/src/styles/globals.css +++ b/packages/react/src/styles/globals.css @@ -6,6 +6,11 @@ @source "../../../../apps/local/src/**/*.{ts,tsx}"; @source "../../../../apps/cloud/src/**/*.{ts,tsx}"; @source "../../../plugins/*/src/**/*.{ts,tsx}"; +/* The artifact page embeds the MCP-Apps shell, so the shell's own chrome + (loading state, trusted-approval modal) must compile into the app stylesheet. + Classes the MODEL invents at runtime are not scannable at build time — the + shell compiles those in-browser inside its sandboxed frame. */ +@source "../../../hosts/mcp-apps-shell/src/shell/**/*.{ts,tsx}"; @theme inline { /* Geist sets UI and prose; Geist Mono sets code, slugs, IDs, counts, and labels. From 7432bac0aebcbf53fdedf27c6f8cfc288220f67b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:06:23 -0700 Subject: [PATCH 06/60] Add artifacts e2e scenarios --- .changeset/olive-crabs-repeat.md | 5 + e2e/scenarios/artifacts.test.ts | 293 +++++++++++++++++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 .changeset/olive-crabs-repeat.md create mode 100644 e2e/scenarios/artifacts.test.ts diff --git a/.changeset/olive-crabs-repeat.md b/.changeset/olive-crabs-repeat.md new file mode 100644 index 000000000..79f7beeae --- /dev/null +++ b/.changeset/olive-crabs-repeat.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Add an Artifacts tab. Interactive components an agent generates with `render-ui` are saved and listed in the console, and each one has its own page that renders it live — the page an MCP client without MCP Apps support deep-links to. Artifacts can be renamed and deleted from the console, and agents find them again by title. diff --git a/e2e/scenarios/artifacts.test.ts b/e2e/scenarios/artifacts.test.ts new file mode 100644 index 000000000..32bb4e4a9 --- /dev/null +++ b/e2e/scenarios/artifacts.test.ts @@ -0,0 +1,293 @@ +// Cross-target: the artifacts journey, end to end. +// +// An agent on a client that cannot display MCP Apps calls `render-ui`. The +// product promise under test is vision.md's delivery negotiation: the model +// behaves identically either way, and only DELIVERY changes — a client without +// MCP Apps gets a deep link into the web app instead of an embedded widget. +// Persistence is what makes that possible, so the same artifact must then be +// reachable three ways: the deep link, the Artifacts tab, and back through MCP +// by title. +// +// Two scenarios, split by what they prove: +// 1. render-ui saves + delivers a working deep link, and the page renders +// the component live (the fallback path a non-Apps client actually walks). +// 2. Rename and delete in the console are what MCP reads back afterwards +// (the console and the agent share one store, not two caches). +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { AccountHttpApi } from "@executor-js/api"; +import type { ArtifactId } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Mcp, Target } from "../src/services"; + +const api = composePluginApi([] as const); + +/** + * The component `render-ui` persists. + * + * It must declare none of the ~280 globals the shell puts in scope (`Card`, + * `useState`, …) or the server's guard rejects it before it is ever saved, and + * its rendered text must be distinctive enough to assert on inside the shell's + * nested sandbox iframe. + */ +const artifactSource = (marker: string) => ` +function App() { + return ( +
+

Release Readiness

+

${marker}

+
+ ); +} +`; + +/** Selfhost shares one workspace across scenarios, so every title is unique to + * this run and assertions look for "mine", never "the only one". */ +const uniqueSuffix = () => randomBytes(4).toString("hex"); + +const structuredOf = (result: { readonly raw: unknown }): Record => + ((result.raw as { structuredContent?: Record }).structuredContent ?? + {}) as Record; + +scenario( + "Artifacts · render-ui hands a non-Apps client a deep link that renders the live component", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const browser = yield* Browser; + const { client: apiClient } = yield* Api; + + const identity = yield* target.newIdentity(); + const client = yield* apiClient(api, identity); + const session = mcp.session(identity); + + const suffix = uniqueSuffix(); + const title = `Release Readiness ${suffix}`; + const marker = `artifact-ok-${suffix}`; + + // Tracked so cleanup runs even when an assertion below fails. + let artifactId: ArtifactId | undefined; + + yield* Effect.gen(function* () { + // `mcp.session` advertises no MCP-Apps capability — which is exactly the + // client under test here, so no capability juggling is needed. + const tools = yield* session.listTools(); + expect(tools, "render-ui is advertised regardless of MCP-Apps support").toContain( + "render-ui", + ); + + const rendered = yield* session.call("render-ui", { + code: artifactSource(marker), + title, + description: "Whether the current release is ready to ship", + }); + + expect(rendered.ok, `render-ui succeeded: ${rendered.text}`).toBe(true); + + const structured = structuredOf(rendered); + // `fallback_unavailable` here would mean the host has no webBaseUrl + // wired — a mis-wired deployment, not a passing test. + expect( + structured.status, + `a non-Apps client gets a deep link, not an embedded widget: ${JSON.stringify(structured)}`, + ).toBe("fallback_url"); + + artifactId = structured.artifactId as ArtifactId; + expect(artifactId, "the artifact was persisted and its id returned").toBeTruthy(); + + const url = String(structured.url); + expect(rendered.text, "the model is handed the URL to relay to the user").toContain(url); + + // The link must point at THIS deployment and at the artifact's own page — + // a bare `/artifacts/:id`, which the console canonicalizes onto the + // active org slug after landing. + const parsed = new URL(url); + expect(parsed.origin, `the deep link targets this deployment (${url})`).toBe( + new URL(target.baseUrl).origin, + ); + expect(parsed.pathname, `the deep link addresses the artifact (${url})`).toBe( + `/artifacts/${artifactId}`, + ); + + // The org the console will canonicalize to, so the landing URL can be + // asserted rather than guessed. + const accountClient = yield* apiClient(AccountHttpApi, identity); + const me = yield* accountClient.account.me(); + const orgSlug = me.organization?.slug; + + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the artifact link the agent handed over", async () => { + await page.goto(url, { waitUntil: "networkidle" }); + }); + + await step("The artifact page is titled with the artifact's name", async () => { + await page.getByRole("heading", { name: title }).waitFor({ timeout: 20_000 }); + if (orgSlug) { + // A bare deep link canonicalizes onto the active org in place, + // keeping the path — the artifact must not be lost in the rewrite. + await page.waitForURL(`**/${orgSlug}/artifacts/${artifactId}`, { timeout: 20_000 }); + } + // Landing straight on the deep link (no list visit first) must still + // offer the management affordances and the way back to the list. + await page.getByRole("button", { name: "Rename" }).waitFor(); + await page.getByRole("button", { name: "Delete" }).waitFor(); + await page.getByRole("button", { name: "Artifacts" }).waitFor(); + }); + + await step("The saved component renders live inside the shell", async () => { + // Deliberately scoped THROUGH the iframe rather than the page: the + // generated code runs in the shell's sandboxed `srcDoc` frame, so + // finding the marker there proves the stored JSX was compiled and + // executed inside the sandbox — not echoed as text by the page. An + // unscoped lookup would still pass if the sandbox broke. + const rendered = page.frameLocator("iframe").getByTestId("artifact-marker"); + await rendered.waitFor({ timeout: 30_000 }); + expect(await rendered.textContent()).toContain(marker); + }); + + await step("The artifact is listed on the Artifacts tab", async () => { + await page.getByRole("link", { name: "Artifacts" }).first().click(); + await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); + await page.getByRole("link", { name: `Open artifact ${title}` }).waitFor({ + timeout: 20_000, + }); + }); + }); + + // The same row, read back through the agent's own surface. + const listed = yield* session.call("list-artifacts", {}); + expect(listed.text, "the agent can find the artifact by title").toContain(title); + + const shown = yield* session.call("show-artifact", { id: artifactId }); + expect(shown.ok, `show-artifact returned the artifact: ${shown.text}`).toBe(true); + expect( + String(structuredOf(shown).url ?? shown.text), + "show-artifact delivers the same deep link for a non-Apps client", + ).toContain(String(artifactId)); + }).pipe( + Effect.ensuring( + Effect.suspend(() => + artifactId === undefined + ? Effect.void + : client.artifacts.remove({ params: { artifactId } }), + ).pipe(Effect.ignore), + ), + ); + }), +); + +scenario( + "Artifacts · renaming and deleting in the console is what the agent sees next", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const browser = yield* Browser; + const { client: apiClient } = yield* Api; + + const identity = yield* target.newIdentity(); + const client = yield* apiClient(api, identity); + const session = mcp.session(identity); + + const suffix = uniqueSuffix(); + const originalTitle = `Draft Dashboard ${suffix}`; + const renamedTitle = `Quarterly Dashboard ${suffix}`; + + let artifactId: ArtifactId | undefined; + + yield* Effect.gen(function* () { + const rendered = yield* session.call("render-ui", { + code: artifactSource(`rename-${suffix}`), + title: originalTitle, + description: "A dashboard the user will rename", + }); + expect(rendered.ok, `render-ui succeeded: ${rendered.text}`).toBe(true); + artifactId = structuredOf(rendered).artifactId as ArtifactId; + expect(artifactId, "the artifact was persisted").toBeTruthy(); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the Artifacts tab", async () => { + await page.goto("/artifacts", { waitUntil: "networkidle" }); + await page.getByRole("link", { name: `Open artifact ${originalTitle}` }).waitFor({ + timeout: 20_000, + }); + }); + + await step("Rename the artifact to something askable", async () => { + // Row actions reveal on hover; the row is the link's enclosing entry. + const row = page.locator('[data-slot="card-stack-entry"]').filter({ + hasText: originalTitle, + }); + await row.hover(); + await row.getByRole("button", { name: "Rename" }).click(); + + const dialog = page.getByRole("dialog"); + await dialog.getByRole("heading", { name: "Rename Artifact" }).waitFor(); + await dialog.getByRole("textbox").fill(renamedTitle); + await dialog.getByRole("button", { name: "Save Title" }).click(); + await dialog.waitFor({ state: "hidden", timeout: 20_000 }); + }); + + await step("The list shows the new name", async () => { + await page.getByRole("link", { name: `Open artifact ${renamedTitle}` }).waitFor({ + timeout: 20_000, + }); + }); + }); + + // The rename is what the agent now matches against — the promise that + // "show me my quarterly dashboard" works after a rename in the console. + const afterRename = yield* session.call("list-artifacts", {}); + expect(afterRename.text, "the agent sees the new title").toContain(renamedTitle); + expect(afterRename.text, "the old title is gone from the agent's view").not.toContain( + originalTitle, + ); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Delete the artifact from the list", async () => { + await page.goto("/artifacts", { waitUntil: "networkidle" }); + const row = page.locator('[data-slot="card-stack-entry"]').filter({ + hasText: renamedTitle, + }); + await row.waitFor({ timeout: 20_000 }); + await row.hover(); + await row.getByRole("button", { name: "Delete" }).click(); + + const confirm = page.getByRole("alertdialog"); + await confirm.getByRole("heading", { name: `Delete ${renamedTitle}?` }).waitFor(); + await confirm.getByRole("button", { name: "Delete Artifact" }).click(); + await confirm.waitFor({ state: "hidden", timeout: 20_000 }); + }); + + await step("The artifact is gone from the list", async () => { + await page + .getByRole("link", { name: `Open artifact ${renamedTitle}` }) + .waitFor({ state: "detached", timeout: 20_000 }); + }); + }); + + const afterDelete = yield* session.call("list-artifacts", {}); + expect(afterDelete.text, "the agent no longer offers the deleted artifact").not.toContain( + renamedTitle, + ); + + const missing = yield* session.call("show-artifact", { id: artifactId }); + expect(missing.ok, "fetching a deleted artifact is an error, not an empty render").toBe( + false, + ); + }).pipe( + Effect.ensuring( + Effect.suspend(() => + artifactId === undefined + ? Effect.void + : client.artifacts.remove({ params: { artifactId } }), + ).pipe(Effect.ignore), + ), + ); + }), +); From 5a66ede5fc6e32a2bc24dde8c9879392440af0da Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:26:17 -0700 Subject: [PATCH 07/60] Load the artifact shell in the browser only The MCP-Apps shell imports @tailwindcss/browser, which touches document at import scope. Importing the shell statically from the root routes put it in cloud's SSR graph, so every document request failed with "document is not defined" while the entry graph loaded. The renderer seam now carries an async loader instead of a component. The composition roots register the shell through a dynamic import that only resolves in the browser, and the artifact page renders a placeholder frame server-side, hydrating the shell behind ClientOnly and Suspense. --- apps/cloud/src/routes/__root.tsx | 13 ++++-- apps/host-cloudflare/web/routes/__root.tsx | 13 ++++-- apps/host-selfhost/web/routes/__root.tsx | 13 ++++-- packages/app/src/routes/__root.tsx | 13 ++++-- .../src/shell/artifact-renderer.tsx | 41 +++++++++-------- packages/react/src/api/artifact-renderer.tsx | 46 +++++++++++++++---- packages/react/src/pages/artifact-detail.tsx | 34 +++++++++++--- 7 files changed, 128 insertions(+), 45 deletions(-) diff --git a/apps/cloud/src/routes/__root.tsx b/apps/cloud/src/routes/__root.tsx index 32cc11b8a..474eb8045 100644 --- a/apps/cloud/src/routes/__root.tsx +++ b/apps/cloud/src/routes/__root.tsx @@ -21,7 +21,7 @@ import { EXECUTOR_ORG_HEADER } from "@executor-js/react/api/server-connection"; import { OrgSlugGate } from "@executor-js/react/multiplayer/org-slug-gate"; import { Toaster } from "@executor-js/react/components/sonner"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; -import { ArtifactShellProvider } from "@executor-js/mcp-apps-shell/shell/artifact-renderer"; +import { ArtifactRendererProvider } from "@executor-js/react/api/artifact-renderer"; import { plugins as clientPlugins } from "virtual:executor/plugins-client"; import type { AuthHint } from "@executor-js/react/multiplayer/auth-hint"; import { AuthProvider, useAuth } from "../web/auth"; @@ -62,6 +62,13 @@ if (typeof window !== "undefined" && import.meta.env.VITE_PUBLIC_POSTHOG_KEY) { }); } +// The MCP-Apps shell is browser-only — it imports `@tailwindcss/browser`, which +// touches `document` at import scope. A static import here would put it in this +// SSR app's server graph and 500 every document request, so it is registered as +// a dynamic import the artifact page resolves in the browser. Module scope keeps +// the loader identity stable, so the lazy component behind it never remounts. +const artifactRendererLoader = () => import("@executor-js/mcp-apps-shell/shell/artifact-renderer"); + const analyticsClient: AnalyticsClient | undefined = typeof window !== "undefined" && import.meta.env.VITE_PUBLIC_POSTHOG_KEY ? (name, properties) => posthog.capture(name, properties) @@ -328,9 +335,9 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { a bare URL gets rewritten — onto the auth org, the one thing it can mean. */} - + - + diff --git a/apps/host-cloudflare/web/routes/__root.tsx b/apps/host-cloudflare/web/routes/__root.tsx index cad2fd5ec..24f8485c1 100644 --- a/apps/host-cloudflare/web/routes/__root.tsx +++ b/apps/host-cloudflare/web/routes/__root.tsx @@ -3,7 +3,7 @@ import { useEffect, type ReactNode } from "react"; import { ExecutorProvider } from "@executor-js/react/api/provider"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; -import { ArtifactShellProvider } from "@executor-js/mcp-apps-shell/shell/artifact-renderer"; +import { ArtifactRendererProvider } from "@executor-js/react/api/artifact-renderer"; import { OrganizationProvider } from "@executor-js/react/api/organization-context"; import { OrgSlugGate } from "@executor-js/react/multiplayer/org-slug-gate"; import { Toaster } from "@executor-js/react/components/sonner"; @@ -25,6 +25,13 @@ import { plugins as clientPlugins } from "virtual:executor/plugins-client"; // omits the API-keys nav item and just uses the default set. // --------------------------------------------------------------------------- +// The MCP-Apps shell is browser-only — it imports `@tailwindcss/browser`, which +// touches `document` at import scope. It is registered as a dynamic import the +// artifact page resolves in the browser, never a static one, so it stays out of +// any server graph. Module scope keeps the loader identity stable, so the lazy +// component behind it never remounts. +const artifactRendererLoader = () => import("@executor-js/mcp-apps-shell/shell/artifact-renderer"); + export const Route = createRootRoute({ component: RootComponent, }); @@ -79,13 +86,13 @@ function AuthenticatedApp() { wrangler.jsonc run_worker_first), so a slug-pinned URL would fall through to the SPA. */} - + {organization ? ( {gated} ) : ( gated )} - + diff --git a/apps/host-selfhost/web/routes/__root.tsx b/apps/host-selfhost/web/routes/__root.tsx index 15c455228..96796c2a2 100644 --- a/apps/host-selfhost/web/routes/__root.tsx +++ b/apps/host-selfhost/web/routes/__root.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, type ReactNode } from "react"; import { ExecutorProvider } from "@executor-js/react/api/provider"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; -import { ArtifactShellProvider } from "@executor-js/mcp-apps-shell/shell/artifact-renderer"; +import { ArtifactRendererProvider } from "@executor-js/react/api/artifact-renderer"; import { OrganizationProvider } from "@executor-js/react/api/organization-context"; import { OrgSlugGate } from "@executor-js/react/multiplayer/org-slug-gate"; import { Toaster } from "@executor-js/react/components/sonner"; @@ -34,6 +34,13 @@ import { fetchNeedsSetup } from "../setup-status"; // Auth), injected here. No billing, Sentry, or PostHog. // --------------------------------------------------------------------------- +// The MCP-Apps shell is browser-only — it imports `@tailwindcss/browser`, which +// touches `document` at import scope. It is registered as a dynamic import the +// artifact page resolves in the browser, never a static one, so it stays out of +// any server graph. Module scope keeps the loader identity stable, so the lazy +// component behind it never remounts. +const artifactRendererLoader = () => import("@executor-js/mcp-apps-shell/shell/artifact-renderer"); + export const Route = createRootRoute({ notFoundComponent: NotFoundPage, component: RootComponent, @@ -177,13 +184,13 @@ function AuthenticatedApp() { a slug-pinned URL would 404, and a single-org instance has nothing to select anyway. */} - + {organization ? ( {gated} ) : ( gated )} - + diff --git a/packages/app/src/routes/__root.tsx b/packages/app/src/routes/__root.tsx index 020053557..348023535 100644 --- a/packages/app/src/routes/__root.tsx +++ b/packages/app/src/routes/__root.tsx @@ -3,10 +3,17 @@ import { ExecutorProvider } from "@executor-js/react/api/provider"; import { LocalAuthGate } from "@executor-js/react/api/local-auth"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; import { Toaster } from "@executor-js/react/components/sonner"; -import { ArtifactShellProvider } from "@executor-js/mcp-apps-shell/shell/artifact-renderer"; +import { ArtifactRendererProvider } from "@executor-js/react/api/artifact-renderer"; import { plugins as clientPlugins } from "virtual:executor/plugins-client"; import { Shell } from "../web/shell"; +// The MCP-Apps shell is browser-only — it imports `@tailwindcss/browser`, which +// touches `document` at import scope. It is registered as a dynamic import the +// artifact page resolves in the browser, never a static one, so it stays out of +// any server graph. Module scope keeps the loader identity stable, so the lazy +// component behind it never remounts. +const artifactRendererLoader = () => import("@executor-js/mcp-apps-shell/shell/artifact-renderer"); + export const Route = createRootRoute({ notFoundComponent: NotFoundPage, component: RootComponent, @@ -36,11 +43,11 @@ function RootComponent() { return ( - + - + diff --git a/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx b/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx index 308011713..13b4c51a5 100644 --- a/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx +++ b/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx @@ -1,4 +1,3 @@ -import { ArtifactRendererProvider } from "@executor-js/react/api/artifact-renderer"; import type { ArtifactRendererProps } from "@executor-js/react/api/artifact-renderer"; import { McpAppsShell, type McpAppsShellHost } from "./shell-app"; @@ -6,30 +5,34 @@ import { McpAppsShell, type McpAppsShellHost } from "./shell-app"; /** * Binds the MCP-Apps shell to the console's artifact page. * - * The console declares an artifact-renderer seam it cannot fill itself: this - * package depends on `@executor-js/react` for its component barrel, so the - * reverse import would close a package cycle. App composition roots — which - * already depend on both — mount this provider to complete the wiring. + * This module is BROWSER-ONLY and must never enter a server graph: it pulls in + * `shell-app`, which imports `@tailwindcss/browser`, and that package builds a + * ` + +
@@ -401,14 +436,8 @@ export function McpAppsShell({ if (error) { return ( -
- - - Error - - {error} - - +
+
); } @@ -436,7 +465,11 @@ export function McpAppsShell({ return (
- - Runtime Error - - {this.state.error.message} - {this.state.error.stack && ( -
{this.state.error.stack}
- )} -
- + ); } return this.props.children; diff --git a/packages/hosts/mcp-apps-shell/src/shell/theme-tokens.pin.test.ts b/packages/hosts/mcp-apps-shell/src/shell/theme-tokens.pin.test.ts new file mode 100644 index 000000000..99853c69c --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/theme-tokens.pin.test.ts @@ -0,0 +1,196 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "@effect/vitest"; + +/** + * The shell's theme must BE executor's theme. + * + * Artifacts render inside the console, so a shell that themes itself + * independently is immediately visible as "not this product" — which is exactly + * what the stock shadcn palette it shipped before did (a teal `--primary` in a + * strictly grayscale app). The values therefore come from + * `packages/react/src/styles/globals.css`, the app's own source of truth, which + * `design.md` serializes. + * + * They are COPIED rather than imported, and this test is the price of that copy. + * Importing is not available: the shell's stylesheet is compiled twice — once by + * `vite.config.shell.ts`, and again in-browser by `@tailwindcss/browser` inside + * the sandboxed frame, where a cross-package `@import` has no resolver and no + * filesystem. So the copy is deliberate, and this pin makes the drift loud: + * change a token in the app and this fails until the shell matches. + * + * Only the shared semantic tokens are pinned. The shell has no sidebar, and the + * app has no chart palette or artifact container, so neither side is forced to + * carry the other's surface. + */ + +const here = path.dirname(fileURLToPath(import.meta.url)); +const shellCss = readFileSync(path.join(here, "theme.css"), "utf-8"); +const shellGlobalsCss = readFileSync(path.join(here, "globals.css"), "utf-8"); +const appCss = readFileSync(path.join(here, "../../../../react/src/styles/globals.css"), "utf-8"); + +/** The tokens both stylesheets define, and that a model's UI actually renders + * against. `sidebar-*` and `scrollbar-*` are app chrome the shell has none of. */ +const SHARED_TOKENS = [ + "background", + "foreground", + "card", + "card-foreground", + "popover", + "popover-foreground", + "primary", + "primary-foreground", + "secondary", + "secondary-foreground", + "muted", + "muted-foreground", + "accent", + "accent-foreground", + "destructive", + "border", + "input", + "ring", + "radius", +] as const; + +/** The declarations inside the block a selector opens, by brace matching — + * `:root` and `.dark` both appear more than once across the media query, and a + * regex to the next `}` would stop at the first nested rule. */ +const blockAfter = (css: string, from: number): string => { + const open = css.indexOf("{", from); + if (open === -1) return ""; + let depth = 0; + for (let i = open; i < css.length; i += 1) { + if (css[i] === "{") depth += 1; + else if (css[i] === "}") { + depth -= 1; + if (depth === 0) return css.slice(open + 1, i); + } + } + return ""; +}; + +/** Every `--token: value` in the FIRST top-level block for `selector`, ignoring + * ones nested in a media query (which restate the same values). */ +const tokensIn = (css: string, selector: string): Map => { + // A top-level occurrence is one at the start of a line with no leading + // indentation — the media-query copies are indented. + const match = new RegExp(`^${selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\{`, "m").exec( + css, + ); + const block = match ? blockAfter(css, match.index) : ""; + const tokens = new Map(); + for (const declaration of block.matchAll(/--([\w-]+)\s*:\s*([^;]+);/g)) { + const name = declaration[1]; + const value = declaration[2]; + if (name && value) tokens.set(name, value.trim()); + } + return tokens; +}; + +describe("shell theme tokens", () => { + it("match the app's light theme", () => { + const shell = tokensIn(shellCss, ":root"); + const app = tokensIn(appCss, ":root"); + for (const token of SHARED_TOKENS) { + expect(shell.get(token), `--${token} (light)`).toBe(app.get(token)); + } + }); + + it("match the app's dark theme", () => { + const shell = tokensIn(shellCss, ".dark"); + const app = tokensIn(appCss, ".dark"); + for (const token of SHARED_TOKENS) { + if (token === "radius") continue; // declared once, on :root + expect(shell.get(token), `--${token} (dark)`).toBe(app.get(token)); + } + }); + + it("keeps the shell's dark class and prefers-color-scheme block in agreement", () => { + const darkClass = tokensIn(shellCss, ".dark"); + // The media-query copy is indented, so `tokensIn` skips it; find it directly. + const media = /@media \(prefers-color-scheme: dark\) \{\s*:root \{([\s\S]*?)\n {2}\}/.exec( + shellCss, + ); + expect(media, "shell declares a prefers-color-scheme dark block").not.toBeNull(); + const mediaTokens = new Map(); + for (const declaration of (media?.[1] ?? "").matchAll(/--([\w-]+)\s*:\s*([^;]+);/g)) { + const name = declaration[1]; + const value = declaration[2]; + if (name && value) mediaTokens.set(name, value.trim()); + } + for (const [name, value] of mediaTokens) { + expect(darkClass.get(name), `--${name}`).toBe(value); + } + }); + + it("carries an ordered eight-slot chart palette in both themes", () => { + const light = tokensIn(shellCss, ":root"); + const dark = tokensIn(shellCss, ".dark"); + for (let slot = 1; slot <= 8; slot += 1) { + expect(light.get(`chart-${slot}`), `--chart-${slot} (light)`).toMatch(/^#[0-9a-f]{6}$/); + expect(dark.get(`chart-${slot}`), `--chart-${slot} (dark)`).toMatch(/^#[0-9a-f]{6}$/); + } + }); + + it("holds the chart palette to the system's restraint", () => { + // No neon. A saturated series color competes with the numbers it labels and + // reads as a different product; the ceiling here is what keeps "muted" from + // drifting back to a stock fully-saturated ramp on the next edit. HSL + // saturation, because that is the axis "muted" actually names — for + // reference, the palette tops out around 0.54 where a typical vivid chart + // ramp sits between 0.68 and 1.0. + const saturation = (hex: string): number => { + const [r, g, b] = [1, 3, 5].map((at) => Number.parseInt(hex.slice(at, at + 2), 16) / 255); + const max = Math.max(r!, g!, b!); + const min = Math.min(r!, g!, b!); + const lightness = (max + min) / 2; + if (max === min) return 0; + return lightness > 0.5 ? (max - min) / (2 - max - min) : (max - min) / (max + min); + }; + + for (const selector of [":root", ".dark"]) { + const tokens = tokensIn(shellCss, selector); + for (let slot = 1; slot <= 8; slot += 1) { + const value = tokens.get(`chart-${slot}`) ?? ""; + expect(saturation(value), `--chart-${slot} in ${selector} is desaturated`).toBeLessThan( + 0.6, + ); + } + } + }); + + it("leads the palette with a neutral, so a single-series chart stays grayscale", () => { + for (const selector of [":root", ".dark"]) { + const value = tokensIn(shellCss, selector).get("chart-1") ?? ""; + const [r, g, b] = [1, 3, 5].map((at) => Number.parseInt(value.slice(at, at + 2), 16)); + expect(Math.max(r!, g!, b!) - Math.min(r!, g!, b!), `--chart-1 in ${selector}`).toBe(0); + } + }); + + it("sets Geist as the shell's own type, from inlined faces", () => { + expect(shellCss).toMatch(/--font-sans:\s*"Geist"/); + expect(shellCss).toMatch(/--font-mono:\s*"Geist Mono"/); + // Referenced as a local asset: a remote URL would be blocked by the inner + // frame's `font-src data:` and silently fall back to system-ui. + expect(shellGlobalsCss).toMatch(/url\("\.\/fonts\/geist-sans\.woff2"\)/); + expect(shellGlobalsCss).toMatch(/url\("\.\/fonts\/geist-mono\.woff2"\)/); + expect(shellGlobalsCss).not.toMatch(/fonts\.googleapis\.com|fonts\.gstatic\.com/); + }); + + // The theme is SHIPPED to the inner frame as source, so anything the frame's + // Tailwind compiler cannot resolve has to stay out of it. `@font-face` is the + // trap: its `url()` is rewritten to a `data:` URL by the build, and the frame + // has neither a bundler nor a filesystem to do that with — so the faces live + // in `globals.css` and only the tokens travel. + it("keeps build-resolved CSS out of the shared theme source", () => { + // Comments are stripped first: this file's own header explains the rule by + // naming the at-rules it forbids, which would otherwise match. + const declarations = shellCss.replace(/\/\*[\s\S]*?\*\//g, ""); + expect(declarations).not.toMatch(/@font-face/); + expect(declarations).not.toMatch(/@source/); + expect(declarations).not.toMatch(/url\(/); + expect(shellGlobalsCss).toMatch(/@import "\.\/theme\.css"/); + }); +}); diff --git a/packages/hosts/mcp-apps-shell/src/shell/theme.css b/packages/hosts/mcp-apps-shell/src/shell/theme.css new file mode 100644 index 000000000..96088907b --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/theme.css @@ -0,0 +1,265 @@ +/* + * Executor's design tokens, as Tailwind theme SOURCE. + * + * Split out of `globals.css` because this file has two consumers: + * + * 1. The shell's own build, which `@import`s it and compiles it ahead of time. + * 2. The sandboxed inner frame, which embeds it VERBATIM in a + * `");\n currentlyRenderingBoundaryHasStylesToHoist = true;\n rules.length = 0;\n hrefs.length = 0;\n }\n }\n function hasStylesToHoist(stylesheet) {\n return 2 !== stylesheet.state ? currentlyRenderingBoundaryHasStylesToHoist = true : false;\n }\n function writeHoistablesForBoundary(destination, hoistableState, renderState) {\n currentlyRenderingBoundaryHasStylesToHoist = false;\n destinationHasCapacity = true;\n currentlyFlushingRenderState = renderState;\n hoistableState.styles.forEach(flushStyleTagsLateForBoundary, destination);\n currentlyFlushingRenderState = null;\n hoistableState.stylesheets.forEach(hasStylesToHoist);\n currentlyRenderingBoundaryHasStylesToHoist && (renderState.stylesToHoist = true);\n return destinationHasCapacity;\n }\n function flushResource(resource) {\n for (var i = 0; i < resource.length; i++) this.push(resource[i]);\n resource.length = 0;\n }\n var stylesheetFlushingQueue = [];\n function flushStyleInPreamble(stylesheet) {\n pushLinkImpl(stylesheetFlushingQueue, stylesheet.props);\n for (var i = 0; i < stylesheetFlushingQueue.length; i++)\n this.push(stylesheetFlushingQueue[i]);\n stylesheetFlushingQueue.length = 0;\n stylesheet.state = 2;\n }\n function flushStylesInPreamble(styleQueue) {\n var hasStylesheets = 0 < styleQueue.sheets.size;\n styleQueue.sheets.forEach(flushStyleInPreamble, this);\n styleQueue.sheets.clear();\n var rules = styleQueue.rules, hrefs = styleQueue.hrefs;\n if (!hasStylesheets || hrefs.length) {\n this.push(currentlyFlushingRenderState.startInlineStyle);\n this.push(\' data-precedence="\');\n this.push(styleQueue.precedence);\n styleQueue = 0;\n if (hrefs.length) {\n for (this.push(\'" data-href="\'); styleQueue < hrefs.length - 1; styleQueue++)\n this.push(hrefs[styleQueue]), this.push(" ");\n this.push(hrefs[styleQueue]);\n }\n this.push(\'">\');\n for (styleQueue = 0; styleQueue < rules.length; styleQueue++)\n this.push(rules[styleQueue]);\n this.push("");\n rules.length = 0;\n hrefs.length = 0;\n }\n }\n function preloadLateStyle(stylesheet) {\n if (0 === stylesheet.state) {\n stylesheet.state = 1;\n var props = stylesheet.props;\n pushLinkImpl(stylesheetFlushingQueue, {\n rel: "preload",\n as: "style",\n href: stylesheet.props.href,\n crossOrigin: props.crossOrigin,\n fetchPriority: props.fetchPriority,\n integrity: props.integrity,\n media: props.media,\n hrefLang: props.hrefLang,\n referrerPolicy: props.referrerPolicy\n });\n for (stylesheet = 0; stylesheet < stylesheetFlushingQueue.length; stylesheet++)\n this.push(stylesheetFlushingQueue[stylesheet]);\n stylesheetFlushingQueue.length = 0;\n }\n }\n function preloadLateStyles(styleQueue) {\n styleQueue.sheets.forEach(preloadLateStyle, this);\n styleQueue.sheets.clear();\n }\n function pushCompletedShellIdAttribute(target, resumableState) {\n 0 === (resumableState.instructions & 32) && (resumableState.instructions |= 32, target.push(\n \' id="\',\n escapeTextForBrowser("_" + resumableState.idPrefix + "R_"),\n \'"\'\n ));\n }\n function writeStyleResourceDependenciesInJS(destination, hoistableState) {\n destination.push("[");\n var nextArrayOpenBrackChunk = "[";\n hoistableState.stylesheets.forEach(function(resource) {\n if (2 !== resource.state)\n if (3 === resource.state)\n destination.push(nextArrayOpenBrackChunk), resource = escapeJSObjectForInstructionScripts(\n "" + resource.props.href\n ), destination.push(resource), destination.push("]"), nextArrayOpenBrackChunk = ",[";\n else {\n destination.push(nextArrayOpenBrackChunk);\n var precedence = resource.props["data-precedence"], props = resource.props, coercedHref = sanitizeURL("" + resource.props.href);\n coercedHref = escapeJSObjectForInstructionScripts(coercedHref);\n destination.push(coercedHref);\n precedence = "" + precedence;\n destination.push(",");\n precedence = escapeJSObjectForInstructionScripts(precedence);\n destination.push(precedence);\n for (var propKey in props)\n if (hasOwnProperty.call(props, propKey) && (precedence = props[propKey], null != precedence))\n switch (propKey) {\n case "href":\n case "rel":\n case "precedence":\n case "data-precedence":\n break;\n case "children":\n case "dangerouslySetInnerHTML":\n throw Error(formatProdErrorMessage3(399, "link"));\n default:\n writeStyleResourceAttributeInJS(\n destination,\n propKey,\n precedence\n );\n }\n destination.push("]");\n nextArrayOpenBrackChunk = ",[";\n resource.state = 3;\n }\n });\n destination.push("]");\n }\n function writeStyleResourceAttributeInJS(destination, name, value) {\n var attributeName = name.toLowerCase();\n switch (typeof value) {\n case "function":\n case "symbol":\n return;\n }\n switch (name) {\n case "innerHTML":\n case "dangerouslySetInnerHTML":\n case "suppressContentEditableWarning":\n case "suppressHydrationWarning":\n case "style":\n case "ref":\n return;\n case "className":\n attributeName = "class";\n name = "" + value;\n break;\n case "hidden":\n if (false === value) return;\n name = "";\n break;\n case "src":\n case "href":\n value = sanitizeURL(value);\n name = "" + value;\n break;\n default:\n if (2 < name.length && ("o" === name[0] || "O" === name[0]) && ("n" === name[1] || "N" === name[1]) || !isAttributeNameSafe(name))\n return;\n name = "" + value;\n }\n destination.push(",");\n attributeName = escapeJSObjectForInstructionScripts(attributeName);\n destination.push(attributeName);\n destination.push(",");\n attributeName = escapeJSObjectForInstructionScripts(name);\n destination.push(attributeName);\n }\n function createHoistableState() {\n return { styles: /* @__PURE__ */ new Set(), stylesheets: /* @__PURE__ */ new Set(), suspenseyImages: false };\n }\n function prefetchDNS(href) {\n var request = currentRequest ? currentRequest : null;\n if (request) {\n var resumableState = request.resumableState, renderState = request.renderState;\n if ("string" === typeof href && href) {\n if (!resumableState.dnsResources.hasOwnProperty(href)) {\n resumableState.dnsResources[href] = null;\n resumableState = renderState.headers;\n var header, JSCompiler_temp;\n if (JSCompiler_temp = resumableState && 0 < resumableState.remainingCapacity)\n JSCompiler_temp = (header = "<" + ("" + href).replace(\n regexForHrefInLinkHeaderURLContext,\n escapeHrefForLinkHeaderURLContextReplacer\n ) + ">; rel=dns-prefetch", 0 <= (resumableState.remainingCapacity -= header.length + 2));\n JSCompiler_temp ? (renderState.resets.dns[href] = null, resumableState.preconnects && (resumableState.preconnects += ", "), resumableState.preconnects += header) : (header = [], pushLinkImpl(header, { href, rel: "dns-prefetch" }), renderState.preconnects.add(header));\n }\n enqueueFlush(request);\n }\n } else previousDispatcher.D(href);\n }\n function preconnect(href, crossOrigin) {\n var request = currentRequest ? currentRequest : null;\n if (request) {\n var resumableState = request.resumableState, renderState = request.renderState;\n if ("string" === typeof href && href) {\n var bucket = "use-credentials" === crossOrigin ? "credentials" : "string" === typeof crossOrigin ? "anonymous" : "default";\n if (!resumableState.connectResources[bucket].hasOwnProperty(href)) {\n resumableState.connectResources[bucket][href] = null;\n resumableState = renderState.headers;\n var header, JSCompiler_temp;\n if (JSCompiler_temp = resumableState && 0 < resumableState.remainingCapacity) {\n JSCompiler_temp = "<" + ("" + href).replace(\n regexForHrefInLinkHeaderURLContext,\n escapeHrefForLinkHeaderURLContextReplacer\n ) + ">; rel=preconnect";\n if ("string" === typeof crossOrigin) {\n var escapedCrossOrigin = ("" + crossOrigin).replace(\n regexForLinkHeaderQuotedParamValueContext,\n escapeStringForLinkHeaderQuotedParamValueContextReplacer\n );\n JSCompiler_temp += \'; crossorigin="\' + escapedCrossOrigin + \'"\';\n }\n JSCompiler_temp = (header = JSCompiler_temp, 0 <= (resumableState.remainingCapacity -= header.length + 2));\n }\n JSCompiler_temp ? (renderState.resets.connect[bucket][href] = null, resumableState.preconnects && (resumableState.preconnects += ", "), resumableState.preconnects += header) : (bucket = [], pushLinkImpl(bucket, {\n rel: "preconnect",\n href,\n crossOrigin\n }), renderState.preconnects.add(bucket));\n }\n enqueueFlush(request);\n }\n } else previousDispatcher.C(href, crossOrigin);\n }\n function preload(href, as, options2) {\n var request = currentRequest ? currentRequest : null;\n if (request) {\n var resumableState = request.resumableState, renderState = request.renderState;\n if (as && href) {\n switch (as) {\n case "image":\n if (options2) {\n var imageSrcSet = options2.imageSrcSet;\n var imageSizes = options2.imageSizes;\n var fetchPriority = options2.fetchPriority;\n }\n var key = imageSrcSet ? imageSrcSet + "\\n" + (imageSizes || "") : href;\n if (resumableState.imageResources.hasOwnProperty(key)) return;\n resumableState.imageResources[key] = PRELOAD_NO_CREDS;\n resumableState = renderState.headers;\n var header;\n resumableState && 0 < resumableState.remainingCapacity && "string" !== typeof imageSrcSet && "high" === fetchPriority && (header = getPreloadAsHeader(href, as, options2), 0 <= (resumableState.remainingCapacity -= header.length + 2)) ? (renderState.resets.image[key] = PRELOAD_NO_CREDS, resumableState.highImagePreloads && (resumableState.highImagePreloads += ", "), resumableState.highImagePreloads += header) : (resumableState = [], pushLinkImpl(\n resumableState,\n assign2(\n { rel: "preload", href: imageSrcSet ? void 0 : href, as },\n options2\n )\n ), "high" === fetchPriority ? renderState.highImagePreloads.add(resumableState) : (renderState.bulkPreloads.add(resumableState), renderState.preloads.images.set(key, resumableState)));\n break;\n case "style":\n if (resumableState.styleResources.hasOwnProperty(href)) return;\n imageSrcSet = [];\n pushLinkImpl(\n imageSrcSet,\n assign2({ rel: "preload", href, as }, options2)\n );\n resumableState.styleResources[href] = !options2 || "string" !== typeof options2.crossOrigin && "string" !== typeof options2.integrity ? PRELOAD_NO_CREDS : [options2.crossOrigin, options2.integrity];\n renderState.preloads.stylesheets.set(href, imageSrcSet);\n renderState.bulkPreloads.add(imageSrcSet);\n break;\n case "script":\n if (resumableState.scriptResources.hasOwnProperty(href)) return;\n imageSrcSet = [];\n renderState.preloads.scripts.set(href, imageSrcSet);\n renderState.bulkPreloads.add(imageSrcSet);\n pushLinkImpl(\n imageSrcSet,\n assign2({ rel: "preload", href, as }, options2)\n );\n resumableState.scriptResources[href] = !options2 || "string" !== typeof options2.crossOrigin && "string" !== typeof options2.integrity ? PRELOAD_NO_CREDS : [options2.crossOrigin, options2.integrity];\n break;\n default:\n if (resumableState.unknownResources.hasOwnProperty(as)) {\n if (imageSrcSet = resumableState.unknownResources[as], imageSrcSet.hasOwnProperty(href))\n return;\n } else\n imageSrcSet = {}, resumableState.unknownResources[as] = imageSrcSet;\n imageSrcSet[href] = PRELOAD_NO_CREDS;\n if ((resumableState = renderState.headers) && 0 < resumableState.remainingCapacity && "font" === as && (key = getPreloadAsHeader(href, as, options2), 0 <= (resumableState.remainingCapacity -= key.length + 2)))\n renderState.resets.font[href] = PRELOAD_NO_CREDS, resumableState.fontPreloads && (resumableState.fontPreloads += ", "), resumableState.fontPreloads += key;\n else\n switch (resumableState = [], href = assign2({ rel: "preload", href, as }, options2), pushLinkImpl(resumableState, href), as) {\n case "font":\n renderState.fontPreloads.add(resumableState);\n break;\n default:\n renderState.bulkPreloads.add(resumableState);\n }\n }\n enqueueFlush(request);\n }\n } else previousDispatcher.L(href, as, options2);\n }\n function preloadModule(href, options2) {\n var request = currentRequest ? currentRequest : null;\n if (request) {\n var resumableState = request.resumableState, renderState = request.renderState;\n if (href) {\n var as = options2 && "string" === typeof options2.as ? options2.as : "script";\n switch (as) {\n case "script":\n if (resumableState.moduleScriptResources.hasOwnProperty(href)) return;\n as = [];\n resumableState.moduleScriptResources[href] = !options2 || "string" !== typeof options2.crossOrigin && "string" !== typeof options2.integrity ? PRELOAD_NO_CREDS : [options2.crossOrigin, options2.integrity];\n renderState.preloads.moduleScripts.set(href, as);\n break;\n default:\n if (resumableState.moduleUnknownResources.hasOwnProperty(as)) {\n var resources = resumableState.unknownResources[as];\n if (resources.hasOwnProperty(href)) return;\n } else\n resources = {}, resumableState.moduleUnknownResources[as] = resources;\n as = [];\n resources[href] = PRELOAD_NO_CREDS;\n }\n pushLinkImpl(as, assign2({ rel: "modulepreload", href }, options2));\n renderState.bulkPreloads.add(as);\n enqueueFlush(request);\n }\n } else previousDispatcher.m(href, options2);\n }\n function preinitStyle(href, precedence, options2) {\n var request = currentRequest ? currentRequest : null;\n if (request) {\n var resumableState = request.resumableState, renderState = request.renderState;\n if (href) {\n precedence = precedence || "default";\n var styleQueue = renderState.styles.get(precedence), resourceState = resumableState.styleResources.hasOwnProperty(href) ? resumableState.styleResources[href] : void 0;\n null !== resourceState && (resumableState.styleResources[href] = null, styleQueue || (styleQueue = {\n precedence: escapeTextForBrowser(precedence),\n rules: [],\n hrefs: [],\n sheets: /* @__PURE__ */ new Map()\n }, renderState.styles.set(precedence, styleQueue)), precedence = {\n state: 0,\n props: assign2(\n { rel: "stylesheet", href, "data-precedence": precedence },\n options2\n )\n }, resourceState && (2 === resourceState.length && adoptPreloadCredentials(precedence.props, resourceState), (renderState = renderState.preloads.stylesheets.get(href)) && 0 < renderState.length ? renderState.length = 0 : precedence.state = 1), styleQueue.sheets.set(href, precedence), enqueueFlush(request));\n }\n } else previousDispatcher.S(href, precedence, options2);\n }\n function preinitScript(src, options2) {\n var request = currentRequest ? currentRequest : null;\n if (request) {\n var resumableState = request.resumableState, renderState = request.renderState;\n if (src) {\n var resourceState = resumableState.scriptResources.hasOwnProperty(src) ? resumableState.scriptResources[src] : void 0;\n null !== resourceState && (resumableState.scriptResources[src] = null, options2 = assign2({ src, async: true }, options2), resourceState && (2 === resourceState.length && adoptPreloadCredentials(options2, resourceState), src = renderState.preloads.scripts.get(src)) && (src.length = 0), src = [], renderState.scripts.add(src), pushScriptImpl(src, options2), enqueueFlush(request));\n }\n } else previousDispatcher.X(src, options2);\n }\n function preinitModuleScript(src, options2) {\n var request = currentRequest ? currentRequest : null;\n if (request) {\n var resumableState = request.resumableState, renderState = request.renderState;\n if (src) {\n var resourceState = resumableState.moduleScriptResources.hasOwnProperty(\n src\n ) ? resumableState.moduleScriptResources[src] : void 0;\n null !== resourceState && (resumableState.moduleScriptResources[src] = null, options2 = assign2({ src, type: "module", async: true }, options2), resourceState && (2 === resourceState.length && adoptPreloadCredentials(options2, resourceState), src = renderState.preloads.moduleScripts.get(src)) && (src.length = 0), src = [], renderState.scripts.add(src), pushScriptImpl(src, options2), enqueueFlush(request));\n }\n } else previousDispatcher.M(src, options2);\n }\n function adoptPreloadCredentials(target, preloadState) {\n null == target.crossOrigin && (target.crossOrigin = preloadState[0]);\n null == target.integrity && (target.integrity = preloadState[1]);\n }\n function getPreloadAsHeader(href, as, params) {\n href = ("" + href).replace(\n regexForHrefInLinkHeaderURLContext,\n escapeHrefForLinkHeaderURLContextReplacer\n );\n as = ("" + as).replace(\n regexForLinkHeaderQuotedParamValueContext,\n escapeStringForLinkHeaderQuotedParamValueContextReplacer\n );\n as = "<" + href + \'>; rel=preload; as="\' + as + \'"\';\n for (var paramName in params)\n hasOwnProperty.call(params, paramName) && (href = params[paramName], "string" === typeof href && (as += "; " + paramName.toLowerCase() + \'="\' + ("" + href).replace(\n regexForLinkHeaderQuotedParamValueContext,\n escapeStringForLinkHeaderQuotedParamValueContextReplacer\n ) + \'"\'));\n return as;\n }\n var regexForHrefInLinkHeaderURLContext = /[<>\\r\\n]/g;\n function escapeHrefForLinkHeaderURLContextReplacer(match2) {\n switch (match2) {\n case "<":\n return "%3C";\n case ">":\n return "%3E";\n case "\\n":\n return "%0A";\n case "\\r":\n return "%0D";\n default:\n throw Error(\n "escapeLinkHrefForHeaderContextReplacer encountered a match it does not know how to replace. this means the match regex and the replacement characters are no longer in sync. This is a bug in React"\n );\n }\n }\n var regexForLinkHeaderQuotedParamValueContext = /["\';,\\r\\n]/g;\n function escapeStringForLinkHeaderQuotedParamValueContextReplacer(match2) {\n switch (match2) {\n case \'"\':\n return "%22";\n case "\'":\n return "%27";\n case ";":\n return "%3B";\n case ",":\n return "%2C";\n case "\\n":\n return "%0A";\n case "\\r":\n return "%0D";\n default:\n throw Error(\n "escapeStringForLinkHeaderQuotedParamValueContextReplacer encountered a match it does not know how to replace. this means the match regex and the replacement characters are no longer in sync. This is a bug in React"\n );\n }\n }\n function hoistStyleQueueDependency(styleQueue) {\n this.styles.add(styleQueue);\n }\n function hoistStylesheetDependency(stylesheet) {\n this.stylesheets.add(stylesheet);\n }\n function hoistHoistables(parentState, childState) {\n childState.styles.forEach(hoistStyleQueueDependency, parentState);\n childState.stylesheets.forEach(hoistStylesheetDependency, parentState);\n childState.suspenseyImages && (parentState.suspenseyImages = true);\n }\n function createRenderState(resumableState, generateStaticMarkup) {\n var idPrefix = resumableState.idPrefix, bootstrapChunks = [], bootstrapScriptContent = resumableState.bootstrapScriptContent, bootstrapScripts = resumableState.bootstrapScripts, bootstrapModules = resumableState.bootstrapModules;\n void 0 !== bootstrapScriptContent && (bootstrapChunks.push("",\n ("" + bootstrapScriptContent).replace(scriptRegex, scriptReplacer),\n "<\\/script>"\n ));\n bootstrapScriptContent = idPrefix + "P:";\n var JSCompiler_object_inline_segmentPrefix_1673 = idPrefix + "S:";\n idPrefix += "B:";\n var JSCompiler_object_inline_preconnects_1687 = /* @__PURE__ */ new Set(), JSCompiler_object_inline_fontPreloads_1688 = /* @__PURE__ */ new Set(), JSCompiler_object_inline_highImagePreloads_1689 = /* @__PURE__ */ new Set(), JSCompiler_object_inline_styles_1690 = /* @__PURE__ */ new Map(), JSCompiler_object_inline_bootstrapScripts_1691 = /* @__PURE__ */ new Set(), JSCompiler_object_inline_scripts_1692 = /* @__PURE__ */ new Set(), JSCompiler_object_inline_bulkPreloads_1693 = /* @__PURE__ */ new Set(), JSCompiler_object_inline_preloads_1694 = {\n images: /* @__PURE__ */ new Map(),\n stylesheets: /* @__PURE__ */ new Map(),\n scripts: /* @__PURE__ */ new Map(),\n moduleScripts: /* @__PURE__ */ new Map()\n };\n if (void 0 !== bootstrapScripts)\n for (var i = 0; i < bootstrapScripts.length; i++) {\n var scriptConfig = bootstrapScripts[i], src, crossOrigin = void 0, integrity = void 0, props = {\n rel: "preload",\n as: "script",\n fetchPriority: "low",\n nonce: void 0\n };\n "string" === typeof scriptConfig ? props.href = src = scriptConfig : (props.href = src = scriptConfig.src, props.integrity = integrity = "string" === typeof scriptConfig.integrity ? scriptConfig.integrity : void 0, props.crossOrigin = crossOrigin = "string" === typeof scriptConfig || null == scriptConfig.crossOrigin ? void 0 : "use-credentials" === scriptConfig.crossOrigin ? "use-credentials" : "");\n scriptConfig = resumableState;\n var href = src;\n scriptConfig.scriptResources[href] = null;\n scriptConfig.moduleScriptResources[href] = null;\n scriptConfig = [];\n pushLinkImpl(scriptConfig, props);\n JSCompiler_object_inline_bootstrapScripts_1691.add(scriptConfig);\n bootstrapChunks.push(\'after
', + ); + expect(result).toBe("
beforeafter
"); + expect(result).not.toContain("fetch"); + expect(result).not.toContain("evil.test"); + }); + + it("removes a style element AND its body", () => { + const result = sanitizeArtifactPreviewMarkup( + "
ok
", + ); + expect(result).toBe("
ok
"); + expect(result).not.toContain("evil.test"); + }); + + it("does not let a nested tag close the skip early", () => { + const result = sanitizeArtifactPreviewMarkup( + "
after
", + ); + expect(result).toBe("
after
"); + expect(result).not.toContain("still inside"); + expect(result).not.toContain("payload"); + }); + + it("removes noscript, template and object with their contents", () => { + for (const tag of ["noscript", "template", "object"]) { + const result = sanitizeArtifactPreviewMarkup(`
<${tag}>hiddenkept
`); + expect(result).toBe("
kept
"); + } + }); + }); + + describe("event handlers", () => { + it("strips every on* attribute", () => { + const result = sanitizeArtifactPreviewMarkup( + '
x
', + ); + expect(result).toBe('
x
'); + expect(result).not.toContain("steal"); + }); + + it("strips handlers whatever their casing", () => { + const result = sanitizeArtifactPreviewMarkup('
x
'); + expect(result).not.toContain("steal"); + }); + + it("strips a handler whose value contains a closing angle bracket", () => { + const result = sanitizeArtifactPreviewMarkup( + '
x
', + ); + expect(result).toBe('
x
'); + expect(result).not.toContain("steal"); + }); + }); + + describe("external references", () => { + it("drops img entirely, so no request is ever made", () => { + const result = sanitizeArtifactPreviewMarkup( + '
ok
', + ); + expect(result).toBe("
ok
"); + expect(result).not.toContain("evil.test"); + }); + + it("drops iframe and its content", () => { + const result = sanitizeArtifactPreviewMarkup( + '
ok
', + ); + expect(result).toBe("
ok
"); + }); + + it("unwraps an anchor but keeps its text, dropping the href", () => { + const result = sanitizeArtifactPreviewMarkup( + '
', + ); + expect(result).toBe("
Dashboard
"); + expect(result).not.toContain("evil.test"); + }); + + it("drops a style declaration that can fetch", () => { + const result = sanitizeArtifactPreviewMarkup( + '
x
', + ); + expect(result).toBe('
x
'); + expect(result).not.toContain("evil.test"); + }); + + it("drops a url() smuggled into an allowed style property", () => { + const result = sanitizeArtifactPreviewMarkup( + '
x
', + ); + expect(result).toBe('
x
'); + expect(result).not.toContain("evil.test"); + }); + + it("drops position and transform, which would escape the card", () => { + const result = sanitizeArtifactPreviewMarkup( + '
x
', + ); + expect(result).toBe('
x
'); + }); + + it("drops an svg url() paint that points anywhere but a local id", () => { + const result = sanitizeArtifactPreviewMarkup( + '', + ); + expect(result).toContain(" { + const result = sanitizeArtifactPreviewMarkup( + '
ok
', + ); + expect(result).toBe("
ok
"); + }); + + it("neutralises a javascript: scheme wherever it lands", () => { + const result = sanitizeArtifactPreviewMarkup( + '
x
', + ); + expect(result).not.toContain("javascript:"); + }); + }); + + describe("interactive elements", () => { + it("unwraps a button, keeping its label", () => { + const result = sanitizeArtifactPreviewMarkup("
"); + expect(result).toBe("
Refresh
"); + }); + + it("drops form controls", () => { + const result = sanitizeArtifactPreviewMarkup( + '
ok
', + ); + expect(result).toBe("
ok
"); + expect(result).not.toContain("evil.test"); + }); + }); + + describe("ids", () => { + it("namespaces ids so a preview cannot collide with the host document", () => { + const result = sanitizeArtifactPreviewMarkup('
x
'); + expect(result).toBe('
x
'); + }); + + it("rewrites local paint references in step with the ids", () => { + const result = sanitizeArtifactPreviewMarkup( + '' + + '', + ); + expect(result).toContain('id="artifact-preview-grad"'); + expect(result).toContain('fill="url(#artifact-preview-grad)"'); + }); + }); + + describe("text and entities", () => { + it("keeps escaped markup escaped, and does not let it re-enter as an element", () => { + const result = sanitizeArtifactPreviewMarkup( + "
<script>alert(1)</script>
", + ); + expect(result).toBe("
<script>alert(1)</script>
"); + expect(result).not.toContain(" { + // The input is already-escaped HTML. Escaping `&` a second time would + // turn `&` into `&amp;` and corrupt every Tailwind arbitrary + // variant in the markup. + const once = sanitizeArtifactPreviewMarkup("
a < b && c
"); + expect(once).toBe("
a < b && c
"); + expect(sanitizeArtifactPreviewMarkup(once ?? "")).toBe(once); + }); + + it("preserves a Tailwind arbitrary variant containing an ampersand", () => { + // What React writes for `[&_.recharts-layer]:outline-hidden`. + const result = sanitizeArtifactPreviewMarkup( + '
x
', + ); + expect(result).toBe('
x
'); + expect(result).not.toContain("&amp;"); + }); + }); + + describe("size", () => { + it("returns null past the cap", () => { + const huge = `
${"x".repeat(ARTIFACT_PREVIEW_MARKUP_LIMIT + 1)}
`; + expect(sanitizeArtifactPreviewMarkup(huge)).toBeNull(); + }); + + it("keeps markup just under the cap", () => { + const body = "x".repeat(1000); + expect(sanitizeArtifactPreviewMarkup(`
${body}
`)).toBe(`
${body}
`); + }); + }); + + describe("nothing usable", () => { + it("returns null for empty input", () => { + expect(sanitizeArtifactPreviewMarkup("")).toBeNull(); + }); + + it("returns null when everything sanitized away", () => { + expect(sanitizeArtifactPreviewMarkup("")).toBeNull(); + }); + + it("returns null for whitespace-only structure", () => { + expect(sanitizeArtifactPreviewMarkup("
")).toBeNull(); + }); + + it("keeps a purely geometric render that has no text at all", () => { + // A chart-only artifact is a real preview even with zero text nodes. + const result = sanitizeArtifactPreviewMarkup( + '
', + ); + expect(result).not.toBeNull(); + expect(result).toContain(" { + it("does not hang or throw on an unterminated tag", () => { + expect(() => sanitizeArtifactPreviewMarkup('
{ + expect(() => sanitizeArtifactPreviewMarkup("
", next + 4); + index = end === -1 ? markup.length : end + 3; + continue; + } + if (markup.startsWith("", next); + index = end === -1 ? markup.length : end + 1; + continue; + } + + // Walk to this tag's `>`, skipping any inside a quoted attribute value. + let cursor = next + 1; + let quote = ""; + while (cursor < markup.length) { + const char = markup[cursor]; + if (quote !== "") { + if (char === quote) quote = ""; + } else if (char === '"' || char === "'") { + quote = char; + } else if (char === ">") { + break; + } + cursor += 1; + } + if (cursor >= markup.length) break; + + const inner = markup.slice(next + 1, cursor); + index = cursor + 1; + + const closing = inner.startsWith("/"); + const body = closing ? inner.slice(1) : inner; + const selfClosing = body.endsWith("/"); + const normalized = selfClosing ? body.slice(0, -1) : body; + const nameEnd = normalized.search(/[\s/]/); + const tag = (nameEnd === -1 ? normalized : normalized.slice(0, nameEnd)).toLowerCase(); + if (tag === "") continue; + + if (skipDepth > 0) { + // Inside a cut subtree: track only the tag that opened it, so a `
` + // nested in a dropped `", + ); + const externalRefs = [...documentOnly.matchAll(/<(?:script|link)[^>]*(?:src|href)="([^"]+)"/g)] .map((match) => match[1]) .filter((url) => !url.startsWith("data:")); expect(externalRefs).toEqual([]); From 0612084d1b922040de29ec99782be901b755aeb8 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:36:48 -0700 Subject: [PATCH 46/60] Capture the settled render as markup, not pixels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rasterizing was the intent and cannot work: the render frame is sandboxed without allow-same-origin, so it runs in an opaque origin, every image it can build taints the canvas, and toDataURL throws. Measured, not assumed — a 203-byte inline SVG fails exactly as the real one does, so nothing short of giving the frame a real origin fixes it, and that origin is the sandbox. Capturing the settled DOM keeps what the upgrade was for: the card shows the artifact with its real numbers instead of its skeleton. Both previews are now markup through one sanitizer, so the console renders them identically. --- apps/cloud/src/routeTree.gen.ts | 4 - .../artifact-preview-gallery.test.ts | 279 ++++++++++++++++++ e2e/scenarios/artifacts.test.ts | 69 +++++ packages/core/api/src/artifacts/api.ts | 48 +-- packages/core/api/src/handlers/artifacts.ts | 23 +- packages/core/sdk/src/artifact.ts | 56 ++-- packages/core/sdk/src/artifacts.test.ts | 14 +- packages/core/sdk/src/index.ts | 1 - packages/core/sdk/src/shared.ts | 2 +- .../src/shell/inner-renderer.tsx | 110 ++----- .../mcp-apps-shell/src/shell/shell-app.tsx | 4 +- .../src/components/artifact-preview.test.tsx | 15 +- .../react/src/components/artifact-preview.tsx | 61 ++-- 13 files changed, 478 insertions(+), 208 deletions(-) create mode 100644 e2e/scenarios/artifact-preview-gallery.test.ts diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index 37493600a..d13911093 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -520,15 +520,11 @@ export const routeTree = rootRouteImport ._addFileTypes() import type { getRouter } from './router.tsx' - import type { startInstance } from './start.ts' - declare module '@tanstack/react-start' { interface Register { ssr: true - router: Awaited> - config: Awaited> } } diff --git a/e2e/scenarios/artifact-preview-gallery.test.ts b/e2e/scenarios/artifact-preview-gallery.test.ts new file mode 100644 index 000000000..148dba0e2 --- /dev/null +++ b/e2e/scenarios/artifact-preview-gallery.test.ts @@ -0,0 +1,279 @@ +// A visual check of the artifact gallery's previews, in light and dark. +// +// The other artifacts scenarios prove the mechanism: the layout preview is +// stored at create time and upgraded to a snapshot once the artifact is opened. +// This one exists to show what that actually LOOKS like across the shapes a +// model really produces — a stat grid, a chart, and a component whose data is +// still loading — because "the preview is real" is a claim about appearance and +// the only honest way to check appearance is to look at it. +// +// It seeds through `create-artifact` rather than the storage API, so every +// preview here was produced by the same smoke render a model's create runs +// through. +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Browser, Mcp, Target } from "../src/services"; + +const uniqueSuffix = () => randomBytes(3).toString("hex"); + +/** A stat grid over a table: the most common dashboard shape. */ +const dashboardSource = ` +function App() { + const metrics = [ + { label: "Active users", value: "12,480", delta: "+8.2% vs last week" }, + { label: "Sessions", value: "48,201", delta: "+3.1% vs last week" }, + { label: "Error rate", value: "0.42%", delta: "-0.3% vs last week" }, + { label: "p95 latency", value: "284ms", delta: "+12ms vs last week" }, + ]; + const rows = Array.from({ length: 6 }, (_, i) => ({ + name: "checkout-service-" + (i + 1), + status: i % 3 === 0 ? "degraded" : "healthy", + })); + return ( +
+
+

Release Readiness

+

Rolling 24h across all services

+
+
+ {metrics.map((m) => ( + + + {m.label} + {m.value} + + + {m.delta} + + + ))} +
+ + Services + + + + + Service + Status + + + + {rows.map((r) => ( + + {r.name} + + + {r.status} + + + + ))} + +
+
+
+
+ ); +}`; + +/** A chart: the shape whose preview is almost entirely SVG geometry. */ +const chartSource = ` +function App() { + const data = [ + { month: "Jan", revenue: 4200 }, { month: "Feb", revenue: 5100 }, + { month: "Mar", revenue: 4800 }, { month: "Apr", revenue: 6300 }, + { month: "May", revenue: 7100 }, { month: "Jun", revenue: 8250 }, + ]; + return ( +
+
+

Revenue by month

+

Current fiscal year

+
+ + + + + + + + + + + + +
+ ); +}`; + +/** + * A component showing its loading state: the shape whose preview is a skeleton. + * + * Deliberately does NOT call a tool. A real `tools.*` query would need a bound + * connection, and binding one here would test the binding path rather than the + * preview — while the SKELETON, which is what this case is about, is identical + * either way: the smoke render's transport never settles, so every querying + * artifact previews exactly this. + */ +const loadingSource = ` +function App() { + return ( +
+
+

Open Issues

+

Loading from the tracker

+
+ +
+ ); +}`; + +scenario( + "Artifacts · the gallery shows each artifact's real layout, in light and dark", + { timeout: 240_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const browser = yield* Browser; + + const identity = yield* target.newIdentity(); + const session = mcp.session(identity); + const suffix = uniqueSuffix(); + + const seeded = [ + ["Release Readiness", dashboardSource, "Service health across the fleet"], + ["Revenue by Month", chartSource, "Monthly revenue for the fiscal year"], + ["Open Issues", loadingSource, "Open GitHub issues, live"], + ] as const; + + for (const [name, code, description] of seeded) { + const result = yield* session.call("create-artifact", { + code, + title: `${name} ${suffix}`, + description, + }); + expect(result.ok, `${name} saved: ${result.text}`).toBe(true); + } + + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the Artifacts gallery", async () => { + await page.goto(`${target.baseUrl}/artifacts`, { waitUntil: "networkidle" }); + await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); + // Every seeded artifact is present before anything is measured. + for (const [name] of seeded) { + await page + .getByRole("link", { name: `Open artifact ${name} ${suffix}` }) + .waitFor({ timeout: 30_000 }); + } + }); + + await step("Every card draws the artifact's own layout", async () => { + const previews = page.locator('[data-slot="artifact-preview"]'); + await expect + .poll(async () => await previews.count(), { timeout: 20_000 }) + .toBe(seeded.length); + + // Not one schematic among them: all three previews came from a real + // render of the artifact's own source. + for (let index = 0; index < seeded.length; index += 1) { + expect( + await previews.nth(index).getAttribute("data-preview-kind"), + `card ${index} shows a captured preview rather than the fallback`, + ).toBe("layout"); + } + + // And the content is the artifact's, not a generic frame: each card + // carries text or geometry its own source produced. + const gallery = await page.locator('[data-slot="artifact-grid"]').innerText(); + expect(gallery, "the dashboard's own heading is on its card").toContain( + "Release Readiness", + ); + expect(gallery, "the dashboard's real stat labels are on its card").toContain( + "Active users", + ); + expect(gallery, "the chart artifact's heading is on its card").toContain( + "Revenue by month", + ); + + // A chart's LAYOUT preview is its frame and heading, not its bars. + // + // That is a property of Recharts, not of the sanitizer: its + // `ResponsiveContainer` measures the element it is inside, and on a + // server there is nothing to measure, so it renders a sized wrapper and + // no SVG at all. Asserting bars here would be asserting something no + // server render can produce. What the card can promise is the artifact's + // identity and its frame — and the second layer, the snapshot taken once + // the artifact has actually been opened in a browser, is what fills in + // the geometry. See the artifacts scenario for that half. + const chartCard = page + .locator('[data-slot="artifact-card"]') + .filter({ hasText: `Revenue by Month ${suffix}` }); + const chartPreview = chartCard.locator('[data-slot="artifact-preview"]'); + expect( + await chartPreview.getAttribute("data-preview-kind"), + "the chart artifact still gets a real layout preview", + ).toBe("layout"); + expect( + await chartPreview.locator(".recharts-responsive-container").count(), + "the chart's frame is drawn even though its geometry needs a viewport", + ).toBeGreaterThan(0); + + // The preview's STRUCTURE has to survive, not just its text. The card + // renders the markup in the console document, so a layout class the + // model used that this stylesheet never compiled would silently + // collapse a four-up stat row into one column — the preview would still + // "work" and would be showing the wrong shape. `grid-cols-4` is the + // case: the console itself never needs one, so only the safelist in + // `globals.css` puts it in the stylesheet. + const statColumns = await page + .locator('[data-slot="artifact-card"]') + .filter({ hasText: `Release Readiness ${suffix}` }) + .locator('[data-slot="artifact-preview"] .grid-cols-4') + .first() + .evaluate( + (node) => + globalThis + .getComputedStyle(node) + .gridTemplateColumns.split(" ") + .filter((track) => track.length > 0).length, + ); + expect( + statColumns, + "the dashboard's four-column stat row is still four columns in the preview", + ).toBe(4); + }); + + await step("Gallery in dark mode", async () => { + // The stored markup is token-based, so the SAME markup has to read + // correctly against the console's dark surface without being restyled. + await page.emulateMedia({ colorScheme: "dark" }); + await page.reload({ waitUntil: "networkidle" }); + await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); + await page.locator('[data-slot="artifact-preview"]').first().waitFor({ timeout: 20_000 }); + + // The card's own background follows the theme, and the preview inside + // it inherits the console's tokens rather than carrying its own. + const card = page.locator('[data-slot="artifact-card"]').first(); + const surface = await card.evaluate( + (node) => globalThis.getComputedStyle(node).backgroundColor, + ); + expect(surface, "the card is drawn on a dark surface").not.toBe("rgb(255, 255, 255)"); + }); + + await step("Gallery in light mode", async () => { + await page.emulateMedia({ colorScheme: "light" }); + await page.reload({ waitUntil: "networkidle" }); + await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); + await page.locator('[data-slot="artifact-preview"]').first().waitFor({ timeout: 20_000 }); + }); + }); + }), +); diff --git a/e2e/scenarios/artifacts.test.ts b/e2e/scenarios/artifacts.test.ts index 135dbcc95..b2bd8e5e9 100644 --- a/e2e/scenarios/artifacts.test.ts +++ b/e2e/scenarios/artifacts.test.ts @@ -402,6 +402,35 @@ scenario( 80, ); + // And it is the artifact's REAL layout, not the id-seeded schematic. + // The smoke render that validated this code at create time also + // produced its loading-state markup, which is what the card draws — + // so the artifact's own heading is present in the card itself. + expect( + await preview.getAttribute("data-preview-kind"), + "the card shows the captured layout rather than the fallback schematic", + ).toBe("layout"); + await expect + .poll(async () => await preview.innerText(), { + timeout: 10_000, + message: "the preview carries the artifact's own rendered content", + }) + .toContain("Release Readiness"); + + // The preview is inert by construction: nothing in it may be clicked, + // focused or read out, because it is a picture of a UI and not the UI. + const inert = await preview.evaluate((node) => ({ + pointerEvents: globalThis.getComputedStyle(node).pointerEvents, + hidden: node.getAttribute("aria-hidden"), + scripts: node.querySelectorAll("script, iframe, img, form, button, a").length, + })); + expect(inert.pointerEvents, "the preview cannot be interacted with").toBe("none"); + expect(inert.hidden, "the preview is hidden from assistive technology").toBe("true"); + expect( + inert.scripts, + "the sanitizer left no script, frame, image, form or interactive element", + ).toBe(0); + const columns = await page.locator('[data-slot="artifact-grid"]').evaluate( (grid) => globalThis @@ -411,6 +440,46 @@ scenario( ); expect(columns, "the gallery lays out three cards per row when wide").toBe(3); }); + + await step("Opening the artifact upgrades its card to the settled render", async () => { + // The second layer. The create-time preview is the artifact's LOADING + // state — it is captured with a transport that never settles, so it + // has no data in it. Opening the artifact renders it for real, and + // once that render goes quiet the shell hands the settled markup back + // and the console stores it in place of the loading one. + // + // The observable difference is the DATA: this artifact's rows only + // exist in a settled render, so finding one on the card proves the + // upgrade landed rather than just that some preview exists. + await page.getByRole("link", { name: `Open artifact ${title}` }).click(); + await artifactContent(page).getByTestId("artifact-marker").waitFor({ timeout: 30_000 }); + + const card = page.locator('[data-slot="artifact-card"]').filter({ hasText: title }); + const preview = card.locator('[data-slot="artifact-preview"]'); + // Polled rather than slept on: the capture is debounced behind a + // settle window, so the honest assertion is that it arrives, not when. + await expect + .poll( + async () => { + await page.getByRole("link", { name: "Artifacts" }).first().click(); + await card.waitFor({ timeout: 20_000 }); + return await preview.innerText(); + }, + { + timeout: 90_000, + interval: 3_000, + message: "the settled render replaced the loading-state preview", + }, + ) + .toContain(marker); + + expect( + await preview.getAttribute("data-preview-kind"), + "the upgraded preview is still rendered as a captured layout", + ).toBe("layout"); + const box = await preview.boundingBox(); + expect(box?.height ?? 0, "the upgraded preview has layout extent").toBeGreaterThan(80); + }); }); // The same row, read back through the agent's own surface. diff --git a/packages/core/api/src/artifacts/api.ts b/packages/core/api/src/artifacts/api.ts index 9e38b0396..f83f50ee0 100644 --- a/packages/core/api/src/artifacts/api.ts +++ b/packages/core/api/src/artifacts/api.ts @@ -27,16 +27,13 @@ const ArtifactParams = { artifactId: ArtifactId }; // --------------------------------------------------------------------------- /** - * What the gallery draws for an artifact. - * - * `layout` is sanitized markup from the create-time smoke render — the - * artifact's real composition with no data in it. `image` is a raster snapshot - * of a settled render, which DOES contain whatever the viewer could see. + * What the gallery draws for an artifact: sanitized markup of a real render, + * either its create-time loading state or a settled one captured on open. */ -const ArtifactPreviewResponse = Schema.Union([ - Schema.Struct({ kind: Schema.Literal("layout"), markup: Schema.String }), - Schema.Struct({ kind: Schema.Literal("image"), src: Schema.String }), -]); +const ArtifactPreviewResponse = Schema.Struct({ + kind: Schema.Literal("layout"), + markup: Schema.String, +}); /** What a list returns — no `code`, so a long list stays cheap. The preview is * the exception, because the list is the surface that draws it. */ @@ -81,28 +78,31 @@ const RenameArtifactPayload = Schema.Struct({ export const ARTIFACT_PREVIEW_IMAGE_LIMIT = 512 * 1024; /** - * A raster snapshot of the artifact as the viewer just saw it. + * A snapshot of the artifact as the viewer just saw it, WITH its data. * * ## Data safety — read before widening who may call this * - * Unlike the layout preview, this image is rendered WITH DATA. It is pixels of - * whatever the capturing viewer was allowed to see, which makes it per-viewer - * state that happens to be stored on a shared-shaped row. + * Unlike the layout preview, this is captured after the artifact's queries + * resolved, so it contains whatever the capturing viewer was allowed to see. + * That makes it per-viewer state that happens to be stored on a shared-shaped + * row. * - * That is correct only because of a property that holds TODAY and not by - * construction: artifacts are viewer-owned, so the only person who can read the - * row is the person whose data is in the picture. When org-tier sharing lands — - * the `owner` column already anticipates it — this stops being true, and an - * image preview would leak one member's numbers into another member's gallery. + * It is correct today only because of a property that holds by circumstance and + * not by construction: artifacts are viewer-owned, so the only person who can + * read the row is the person whose data is in the snapshot. When org-tier + * sharing lands — the `owner` column already anticipates it — that stops being + * true, and this preview would leak one member's numbers into another member's + * gallery. * - * At that point this preview must either move to per-viewer storage keyed by - * (artifact, viewer), or be excluded from any view the owner did not capture. - * The layout preview is the one that stays shareable, which is why the column - * carries both kinds rather than being replaced by this one. + * At that point it must either move to per-viewer storage keyed by (artifact, + * viewer), or be excluded from any view its capturer does not own. The LAYOUT + * preview, written at create time from a render with no data in it, is the one + * that stays shareable — which is why the column carries both kinds rather than + * being replaced by this one. */ const SetArtifactPreviewPayload = Schema.Struct({ - /** A `data:image/...` URL. Anything else is rejected: the endpoint stores - * images, and the layout half is written by the save path only. */ + /** Markup from the settled render. Sanitized by the handler through the same + * allowlist as the create-time preview before it is ever stored. */ preview: Schema.String.check(Schema.isMaxLength(ARTIFACT_PREVIEW_IMAGE_LIMIT)), }); diff --git a/packages/core/api/src/handlers/artifacts.ts b/packages/core/api/src/handlers/artifacts.ts index 0365c122d..42c5520bf 100644 --- a/packages/core/api/src/handlers/artifacts.ts +++ b/packages/core/api/src/handlers/artifacts.ts @@ -1,6 +1,10 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; import { Effect } from "effect"; -import { isImagePreviewValue, type Artifact, type ArtifactSummary } from "@executor-js/sdk"; +import { + sanitizeArtifactPreviewMarkup, + type Artifact, + type ArtifactSummary, +} from "@executor-js/sdk"; import { ExecutorApi } from "../api"; import { ExecutorService } from "../services"; @@ -81,15 +85,20 @@ export const ArtifactsHandlers = HttpApiBuilder.group(ExecutorApi, "artifacts", .handle("setPreview", ({ params: path, payload }) => capture( Effect.gen(function* () { - // Only an image may come in here. The layout preview is written by - // the save path from a sandboxed render this server drove itself; - // accepting markup on an open endpoint would mean accepting markup - // from the browser, which is a different trust story entirely. - if (!isImagePreviewValue(payload.preview)) return { stored: false }; + // This markup came from a BROWSER, not from this server's own + // sandbox, so it is sanitized here before it is stored — through the + // same allowlist the create-time preview goes through, which is what + // makes the console safe to render either kind identically. + // + // A payload that sanitizes to nothing is reported as not stored + // rather than as an error: the caller is a viewer who merely opened + // an artifact, and the card simply keeps its layout preview. + const sanitized = sanitizeArtifactPreviewMarkup(payload.preview); + if (sanitized === null) return { stored: false }; const executor = yield* ExecutorService; yield* executor.artifacts.setPreview({ id: path.artifactId, - preview: payload.preview, + preview: sanitized, }); return { stored: true }; }), diff --git a/packages/core/sdk/src/artifact.ts b/packages/core/sdk/src/artifact.ts index 011b08ce0..683ed61b9 100644 --- a/packages/core/sdk/src/artifact.ts +++ b/packages/core/sdk/src/artifact.ts @@ -67,45 +67,39 @@ const bindingsFromColumn = (value: unknown): ArtifactBindings | null => { /** * What the gallery draws for an artifact, when it has anything to draw. * - * Two kinds, in quality order: + * Always sanitized markup, produced at one of two moments: * - * - `layout` — sanitized markup from the create-time smoke render. The - * artifact's real composition in its LOADING state, so it contains no live - * data and is derived purely from the source. Written on every save. - * - `image` — a raster snapshot captured after a viewer opened the artifact - * and it settled with real data. Strictly better to look at, and strictly - * more sensitive: see the sharing note on `ArtifactPreview`. + * - at CREATE, from the smoke render — the artifact's real composition in its + * LOADING state (frames, tables, skeletons), containing no data at all + * because nothing was fetched; + * - at OPEN, from the settled render in a viewer's browser — the same + * composition with real numbers, rows and labels in it. * - * Stored in one column and discriminated by shape rather than by a second - * column: an image is always a `data:image/` URL and markup never can be, so - * the shape is already a total discriminator and a `preview_kind` column could - * only ever disagree with it. + * Pixels were the intent for the second one and are not achievable: the render + * frame is sandboxed into an opaque origin, so every image it can build taints + * a canvas and `toDataURL` throws. See `captureSettledHtml` in the shell's + * inner renderer for the measurement. Markup keeps what the upgrade was FOR — + * a card that shows the artifact's real content — and gives up only the + * typeface and pixel-exact fidelity. + * + * The two are one type because the console renders them identically. They are + * NOT interchangeable for sharing, which is why the source is recorded: see the + * data-safety note on `Artifact.preview`. */ -export type ArtifactPreview = - | { readonly kind: "layout"; readonly markup: string } - | { readonly kind: "image"; readonly src: string }; - -/** The prefix an image preview always has, and markup never does. */ -const IMAGE_PREVIEW_PREFIX = "data:image/"; +export type ArtifactPreview = { + readonly kind: "layout"; + readonly markup: string; +}; /** * Read the stored column as a preview. * - * Absent, empty, or anything that is not a string is `null` — the caller falls - * back to the schematic. A `data:image/` value is an image; everything else is - * markup, which is safe to assume because only this host writes the column and - * only ever after sanitizing. + * Absent, empty, or anything that is not a string is `null`, and the caller + * falls back to the schematic. Anything else is markup this host sanitized + * before storing — nothing else writes the column. */ -export const previewFromColumn = (value: unknown): ArtifactPreview | null => { - if (typeof value !== "string" || value === "") return null; - return value.startsWith(IMAGE_PREVIEW_PREFIX) - ? { kind: "image", src: value } - : { kind: "layout", markup: value }; -}; - -/** Whether a stored preview value is a raster snapshot rather than markup. */ -export const isImagePreviewValue = (value: string): boolean => - value.startsWith(IMAGE_PREVIEW_PREFIX); +export const previewFromColumn = (value: unknown): ArtifactPreview | null => + typeof value === "string" && value !== "" ? { kind: "layout", markup: value } : null; export interface Artifact { readonly id: ArtifactId; diff --git a/packages/core/sdk/src/artifacts.test.ts b/packages/core/sdk/src/artifacts.test.ts index 75cba1fdd..d07a56fbc 100644 --- a/packages/core/sdk/src/artifacts.test.ts +++ b/packages/core/sdk/src/artifacts.test.ts @@ -262,7 +262,7 @@ describe("executor.artifacts", () => { describe("previews", () => { const MARKUP = '

Revenue

'; - const IMAGE = "data:image/png;base64,iVBORw0KGgo="; + const SETTLED = '

Revenue

12,480 active

'; it.effect("round-trips layout markup, and reads it back as a layout preview", () => Effect.gen(function* () { @@ -311,7 +311,7 @@ describe("executor.artifacts", () => { }), ); - it.effect("setPreview upgrades a layout preview to an image", () => + it.effect("setPreview upgrades the preview to the settled render", () => Effect.gen(function* () { const executor = yield* makeTestExecutor(); const saved = yield* executor.artifacts.save({ @@ -319,10 +319,10 @@ describe("executor.artifacts", () => { code: CODE, preview: MARKUP, }); - yield* executor.artifacts.setPreview({ id: saved.id, preview: IMAGE }); + yield* executor.artifacts.setPreview({ id: saved.id, preview: SETTLED }); expect((yield* executor.artifacts.get(saved.id)).preview).toEqual({ - kind: "image", - src: IMAGE, + kind: "layout", + markup: SETTLED, }); }), ); @@ -333,7 +333,7 @@ describe("executor.artifacts", () => { // move it to the front — being looked at is not an edit. const executor = yield* makeTestExecutor(); const saved = yield* executor.artifacts.save({ title: "Revenue", code: CODE }); - yield* executor.artifacts.setPreview({ id: saved.id, preview: IMAGE }); + yield* executor.artifacts.setPreview({ id: saved.id, preview: SETTLED }); expect((yield* executor.artifacts.get(saved.id)).updatedAt).toEqual(saved.updatedAt); }), ); @@ -342,7 +342,7 @@ describe("executor.artifacts", () => { Effect.gen(function* () { const executor = yield* makeTestExecutor(); const result = yield* Effect.result( - executor.artifacts.setPreview({ id: "art_missing", preview: IMAGE }), + executor.artifacts.setPreview({ id: "art_missing", preview: SETTLED }), ); expect(Result.isFailure(result)).toBe(true); if (!Result.isFailure(result)) return; diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index e3e7e504a..f0a610a19 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -196,7 +196,6 @@ export { rowToArtifact, rowToArtifactSummary, previewFromColumn, - isImagePreviewValue, ArtifactBinding, ArtifactBindings, type Artifact, diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index 95e33b343..22ba145c7 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -105,7 +105,7 @@ export type { ToolPolicyAction } from "./core-schema"; // Artifact projections (the row mappers are server-side; the binding schemas // are shared, because the HTTP contract carries them). -export { ArtifactBinding, ArtifactBindings, isImagePreviewValue } from "./artifact"; +export { ArtifactBinding, ArtifactBindings } from "./artifact"; export type { Artifact, ArtifactPreview, diff --git a/packages/hosts/mcp-apps-shell/src/shell/inner-renderer.tsx b/packages/hosts/mcp-apps-shell/src/shell/inner-renderer.tsx index 3296b834a..e2fddccb4 100644 --- a/packages/hosts/mcp-apps-shell/src/shell/inner-renderer.tsx +++ b/packages/hosts/mcp-apps-shell/src/shell/inner-renderer.tsx @@ -182,93 +182,46 @@ const SETTLE_QUIET_MS = 2000; /** A ceiling on waiting for quiet, for an artifact that polls forever. */ const SETTLE_DEADLINE_MS = 15_000; -/** The capture's pixel size. 16:10, matching the gallery card's box. */ -const CAPTURE_WIDTH = 640; -const CAPTURE_HEIGHT = 400; - -/** Cap on the encoded data URL, mirroring the endpoint's own limit. */ +/** Cap on the captured markup, mirroring the endpoint's own limit. */ const CAPTURE_LIMIT = 512 * 1024; /** At most one capture per render: the first settled picture is the artifact. */ let capturedThisRender = false; /** - * Rasterize the rendered DOM to a PNG data URL. + * Serialize the settled render as sanitizable HTML. + * + * ## Why this is not a raster + * + * The intent was pixels: SVG `foreignObject` -> `` -> canvas -> PNG. That + * cannot work here, and the reason is structural rather than incidental. * - * SVG `foreignObject` is the only way to get pixels without an external - * service: the live DOM is serialized into an SVG, the SVG is loaded as an - * image, and the image is drawn to a canvas. Two properties make it work here - * and would not hold in general: + * The inner frame is a `srcdoc` iframe sandboxed WITHOUT `allow-same-origin`, + * so it runs in an opaque origin. Every image is therefore cross-origin to it, + * including one built from its own bytes — a 203-byte inline SVG through a blob + * URL taints the canvas exactly as a remote JPEG would, and `toDataURL` then + * throws `SecurityError: Tainted canvases may not be exported`. Measured, not + * assumed: the trivial case fails identically to the real one. Nothing short of + * granting the frame a real origin fixes it, and that origin is the entire + * sandbox — the frame runs model-written code, so it is not negotiable. * - * - The frame's CSP allows `img-src data: blob:`, so the SVG can be loaded. - * - Every font is already a `data:` URL in this document (the shell inlines - * Geist), so serialized styles carry their own fonts rather than - * referencing files the SVG image load cannot fetch. + * So the upgrade captures the settled DOM instead. That keeps what actually + * makes the second layer worth having: the layout preview is the artifact + * before its data arrives, and this is the artifact WITH its data — real + * numbers, real rows, real labels — which is the difference a viewer sees on + * the card. It gives up only the exact typeface and pixel-level fidelity. * - * It can still fail — a tainted canvas, a font that did not serialize, a - * browser that refuses the load — and every failure path returns `null` rather - * than throwing. The caller then keeps the layout preview, which is a good - * picture already; a missing upgrade is never worth an error in front of a user. + * The markup goes through the same sanitizer as the create-time preview, so + * nothing about how the console renders it changes. */ -const rasterize = async (target: HTMLElement): Promise => { - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort rasterization, every failure degrades to "no upgrade" +const captureSettledHtml = (target: HTMLElement): string | null => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort capture, every failure degrades to "no upgrade" try { - const width = Math.max(1, target.scrollWidth || target.clientWidth); - const height = Math.max(1, target.scrollHeight || target.clientHeight); - - // The document's compiled CSS, inlined into the SVG. Without it the clone - // is unstyled markup; `collectShellCss`'s equivalent here reads the same - // rules the frame is actually painting with, fonts included. - let css = ""; - for (const sheet of Array.from(document.styleSheets)) { - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: cross-origin sheets throw on access and are simply skipped - try { - for (const rule of Array.from(sheet.cssRules)) css += rule.cssText; - } catch { - continue; - } - } - - const clone = target.cloneNode(true) as HTMLElement; - clone.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); - // The capture must look like the theme the viewer is in, and the `.dark` - // class lives on the documentElement, which is not inside the clone. - const dark = document.documentElement.classList.contains("dark"); - const background = globalThis.getComputedStyle(document.body).backgroundColor; - - const svg = - `` + - `
` + - `${new XMLSerializer().serializeToString(clone)}` + - `
`; - - const source = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; - const image = new Image(); - const loaded = new Promise((resolve) => { - image.onload = () => resolve(true); - image.onerror = () => resolve(false); - }); - image.src = source; - if (!(await loaded)) return null; - - const canvas = document.createElement("canvas"); - canvas.width = CAPTURE_WIDTH; - canvas.height = CAPTURE_HEIGHT; - const context = canvas.getContext("2d"); - if (!context) return null; - context.fillStyle = background || (dark ? "#000" : "#fff"); - context.fillRect(0, 0, CAPTURE_WIDTH, CAPTURE_HEIGHT); - // Fit to WIDTH and anchor at the top, cropping any overflow rather than - // squashing: the artifact's header is what identifies it, and a tall - // artifact's tail is not worth distorting the part that is recognisable. - // The source rect is the top slice that fills the 16:10 box at that scale. - const scale = CAPTURE_WIDTH / width; - const sourceHeight = Math.min(height, CAPTURE_HEIGHT / scale); - context.drawImage(image, 0, 0, width, sourceHeight, 0, 0, CAPTURE_WIDTH, sourceHeight * scale); - const url = canvas.toDataURL("image/png"); - return url.length > CAPTURE_LIMIT || !url.startsWith("data:image/") ? null : url; + const html = target.innerHTML; + // Bounded here as well as at the endpoint: a settled render can be far + // larger than its loading state (every row is present now), and the whole + // point of the cap is that the gallery stays cheap to list. + return html.length === 0 || html.length > CAPTURE_LIMIT ? null : html; } catch { return null; } @@ -302,9 +255,8 @@ const captureWhenSettled = () => { if (quietTimer !== undefined) clearTimeout(quietTimer); if (capturedThisRender) return; capturedThisRender = true; - void rasterize(mount).then((preview) => { - if (preview) sendParent({ type: "executor.renderer.preview", preview }); - }); + const preview = captureSettledHtml(mount); + if (preview !== null) sendParent({ type: "executor.renderer.preview", preview }); }; const arm = () => { diff --git a/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx b/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx index 1919b33a4..c1d57dcd3 100644 --- a/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx +++ b/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx @@ -79,7 +79,7 @@ type RendererRequest = * Advertised by the console through `McpUiHostContext`'s open index signature, * which is the spec's own forward-compatibility seam. Absent everywhere else — * a real MCP client has nowhere to put a preview — and absent means the inner - * frame never rasterizes anything, so the cost is not paid where it cannot pay + * frame never captures anything, so the cost is not paid where it cannot pay * off. */ const hostContextAllowsPreviewCapture = (ctx: McpUiHostContext | undefined): boolean => @@ -477,7 +477,7 @@ export function McpAppsShell({ capturePreviewRef.current && artifactId !== undefined && typeof data.preview === "string" && - data.preview.startsWith("data:image/") + data.preview !== "" ) { savePreviewRef.current(artifactId, data.preview); } diff --git a/packages/react/src/components/artifact-preview.test.tsx b/packages/react/src/components/artifact-preview.test.tsx index 0b1727980..f4a4a0508 100644 --- a/packages/react/src/components/artifact-preview.test.tsx +++ b/packages/react/src/components/artifact-preview.test.tsx @@ -7,18 +7,14 @@ import { } from "./artifact-preview"; /** - * The fallback chain: a real snapshot beats a layout beats the schematic. + * The fallback: a real render beats the schematic. * * Every producer upstream fails open — the smoke render, the sanitizer, the - * capture — so "there is always something to draw" is the property that makes - * all of that safe, and it is pinned here. + * settled capture — so "there is always something to draw" is the property that + * makes all of that safe, and it is pinned here. */ describe("artifactPreviewKind", () => { - it("prefers a real snapshot over everything else", () => { - expect(artifactPreviewKind({ kind: "image", src: "data:image/png;base64,AAA" })).toBe("image"); - }); - - it("falls back to the layout when there is no snapshot", () => { + it("draws a captured render when there is one", () => { expect(artifactPreviewKind({ kind: "layout", markup: "
x
" })).toBe("layout"); }); @@ -27,8 +23,7 @@ describe("artifactPreviewKind", () => { expect(artifactPreviewKind(undefined)).toBe("schematic"); }); - it("treats an empty preview of either kind as no preview", () => { - expect(artifactPreviewKind({ kind: "image", src: "" })).toBe("schematic"); + it("treats an empty preview as no preview", () => { expect(artifactPreviewKind({ kind: "layout", markup: "" })).toBe("schematic"); }); }); diff --git a/packages/react/src/components/artifact-preview.tsx b/packages/react/src/components/artifact-preview.tsx index e00220478..a880c25ff 100644 --- a/packages/react/src/components/artifact-preview.tsx +++ b/packages/react/src/components/artifact-preview.tsx @@ -2,21 +2,20 @@ // The artifact gallery's preview slot. // // An artifact is a live component, so the grid needs something to show for it. -// Three things can be shown, and this component picks the best one available: +// Two things can be shown, and this component prefers the first: // -// 1. an IMAGE — a raster snapshot taken after a viewer opened the artifact -// and it settled with real data. The real thing, pixels and all. -// 2. a LAYOUT — sanitized markup from the create-time smoke render, which is -// the artifact's true composition in its loading state (cards, tables, -// chart frames, skeletons) with no data in it. Every artifact has one from -// the moment it is saved. -// 3. a SCHEMATIC — the deterministic figure below, derived from the id. +// 1. a CAPTURED RENDER — sanitized markup of the artifact actually rendering. +// Written at create time from the smoke render, which produces its loading +// composition (cards, tables, chart frames, skeletons) with no data in it, +// and REPLACED once a viewer opens the artifact and it settles, at which +// point the same composition carries real numbers and rows. +// 2. a SCHEMATIC — the deterministic figure below, derived from the id. // -// The fallback chain is strict and total: image, else layout, else schematic. -// Nothing here can fail to render something, which is what lets every producer -// of 1 and 2 fail open. +// The fallback is total: a real render if there is one, otherwise the +// schematic. Nothing here can fail to draw something, which is what lets every +// producer upstream fail open. // -// ## Why the layout markup is inserted directly, and not into an iframe +// ## Why the markup is inserted directly, and not into an iframe // // An iframe with `srcdoc` is the reflex for "render HTML I did not write", and // it is the wrong tool here on both axes: @@ -399,21 +398,6 @@ function LayoutPreview(props: { readonly markup: string }) { ); } -/** A raster snapshot of a settled render — the artifact as it really looked. */ -function ImagePreview(props: { readonly src: string }) { - return ( - - ); -} - /** * What the gallery draws for an artifact. * @@ -429,31 +413,24 @@ export function ArtifactPreview(props: { readonly preview?: ArtifactPreviewValue | null; readonly className?: string; }) { - const kind = artifactPreviewKind(props.preview); - if (kind === "image" && props.preview?.kind === "image") { - return ; - } - if (kind === "layout" && props.preview?.kind === "layout") { + if (artifactPreviewKind(props.preview) === "layout" && props.preview) { return ; } return ; } /** - * Which of the three previews an artifact gets. + * Which preview an artifact gets. * - * Exported so the fallback chain is testable as a pure function rather than - * only through a rendered tree: "image beats layout beats schematic, and an - * empty one of either is not a preview" is the contract, and it is the part - * that would silently regress. + * Exported so the fallback is testable as a pure function rather than only + * through a rendered tree: "a real render beats the schematic, and an empty one + * is not a real render" is the contract, and it is the part that would silently + * regress — every producer upstream fails open, so this is what guarantees + * there is always something to draw. */ export const artifactPreviewKind = ( preview: ArtifactPreviewValue | null | undefined, -): "image" | "layout" | "schematic" => { - if (preview?.kind === "image" && preview.src !== "") return "image"; - if (preview?.kind === "layout" && preview.markup !== "") return "layout"; - return "schematic"; -}; +): "layout" | "schematic" => (preview && preview.markup !== "" ? "layout" : "schematic"); /** * The fallback figure, for an artifact with no captured preview. From 57ec2ac490aabad940c81aca3ba4729aebcecfa1 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:50:58 -0700 Subject: [PATCH 47/60] Give an artifact a viewport to lay out against MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An artifact rendered on the console's detail page IS the page, but the shell only ever ran one way: the inner renderer reports its content height, the host grows the frame, and the console page scrolls. A generated dashboard with 143 rows therefore pushed its own header, search box and filters off the top of the screen, and no amount of `h-full` in the artifact could hold them there — `h-full` against an unbounded parent is just `h-auto`. Inline growth stays exactly as it is for chat hosts, where an artifact is one block in a scrolling transcript. The detail page now negotiates the other mode through the MCP Apps host context: `displayMode: "fullscreen"` plus a fixed `containerDimensions.height`, both spec fields, since `McpUiDisplayMode` is a closed union with no room for one of our own. The host measures its container with a ResizeObserver, sizes the frame to it, and ignores the app's size reports; the app keeps reporting them unconditionally, which is what keeps the mode entirely the host's business. The rest is the height chain, which had to hold at every link: content area, outer frame, shell document, inner srcdoc frame, `#root`, and the artifact's own root element. `min-height: 100%` is what a document uses to grow; a definite `height: 100%` is what a percentage child resolves against, so fill mode swaps to one under a document class and makes `.artifact-root` a flex column its child can stretch inside. --- .../src/shell/artifact-renderer.tsx | 201 ++++++++++++++---- .../mcp-apps-shell/src/shell/display-mode.ts | 104 +++++++++ .../src/shell/inner-renderer.tsx | 40 +++- .../mcp-apps-shell/src/shell/shell-app.tsx | 99 ++++++++- .../hosts/mcp-apps-shell/src/shell/theme.css | 52 +++++ packages/react/src/pages/artifact-detail.tsx | 33 ++- 6 files changed, 479 insertions(+), 50 deletions(-) create mode 100644 packages/hosts/mcp-apps-shell/src/shell/display-mode.ts diff --git a/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx b/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx index 7383975c5..5fb45ccc5 100644 --- a/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx +++ b/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx @@ -16,6 +16,11 @@ import shellHtmlUrl from "virtual:executor-mcp-apps-shell-html"; // friends), which do not exist in the console's bundle — importing a constant // from it drags them in and the artifact renderer fails to load entirely. import { PREVIEW_CAPTURE_HOST_CONTEXT_KEY, SAVE_ARTIFACT_PREVIEW_TOOL } from "./preview-capture"; +// Same reasoning as `./preview-capture`: a module with no build-graph imports of +// its own, so both the host half (here) and the app half (`shell-app.tsx`) can +// agree on the vocabulary without either dragging in the other's virtual +// modules. +import { FILL_DISPLAY_MODE, INLINE_DISPLAY_MODE } from "./display-mode"; /** * Hosts the MCP-Apps shell on the console's artifact page. @@ -64,12 +69,19 @@ type HttpShellHost = { readonly savePreview: (artifactId: string, preview: string) => void; }; -/** The frame height before the app has reported its content size. Tall enough - * that the shell's own loading state is not itself clipped. */ +/** The frame height before the app has reported its content size, in the inline + * fallback path. Tall enough that the shell's own loading state is not itself + * clipped. */ const INITIAL_FRAME_HEIGHT = 320; -/** A floor under the app's reported height, so a transient zero-height report - * (mid-render, or between artifacts) never collapses the frame. */ +/** + * A floor under the measured viewport. + * + * The container is measured, not guessed, so this only guards the degenerate + * readings — a container measured while the page is still laying out, or a + * window dragged to a sliver. Below this there is no room for a header and a + * scroll region both, and a frame of a few pixels reads as a broken page. + */ const MIN_FRAME_HEIGHT = 160; /** @@ -81,11 +93,33 @@ const readDocumentTheme = (): "light" | "dark" => { return globalThis.window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"; }; -const hostContextFor = (theme: "light" | "dark"): McpUiHostContext => ({ +/** + * What this host tells the app about the surface it has. + * + * The detail page is the one place an artifact is not embedded in a flow of + * other content — it IS the page — so it advertises the spec's `fullscreen` + * mode and hands over a FIXED `containerDimensions.height`. Together those two + * fields are the whole negotiation: the mode says "you own this surface", the + * fixed height says how much of it there is. See `./display-mode` for why + * `fullscreen` rather than a mode of our own, and why a fixed `height` rather + * than a `maxHeight`. + * + * `availableDisplayModes` lists both, truthfully: this host can render an + * artifact either way, and an app that asks to go back to `inline` is asking + * for something the page could honor. + * + * Before the container has been measured (`viewport === null`) the page has not + * yet laid out, and claiming a surface whose size is unknown would have the app + * resolve `height: 100%` against nothing. So until then this is an ordinary + * inline host, which is also what makes the mode switch a no-op for a viewport + * that never resolves. + */ +const hostContextFor = (theme: "light" | "dark", viewport: number | null): McpUiHostContext => ({ theme, - displayMode: "inline", - availableDisplayModes: ["inline"], + displayMode: viewport === null ? INLINE_DISPLAY_MODE : FILL_DISPLAY_MODE, + availableDisplayModes: [INLINE_DISPLAY_MODE, FILL_DISPLAY_MODE], platform: "web", + ...(viewport === null ? {} : { containerDimensions: { height: viewport } }), // The console is the one host with somewhere to put a preview, so it is the // one host that asks for one. Carried on the spec's open index signature, // which is what it exists for; every other host omits it and the shell @@ -95,7 +129,25 @@ const hostContextFor = (theme: "light" | "dark"): McpUiHostContext => ({ export default function ArtifactShell(props: ArtifactRendererProps) { const frameRef = useRef(null); - const [height, setHeight] = useState(INITIAL_FRAME_HEIGHT); + const containerRef = useRef(null); + /** + * The measured height of the page's content area, or `null` before the first + * measurement. + * + * This — not the app's reported content height — is the frame's height. That + * inversion is the fix: the artifact page gives the artifact a viewport and + * holds it there, so the artifact's own `flex-1 min-h-0 overflow-auto` has + * something to resolve against and its header can stay put while its table + * scrolls underneath. + */ + const [viewport, setViewport] = useState(null); + /** + * The app's own reported content height — the inline story, kept for the case + * where the container never resolves to a usable size (a host embedding this + * component somewhere with no bounded height of its own). On the detail page + * the observer resolves immediately and this is never read. + */ + const [contentHeight, setContentHeight] = useState(INITIAL_FRAME_HEIGHT); const [theme, setTheme] = useState<"light" | "dark">(() => readDocumentTheme()); // Read through refs inside the bridge: the bridge is built once per artifact, @@ -107,6 +159,11 @@ export default function ArtifactShell(props: ArtifactRendererProps) { codeRef.current = props.code; const artifactIdRef = useRef(props.artifactId); artifactIdRef.current = props.artifactId; + // The bridge is built once per artifact and must see the CURRENT viewport, + // both when it opens (so its initial host context is already the fill one) + // and inside `onsizechange` (so it knows to ignore the report). + const viewportRef = useRef(viewport); + viewportRef.current = viewport; const bridgeRef = useRef(null); @@ -125,9 +182,52 @@ export default function ArtifactShell(props: ArtifactRendererProps) { }; }, []); + /** + * Measure the surface the page has given this artifact, and keep measuring. + * + * A `ResizeObserver` on the container rather than a `window` resize listener: + * the content area is a flex child of the console's own layout, so it changes + * height for reasons that never touch the window — the sidebar collapsing, a + * banner appearing above it. Observing the box that actually holds the frame + * catches all of those and the window resize alike. + * + * `borderBoxSize` is read in preference to `contentRect` so a container that + * ever gains padding or a border still reports the space the frame occupies. + */ + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const measure = (next: number) => { + const bounded = Math.max(MIN_FRAME_HEIGHT, Math.floor(next)); + // Guard the state write, not just the render: an observer that fires on + // every sub-pixel reflow would otherwise push a new host context — and + // therefore a `ui/notifications/host-context-changed` — into the frame + // continuously while a user drags a window edge. + setViewport((prev) => (prev === bounded ? prev : bounded)); + }; + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + const borderBox = entry.borderBoxSize?.[0]?.blockSize; + measure(typeof borderBox === "number" ? borderBox : entry.contentRect.height); + }); + observer.observe(container); + // Seed from the current layout rather than waiting for the observer's first + // callback, so the very first host context the bridge is built with already + // carries a viewport and the app never renders a frame in the wrong mode. + measure(container.getBoundingClientRect().height); + + return () => observer.disconnect(); + }, []); + + // Both the theme and the viewport travel on the same context, and + // `setHostContext` diffs for us — it notifies the app only about fields that + // actually changed, so a resize does not re-announce the theme. useEffect(() => { - bridgeRef.current?.setHostContext(hostContextFor(theme)); - }, [theme]); + bridgeRef.current?.setHostContext(hostContextFor(theme, viewport)); + }, [theme, viewport]); /** * Build and connect the bridge the moment the iframe ELEMENT mounts — as the @@ -182,7 +282,7 @@ export default function ArtifactShell(props: ArtifactRendererProps) { null, { name: "Executor Console", version: "1.0.0" }, { openLinks: {}, serverTools: {} }, - { hostContext: hostContextFor(readDocumentTheme()) }, + { hostContext: hostContextFor(readDocumentTheme(), viewportRef.current) }, ); // Deliver the stored source the way a real host does after `create-artifact`: the @@ -206,12 +306,25 @@ export default function ArtifactShell(props: ArtifactRendererProps) { }); }; - // The app reports its content height; the host owns the frame's size. This - // is the half that was missing inline — the page scrolls, the frame grows, - // and nothing clips. + /** + * The app keeps reporting its content height. In fill mode this host + * deliberately does nothing with it. + * + * That is the clean division the mode buys: the HOST owns the mode, so the + * app does not need a branch for "am I being measured" — it reports its size + * truthfully in every mode, exactly as the protocol says, and a host that + * has already decided the frame's height simply does not consume the report. + * Acting on it here would undo the whole fix, since the app's content is now + * as tall as the viewport it was given and growing the frame to match would + * chase itself. + * + * Inline hosts — every chat client — are untouched: the frame grows, the + * page scrolls, nothing clips. + */ bridge.onsizechange = (params) => { + if (viewportRef.current !== null) return; if (typeof params.height !== "number") return; - setHeight(Math.max(MIN_FRAME_HEIGHT, Math.ceil(params.height))); + setContentHeight(Math.max(MIN_FRAME_HEIGHT, Math.ceil(params.height))); }; bridge.oncalltool = (params) => { @@ -257,29 +370,39 @@ export default function ArtifactShell(props: ArtifactRendererProps) { }, []); return ( -