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/.oxfmtrc.json b/.oxfmtrc.json index d92df54ec..73f725581 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -13,6 +13,7 @@ "*.tsbuildinfo", "executor-*.tgz", "**/routeTree.gen.ts", + "**/smoke-harness-bundle.gen.ts", "apps/cloud/src/services/executor-schema.ts" ] } 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/apps/cli/src/main.ts b/apps/cli/src/main.ts index bae18617c..06ca6586d 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -1337,14 +1337,19 @@ const runBackgroundDaemonStart = (input: { // Stdio MCP session: a pure stdio <-> HTTP bridge to the owning local daemon. // --------------------------------------------------------------------------- -const mcpUrlForActiveLocalServer = ( - connection: ExecutorServerConnection, - elicitationMode: "browser" | "model", -): URL => { - const url = new URL("/mcp", connection.origin); - if (elicitationMode === "browser") { +const mcpUrlForActiveLocalServer = (input: { + readonly connection: ExecutorServerConnection; + readonly elicitationMode: "browser" | "model"; + readonly artifacts: boolean; +}): URL => { + const url = new URL("/mcp", input.connection.origin); + if (input.elicitationMode === "browser") { url.searchParams.set("elicitation_mode", "browser"); } + // Only the opt-in is spelled out; the default endpoint stays clean. + if (input.artifacts) { + url.searchParams.set("artifacts", "true"); + } return url; }; @@ -1359,11 +1364,16 @@ const mcpUrlForActiveLocalServer = ( const runMcpHttpBridge = async (input: { readonly manifest: ExecutorLocalServerManifest; readonly elicitationMode: "browser" | "model"; + readonly artifacts: boolean; }): Promise => { const stdio = new StdioServerTransport(); const authorization = getExecutorServerAuthorizationHeader(input.manifest.connection); const http = new StreamableHTTPClientTransport( - mcpUrlForActiveLocalServer(input.manifest.connection, input.elicitationMode), + mcpUrlForActiveLocalServer({ + connection: input.manifest.connection, + elicitationMode: input.elicitationMode, + artifacts: input.artifacts, + }), authorization ? { requestInit: { headers: { Authorization: authorization } } } : undefined, ); @@ -1438,7 +1448,10 @@ const runMcpHttpBridge = async (input: { } }; -const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "model" }) => +const runStdioMcpSession = (input: { + readonly elicitationMode: "browser" | "model"; + readonly artifacts: boolean; +}) => Effect.gen(function* () { // `executor mcp` never owns the local database. If a local server is already // running, bridge this stdio client to it; otherwise ensure a durable @@ -1450,7 +1463,11 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode const active = yield* readActiveLocalServerManifest(); if (active) { yield* Effect.promise(() => - runMcpHttpBridge({ manifest: active, elicitationMode: input.elicitationMode }), + runMcpHttpBridge({ + manifest: active, + elicitationMode: input.elicitationMode, + artifacts: input.artifacts, + }), ); return; } @@ -1472,7 +1489,11 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode ); } yield* Effect.promise(() => - runMcpHttpBridge({ manifest: elected, elicitationMode: input.elicitationMode }), + runMcpHttpBridge({ + manifest: elected, + elicitationMode: input.elicitationMode, + artifacts: input.artifacts, + }), ); }); @@ -2781,11 +2802,18 @@ const mcpCommand = Command.make( "Choose the stdio approval flow: browser approval or a CLI resume tool exposed to the model.", ), ), + artifacts: Options.boolean("artifacts") + .pipe(Options.withDefault(false)) + .pipe( + Options.withDescription( + "Serve the artifact surface on this connection: the artifact tools, the app shell resource, and the artifact skills. Withheld by default.", + ), + ), }, - ({ scope, elicitationMode }) => + ({ scope, elicitationMode, artifacts }) => Effect.gen(function* () { applyScope(scope); - yield* runStdioMcpSession({ elicitationMode }); + yield* runStdioMcpSession({ elicitationMode, artifacts }); }), ).pipe(Command.withDescription("Start an MCP server over stdio")); 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/0014_new_karma.sql b/apps/cloud/drizzle/0014_new_karma.sql new file mode 100644 index 000000000..606cbfd19 --- /dev/null +++ b/apps/cloud/drizzle/0014_new_karma.sql @@ -0,0 +1 @@ +ALTER TABLE "artifact" ADD COLUMN "bindings" json; \ No newline at end of file diff --git a/apps/cloud/drizzle/0015_equal_the_leader.sql b/apps/cloud/drizzle/0015_equal_the_leader.sql new file mode 100644 index 000000000..585f98e02 --- /dev/null +++ b/apps/cloud/drizzle/0015_equal_the_leader.sql @@ -0,0 +1 @@ +ALTER TABLE "artifact" ADD COLUMN "preview" text; \ 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/0014_snapshot.json b/apps/cloud/drizzle/meta/0014_snapshot.json new file mode 100644 index 000000000..6d3345469 --- /dev/null +++ b/apps/cloud/drizzle/meta/0014_snapshot.json @@ -0,0 +1,1480 @@ +{ + "id": "0857c83f-0124-4739-bd88-a0a4a92aab84", + "prevId": "edc90067-822c-443a-8fcd-3bac77bca317", + "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 + }, + "bindings": { + "name": "bindings", + "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": { + "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/0015_snapshot.json b/apps/cloud/drizzle/meta/0015_snapshot.json new file mode 100644 index 000000000..8aaf1637b --- /dev/null +++ b/apps/cloud/drizzle/meta/0015_snapshot.json @@ -0,0 +1,1486 @@ +{ + "id": "d666b31a-c3d1-4bd7-9bd6-85f2abc4fb55", + "prevId": "0857c83f-0124-4739-bd88-a0a4a92aab84", + "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 + }, + "bindings": { + "name": "bindings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "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..fa9057083 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -92,6 +92,27 @@ "when": 1785193313601, "tag": "0012_woozy_shiva", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1785354087138, + "tag": "0013_curly_rocket_racer", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1785355312973, + "tag": "0014_new_karma", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1785355354955, + "tag": "0015_equal_the_leader", + "breakpoints": true } ] } diff --git a/apps/cloud/package.json b/apps/cloud/package.json index 6409dccb9..372f63901 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -32,7 +32,8 @@ "db:backfill-org-slugs:dev": "op run --env-file=.env.op -- bun run scripts/backfill-org-slugs.ts", "db:backfill-subjects:prod": "op run --env-file=.env.production -- bun run scripts/backfill-subjects.ts", "db:backfill-subjects:dev": "op run --env-file=.env.op -- bun run scripts/backfill-subjects.ts", - "routes:gen": "bun scripts/gen-routes.ts" + "routes:gen": "bun scripts/gen-routes.ts", + "vendor-wasm": "bun run scripts/vendor-quickjs-wasm.ts" }, "dependencies": { "@cloudflare/vite-plugin": "^1.31.1", @@ -43,6 +44,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:*", @@ -53,6 +55,7 @@ "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", "@executor-js/vite-plugin": "workspace:*", + "@jitl/quickjs-wasmfile-release-sync": "catalog:", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "~1.9.0", "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", @@ -74,6 +77,7 @@ "jose": "^5.6.3", "postgres": "^3.4.9", "posthog-js": "^1.372.5", + "quickjs-emscripten-core": "0.31.0", "react": "catalog:", "react-dom": "catalog:", "sonner": "^2.0.7" diff --git a/apps/cloud/scripts/vendor-quickjs-wasm.ts b/apps/cloud/scripts/vendor-quickjs-wasm.ts new file mode 100644 index 000000000..1518babf4 --- /dev/null +++ b/apps/cloud/scripts/vendor-quickjs-wasm.ts @@ -0,0 +1,18 @@ +// Vendors the QuickJS engine WASM into src/ so wrangler's CompiledWasm module +// rule (rooted at the app dir) can statically compile it at build time. Workers +// forbid runtime WASM compilation, and the rule's glob won't reach the +// monorepo-root node_modules, so the bytes must live inside this app. +// +// Re-run after bumping @jitl/quickjs-wasmfile-release-sync: +// bun run scripts/vendor-quickjs-wasm.ts +import { copyFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const source = require.resolve("@jitl/quickjs-wasmfile-release-sync/wasm"); +const dest = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "quickjs-engine.wasm"); + +copyFileSync(source, dest); +console.log(`vendored ${source} -> ${dest}`); diff --git a/apps/cloud/src/db/executor-schema.ts b/apps/cloud/src/db/executor-schema.ts index 55db86471..e95ffab26 100644 --- a/apps/cloud/src/db/executor-schema.ts +++ b/apps/cloud/src/db/executor-schema.ts @@ -226,6 +226,28 @@ 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(), + bindings: json("bindings"), + preview: text("preview"), + 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/apps/cloud/src/engine/execution-gate.ts b/apps/cloud/src/engine/execution-gate.ts index 6c12d53ef..82d5f4c2a 100644 --- a/apps/cloud/src/engine/execution-gate.ts +++ b/apps/cloud/src/engine/execution-gate.ts @@ -100,6 +100,10 @@ export const withPreExecutionGate = ( ), // resume is never gated: paused executions must be able to complete. resume: (executionId, response) => engine.resume(executionId, response), + // Optional member, so it must be forwarded explicitly — a decorator that + // rebuilds the object literal drops it, and the host then reads every settled + // execution as "never existed". + isExecutionSettled: engine.isExecutionSettled, getPausedExecution: (executionId) => engine.getPausedExecution(executionId), pausedExecutionCount: () => engine.pausedExecutionCount(), hasPausedExecutions: () => engine.hasPausedExecutions(), diff --git a/apps/cloud/src/engine/execution-usage.ts b/apps/cloud/src/engine/execution-usage.ts index 50ba3fc34..94b85cb39 100644 --- a/apps/cloud/src/engine/execution-usage.ts +++ b/apps/cloud/src/engine/execution-usage.ts @@ -12,12 +12,15 @@ export const withExecutionUsageTracking = ( engine .execute(code, options) .pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))), - executeWithPause: (code) => + // `options` MUST be forwarded: it carries `autoApprove`, and dropping it + // silently turned every auto-approved execution back into a paused one. + executeWithPause: (code, options) => engine - .executeWithPause(code) + .executeWithPause(code, options) .pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))), // resume doesn't count as usage resume: (executionId, response) => engine.resume(executionId, response), + isExecutionSettled: engine.isExecutionSettled, getPausedExecution: (executionId) => engine.getPausedExecution(executionId), pausedExecutionCount: () => engine.pausedExecutionCount(), hasPausedExecutions: () => engine.hasPausedExecutions(), diff --git a/apps/cloud/src/mcp/agent-handler.ts b/apps/cloud/src/mcp/agent-handler.ts index 33b5f88e2..7738e79c6 100644 --- a/apps/cloud/src/mcp/agent-handler.ts +++ b/apps/cloud/src/mcp/agent-handler.ts @@ -11,6 +11,7 @@ import { } from "@executor-js/host-mcp"; import { currentPropagationHeaders, + readArtifactsEnabled, readElicitationMode, withVerifiedIdentityHeaders, } from "@executor-js/cloudflare/mcp/do-headers"; @@ -131,6 +132,7 @@ const propsForPrincipal = ( organizationId: principal.organizationId, userId: principal.accountId, elicitationMode: readElicitationMode(request), + artifactsEnabled: readArtifactsEnabled(request), resource, webOrigin: new URL(request.url).origin, }, diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index c2d249aef..075e6ad37 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -25,6 +25,9 @@ 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/create-artifact"; +import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; +import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; import { McpAgentSessionDOBase, type BuiltMcpServer, @@ -64,6 +67,7 @@ import { type DbServiceShape, } from "../db/db"; import { makeExecutionStack } from "../engine/execution-stack"; +import { preloadQuickJs } from "../quickjs"; import { CloudMeteredExecutionStackLayer } from "../engine/execution-stack-metered"; import { AutumnService } from "../extensions/billing/service"; import { DoTelemetryLive, flushTracerProvider } from "../observability/telemetry"; @@ -209,6 +213,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase { const self = this; return Effect.gen(function* () { + // QuickJS-WASM must be loaded before anything asks for a sandbox: the + // default variant cannot fetch its own `.wasm` on Workers. Cloud runs + // user `execute` code on the dynamic-worker runtime, but the artifact + // smoke render is a QuickJS sandbox on every host — without this it fails + // open on each create and the check silently does nothing. + // Idempotent per isolate. + yield* Effect.promise(() => preloadQuickJs()); const { executor, engine } = yield* makeExecutionStack( sessionMeta.userId, sessionMeta.organizationId, @@ -250,6 +262,18 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase self.currentParentSpan(), debug: env.EXECUTOR_MCP_DEBUG === "true", browserApprovalStore: self.browserApprovalStore, diff --git a/apps/cloud/src/quickjs-engine.wasm b/apps/cloud/src/quickjs-engine.wasm new file mode 100644 index 000000000..ee1a98f5a Binary files /dev/null and b/apps/cloud/src/quickjs-engine.wasm differ diff --git a/apps/cloud/src/quickjs.ts b/apps/cloud/src/quickjs.ts new file mode 100644 index 000000000..9d84484ed --- /dev/null +++ b/apps/cloud/src/quickjs.ts @@ -0,0 +1,35 @@ +import { newQuickJSWASMModuleFromVariant, newVariant } from "quickjs-emscripten-core"; +import baseVariant from "@jitl/quickjs-wasmfile-release-sync"; +// Static .wasm import: wrangler/workerd compiles this to a WebAssembly.Module at +// BUILD time. Workers forbid runtime WASM compilation (both fetching the .wasm +// and `WebAssembly.instantiate()` of bytes are blocked), so the engine bytes +// MUST be a pre-compiled module imported like this. The file is vendored into +// src/ (copied from @jitl/quickjs-wasmfile-release-sync) because wrangler's +// CompiledWasm module rule is rooted at the app dir and won't match the +// monorepo-root node_modules path — see scripts/vendor-quickjs-wasm.ts. +import wasmModule from "./quickjs-engine.wasm"; + +import { setQuickJSModule } from "@executor-js/runtime-quickjs"; + +// --------------------------------------------------------------------------- +// QuickJS-on-Workers WASM loading. +// +// The base variant's module loader resolves to the variant package's `workerd` +// build (its `./emscripten-module` export has a `workerd` condition wrangler +// selects) — that build expects the WASM module to be supplied rather than +// fetched/compiled at runtime. `newVariant(base, { wasmModule })` hands it the +// statically-imported, pre-compiled module, and `setQuickJSModule` makes every +// `makeQuickJsExecutor()` reuse it. Preloaded once per isolate. +// --------------------------------------------------------------------------- + +let preloaded: Promise | null = null; + +export const preloadQuickJs = (): Promise => { + if (!preloaded) { + const variant = newVariant(baseVariant, { wasmModule }); + preloaded = newQuickJSWASMModuleFromVariant(variant).then((mod) => { + setQuickJSModule(mod); + }); + } + return preloaded; +}; diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index e57f3de44..d13911093 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: @@ -459,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/apps/cloud/src/routes/__root.tsx b/apps/cloud/src/routes/__root.tsx index 2ae3e48cf..474eb8045 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 { 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"; @@ -61,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) @@ -327,7 +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/cloud/src/wasm.d.ts b/apps/cloud/src/wasm.d.ts new file mode 100644 index 000000000..1bfd61f61 --- /dev/null +++ b/apps/cloud/src/wasm.d.ts @@ -0,0 +1,6 @@ +// On Cloudflare Workers, a `.wasm` import resolves to a pre-compiled +// `WebAssembly.Module` (wrangler's built-in CompiledWasm module rule). +declare module "*.wasm" { + const wasmModule: WebAssembly.Module; + export default wasmModule; +} diff --git a/apps/cloud/vite.config.ts b/apps/cloud/vite.config.ts index 4cdbd7cf4..ec565da2f 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, mcpAppsShellAsset } from "@executor-js/mcp-apps-shell/vite"; import { unstable_readConfig } from "wrangler"; import { routes } from "./tsr.routes"; @@ -126,6 +127,13 @@ export default defineConfig(({ command, mode }) => { plugins: [ devCrashGuard(), tailwindcss(), + // The artifact page hosts the MCP-Apps shell as a sandboxed iframe over + // the MCP-Apps protocol: `mcpAppsShellAsset` emits the built shell + // document and hands the page its URL, and `innerRendererPlugin` inlines + // the shell's own sandboxed inner frame from + // `virtual:executor-inner-renderer`. + mcpAppsShellAsset() as Plugin, + innerRendererPlugin(), executorVitePlugin(), cloudflare({ viteEnvironment: { name: "ssr" }, inspectorPort: false }), tanstackStart({ 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/agent-handler.ts b/apps/host-cloudflare/src/mcp/agent-handler.ts index c2f8bb579..5ec09fc1b 100644 --- a/apps/host-cloudflare/src/mcp/agent-handler.ts +++ b/apps/host-cloudflare/src/mcp/agent-handler.ts @@ -9,6 +9,7 @@ import { } from "@executor-js/host-mcp"; import { currentPropagationHeaders, + readArtifactsEnabled, readElicitationMode, withVerifiedIdentityHeaders, } from "@executor-js/cloudflare/mcp/do-headers"; @@ -78,6 +79,7 @@ const propsForPrincipal = ( organizationId: principal.organizationId, userId: principal.accountId, elicitationMode: readElicitationMode(request), + artifactsEnabled: readArtifactsEnabled(request), // host-cloudflare only routes the bare `/mcp` endpoint to the Agent // bridge (see worker.ts), so the session always serves the default // resource. diff --git a/apps/host-cloudflare/src/mcp/session-durable-object.ts b/apps/host-cloudflare/src/mcp/session-durable-object.ts index 95b366b7e..f211f5216 100644 --- a/apps/host-cloudflare/src/mcp/session-durable-object.ts +++ b/apps/host-cloudflare/src/mcp/session-durable-object.ts @@ -5,6 +5,9 @@ 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/create-artifact"; +import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; +import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; import type { ExecutorDbHandle } from "@executor-js/api/server"; import { McpAgentSessionDOBase, @@ -100,6 +103,7 @@ export class McpSessionDO extends McpAgentSessionDOBase preloadQuickJs()); - const { engine } = yield* makeExecutionStack( + const { engine, executor } = yield* makeExecutionStack( sessionMeta.userId, sessionMeta.organizationId, sessionMeta.organizationName, @@ -124,8 +128,23 @@ export class McpSessionDO extends McpAgentSessionDOBase 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..24f8485c1 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 { 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"; @@ -24,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, }); @@ -78,7 +86,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/package.json b/apps/host-selfhost/package.json index f0bcde52b..f242a6cc3 100644 --- a/apps/host-selfhost/package.json +++ b/apps/host-selfhost/package.json @@ -27,6 +27,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-selfhost/src/mcp/session-store.ts b/apps/host-selfhost/src/mcp/session-store.ts index ff005cf62..0f2233be9 100644 --- a/apps/host-selfhost/src/mcp/session-store.ts +++ b/apps/host-selfhost/src/mcp/session-store.ts @@ -22,6 +22,9 @@ import { SelfHostExecutionStackLayer } from "../execution"; // identical seam with its own stack layer. // --------------------------------------------------------------------------- +import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; +import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; + export { McpEngineBuildError } from "@executor-js/host-mcp/in-memory-session-store"; /** @@ -36,6 +39,7 @@ export const makeSelfHostMcpSessionStore = ( makeInMemoryMcpSessionStore( makeMcpBuildServer( SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))), + { loadAppShellHtml: loadMcpAppsShellHtml, smokeRenderArtifact }, ), { webBaseUrl }, ); diff --git a/apps/host-selfhost/tsconfig.json b/apps/host-selfhost/tsconfig.json index e214693ed..18cfacc0a 100644 --- a/apps/host-selfhost/tsconfig.json +++ b/apps/host-selfhost/tsconfig.json @@ -3,6 +3,10 @@ "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", + // This host opts into the create-time artifact smoke render, whose entry + // dynamically imports a `.tsx` component graph. Nothing here renders + // anything; the compiler still resolves that graph. + "jsx": "react-jsx", "strict": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/apps/host-selfhost/vite.config.ts b/apps/host-selfhost/vite.config.ts index 9cf280edd..93a15cce5 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, mcpAppsShellAsset } 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,12 @@ export default defineConfig({ plugins: [ executorApiPlugin(), tailwindcss(), + // The artifact page hosts the MCP-Apps shell as a sandboxed iframe over the + // MCP-Apps protocol: `mcpAppsShellAsset` emits the built shell document and + // hands the page its URL, and `innerRendererPlugin` inlines the shell's own + // sandboxed inner frame from `virtual:executor-inner-renderer`. + mcpAppsShellAsset() as Plugin, + 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..96796c2a2 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 { 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"; @@ -33,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, @@ -176,7 +184,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/apps/local/package.json b/apps/local/package.json index a6b2ade66..929de78cb 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -29,6 +29,7 @@ "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", "@executor-js/integrations-registry": "workspace:*", + "@executor-js/mcp-apps-shell": "workspace:*", "@executor-js/plugin-desktop-settings": "workspace:*", "@executor-js/plugin-example": "workspace:*", "@executor-js/plugin-file-secrets": "workspace:*", diff --git a/apps/local/src/executor.ts b/apps/local/src/executor.ts index 5fa7c9a0f..2386daf35 100644 --- a/apps/local/src/executor.ts +++ b/apps/local/src/executor.ts @@ -91,6 +91,10 @@ const loadLocalPlugins = (options: LocalExecutorOptions = {}) => 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..4395ef337 100644 --- a/apps/local/src/main.ts +++ b/apps/local/src/main.ts @@ -1,6 +1,9 @@ import { Context, Data, Effect, Layer, ManagedRuntime } from "effect"; import { createExecutionEngine } from "@executor-js/execution"; +import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; +import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; +import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import { makeLocalApiHandler } from "./app"; import { createExecutorHandle, disposeExecutor, getExecutorBundle } from "./executor"; @@ -81,15 +84,46 @@ export const createServerHandlers = async (token: string): Promise { - if (resource.kind === "default") return { config: { engine } }; + if (resource.kind === "default") { + return { + config: { + engine, + artifacts: executor.artifacts, + connections: executor.connections, + ...appsConfig, + }, + }; + } const handle = await createExecutorHandle({ activeToolkitSlug: resource.slug, }); @@ -98,7 +132,12 @@ 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/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..b93940d34 --- /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 `create-artifact` 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("create-artifact", { 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 + `create-artifact` (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 + +`create-artifact`'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..49f264c2f --- /dev/null +++ b/e2e/mcp-apps/playwright.config.ts @@ -0,0 +1,31 @@ +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", + // `--artifacts` because artifacts are opt-in per MCP connection: a bare + // `executor mcp` serves no artifact tools and no ui:// shell resource, so + // there would be nothing for sunpeak to mount. + args: [resolve(repo, "apps/cli/bin/executor.ts"), "mcp", "--artifacts"], + 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/create-artifact.spec.ts b/e2e/mcp-apps/tests/create-artifact.spec.ts new file mode 100644 index 000000000..340a7d3c3 --- /dev/null +++ b/e2e/mcp-apps/tests/create-artifact.spec.ts @@ -0,0 +1,87 @@ +import { test, expect } from "sunpeak/test"; + +// executor's create-artifact 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("create-artifact mounts an interactive React widget", async ({ inspector }) => { + const result = await inspector.renderTool("create-artifact", { + 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("create-artifact renders in dark theme", async ({ inspector }) => { + const result = await inspector.renderTool( + "create-artifact", + { 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("create-artifact returns an inline widget, not the deep-link fallback", async ({ mcp }) => { + const result = await mcp.callTool("create-artifact", { + 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("create-artifact", { + 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/e2e/scenarios/artifact-approval.test.ts b/e2e/scenarios/artifact-approval.test.ts new file mode 100644 index 000000000..d2d4ef937 --- /dev/null +++ b/e2e/scenarios/artifact-approval.test.ts @@ -0,0 +1,229 @@ +// Cross-target: approving a destructive action triggered from inside an +// artifact. +// +// This is the console's own path, not MCP. The artifact page has no MCP client, +// so the shell's host adapter posts `POST /executions` (with the artifact id) +// and then `POST /executions/:id/resume` when the human answers the modal. The +// existing approval coverage all runs over MCP, where the engine lives for the +// lifetime of a session Durable Object — so none of it could see that on a host +// building an executor per request the pause and the resume land on DIFFERENT +// engine instances, and the approval failed with `ExecutionNotFoundError`. +// +// Two things are asserted that the older scenarios could not: +// +// 1. The approval is honoured at all on this path, and the side effect lands. +// 2. It is still honoured after a human-scale delay. The reported symptom was +// an approval that took "tens of seconds", so a scenario approving +// instantly would stay green against the broken code. It is worth being +// precise about why: the bug is NOT time-based (a resume 7ms after the +// pause failed identically), so the delay is not what makes this catch the +// regression — the artifact-scoped path is. The delay guards the separate +// promise that an approval window is human-scale. +import { randomUUID } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import type { ArtifactId } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Target } from "../src/services"; + +const coreApi = composePluginApi([] as const); + +/** + * How long the scenario waits before approving. + * + * Long enough to be a real human pause rather than a same-tick round trip, and + * well past the 4-minute paused-execution lease the MCP plane advertises being + * irrelevant here. Kept modest so the scenario stays inside the default timeout. + */ +const APPROVAL_DELAY_MS = 65_000; + +/** + * The one call the shell's proxy is allowed to emit: a single awaited tool call, + * no statements around it. `policies.create` carries a `requiresApproval` + * annotation, so with no matching policy in play the annotation is the only + * thing that can pause it — and the pattern is unique-per-run and matches no + * real tool, so the created rule is inert. + * + * `executor` is a reserved tool root (`RESERVED_TOOL_ROOTS`), so it resolves + * without a connection binding — which lets the scenario exercise the + * artifact-scoped approval path without first connecting an integration. + */ +const artifactActionCode = (pattern: string) => + `return await tools.executor.coreTools.policies.create(${JSON.stringify({ + owner: "user", + pattern, + action: "block", + })})`; + +/** A minimal artifact. Its source is irrelevant to the approval path — what + * matters is that a real artifact id scopes the call, exactly as the rendered + * page supplies it. */ +const artifactSource = ` +function App() { + return
Approval fixture
; +} +`; + +const pausedExecutionId = (structured: unknown): string | undefined => + (structured as { readonly executionId?: string } | null)?.executionId; + +scenario( + "Artifacts · a destructive action approved from an artifact runs, even minutes later", + { timeout: 240_000 }, + Effect.gen(function* () { + const target = yield* Target; + const apiSurface = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* apiSurface.client(coreApi, identity); + + const pattern = `artifact-approval-${randomUUID().slice(0, 8)}.*`; + const code = artifactActionCode(pattern); + + const artifact = yield* client.artifacts.save({ + payload: { + title: `Approval fixture ${randomUUID().slice(0, 8)}`, + code: artifactSource, + }, + }); + + const cleanup = Effect.all( + [ + client.artifacts + .remove({ params: { artifactId: artifact.id as ArtifactId } }) + .pipe(Effect.ignore), + client.policies.list().pipe( + Effect.flatMap((list) => + Effect.forEach( + list.filter((p) => p.pattern === pattern), + (p) => + client.policies + .remove({ params: { policyId: p.id }, payload: { owner: "user" } }) + .pipe(Effect.ignore), + ), + ), + Effect.ignore, + ), + ], + { discard: true }, + ); + + yield* Effect.gen(function* () { + // The artifact fires its mutation. The tool gates itself, so the shell + // gets a pause back and renders the approval modal. + const paused = yield* client.executions.execute({ + payload: { code, artifactId: artifact.id }, + }); + expect(paused.status, "a destructive artifact action pauses for approval").toBe("paused"); + if (paused.status !== "paused") return; // narrowing only + + const executionId = pausedExecutionId(paused.structured); + expect(executionId, "the pause carries an execution id to approve").toBeTruthy(); + if (!executionId) return; // narrowing only + + // Nothing may happen while the human is still deciding. + const beforeApproval = yield* client.policies.list(); + expect( + beforeApproval.some((p) => p.pattern === pattern), + "the action does not run while it is waiting on approval", + ).toBe(false); + + // The human reads the request and decides. This is the wait that makes the + // approval window a real promise rather than a same-request formality. + yield* Effect.sleep(APPROVAL_DELAY_MS); + + const approved = yield* client.executions.resume({ + params: { executionId }, + payload: { action: "accept", content: {} }, + }); + + expect(approved.status, "approving the action runs it to completion").toBe("completed"); + if (approved.status !== "completed") return; // narrowing only + expect(approved.isError, "the approved action is not an error").toBe(false); + + const afterApproval = yield* client.policies.list(); + expect( + afterApproval.some((p) => p.pattern === pattern), + "the approved action's side effect actually landed", + ).toBe(true); + }).pipe(Effect.ensuring(cleanup)); + }), +); + +scenario( + "Artifacts · declining an artifact action leaves it unrun", + {}, + Effect.gen(function* () { + const target = yield* Target; + const apiSurface = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* apiSurface.client(coreApi, identity); + + const pattern = `artifact-decline-${randomUUID().slice(0, 8)}.*`; + const code = artifactActionCode(pattern); + + const artifact = yield* client.artifacts.save({ + payload: { + title: `Decline fixture ${randomUUID().slice(0, 8)}`, + code: artifactSource, + }, + }); + + const cleanup = Effect.all( + [ + client.artifacts + .remove({ params: { artifactId: artifact.id as ArtifactId } }) + .pipe(Effect.ignore), + client.policies.list().pipe( + Effect.flatMap((list) => + Effect.forEach( + list.filter((p) => p.pattern === pattern), + (p) => + client.policies + .remove({ params: { policyId: p.id }, payload: { owner: "user" } }) + .pipe(Effect.ignore), + ), + ), + Effect.ignore, + ), + ], + { discard: true }, + ); + + yield* Effect.gen(function* () { + const paused = yield* client.executions.execute({ + payload: { code, artifactId: artifact.id }, + }); + expect(paused.status, "the action pauses for approval").toBe("paused"); + if (paused.status !== "paused") return; // narrowing only + + const executionId = pausedExecutionId(paused.structured); + if (!executionId) return; // narrowing only + + yield* client.executions.resume({ + params: { executionId }, + payload: { action: "decline" }, + }); + + const afterDecline = yield* client.policies.list(); + expect( + afterDecline.some((p) => p.pattern === pattern), + "a declined action never runs", + ).toBe(false); + + // A declined approval is spent: the same id cannot be re-approved into a + // run afterwards. + yield* client.executions + .resume({ params: { executionId }, payload: { action: "accept", content: {} } }) + .pipe(Effect.ignore); + + const afterRetry = yield* client.policies.list(); + expect( + afterRetry.some((p) => p.pattern === pattern), + "approving after a decline does not resurrect the action", + ).toBe(false); + }).pipe(Effect.ensuring(cleanup)); + }), +); diff --git a/e2e/scenarios/artifact-loading-surface.test.ts b/e2e/scenarios/artifact-loading-surface.test.ts new file mode 100644 index 000000000..33753fc0c --- /dev/null +++ b/e2e/scenarios/artifact-loading-surface.test.ts @@ -0,0 +1,330 @@ +// Opening an artifact shows ONE loading surface, not three. +// +// ## What went wrong +// +// Opening an artifact used to walk through three visually distinct placeholders +// in quick succession, each in its own layout and each visible for a few hundred +// milliseconds: +// +// 1. "Loading artifact…" — the row being fetched, small corner text +// 2. "Preparing the renderer…" — the lazy chunk arriving, a different corner +// 3. "Preparing interactive UI" — the shell booting, a card in the middle +// +// Those are executor's own internal boundaries — an HTTP request, a code-split +// point, an iframe handshake — and none of them is a fact a person opening a +// saved dashboard has any use for. What they produced was churn: three +// different things flashing in three different places. Three quick loading +// states are worse than one longer one, because every transition is a fresh +// demand on the eye. +// +// ## What is asserted, and why it is asserted this way +// +// The honest test of "the user never saw a placeholder" is not a screenshot at +// one moment — the states this is about were only ever visible for a few +// hundred milliseconds, so any single sample would miss them and pass against +// the old code too. So the page is SAMPLED CONTINUOUSLY, from before navigation +// until the artifact is live, and the assertion is over everything that was ever +// on screen: +// +// - none of the three placeholder texts ever appeared; +// - the stage's bounding box never moved, so nothing was ever laid out twice. +// +// Both directions are covered, because they fail differently: warm (from the +// gallery, renderer chunk already fetched) and cold (a direct URL in a fresh +// context, nothing cached). +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect, Predicate } from "effect"; +import type { Page } from "playwright"; +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); + +const uniqueSuffix = () => randomBytes(4).toString("hex"); + +/** + * The placeholder texts that must never reach a user again. + * + * Named literally rather than by testid: the point is that the WORDS are gone + * from the experience, and a rename that kept the churn under a different + * caption should not pass. Each string is the exact one the corresponding + * placeholder rendered before this change. + */ +const FORBIDDEN_LOADING_TEXT = [ + "Loading artifact", + "Preparing the renderer", + "Preparing interactive UI", +] as const; + +/** A tall, distinctive artifact — the same shape the main artifacts scenario + * uses, so the fill-mode sizing under test here is the real one. */ +const artifactSource = (marker: string) => ` +function App() { + return ( +
+
+

Loading Surface

+

${marker}

+
+
+ {Array.from({ length: 30 }, (_, i) => ( +

+ Row {i + 1}: ${marker} +

+ ))} +
+
+ ); +} +`; + +const structuredOf = (result: { readonly raw: unknown }): Record => + ((result.raw as { structuredContent?: Record }).structuredContent ?? + {}) as Record; + +const artifactContent = (page: Page) => + page.frameLocator('[data-testid="artifact-shell-frame"]').frameLocator("iframe"); + +type Sample = { + readonly text: string; + readonly stage: { + readonly x: number; + readonly y: number; + readonly w: number; + readonly h: number; + } | null; +}; + +declare global { + // eslint-disable-next-line no-var + var __loadingSamples: Array | undefined; + // eslint-disable-next-line no-var + var __loadingSampler: number | undefined; +} + +/** + * Watch the CONSOLE document continuously for the whole open. + * + * Installed as an init script so it is running before the first byte of the + * page, and polls on an animation frame — fast enough that a placeholder + * visible for even one paint is recorded. `innerText` of the console document + * only (never the frames): the shell's own inner states live one document down + * and are covered separately, and reaching into a frame from here would sample + * the artifact's own text as if it were the console's. + * + * The stage's box is sampled alongside, because a placeholder that came and + * went without changing any TEXT would still have moved the layout, and a + * layout that jumps is the same defect wearing a different hat. + */ +const startSampling = async (page: Page): Promise => { + await page.addInitScript(() => { + globalThis.__loadingSamples = []; + const sample = () => { + const stage = document.querySelector( + '[data-testid="artifact-shell-container"], [data-slot="artifact-stage-skeleton"]', + ); + const box = stage?.getBoundingClientRect(); + globalThis.__loadingSamples?.push({ + text: document.body?.innerText ?? "", + stage: box + ? { + x: Math.round(box.x), + y: Math.round(box.y), + w: Math.round(box.width), + h: Math.round(box.height), + } + : null, + }); + globalThis.__loadingSampler = requestAnimationFrame(sample); + }; + sample(); + }); +}; + +const readSamples = (page: Page): Promise> => + page.evaluate(() => globalThis.__loadingSamples ?? []); + +/** + * Assert the whole open was one surface. + * + * Two independent properties over the same recording — no placeholder words + * ever rendered, and the stage never changed size or position — because either + * one alone would let the churn back in through the other door. + */ +const expectSingleLoadingSurface = (samples: ReadonlyArray, label: string): void => { + expect(samples.length, `${label}: the sampler actually ran`).toBeGreaterThan(3); + + for (const forbidden of FORBIDDEN_LOADING_TEXT) { + const hit = samples.findIndex((entry) => entry.text.includes(forbidden)); + expect( + hit, + `${label}: "${forbidden}" was on screen at sample ${hit} of ${samples.length} — the open still walks through more than one loading state`, + ).toBe(-1); + } + + // The stage's geometry, over every frame in which a stage existed at all. + // The skeleton and the artifact frame share one box by construction (the + // skeleton is an absolutely-positioned cover over the frame's own container), + // so a change here means one of them was laid out differently from the other. + const boxes = samples.map((entry) => entry.stage).filter(Predicate.isNotNull); + expect(boxes.length, `${label}: the stage was on screen at some point`).toBeGreaterThan(0); + + const first = boxes[0]; + if (!first) return; + for (const [index, box] of boxes.entries()) { + // A pixel of tolerance for sub-pixel rounding as the scrollbar settles. + expect( + Math.abs(box.w - first.w) <= 1 && Math.abs(box.h - first.h) <= 1, + `${label}: the stage changed size mid-load at sample ${index} (${JSON.stringify(box)} vs ${JSON.stringify(first)}) — the artifact appeared in a different box than the skeleton held`, + ).toBe(true); + } +}; + +scenario( + "Artifacts · opening an artifact shows one loading surface, not three", + { timeout: 240_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); + // Artifacts are opt-in per MCP connection, so this session asks for the + // surface it is here to exercise (`?artifacts=true`). + const session = mcp.session(identity, { artifacts: true }); + + const suffix = uniqueSuffix(); + const title = `Loading Surface ${suffix}`; + const marker = `surface-ok-${suffix}`; + + let artifactId: ArtifactId | undefined; + + yield* Effect.gen(function* () { + const created = yield* session.call("create-artifact", { + code: artifactSource(marker), + title, + description: "An artifact opened to watch how it loads", + }); + expect(created.ok, `create-artifact succeeded: ${created.text}`).toBe(true); + artifactId = structuredOf(created).artifactId as ArtifactId; + expect(artifactId, "the artifact was persisted").toBeTruthy(); + + const accountClient = yield* apiClient(AccountHttpApi, identity); + const me = yield* accountClient.account.me(); + const orgSlug = me.organization?.slug; + const detailPath = orgSlug + ? `/${orgSlug}/artifacts/${artifactId}` + : `/artifacts/${artifactId}`; + + // ------------------------------------------------------------------ + // WARM: the journey a user actually takes — gallery, then a click. + // ------------------------------------------------------------------ + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the Artifacts gallery", async () => { + await startSampling(page); + await page.goto(`${target.baseUrl}/artifacts`, { waitUntil: "networkidle" }); + await page + .getByRole("link", { name: `Open artifact ${title}` }) + .waitFor({ timeout: 30_000 }); + // Discard everything from the gallery's own load: this scenario is + // about the OPEN, and the list has a loading state of its own that is + // out of scope here. + await page.evaluate(() => { + globalThis.__loadingSamples = []; + }); + }); + + await step("Click through to the artifact", async () => { + await page.getByRole("link", { name: `Open artifact ${title}` }).click(); + await artifactContent(page).getByTestId("artifact-marker").waitFor({ timeout: 60_000 }); + }); + + await step("The whole open was one surface", async () => { + expectSingleLoadingSurface(await readSamples(page), "warm open"); + }); + + await step("The skeleton hands over and gets out of the way", async () => { + // The cover is removed once the artifact has painted — not left over + // it at zero opacity, which would swallow every click. + await expect + .poll(async () => await page.locator('[data-testid="artifact-stage-cover"]').count(), { + timeout: 20_000, + message: "the loading cover was removed after the artifact rendered", + }) + .toBe(0); + }); + + await step("Fill-mode sizing survived the handoff", async () => { + // The frame is the viewport below the title bar, exactly as it is in + // the steady state — so nothing about holding a skeleton over it + // changed the height chain it measures against. + const frameBox = await page.locator('[data-testid="artifact-shell-frame"]').boundingBox(); + const viewport = page.viewportSize(); + expect(frameBox?.height ?? 0, "the frame fills the room it was given").toBeGreaterThan( + 200, + ); + expect( + frameBox?.height ?? 0, + "the frame is bounded by the window, not by its content", + ).toBeLessThanOrEqual(viewport?.height ?? 0); + + const pageScrolls = await page.evaluate( + () => document.documentElement.scrollHeight > document.documentElement.clientHeight + 1, + ); + expect(pageScrolls, "the console page still does not scroll").toBe(false); + }); + }); + + // ------------------------------------------------------------------ + // COLD: the deep link, in a context that has never loaded the console. + // + // The harder case, and the one the preload exists for: nothing is cached, + // so the row fetch and the renderer chunk both start from zero. This is + // where the three states used to be most visible, and where a regression + // in the preload would show up first. + // ------------------------------------------------------------------ + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the artifact by URL, cold", async () => { + await startSampling(page); + await page.goto(`${target.baseUrl}${detailPath}`, { waitUntil: "commit" }); + await artifactContent(page).getByTestId("artifact-marker").waitFor({ timeout: 60_000 }); + }); + + await step("The cold open was one surface too", async () => { + expectSingleLoadingSurface(await readSamples(page), "cold open"); + }); + + await step("The artifact is live and interactive", async () => { + const rendered = artifactContent(page).getByTestId("artifact-marker"); + expect(await rendered.textContent()).toContain(marker); + + // Interactive, not merely painted: the cover is gone, so a scroll + // inside the artifact actually reaches it. + const scrolled = await artifactContent(page) + .getByTestId("artifact-scroll") + .evaluate((el) => { + el.scrollTop = 200; + return el.scrollTop; + }); + expect(scrolled, "the artifact takes input after the handoff").toBeGreaterThan(0); + }); + }); + }).pipe( + Effect.ensuring( + Effect.suspend(() => + artifactId === undefined + ? Effect.void + : client.artifacts.remove({ params: { artifactId } }), + ).pipe(Effect.ignore), + ), + ); + }), +); diff --git a/e2e/scenarios/artifact-preview-gallery.test.ts b/e2e/scenarios/artifact-preview-gallery.test.ts new file mode 100644 index 000000000..fe7dabe50 --- /dev/null +++ b/e2e/scenarios/artifact-preview-gallery.test.ts @@ -0,0 +1,281 @@ +// 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(); + // Artifacts are opt-in per MCP connection, so this session asks for the + // surface it is here to exercise (`?artifacts=true`). + const session = mcp.session(identity, { artifacts: true }); + 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 new file mode 100644 index 000000000..212135a1e --- /dev/null +++ b/e2e/scenarios/artifacts.test.ts @@ -0,0 +1,830 @@ +// Cross-target: the artifacts journey, end to end. +// +// An agent on a client that cannot display MCP Apps calls `create-artifact`. 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. create-artifact 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 type { Page } from "playwright"; +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 `create-artifact` 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. + * + * It is purely declarative, which is now the whole contract rather than a + * stylistic choice: there is no `run()` in scope, and code that calls one is + * rejected before it is saved. Rows come from `Array.from`, a display constant — + * an artifact that needed live data would reach for + * `useQuery(tools...queryOptions(...))`. + * + * It is also deliberately TALL — 40 rows at 28px, far more than fits on the + * detail page — and laid out the way the `artifact-style` skill teaches: a + * bounded flex column whose header stays put while one region scrolls. That is + * what the detail page's fill mode exists to support, and what the steps below + * assert: the frame is the viewport, the page itself does not scroll, and the + * heading is still on screen after the rows have been scrolled. + */ +const ARTIFACT_ROW_COUNT = 40; + +const artifactSource = (marker: string) => ` +function App() { + return ( +
+
+

Release Readiness

+

${marker}

+
+
+ {Array.from({ length: ${ARTIFACT_ROW_COUNT} }, (_, i) => ( +

+ Check {i + 1}: ${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; + +/** + * The generated component, two frames down. + * + * The artifact page hosts the shell DOCUMENT in a sandboxed iframe and speaks + * the MCP-Apps protocol to it — the same way any MCP-Apps client loads + * `ui://executor/shell.html`. The shell then compiles the stored JSX into its + * own nested sandbox. Both hops are load-bearing, so tests descend through both. + */ +const artifactContent = (page: Page) => + page.frameLocator('[data-testid="artifact-shell-frame"]').frameLocator("iframe"); + +/** + * A fingerprint of the CONSOLE document's styling. + * + * The shell ships its own Tailwind build and its own palette; the console's is + * strictly grayscale. Sampling a token, a real rendered control, and the + * stylesheet count catches the leak whether it arrives as a `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([]); + + await clientTransport.close(); + await serverTransport.close(); + }); +}); 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..9a585dea4 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx @@ -0,0 +1,432 @@ +// `virtual:executor-mcp-apps-shell-html` is supplied by `mcpAppsShellAsset` +// (`@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 it; a +// consumer's tsconfig `include` covers only its own sources. +/// +import { useCallback, useEffect, useRef, useState } from "react"; +import { AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { ArtifactRendererProps } from "@executor-js/react/api/artifact-renderer"; + +import shellHtmlUrl from "virtual:executor-mcp-apps-shell-html"; +// From `./preview-capture`, NOT from `./shell-app`: that module imports the +// shell build's virtual modules (`virtual:executor-tailwind-browser` and +// 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 import-free-module reasoning as `./preview-capture`: the vocabulary for +// "the artifact is on screen now", shared by the shell document that sends it +// and this host, which is the one host that holds a loading surface until it +// arrives. +import { ARTIFACT_RENDERED_TOOL, RENDER_SIGNAL_HOST_CONTEXT_KEY } from "./render-signal"; +// 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. + * + * The shell is NOT a React component this page mounts. It is a self-contained + * DOCUMENT — the very bytes served as the `ui://executor/shell.html` resource — + * loaded into a sandboxed iframe, with this component implementing the HOST half + * of the MCP Apps protocol against it. The artifact page is therefore an MCP + * Apps client like any other, and the shell runs in one configuration + * everywhere. + * + * That is not architectural purity for its own sake; mounting `McpAppsShell` + * inline was actively broken: + * + * - It imports `@tailwindcss/browser` and its own `globals.css` at IMPORT + * scope, and it themes by toggling `document.documentElement`. Inline, all + * of that landed on the CONSOLE document — the shell's palette (a teal + * `--primary`) overwrote the console's strictly-grayscale tokens for the + * rest of the session, and its runtime Tailwind JIT kept mutating the page. + * - Its inner renderer reports content height over the MCP-Apps resize + * protocol, which expects a host frame to consume it. With no host, nothing + * did, so tall artifacts clipped mid-viewport. + * + * Both symptoms are one root cause — a document-scoped program mounted without + * its document — and both are fixed by giving it one. + * + * This module is still BROWSER-ONLY (it builds a postMessage bridge against a + * live iframe), so the `ArtifactRendererProvider` loader seam is unchanged: apps + * register it through a dynamic `import()` that only ever resolves in the + * browser, and the page holds it behind `ClientOnly` + `Suspense`. It stays the + * DEFAULT export because the seam hands the loader straight to `React.lazy`. + */ + +/** What the page passes as `host` — see `createHttpShellHost` in + * `@executor-js/react/api/shell-host`. Declared structurally because that + * package depends on THIS one for its component barrel, so importing its type + * here would close a package cycle turbo rejects outright. */ +type HttpShellHost = { + readonly callServerTool: (params: { + name: string; + arguments?: Record; + }) => Promise; + readonly getHostContext: () => { readonly theme: "light" | "dark" } | undefined; + readonly openLink: (params: { url: string }) => Promise; + /** Store a settled-render snapshot as this artifact's gallery preview. */ + readonly savePreview: (artifactId: string, preview: string) => void; +}; + +/** 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 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; + +/** + * The console's active theme, resolved the way the console's own CSS resolves + * it: an explicit `.dark` class wins, otherwise the OS preference. + */ +const readDocumentTheme = (): "light" | "dark" => { + if (document.documentElement.classList.contains("dark")) return "dark"; + return globalThis.window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +}; + +/** + * 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: 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 + // skips the capture entirely. + [PREVIEW_CAPTURE_HOST_CONTEXT_KEY]: true, + // The console holds ONE skeleton from navigation until the artifact paints, + // so it is the one host that needs telling when that is. See + // `./render-signal` for why the shell cannot infer it and why no size report + // is the same fact. + [RENDER_SIGNAL_HOST_CONTEXT_KEY]: true, +}); + +export default function ArtifactShell(props: ArtifactRendererProps) { + const frameRef = useRef(null); + 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, + // and rebuilding it when the host identity changes would reload the iframe and + // discard the rendered component. + const hostRef = useRef(props.host as HttpShellHost); + hostRef.current = props.host as HttpShellHost; + const codeRef = useRef(props.code); + codeRef.current = props.code; + const artifactIdRef = useRef(props.artifactId); + artifactIdRef.current = props.artifactId; + // The page's "you can drop the skeleton now" callback, read through a ref for + // the same reason as the host: the bridge is built once per artifact, and + // capturing the prop would pin whichever function existed then. + const onRenderedRef = useRef(props.onRendered); + onRenderedRef.current = props.onRendered; + // 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); + + // Track the console's theme and forward it as host context, which is what + // drives the shell's own `applyTheme` — inside the iframe's document, where it + // belongs, rather than on the console's `documentElement`. + useEffect(() => { + const media = globalThis.window.matchMedia?.("(prefers-color-scheme: dark)"); + const sync = () => setTheme(readDocumentTheme()); + media?.addEventListener("change", sync); + const observer = new MutationObserver(sync); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); + return () => { + media?.removeEventListener("change", sync); + observer.disconnect(); + }; + }, []); + + /** + * 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, viewport)); + }, [theme, viewport]); + + /** + * Build and connect the bridge the moment the iframe ELEMENT mounts — as the + * frame's `ref`, not its `onLoad`. + * + * This ordering is the whole correctness argument, so it is worth stating + * exactly. `ui/initialize` is a fire-and-forget `postMessage`: the app's + * `connect()` sends it once, ext-apps has no retry, no backoff and no + * readiness handshake, and `AppBridge` buffers nothing. The host's transport + * only begins listening inside `bridge.connect()`, whose last synchronous step + * is `window.addEventListener("message", …)`. So a `ui/initialize` posted + * while the host has no listener is delivered to a window that ignores it and + * is gone for good — both sides then wait forever, which is the shell stuck on + * "Connecting" (the app's request does eventually time out after the SDK's 60s + * default, but the host has long since stopped being able to answer). + * + * Connecting in `onLoad` lost that race. The frame's `load` event fires AFTER + * its document has been parsed and its module scripts have run, so the app + * could — and on a cold, dev-served, 4.5MB document did — post `ui/initialize` + * before the host was listening. It "worked" locally only because the two + * landed within a few milliseconds of each other. + * + * A ref callback runs during the commit phase, synchronously after React has + * inserted the element into the document and therefore before the browser has + * fetched a single byte of `src`. `contentWindow` is already non-null there + * (it is the initial `about:blank` window), and a same-origin navigation keeps + * the browsing context's WindowProxy identity — so both the transport's + * `postMessage` target and its `event.source` filter still refer to the shell + * document once it replaces the placeholder. The listener is thus provably up + * before any script in the frame can run, whatever the machine, cache state or + * document size. This is also how sunpeak's host does it, which is why real + * MCP-Apps hosts never saw this bug. + * + * The returned cleanup makes this callback the SOLE owner of the bridge's + * lifecycle. Previously an effect keyed on `props.code` closed + * `bridgeRef.current`, which is a second, unsynchronized owner: under a + * StrictMode double-invoke or any re-render that reorders effects against + * refs, that cleanup could close a bridge a later mount had just connected, + * stranding the shell on "Connecting" for an entirely different reason. Now + * each bridge is created and closed by the same attach/detach pair and can + * only ever close itself. + */ + const attachFrame = useCallback((frame: HTMLIFrameElement | null): (() => void) | undefined => { + frameRef.current = frame; + const contentWindow = frame?.contentWindow; + if (!contentWindow) return undefined; + + const bridge = new AppBridge( + // No MCP client: the artifact page reaches the server over the ordinary + // executions HTTP API, so every app-originated call is answered by the + // handlers below rather than proxied onto an MCP connection. + null, + { name: "Executor Console", version: "1.0.0" }, + { openLinks: {}, serverTools: {} }, + { hostContext: hostContextFor(readDocumentTheme(), viewportRef.current) }, + ); + + // Deliver the stored source the way a real host does after `create-artifact`: the + // tool input, then the tool result carrying `structuredContent.code` — the + // exact shape `renderedInAppResult` builds in the MCP host. The shell + // already handles both, so it takes the same path here as it does under any + // other MCP-Apps client, with no artifact-page-only branch. + bridge.oninitialized = () => { + const code = codeRef.current; + const artifactId = artifactIdRef.current; + void bridge + .sendToolInput({ arguments: { code, artifactId } }) + .then(() => + bridge.sendToolResult({ + content: [{ type: "text", text: "Rendered saved artifact." }], + structuredContent: { code, artifactId }, + }), + ) + .catch((error: unknown) => { + console.error("[executor-console] Failed to deliver the artifact to the shell:", error); + }); + }; + + /** + * 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; + setContentHeight(Math.max(MIN_FRAME_HEIGHT, Math.ceil(params.height))); + }; + + bridge.oncalltool = (params) => { + // The shell's preview upgrade is not a server tool; it is this host's own + // call, answered here. Intercepted before the tool transport so it never + // reaches `/executions` — a snapshot is not an execution and must not be + // metered, approved or logged as one. + if (params.name === SAVE_ARTIFACT_PREVIEW_TOOL) { + const artifactId = params.arguments?.["artifactId"]; + const preview = params.arguments?.["preview"]; + if (typeof artifactId === "string" && typeof preview === "string") { + hostRef.current.savePreview(artifactId, preview); + } + return Promise.resolve({ content: [] } satisfies CallToolResult); + } + + // "The artifact is on screen." Answered here for the same reason the + // preview is: it is this host's own call, not a server tool, and it must + // never reach `/executions`. The page uses it to take its skeleton down + // — see `ArtifactStage`. + if (params.name === ARTIFACT_RENDERED_TOOL) { + onRenderedRef.current?.(); + return Promise.resolve({ content: [] } satisfies CallToolResult); + } + return hostRef.current.callServerTool({ + name: params.name, + ...(params.arguments ? { arguments: params.arguments } : {}), + }); + }; + + bridge.onopenlink = async (params) => { + await hostRef.current.openLink({ url: params.url }); + return {}; + }; + + void bridge + .connect(new PostMessageTransport(contentWindow, contentWindow)) + .catch((error: unknown) => { + console.error("[executor-console] Artifact shell failed to connect:", error); + }); + + bridgeRef.current = bridge; + + // Detach: close THIS bridge, and only clear the shared ref if it is still + // the current one, so a bridge that has already been replaced can never + // null out its successor. + return () => { + void bridge.close(); + if (bridgeRef.current === bridge) bridgeRef.current = null; + if (frameRef.current === frame) frameRef.current = null; + }; + }, []); + + return ( + // The measured box. `h-full` against the page's content area is what makes + // the measurement meaningful: the container is exactly the room the page has + // given the artifact, so the number the observer reads is the number the + // frame should be. `min-h-0` lets it shrink inside the page's flex column + // rather than being floored at its content's height. +
+ + + +`; + +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 + * `create-artifact`, 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. + * + * Every artifact this port mints is pre-bound to the harness's one `inventory` + * connection, under both the implicit role (`inventory`) and an explicit `alt` + * role. That is what lets a generated component here address the short form + * `tools.inventory.listItems` and the role form `tools.inventory("alt").…`, and + * still reach `tools.inventory.*` on the executor — the same rewrite a + * real artifact gets from its stored bindings. + */ +/** The artifact every harness component renders as. Pre-seeded so a test can + * hand its id to `execute-action` exactly as the host does. */ +const HARNESS_ARTIFACT_ID = "art_1"; + +const HARNESS_BINDINGS: ArtifactBindings = { + inventory: { + integration: IntegrationSlug.make(INVENTORY_SLUG), + owner: "org", + connection: ConnectionName.make(INVENTORY_CONNECTION), + }, + alt: { + integration: IntegrationSlug.make(INVENTORY_SLUG), + owner: "org", + connection: ConnectionName.make(INVENTORY_CONNECTION), + }, + // Used by the elicitation fixtures, which run against a stub engine that + // answers any address rather than the real inventory executor. + profile: { + integration: IntegrationSlug.make("profile"), + owner: "org", + connection: ConnectionName.make("main"), + }, +}; + +const makeInMemoryArtifacts = () => { + const rows = new Map(); + let seq = 0; + // Pre-seeded, because these tests push code into the shell directly rather + // than through `create-artifact`. The row exists so `execute-action` has + // bindings to resolve against, the way it would for any real artifact. + rows.set(HARNESS_ARTIFACT_ID, { + id: ArtifactId.make(HARNESS_ARTIFACT_ID), + owner: "user", + title: "Harness", + description: null, + code: "", + bindings: HARNESS_BINDINGS, + preview: null, + createdAt: new Date(0), + updatedAt: new Date(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; + readonly bindings?: ArtifactBindings | null; + readonly preview?: string | null; + }): 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, + bindings: input.bindings ?? HARNESS_BINDINGS, + preview: + input.preview === undefined || input.preview === null + ? null + : ({ kind: "layout", markup: input.preview } as const), + 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(), + // Artifacts are opt-in per connection; this harness drives the artifact + // tools, so it connects the way a `?artifacts=true` client does. + artifactsEnabled: true, + }), + ); + 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.*` 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, artifactId]) => + (window as unknown as BrowserHostWindow).__sendGeneratedUi(value!, artifactId!), + [code, HARNESS_ARTIFACT_ID] as const, + ); + 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.listItems({})", + artifactId: HARNESS_ARTIFACT_ID, + }, + }), + ]), + ); + } finally { + await page.close(); + } + }, 30_000); + + // An artifact that uses two accounts of one integration tags each call site + // with a role. The role has to survive four hops — the inner proxy's `apply` + // trap, the TanStack cache key, the postMessage bridge, and the + // `execute-action` grammar — and be what the server resolves the connection + // from. If any hop dropped it, both reads would collapse onto one binding and + // the two calls would be indistinguishable on the wire. + it("carries an integration role from the proxy through to execute-action", 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, generatedRoleTaggedCode); + + // Both roles resolved to a real connection and returned real rows. + await innerFrame.waitForFunction( + () => + document.querySelector("#primary")?.textContent !== "loading" && + document.querySelector("#secondary")?.textContent !== "loading", + undefined, + { timeout: 10_000 }, + ); + const primary = await innerFrame.locator("#primary").textContent(); + expect(primary).not.toBe(""); + expect(await innerFrame.locator("#secondary").textContent()).toBe(primary); + + // Two separate calls, each naming its own role — not one shared query. + const roleCalls = (await getHostState(page)).toolCalls + .map((call) => (call.arguments as { code?: string } | undefined)?.code) + .filter((code): code is string => Boolean(code?.includes("listItems"))); + expect(roleCalls).toEqual( + expect.arrayContaining([ + 'return await tools.inventory("inventory").listItems({})', + 'return await tools.inventory("alt").listItems({})', + ]), + ); + } finally { + await page.close(); + } + }, 30_000); + + // The design system only exists for the model if the classes it writes turn + // into CSS. They did not: the frame received the shell's COMPILED stylesheet, + // which by construction contains only utilities executor's own components use, + // so anything else silently no-opped — `text-2xl` computed to 16px and + // `md:grid-cols-4` stayed one column. The frame now carries the theme as + // source plus a compiler. This renders classes that appear nowhere in the + // shell's own sources and asserts the COMPUTED result, because "the class is + // in the DOM" was always true even when it did nothing. + it("compiles utility classes the model invents, against executor's tokens", 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, + `function App() { + return ( +
+
24
+
+ abcd +
+
label
+
muted
+
+ ); + }`, + ); + await innerFrame.locator("#probe").waitFor({ timeout: 10_000 }); + + const computed = await innerFrame.evaluate(() => { + const styleOf = (id: string) => getComputedStyle(document.getElementById(id)!); + return { + fontSize: styleOf("size").fontSize, + gridColumns: styleOf("grid").gridTemplateColumns.split(" ").length, + letterSpacing: styleOf("arbitrary").letterSpacing, + labelSize: styleOf("arbitrary").fontSize, + mutedColor: styleOf("token").color, + bodyFont: getComputedStyle(document.body).fontFamily, + bodyNumeric: getComputedStyle(document.body).fontVariantNumeric, + }; + }); + + // A built-in scale step, a responsive variant, and an arbitrary value — + // three different paths through the compiler, none of them in the shell. + expect(computed.fontSize, "text-2xl resolves to its real size").toBe("24px"); + expect(computed.gridColumns, "md:grid-cols-4 produces four columns").toBe(4); + expect(computed.labelSize, "an arbitrary text size compiles").toBe("11px"); + expect(computed.letterSpacing, "an arbitrary tracking compiles").not.toBe("normal"); + + // Compiled against EXECUTOR's theme, not Tailwind's defaults: the muted + // token is the design system's gray, and the type is Geist. + expect(computed.mutedColor, "muted-foreground is executor's gray").toBe("rgb(102, 102, 102)"); + expect(computed.bodyFont, "the frame is set in Geist").toContain("Geist"); + expect(computed.bodyNumeric, "numbers are tabular by default").toContain("tabular-nums"); + } 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 }); + + // A tool-call request from the wrong window, and one from the right + // window with the wrong token. Neither may reach the bridge. + 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."); + } + + const payload = { + type: "executor.toolCall", + path: ["inventory", "listItems"], + args: [{}], + }; + window.dispatchEvent( + new MessageEvent("message", { + source: window, + data: { ...payload, requestId: 1, token }, + }), + ); + window.dispatchEvent( + new MessageEvent("message", { + source: iframe.contentWindow, + data: { ...payload, requestId: 2, token: "wrong" }, + }), + ); + }); + + 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.toolCall", + requestId: 3, + token, + path: ["inventory", "listItems"], + args: [{}], + }, + }), + ); + }); + + await page.waitForFunction( + () => (window as unknown as BrowserHostWindow).__mcpHostState.toolCalls.length === 1, + ); + // Even the accepted message becomes the one proxy grammar, never raw code + // — the outer frame builds the source, the inner frame only names a tool. + expect((await getHostState(page)).toolCalls[0]).toEqual( + expect.objectContaining({ + name: "execute-action", + arguments: { + code: "return await tools.inventory.listItems({})", + artifactId: HARNESS_ARTIFACT_ID, + }, + }), + ); + } 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); + + // One refusal control, not two. `buildElicit` and `enforceApproval` in + // `@executor-js/sdk` raise `ElicitationDeclinedError` for ANY non-accept + // action, so "decline" and "cancel" are the same outcome at the gate and the + // modal offers only Cancel. + it("resumes a canceled approval without performing the mutation", async () => { + if (!browser || !hostServer || !openApiServer) { + throw new Error("Browser harness did not start."); + } + + 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 }); + + expect(await shellFrame.getByRole("button", { name: "Decline" }).count()).toBe(0); + await shellFrame + .locator('[data-testid="trusted-interaction-cancel"]') + .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: "cancel", + content: "{}", + }, + }), + ]); + } finally { + await page.close(); + } + }, 30_000); + + it("tells the user what they are confirming rather than where the modal came from", 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, generatedApprovalCode); + await innerFrame.locator("#ask").click({ timeout: 10_000 }); + + const card = shellFrame.locator('[data-testid="trusted-interaction-card"]'); + await card.waitFor({ timeout: 10_000 }); + const cardText = await card.innerText(); + + expect(cardText).toContain("Approve action"); + expect(cardText).toContain("You've triggered a destructive action in the UI, please confirm"); + expect(cardText).not.toContain("This approval is handled by the Executor shell."); + // The concrete request preview stays — copy changed, evidence did not. + expect(cardText).toContain("createItem"); + } finally { + await page.close(); + } + }, 30_000); + + it("stops asking for a tool the user approved for good, and only that tool", async () => { + if (!browser || !hostServer || !openApiServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + const modal = shellFrame.locator('[data-testid="trusted-interaction-modal"]'); + + try { + // Remembered grants persist per console origin, so start from a known + // empty state rather than inheriting one from an earlier run. + await shellFrame.evaluate(() => globalThis.localStorage.clear()); + + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedTwoToolApprovalCode); + const initialPostCount = openApiServer.postRequests.length; + + await innerFrame.locator("#create-first").click({ timeout: 10_000 }); + await modal.waitFor({ timeout: 10_000 }); + await shellFrame + .locator('[data-testid="trusted-interaction-approve-menu"]') + .click({ timeout: 10_000 }); + await shellFrame + .locator('[data-testid="trusted-interaction-approve-always"]') + .click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => document.querySelector("#create-status")?.textContent === "First Widget", + undefined, + { timeout: 10_000 }, + ); + + // Same tool again: answered from the grant, so no modal ever mounts. + await innerFrame.locator("#create-second").click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => document.querySelector("#create-status")?.textContent === "Second Widget", + undefined, + { timeout: 10_000 }, + ); + expect(await modal.count()).toBe(0); + expect(openApiServer.postRequests.slice(initialPostCount)).toEqual([ + JSON.stringify({ name: "First Widget" }), + JSON.stringify({ name: "Second Widget" }), + ]); + + // A different address on the same artifact is a different action. + await innerFrame.locator("#renew").click({ timeout: 10_000 }); + await modal.waitFor({ timeout: 10_000 }); + await shellFrame + .locator('[data-testid="trusted-interaction-approve"]') + .click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => document.querySelector("#renew-status")?.textContent === "false", + undefined, + { timeout: 10_000 }, + ); + + const hostState = await getHostState(page); + expect( + hostState.resumeCalls.map((call) => (call.arguments as { action?: string }).action), + ).toEqual(["accept", "accept", "accept"]); + } finally { + await shellFrame.evaluate(() => globalThis.localStorage.clear()).catch(() => undefined); + await page.close(); + } + }, 60_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.createItem({"body":{"name":"Mount Widget"}})', + artifactId: HARNESS_ARTIFACT_ID, + }, + }), + ]), + ); + + 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.getDomain({"domain":"openexecutor.com"})', + artifactId: HARNESS_ARTIFACT_ID, + }, + }), + expect.objectContaining({ + name: "execute-action", + arguments: { + code: 'return await tools.inventory.updateDomainAutoRenew({"domain":"openexecutor.com","body":{"autoRenew":true}})', + artifactId: HARNESS_ARTIFACT_ID, + }, + }), + ]), + ); + } finally { + await page.close(); + } + }, 30_000); + + it("pages through a multi-page upstream declaratively with useInfiniteQuery", 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, generatedInfiniteQueryCode); + + // Page 1 only — an infinite query fetches exactly one page up front. The + // whole point is that "get everything" is the user's/component's choice, + // not an opaque 40-iteration loop hidden inside a queryFn. + await innerFrame.waitForFunction( + () => document.querySelector("#paged-items")?.textContent === "alpha,beta", + undefined, + { timeout: 10_000 }, + ); + expect(await innerFrame.locator("#paged-has-next").textContent()).toBe("true"); + + await innerFrame.locator("#paged-more").click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => document.querySelector("#paged-items")?.textContent === "alpha,beta,gamma,delta", + undefined, + { timeout: 10_000 }, + ); + + await innerFrame.locator("#paged-more").click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => + document.querySelector("#paged-items")?.textContent === "alpha,beta,gamma,delta,epsilon", + undefined, + { timeout: 10_000 }, + ); + // `getNextPageParam` returned undefined for the last page, so paging stops. + await innerFrame.waitForFunction( + () => document.querySelector("#paged-has-next")?.textContent === "false", + undefined, + { timeout: 10_000 }, + ); + expect(await innerFrame.locator("#paged-count").textContent()).toBe("3"); + + // Every page went over the wire as an ordinary single tool call — the + // cursor merged into the tool INPUT, not spliced into code. The first + // request carries no cursor at all (`initialPageParam: null`). + const hostState = await getHostState(page); + const pagedCalls = hostState.toolCalls + .map((call) => (call.arguments as { code?: string } | undefined)?.code) + .filter((code): code is string => Boolean(code?.includes("listPaged("))); + expect(pagedCalls).toEqual([ + 'return await tools.inventory.listPaged({"limit":2})', + 'return await tools.inventory.listPaged({"limit":2,"cursor":"2"})', + 'return await tools.inventory.listPaged({"limit":2,"cursor":"4"})', + ]); + } finally { + await page.close(); + } + }, 30_000); + + it("places an infinite-query cursor down a dotted path into a nested input", 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, + generatedNestedInfiniteQueryCode, + ); + await innerFrame.waitForFunction( + () => document.querySelector("#nested-items")?.textContent === "alpha,beta", + undefined, + { timeout: 10_000 }, + ); + + await innerFrame.locator("#nested-more").click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => document.querySelector("#nested-items")?.textContent === "alpha,beta,gamma,delta", + undefined, + { timeout: 10_000 }, + ); + + const hostState = await getHostState(page); + const nestedCalls = hostState.toolCalls + .map((call) => (call.arguments as { code?: string } | undefined)?.code) + .filter((code): code is string => Boolean(code?.includes("listPagedNested("))); + // The merge is a deep write that preserves the sibling `limit`, rather + // than replacing the nested object. + expect(nestedCalls).toEqual([ + 'return await tools.inventory.listPagedNested({"query":{"limit":2}})', + 'return await tools.inventory.listPagedNested({"query":{"limit":2,"since":"2"}})', + ]); + } finally { + await page.close(); + } + }, 30_000); + + it("refuses arbitrary code posted straight at execute-action", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + // The shell offers no way to write this — but the app channel is reachable + // from anything that can speak the bridge, so the SERVER is what has to + // refuse it. Posted through the host's own tools/call path, exactly as a + // hostile frame would. + await renderGeneratedUi(page, shellFrame, generatedStaticCode); + + const rejected = await page.evaluate(async () => { + const response = await fetch("/tools/call", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "execute-action", + arguments: { + code: "let all = []; for (let i = 0; i < 40; i++) { all.push(await tools.inventory.listItems({})) } return all", + }, + }), + }); + return (await response.json()) as { + isError?: boolean; + content?: Array<{ text?: string }>; + }; + }); + + expect(rejected.isError).toBe(true); + expect(rejected.content?.[0]?.text).toContain("single tool call"); + expect(rejected.content?.[0]?.text).toContain("infiniteQueryOptions"); + } 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); + + // "Don't ask again" is consent to skip the prompt, not consent to invent the + // answer. This schema's `name`, `priority` and `tags` are required with no + // defaults, so there is nothing to resume with and the modal must come back. + it("still asks when a remembered tool needs values the user never gave", 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); + const modal = shellFrame.locator('[data-testid="trusted-interaction-modal"]'); + + try { + await shellFrame.evaluate(() => globalThis.localStorage.clear()); + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedSchemaApprovalCode); + + await innerFrame.locator("#ask-schema").click({ timeout: 10_000 }); + await modal.waitFor({ timeout: 10_000 }); + await shellFrame.getByLabel("Display name").fill("Rhea"); + await shellFrame.getByLabel("Priority").selectOption("high"); + await shellFrame.getByLabel("Beta").click(); + await shellFrame + .locator('[data-testid="trusted-interaction-approve-menu"]') + .click({ timeout: 10_000 }); + await shellFrame + .locator('[data-testid="trusted-interaction-approve-always"]') + .click({ timeout: 10_000 }); + await modal.waitFor({ state: "detached", timeout: 10_000 }); + + await innerFrame.locator("#ask-schema").click({ timeout: 10_000 }); + await modal.waitFor({ timeout: 10_000 }); + // The remembered grant did not answer for the user, and nothing resumed + // behind their back. + const hostState = await getHostState(page); + expect(hostState.resumeCalls).toHaveLength(1); + } finally { + await shellFrame.evaluate(() => globalThis.localStorage.clear()).catch(() => undefined); + await page.close(); + await schemaHostServer.close(); + await schemaMcpHarness.close(); + } + }, 60_000); + + /** + * The regression guard for the whole point of fill mode. + * + * What broke before it existed: an artifact's `h-full` resolved against an + * unbounded chain, so `flex-1 min-h-0 overflow-auto` was just a div that grew. + * The table pushed its own header off the top of the screen and the user + * scrolled a document when they wanted an app. + * + * So this asserts the three things that have to be true together, since any + * one of them alone can hold while the layout is still broken: + * + * 1. the scroll region is genuinely bounded and overflowing + * (`scrollHeight > clientHeight`) — not merely tall; + * 2. it actually scrolls when scrolled; + * 3. the artifact's header does NOT move while it does. + */ + it("gives a bounded viewport to an artifact laid out as an app", async () => { + if (!browser || !hostServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + // The console's negotiation, verbatim: the spec's `fullscreen` mode plus a + // FIXED container height. Both are required — a mode with no height is not + // a viewport, and the shell deliberately stays inline for it. + await page.evaluate(() => { + (window as unknown as BrowserHostWindow).__setHostContext({ + displayMode: "fullscreen", + availableDisplayModes: ["inline", "fullscreen"], + containerDimensions: { height: 900 }, + }); + }); + await shellFrame.waitForFunction(() => + document.documentElement.classList.contains("artifact-fill"), + ); + + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedAppLayoutCode); + await innerFrame.locator("#layout-row-119").waitFor({ timeout: 10_000 }); + // The class has to reach the INNER document too — it is the last link in + // the height chain, and the artifact's own `h-full` resolves there. + await innerFrame.waitForFunction(() => + document.documentElement.classList.contains("artifact-fill"), + ); + + const before = await innerFrame.evaluate(() => { + const scroller = document.querySelector("#layout-scroll"); + const header = document.querySelector("#layout-header"); + const thead = document.querySelector("#layout-thead"); + const firstRow = document.querySelector("#layout-row-0"); + if (!scroller || !header || !thead || !firstRow) { + throw new Error("Missing layout elements."); + } + return { + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + headerTop: header.getBoundingClientRect().top, + theadTop: thead.getBoundingClientRect().top, + firstRowTop: firstRow.getBoundingClientRect().top, + bodyScrollHeight: document.body.scrollHeight, + bodyClientHeight: document.body.clientHeight, + }; + }); + + // Bounded: the region is shorter than its content, which is what makes it + // a scroll box rather than a tall div. + expect(before.clientHeight).toBeGreaterThan(0); + expect(before.scrollHeight).toBeGreaterThan(before.clientHeight); + // And the DOCUMENT is not the thing that overflows — the artifact absorbed + // its own content instead of pushing the frame out. + expect(before.bodyScrollHeight).toBeLessThanOrEqual(before.bodyClientHeight + 1); + + const after = await innerFrame.evaluate(() => { + const scroller = document.querySelector("#layout-scroll"); + const header = document.querySelector("#layout-header"); + const thead = document.querySelector("#layout-thead"); + const firstRow = document.querySelector("#layout-row-0"); + if (!scroller || !header || !thead || !firstRow) { + throw new Error("Missing layout elements."); + } + scroller.scrollTop = 400; + return { + scrollTop: scroller.scrollTop, + headerTop: header.getBoundingClientRect().top, + theadTop: thead.getBoundingClientRect().top, + firstRowTop: firstRow.getBoundingClientRect().top, + }; + }); + + // It really scrolled: the rows moved up by the amount scrolled. + expect(after.scrollTop).toBeGreaterThan(0); + expect(before.firstRowTop - after.firstRowTop).toBeCloseTo(after.scrollTop, 0); + // The artifact's own header stayed exactly where it was — this is the + // "Projects" title and its search box that used to scroll away. + expect(after.headerTop).toBeCloseTo(before.headerTop, 0); + // And the sticky table header stayed with it rather than travelling up + // with the rows. Compared against its own starting position rather than + // the container's edge, since `sticky` pins to the padding box (inside the + // 1px border), not the border box. + expect(after.theadTop).toBeCloseTo(before.theadTop, 0); + } finally { + await page.close(); + } + }, 60_000); + + /** + * The other half of the contract, and the one that protects every chat host: + * with no fill context the shell must behave exactly as it always has — report + * its content height and let the host grow the frame. A regression here would + * clip artifacts inside Claude, where there is no bounded viewport to fill. + */ + it("still grows to content for a host that offers no viewport", 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, generatedAppLayoutCode); + await innerFrame.locator("#layout-row-119").waitFor({ timeout: 10_000 }); + + const fillApplied = await shellFrame.evaluate(() => + document.documentElement.classList.contains("artifact-fill"), + ); + expect(fillApplied).toBe(false); + + // The inner document grows past the frame it was given and reports that + // height outward; the scroll region has no bound to scroll inside, which + // is correct — in a thread the transcript is what scrolls. + const metrics = await innerFrame.evaluate(() => ({ + bodyScrollHeight: document.body.scrollHeight, + rootHeight: document.getElementById("root")?.getBoundingClientRect().height ?? 0, + })); + expect(metrics.bodyScrollHeight).toBeGreaterThan(1000); + expect(metrics.rootHeight).toBeGreaterThan(1000); + } finally { + await page.close(); + } + }, 60_000); + + /** + * The render signal, and who is allowed to be affected by it. + * + * The console holds ONE loading surface across the whole open — data fetch, + * renderer chunk, shell boot, compile — and it can only take that surface down + * when the artifact has actually painted. Nothing on the console side can see + * that: the artifact is two sandboxed documents down. So the shell reports it, + * over the `callServerTool` seam, to a host that asked (`executorRenderSignal` + * on the host context). + * + * Two properties, and they matter in opposite directions: + * + * 1. A host that ASKS is told — after the artifact is on screen, not before. + * Firing early would reveal a blank frame, which is the exact defect the + * single surface exists to prevent. + * 2. A host that does NOT ask is completely unaffected: no call it has never + * heard of, and it keeps the shell's own loading card. That is every real + * MCP client, and this is the guard for them — asserted here because the + * default harness context omits the key, exactly like Claude's. + */ + it("tells a host that asked when the artifact painted, and no other host", async () => { + if (!browser || !hostServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + // `openHarness` already waited on it: a host that did not ask still gets + // the shell's own loading card, unchanged. + const before = await getHostState(page); + expect( + before.toolCalls.some((call) => call.name === "executor-artifact-rendered"), + "a host that never asked is not sent the signal", + ).toBe(false); + + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedStaticCode); + await innerFrame.locator("#ready").waitFor({ timeout: 10_000 }); + + // Still silent: the artifact has painted, but this host did not ask. + const afterRender = await getHostState(page); + expect( + afterRender.toolCalls.some((call) => call.name === "executor-artifact-rendered"), + "rendering alone does not make the call", + ).toBe(false); + } finally { + await page.close(); + } + }, 60_000); + + it("signals a console-style host once the artifact is on screen", async () => { + if (!browser || !hostServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + // Become the console: ask for the signal the way `ArtifactShell` does. + await page.evaluate(() => { + (window as unknown as BrowserHostWindow).__setHostContext({ + executorRenderSignal: true, + }); + }); + + // The shell drops its own loading card for a host that holds one, so + // there is no second placeholder under the console's skeleton. + await shellFrame + .locator('[data-testid="shell-loading-state"]') + .waitFor({ state: "detached", timeout: 10_000 }); + + const beforeRender = await getHostState(page); + expect( + beforeRender.toolCalls.some((call) => call.name === "executor-artifact-rendered"), + "nothing is signalled before there is an artifact to look at", + ).toBe(false); + + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedStaticCode); + await innerFrame.locator("#ready").waitFor({ timeout: 10_000 }); + + await page.waitForFunction(() => + (window as unknown as BrowserHostWindow).__mcpHostState.toolCalls.some( + (call: { name?: string }) => call.name === "executor-artifact-rendered", + ), + ); + + // Exactly once — including across a SECOND delivery, which is what the + // latch actually guards. A host replaces the artifact's code (the console + // does exactly this when the tab regains focus and the row has changed), + // the inner frame remounts and paints again, and the signal must not fire + // a second time: the host has long since taken its skeleton down, and a + // repeat would restart a transition that already finished. + const secondFrame = await renderGeneratedUi(page, shellFrame, generatedSecondRenderCode); + await secondFrame.locator("#second").waitFor({ timeout: 10_000 }); + + const after = await getHostState(page); + const signals = after.toolCalls.filter((call) => call.name === "executor-artifact-rendered"); + expect(signals.length, "the signal is latched, not repeated per render").toBe(1); + + // And it never reached the execution transport: a "you may stop waiting" + // is not an execution and must not be metered, approved or logged as one. + expect( + after.toolCalls.some((call) => call.name === "execute-action"), + "the signal is answered by the host, not proxied to execute-action", + ).toBe(false); + } finally { + await page.close(); + } + }, 60_000); + + /** + * The artifact can paint BEFORE the host says it wants to be told. + * + * "The artifact painted" travels up from the inner frame; "tell me when it + * does" travels down on the host context. They are independent channels and + * their order is not guaranteed — a host that advertises the capability in a + * later `host-context-changed` (rather than in its `ui/initialize` reply) + * arrives second. + * + * The paint never happens twice, so a shell that merely tested the capability + * at the moment of the paint would drop the only notification there was ever + * going to be, and the host would hold its loading surface over a fully + * rendered artifact forever. That is a stuck screen, not a cosmetic glitch, + * which is why the fact is remembered and re-checked rather than tested once. + */ + it("still signals a host that asks only after the artifact has painted", async () => { + if (!browser || !hostServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + // Render FIRST, with the harness's default context, which does not ask. + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedStaticCode); + await innerFrame.locator("#ready").waitFor({ timeout: 10_000 }); + + const beforeAsking = await getHostState(page); + expect( + beforeAsking.toolCalls.some((call) => call.name === "executor-artifact-rendered"), + "nothing is sent to a host that has not asked", + ).toBe(false); + + // Only now does the host ask. The paint it is asking about is already in + // the past. + await page.evaluate(() => { + (window as unknown as BrowserHostWindow).__setHostContext({ + executorRenderSignal: true, + }); + }); + + await page.waitForFunction(() => + (window as unknown as BrowserHostWindow).__mcpHostState.toolCalls.some( + (call: { name?: string }) => call.name === "executor-artifact-rendered", + ), + ); + + const after = await getHostState(page); + expect( + after.toolCalls.filter((call) => call.name === "executor-artifact-rendered").length, + "the remembered paint is reported exactly once", + ).toBe(1); + } finally { + await page.close(); + } + }, 60_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-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..609e4b5e7 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.tsx @@ -0,0 +1,101 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { useApp } from "@modelcontextprotocol/ext-apps/react"; +import type { App } from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpAppsShell } from "./shell-app"; + +/** + * The shell's MCP-Apps entry point: connect, then render whatever the host + * sends. + * + * `tool-input` and `tool-result` are ONE-SHOT notifications. A host is free to + * send them the instant the handshake completes — `AppBridge.oninitialized` + * fires on `ui/notifications/initialized`, which `App.connect()` sends before it + * resolves. `McpAppsShell` registers its handlers in an effect, which cannot run + * until React has mounted it, which is at best a frame later. A prompt host + * therefore delivers the artifact into a void, and the shell sits on its loading + * state forever. + * + * That is not hypothetical: it is exactly what the console's artifact page does, + * and the ext-apps SDK warns about the race by name ("handler registered after + * connect() completed the ui/initialize handshake"). + * + * So the notifications are captured HERE, in `onAppCreated` — the callback the + * SDK provides for precisely this, running before `connect()` — and replayed + * into the shell once it has mounted and registered its own handlers. Buffering + * rather than racing means the shell's handlers stay where they belong (with the + * state they drive) and no host has to know how fast this app mounts. + */ + +type ToolInput = { arguments?: Record }; + +/** What arrived before the shell was ready to hear it. */ +type EarlyDelivery = { + toolInput?: ToolInput; + toolResult?: CallToolResult; + /** Set once the shell has taken over; further notifications go straight through. */ + drain?: (delivery: { toolInput?: ToolInput; toolResult?: CallToolResult }) => void; +}; + +const captureEarlyDelivery = (app: App, early: EarlyDelivery) => { + // `addEventListener`, not the `on*` setters: the shell replaces those with its + // own handlers, and this listener must survive that. + app.addEventListener("toolinput", (params) => { + if (early.drain) early.drain({ toolInput: params }); + else early.toolInput = params; + }); + app.addEventListener("toolresult", (params) => { + const result = params as CallToolResult; + if (early.drain) early.drain({ toolResult: result }); + else early.toolResult = result; + }); +}; + +function ConnectedShellApp() { + const earlyRef = React.useRef({}); + const { app, error: connectionError } = useApp({ + appInfo: { name: "Executor Shell", version: "1.0.0" }, + capabilities: {}, + onAppCreated: (created) => captureEarlyDelivery(created, earlyRef.current), + }); + + // Replay once, after the shell's own handlers are in place. The effect runs + // after the child's effects, which is exactly the ordering this needs. + React.useEffect(() => { + if (!app) return; + const early = earlyRef.current; + const deliver = (delivery: { toolInput?: ToolInput; toolResult?: CallToolResult }) => { + if (delivery.toolInput) app.ontoolinput?.(delivery.toolInput); + if (delivery.toolResult) app.ontoolresult?.(delivery.toolResult); + }; + deliver(early); + early.toolInput = undefined; + early.toolResult = undefined; + early.drain = deliver; + }, [app]); + + 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/preview-capture.ts b/packages/hosts/mcp-apps-shell/src/shell/preview-capture.ts new file mode 100644 index 000000000..a5782a46e --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/preview-capture.ts @@ -0,0 +1,36 @@ +/** + * The host call that stores a settled-render snapshot. + * + * ## Why this is its own module + * + * Both ends of the capture name it: the shell document sends it, and the + * console's `ArtifactShell` answers it. But `shell-app.tsx` imports + * `virtual:executor-tailwind-browser` and `virtual:executor-inner-renderer`, + * which only exist inside the standalone shell's own Vite build — so importing + * this constant FROM there drags those virtual modules into the console's + * bundle, where nothing supplies them, and the artifact renderer fails to load + * at all. + * + * Keeping the shared name in a module with no imports is what lets both sides + * agree on it without either pulling in the other's build graph. + * + * ## What it is + * + * Not an MCP tool the server advertises. It rides the `callServerTool` seam + * because that seam already exists in every host, and the console intercepts it + * before the tool transport — a snapshot is not an execution and must never be + * metered, approved or logged as one. A host that does not implement the name + * simply rejects the call, which is exactly the fail-quiet behaviour a preview + * upgrade should have. + */ +export const SAVE_ARTIFACT_PREVIEW_TOOL = "executor-save-artifact-preview"; + +/** + * The host-context key by which a host asks for a preview snapshot. + * + * Carried on `McpUiHostContext`'s open index signature, which is the spec's own + * forward-compatibility seam. Only the console sets it — it is the only host + * with somewhere to put a preview — so everywhere else the inner frame skips + * the capture entirely rather than doing the work and dropping it. + */ +export const PREVIEW_CAPTURE_HOST_CONTEXT_KEY = "executorPreviewCapture"; 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..fa3ee9a05 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/provided-globals.pin.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "@effect/vitest"; +import { PROVIDED_GLOBAL_NAMES } from "@executor-js/execution"; + +import { PROVIDED_SCOPE_NAMES } from "./component-runtime"; + +// The `create-artifact` 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", + "useInfiniteQuery", + "infiniteQueryOptions", + "tools", + "Card", + "cn", + ]) { + expect(PROVIDED_SCOPE_NAMES).toContain(name); + } + }); + + // The decision this branch encodes: artifact code is purely declarative + // `tools.*`. `run` was the escape hatch for arbitrary code and is gone from + // the model-facing surface entirely — re-adding it to the scope must fail + // here, not quietly reopen the hole. + it("binds no arbitrary-code escape hatch", () => { + expect(PROVIDED_SCOPE_NAMES).not.toContain("run"); + expect(PROVIDED_SCOPE_NAMES).not.toContain("eval"); + }); +}); 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..6d7744d0c --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/proxy.ts @@ -0,0 +1,181 @@ +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; + /** + * The paused call's tool address (`executor.coreTools.policies.create`), + * put on the wire by `formatPausedExecution`. It is the only stable + * identity a pause carries — the execution id is per-call — so it is what + * the shell keys a remembered approval by. + */ + address?: unknown; + }; +}; + +export type TrustedInteractionResponse = { + action: "accept" | "decline" | "cancel"; + content?: Record; +}; + +export type RequestTrustedInteraction = ( + interaction: TrustedInteraction, +) => Promise; + +const TOOL_PATH_SEGMENT = /^[A-Za-z_$][\w$]*$/; + +/** + * The ONE grammar the shell ever puts on the `execute-action` wire: + * + * return await tools.("")?(.)*() + * + * A single proxy-shaped tool call, nothing else — no statements, no loops, no + * composition. The server parses `execute-action` against exactly this shape + * (`parseToolCallCode` in `@executor-js/host-mcp`), so an iframe cannot smuggle + * arbitrary code through the app channel even though the shell UI never offers + * a way to write any. `tool-call-grammar.pin.test.ts` pins the two together. + * + * The path's head is an INTEGRATION, and the optional role tags which of the + * artifact's connections for that integration to use. Neither the tier nor the + * connection name is ever on this wire: the server resolves them from the + * bindings stored on the artifact row. That is the whole point of the short + * form — an iframe that fabricated an address would be naming something the + * artifact was never bound to, and the server would refuse it. + * + * Role and args are both `JSON.stringify` output, so the emission stays + * parseable and a role containing a quote cannot break out of the call. + */ +export function toolCallCode( + path: readonly string[], + args: readonly unknown[], + role?: string, +): string { + if (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; + }); + if (role !== undefined && (typeof role !== "string" || role.length === 0)) { + throw new Error("Invalid tool role."); + } + const [head, ...rest] = parts; + const tag = role === undefined ? "" : `(${JSON.stringify(role)})`; + const trailer = rest.length > 0 ? `.${rest.join(".")}` : ""; + return `return await tools.${head}${tag}${trailer}(${JSON.stringify(args[0] ?? {})})`; +} + +/** + * Calls one tool through the MCP Apps bridge, resolving any shell-owned + * approval the execution pauses for. + * + * `tools.github.issues.create({ title: "Bug" })` in the generated iframe arrives + * here as `(["github","issues","create"], [{ title: "Bug" }])` and becomes + * `execute-action` with + * `code: "return await tools.github.issues.create({\"title\":\"Bug\"})"`. + * + * `artifactId` rides alongside the code because the server cannot resolve the + * call without it: the path names an integration role, and the bindings that + * turn a role into a connection live on the artifact row. It is supplied by the + * host (from the tool input the artifact was delivered with), never by the + * generated component — and the server checks the caller owns it. + */ +export function createToolCaller( + app: ToolCallHost, + requestTrustedInteraction: RequestTrustedInteraction, + getArtifactId?: () => string | undefined, +): (path: readonly string[], args: readonly unknown[], role?: string) => Promise { + return (path, args, role) => { + const artifactId = getArtifactId?.(); + return app + .callServerTool({ + name: "execute-action", + arguments: { + code: toolCallCode(path, args, role), + ...(artifactId === undefined ? {} : { artifactId }), + }, + }) + .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/render-signal.ts b/packages/hosts/mcp-apps-shell/src/shell/render-signal.ts new file mode 100644 index 000000000..5f493d53d --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/render-signal.ts @@ -0,0 +1,60 @@ +/** + * The host call that says "the artifact is on screen now". + * + * ## Why a host needs to be told + * + * Opening an artifact is a three-document journey — the console fetches the + * row, loads the shell document into an iframe, and the shell compiles the JSX + * into a further nested frame. A host that wants to hold ONE loading surface + * across all of it (the console does; see `ArtifactStageSkeleton`) needs to know + * when the last of those has actually painted, and nothing in the MCP-Apps + * protocol says so. `ui/notifications/size-changed` is the closest thing and it + * is not the same fact: the shell document reports its own size while it is + * still booting, long before any artifact exists, and a fill-mode host ignores + * the report entirely. + * + * So the shell says it explicitly, once, when it has something to show. + * + * ## "Rendered" means presented, not successful + * + * The signal fires for the ERROR surfaces too — a compile failure, a component + * that threw, an artifact that rendered fine. All three are "the shell is done + * waiting and the user should be looking at the frame rather than a skeleton", + * which is the only question the host is asking. A signal that fired only on + * success would strand a failed artifact under a loading state forever, which is + * a worse bug than the one this exists to fix. + * + * ## Why this is its own module + * + * Same reason as `./preview-capture`, and it is a hard constraint rather than a + * preference: `shell-app.tsx` imports `virtual:executor-tailwind-browser` and + * `virtual:executor-inner-renderer`, which exist only inside the standalone + * shell's own Vite build. Importing a constant FROM there drags those virtual + * modules into the console's bundle, where nothing supplies them, and the + * artifact renderer then fails to load at all. A module with no imports of its + * own is what lets both halves agree on the vocabulary. + * + * ## Why it rides `callServerTool` + * + * Because that seam already exists in every host, exactly as the preview capture + * does. The console intercepts the name before the tool transport, so it never + * reaches `/executions` — a "you can stop waiting now" is not an execution and + * must not be metered, approved or logged as one. + */ +export const ARTIFACT_RENDERED_TOOL = "executor-artifact-rendered"; + +/** + * The host-context key by which a host asks to be told. + * + * Carried on `McpUiHostContext`'s open index signature, the spec's own + * forward-compatibility seam — the same door `PREVIEW_CAPTURE_HOST_CONTEXT_KEY` + * goes through. Only the console sets it, because only the console holds a + * loading surface it needs to take down. Every real MCP client omits it and the + * shell never makes the call, so no host is sent a tool name it has never heard + * of. + * + * A separate key from the preview capture rather than one "this is the console" + * flag: they are two independent capabilities, and a host that wants a seamless + * handoff without storing thumbnails should be able to say exactly that. + */ +export const RENDER_SIGNAL_HOST_CONTEXT_KEY = "executorRenderSignal"; 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..c9cfbe52b --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx @@ -0,0 +1,1098 @@ +// `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"; + +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 { + elicitationDefaultContent, + useElicitationApproval, +} from "@executor-js/react/components/elicitation-approval"; + +import { + createToolCaller, + type ToolCallHost, + type TrustedInteraction, + type TrustedInteractionResponse, +} from "./proxy"; +import * as Components from "./components"; +import { PREVIEW_CAPTURE_HOST_CONTEXT_KEY, SAVE_ARTIFACT_PREVIEW_TOOL } from "./preview-capture"; +import { ARTIFACT_RENDERED_TOOL, RENDER_SIGNAL_HOST_CONTEXT_KEY } from "./render-signal"; +import { isFillDisplayMode } from "./display-mode"; +import innerRendererScript from "virtual:executor-inner-renderer"; +// Raw source, not a compiled stylesheet: the inner frame compiles utilities on +// demand against these tokens. See `buildRendererSrcDoc`. +import themeSource from "./theme.css?raw"; +// The in-frame Tailwind compiler, as text to inline under a CSP that forbids +// fetching anything. Supplied by `tailwindBrowserSourcePlugin` — the package +// exports only its module entry, and this needs the bytes. +import tailwindBrowserScript from "virtual:executor-tailwind-browser"; + +type PendingInteraction = TrustedInteraction & { + resolve: (response: TrustedInteractionResponse) => void; + /** Where a "don't ask again" for this pause would be stored, or `null` when + * the pause cannot be scoped to a tool and artifact — see + * `approvalGrantKey`. `null` hides the option rather than storing a grant + * that would match the wrong thing. */ + grantKey: string | null; +}; + +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; + role?: 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 } + | { type: "executor.renderer.preview"; token: string; preview: unknown } + /** The inner frame has committed and painted a tree — see `RenderSignal` in + * `./inner-renderer`. Relayed to the host as `ARTIFACT_RENDERED_TOOL`. */ + | { type: "executor.renderer.rendered"; token: string }; + +/** + * Whether this host wants a preview snapshot captured. + * + * 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 captures anything, so the cost is not paid where it cannot pay + * off. + */ +const hostContextAllowsPreviewCapture = (ctx: McpUiHostContext | undefined): boolean => + ctx?.[PREVIEW_CAPTURE_HOST_CONTEXT_KEY] === true; + +/** + * Whether this host wants to be told when the artifact is on screen. + * + * Only a host that holds its own loading surface across the open does — the + * console — and it says so on the same open index signature the preview + * capability travels on. Absent everywhere else, and absent means the call is + * never made, so no MCP client is sent a tool name it has never heard of. + */ +const hostContextWantsRenderSignal = (ctx: McpUiHostContext | undefined): boolean => + ctx?.[RENDER_SIGNAL_HOST_CONTEXT_KEY] === true; + +// --------------------------------------------------------------------------- +// Theme application from MCP Apps host context +// --------------------------------------------------------------------------- + +function applyTheme(ctx: McpUiHostContext) { + if (ctx.theme) { + document.documentElement.classList.toggle("dark", ctx.theme === "dark"); + } +} + +/** + * The CSS hook for a bounded viewport. See the `html.artifact-fill` rule in + * `theme.css` for what it does and why it cannot be unconditional. + * + * Applied to the shell's own document here, and to the inner frame's document by + * the inner renderer when the shell tells it the mode — the chain has to hold at + * every link or the artifact's `h-full` still resolves against `auto`. + */ +const FILL_DOCUMENT_CLASS = "artifact-fill"; + +const applyFillMode = (ctx: McpUiHostContext | undefined) => { + document.documentElement.classList.toggle(FILL_DOCUMENT_CLASS, isFillDisplayMode(ctx)); +}; + +/** + * How tall the shell may grow before it scrolls internally, or `undefined` for + * "as tall as the content is". + * + * Precedence, tightest first: + * + * 1. The generated component's own `config.maxHeight` — the author asked for a + * scroll box, so give them one. + * 2. The host's `containerDimensions`, the MCP-Apps way of saying "this is the + * room you have". + * 3. Otherwise NO cap. An app cannot know how much room its host has; the + * protocol's answer is for the app to report its true content height via + * `size-changed` and let the host size the frame. Capping here would also be + * circular for a host that does exactly that — the shell's own viewport IS + * the frame the host is trying to size, so measuring it would pin the frame + * to whatever height it already had. + * + * The old unconditional 800px default was case 3 done wrong: it silently clipped + * anything taller, in every host, including ones with room to spare. + * + * ## This function is not consulted in fill mode + * + * A max height is a ceiling on GROWTH, which is a concept that only means + * something when the frame grows — that is, inline. In fill mode the shell is + * already exactly the host's viewport, so there is nothing left to cap: the + * layout is `height: 100%` all the way down and the artifact scrolls inside + * itself. + * + * That includes `config.maxHeight`. A component asking for a 600px scroll box is + * describing how it wants to sit inside a taller document; on a page where it IS + * the document, honoring it would letterbox the artifact into the top 600px of + * an otherwise empty screen. So in fill mode it is deliberately ignored, and it + * keeps its exact inline meaning everywhere else. + */ +const resolveMaxHeight = ( + config: Record, + hostContext: McpUiHostContext | undefined, +): number | undefined => { + if (isFillDisplayMode(hostContext)) return undefined; + + if (typeof config.maxHeight === "number") return config.maxHeight; + + const container = hostContext?.containerDimensions; + if (container) { + if ("height" in container && typeof container.height === "number") return container.height; + if ("maxHeight" in container && typeof container.maxHeight === "number") { + return container.maxHeight; + } + } + + return undefined; +}; + +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"); + +/** + * The srcDoc the model's component renders into. + * + * It carries TWO stylesheets, and the difference between them is the whole + * reason artifacts used to look half-styled: + * + * - `collectShellCss()` is the shell's own COMPILED stylesheet, scraped out of + * the live document. It has the `@font-face` rules with their `data:` URLs + * already resolved (the build did that), plus every utility executor's own + * components happen to use. It cannot contain anything else, because + * Tailwind only emits utilities it saw in a source file at build time — and + * the model's code did not exist then. + * + * - `themeSource` + the in-frame compiler is what makes the model's OWN + * classes real. `text-2xl`, `md:grid-cols-4`, `tracking-[0.08em]` — none of + * those appear in executor's components, so none of them were in the + * compiled sheet, and they silently did nothing: `text-2xl` computed to + * 16px, a responsive grid stayed one column. The frame therefore gets the + * theme as SOURCE, in the `text/tailwindcss` style tag `@tailwindcss/browser` + * looks for, and compiles utilities on demand against executor's tokens. + * + * The compiler is inlined rather than fetched because the CSP here is + * `default-src 'none'` with no `connect-src` — deliberately, since artifact code + * is untrusted. Nothing in this frame may open a connection, so everything it + * needs travels with it. + */ +const buildRendererSrcDoc = (token: string): string => { + const css = collectShellCss(); + return ` + + + + + + + + + + +
+ + +`; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +// --------------------------------------------------------------------------- +// Remembered approvals ("Approve and don't ask again") +// --------------------------------------------------------------------------- + +/** + * A grant is scoped to ONE tool address, on ONE artifact, for ONE viewer. + * + * - Tool address: `interaction.address`, the only stable identity a pause + * carries (the execution id is minted per call). Approving + * `github.issues.create` here says nothing about `github.repos.delete`. + * - Artifact: an artifact's bindings are what turn that address into a real + * connection, so the same address on a different artifact is a different + * action and asks again. + * - Viewer: v1 scoping is the storage itself. The shell frame is same-origin + * with the console (see the outer sandbox in `artifact-renderer.tsx`), so + * these grants live in the console origin's `localStorage`, which is already + * per browser profile and therefore per signed-in person at this machine. + * There is no viewer id inside the shell frame and inventing one would be a + * fiction; when grants need to follow a viewer across devices they belong on + * the server, keyed by the real viewer id. + * + * Nothing here is a security boundary. The SERVER still gates every call; this + * only decides whether the shell shows the human a modal it would otherwise + * answer the same way. + */ +const APPROVAL_GRANTS_STORAGE_KEY = "executor.shell.approval-grants.v1"; + +/** + * `localStorage`, or `null` when it is unusable. + * + * The shell document is same-origin with the console and normally has storage, + * but that is a property of how it is embedded, not a guarantee: an opaque + * origin throws on the property read itself, and a browser with site data + * blocked throws on write while still exposing the object. Both are probed + * here, once per call, so an approval can never be broken by storage — the + * worst case is that a grant is not remembered. + */ +const approvalGrantStorage = (): Storage | null => { + try { + const storage = globalThis.localStorage; + const probe = `${APPROVAL_GRANTS_STORAGE_KEY}.probe`; + storage.setItem(probe, "1"); + storage.removeItem(probe); + return storage; + } catch { + return null; + } +}; + +const readApprovalGrants = (): ReadonlySet => { + const storage = approvalGrantStorage(); + if (!storage) return new Set(); + try { + const raw = storage.getItem(APPROVAL_GRANTS_STORAGE_KEY); + if (!raw) return new Set(); + const parsed: unknown = JSON.parse(raw); + return new Set( + Array.isArray(parsed) + ? parsed.filter((entry): entry is string => typeof entry === "string") + : [], + ); + } catch { + return new Set(); + } +}; + +const rememberApprovalGrant = (key: string): void => { + const storage = approvalGrantStorage(); + if (!storage) return; + const next = new Set(readApprovalGrants()); + next.add(key); + try { + storage.setItem(APPROVAL_GRANTS_STORAGE_KEY, JSON.stringify([...next])); + } catch { + // Quota, or storage revoked between the probe and the write. The approval + // the user just gave still stands; only the memory of it is lost. + } +}; + +/** + * `null` when this pause cannot be scoped — no artifact id, or a pause with no + * address on it. Such an interaction is never auto-approved and never offers to + * be remembered, because a grant we cannot scope is a grant we cannot honor + * precisely. + */ +const approvalGrantKey = (artifactId: string | undefined, address: unknown): string | null => + artifactId !== undefined && + artifactId.length > 0 && + typeof address === "string" && + address.length > 0 + ? // A space separates the two halves: artifact ids are `art_`-prefixed + // base36 and tool addresses are dot-joined identifiers, so neither can + // contain one and no pair can collide. + `${artifactId} ${address}` + : null; + +// --------------------------------------------------------------------------- +// 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 callToolRef = useRef< + (path: readonly string[], args: readonly unknown[], role?: string) => Promise + >(() => Promise.resolve(null)); + // Which artifact is rendering. Set from the tool input the host delivers + // alongside the code, so the server can look up this artifact's connection + // bindings when the component calls a tool. Held in a ref rather than state + // because only the tool caller reads it, and rebuilding the caller on every + // delivery would race the first call. + const artifactIdRef = useRef(undefined); + const pendingInteractionRef = useRef(null); + const rendererFrameRef = useRef(null); + const rendererRef = useRef(null); + // Whether the embedding host can store a preview snapshot. Held in a ref + // because the renderer message handler is built once and must not be rebuilt + // when the context changes. + const capturePreviewRef = useRef(false); + capturePreviewRef.current = hostContextAllowsPreviewCapture(hostContext); + // Whether the host has bounded this shell. Held in a ref for the same reason + // as the capture flag: the renderer message handler is built once, and reading + // this from the closure would pin whichever mode was current when it was made. + const fillRef = useRef(false); + fillRef.current = isFillDisplayMode(hostContext); + // Hand a captured snapshot to the host. Swallows every failure: a preview is + // a picture for a list, and the artifact the user is looking at does not + // depend on it in any way. + const savePreviewRef = useRef<(artifactId: string, preview: string) => void>(() => {}); + savePreviewRef.current = (artifactId, preview) => { + void Promise.resolve( + app.callServerTool({ + name: SAVE_ARTIFACT_PREVIEW_TOOL, + arguments: { artifactId, preview }, + }), + ).then( + () => undefined, + () => undefined, + ); + }; + // Whether the host asked to be told when the artifact paints. A ref for the + // same reason as the two above: the renderer message handler is built once, + // and reading this from the closure would pin whichever context was current + // when it was made. + const wantsRenderSignalRef = useRef(false); + wantsRenderSignalRef.current = hostContextWantsRenderSignal(hostContext); + /** + * Tell the host the artifact is on screen. At most once per shell. + * + * Once-only because the host's question is "may I take my loading surface + * down", which is answered exactly once; a re-render inside the artifact is + * not a second answer. The latch lives here rather than in the inner frame so + * it survives that frame remounting. + * + * ## The fact outlives the capability to report it + * + * "The artifact painted" and "the host wants to know" arrive over two + * INDEPENDENT channels, and their order is not guaranteed: the paint comes up + * from the inner frame, while the capability comes down on the host context — + * which on a fast host lands before the first render and on a slow one arrives + * after it. Dropping the signal because the context had not arrived yet leaves + * the host holding its loading surface over a fully rendered artifact, + * forever, since the paint never happens twice. + * + * So the paint is REMEMBERED rather than tested against the capability at the + * moment it occurs. Whichever of the two arrives second sends the call. + * + * Swallows every failure, like the preview relay: a host that does not + * implement the name rejects the call, and the correct response to that is + * nothing at all. + */ + const renderSignalSentRef = useRef(false); + /** The artifact has painted, whether or not anyone could be told yet. */ + const renderedRef = useRef(false); + const signalRenderedRef = useRef<() => void>(() => {}); + signalRenderedRef.current = () => { + renderedRef.current = true; + if (renderSignalSentRef.current || !wantsRenderSignalRef.current) return; + renderSignalSentRef.current = true; + void Promise.resolve( + app.callServerTool({ + name: ARTIFACT_RENDERED_TOOL, + ...(artifactIdRef.current === undefined + ? {} + : { arguments: { artifactId: artifactIdRef.current } }), + }), + ).then( + () => undefined, + () => undefined, + ); + }; + + // The other order: the artifact painted before the host said it wanted to + // know. Runs on every host-context change, and the latch inside makes all but + // the first a no-op. + useEffect(() => { + if (renderedRef.current) signalRenderedRef.current(); + }, [hostContext]); + + useEffect(() => { + rendererRef.current = renderer; + }, [renderer]); + + const requestTrustedInteraction = useCallback( + (interaction: TrustedInteraction): Promise => + new Promise((resolve) => { + // Remembered approvals are answered HERE, before any state is set, so a + // granted tool never mounts the modal — not even for a frame. + const grantKey = approvalGrantKey(artifactIdRef.current, interaction.interaction.address); + if (grantKey !== null && readApprovalGrants().has(grantKey)) { + // A schema-bearing elicitation is only auto-answerable when its own + // defaults satisfy it. A required field with no default has no + // answer the user ever gave, so fall through to the modal rather + // than resuming with fabricated values. + const content = elicitationDefaultContent(interaction.interaction.requestedSchema); + if (content !== null) { + resolve({ action: "accept", content }); + return; + } + } + + if (pendingInteractionRef.current) { + resolve({ action: "cancel" }); + return; + } + + const pending = { ...interaction, resolve, grantKey }; + 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, + // The inner frame is the last link in the height chain, and it cannot + // see the host context — so the mode is told to it, the same way the + // theme and the preview capability already are. + fill: fillRef.current, + // Only the console advertises somewhere to put a preview. Passing the + // capability down rather than letting the frame guess keeps the inner + // renderer host-agnostic: under a real MCP client this is absent and + // no capture work happens at all. + capturePreview: capturePreviewRef.current, + }); + 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.rendered") { + signalRenderedRef.current(); + return; + } + + if (data.type === "executor.renderer.error") { + if (typeof data.message === "string") { + console.error("[executor-shell] Renderer error:", data.message); + } + // The frame rendered an error surface, which is still something for the + // user to look at — `RenderSignal` wraps the error path too, so the + // `rendered` message is already on its way. Nothing extra to do here. + return; + } + + if (data.type === "executor.renderer.preview") { + // Relay outward and forget. The picture is a nicety: nothing here waits + // on it, retries it, or reports its failure to the user. It travels + // over the same `callServerTool` seam as every other host call rather + // than a bespoke channel, and only to a host that asked for it. + // + // Through a ref for the same reason the tool caller is: this handler is + // built once, and reading `app` from the closure would pin whichever + // host object existed when it was created. + const artifactId = artifactIdRef.current; + if ( + capturePreviewRef.current && + artifactId !== undefined && + typeof data.preview === "string" && + data.preview !== "" + ) { + savePreviewRef.current(artifactId, data.preview); + } + return; + } + + // The ONLY request the generated iframe may make. There is no code + // channel: the inner renderer sends a tool path plus args, and the outer + // frame is what turns that into the one `execute-action` grammar. + if (data.type === "executor.toolCall") { + if (!Array.isArray(data.path)) { + respond(data.requestId, false, undefined, "Invalid tool path."); + return; + } + callToolRef + .current( + data.path as readonly string[], + Array.isArray(data.args) ? data.args : [], + typeof data.role === "string" && data.role.length > 0 ? data.role : undefined, + ) + .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]); + + /** + * Whether the host has given this shell a bounded viewport to fill. + * + * The shell does not decide this and measures nothing to find out — the host + * owns the mode and says so in its context. That is what lets the inner + * renderer keep reporting its size unconditionally in every mode: a fill-mode + * host simply does not consume the report. + */ + const fill = isFillDisplayMode(hostContext); + + // Relay a mode change to an already-rendered frame. `executor.render` carries + // the mode for a frame that has not booted yet; this covers the frame that is + // already up when the host changes its mind — which is not hypothetical, since + // the console's very first context arrives before it has measured its + // container and switches to fill once it has. + useEffect(() => { + if (renderer) { + postToRenderer({ type: "executor.fill", fill }); + } + }, [fill, 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(() => { + callToolRef.current = createToolCaller( + app, + requestTrustedInteraction, + () => artifactIdRef.current, + ); + + // 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 artifactId = params.arguments?.artifactId; + if (typeof artifactId === "string") artifactIdRef.current = artifactId; + 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; + // The result is where `create-artifact` / `show-artifact` report the id + // they saved under; the input only carries one on a reload replay. + const artifactId = structured?.artifactId; + if (typeof artifactId === "string") artifactIdRef.current = artifactId; + + 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) => { + // A host-context change notification carries only the CHANGED fields, so + // the merged context — not the delta — is what the mode must be read + // from: a resize sends `containerDimensions` alone, with no `displayMode` + // beside it, and judging the delta would read that as "no longer fill". + setHostContext((prev) => { + const next = { ...prev, ...ctx }; + applyFillMode(next); + return next; + }); + 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); + applyFillMode(ctx); + } + }, [app]); + + useEffect(() => { + if (initialCode) renderCode(initialCode); + }, [initialCode, renderCode]); + + /** + * The two surfaces that never reach the inner frame still end the wait. + * + * `error` is a shell-level compile failure and `component` is the data view a + * non-generative tool result renders — neither creates a renderer frame, so + * neither can emit `executor.renderer.rendered`. Without this, a host holding + * a loading surface would hold it forever on exactly the paths where there is + * nothing more coming. + * + * The signal itself is latched once, so this and the frame's own message + * cannot both take effect. + */ + useEffect(() => { + if (error !== null || component !== null) signalRenderedRef.current(); + }, [component, error]); + + if (error) { + return ( +
+ +
+ ); + } + + if (!component && !renderer) { + /* + A host that holds its own loading surface gets NOTHING here. + + The console does: it shows one skeleton from navigation until the + `rendered` signal, and this card would be a second loading state + underneath the first — briefly visible at every edge the cover does not + reach, and drawn for a host that explicitly said it is already handling + the wait. Asking for the signal is exactly the statement "I am showing the + user something; do not show them something else." + + Every other host — every real MCP client — still gets the card, because + there a bare empty frame while the shell boots is worse than a placeholder. + */ + if (wantsRenderSignalRef.current) return null; + + return ( +
+ +
+ ); + } + + const Component = component; + const config = renderer?.config ?? {}; + const maxHeight = resolveMaxHeight(config, hostContext); + const rendererHeight = renderer + ? maxHeight === undefined + ? renderer.height + : Math.min(renderer.height, maxHeight) + : undefined; + + return ( + +
+ {renderer ? ( +