Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .oxlintrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,25 @@
{
"files": ["packages/core/vite-plugin/src/**/*.{ts,tsx}"],
"rules": {
"executor/no-error-constructor": "off",
"executor/no-try-catch-or-throw": "off",
},
},
{
"files": [
"packages/plugins/dynamic-ui/src/client.tsx",
"packages/plugins/dynamic-ui/src/shell/**/*.{ts,tsx}",
"packages/plugins/dynamic-ui/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",
},
},
{
Expand Down
2 changes: 2 additions & 0 deletions apps/cloud/executor.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { defineExecutorConfig } from "@executor-js/sdk";
import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
import { dynamicUiPlugin } from "@executor-js/plugin-dynamic-ui";
import { googleHttpPlugin } from "@executor-js/plugin-google/api";
import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";
import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
Expand Down Expand Up @@ -48,6 +49,7 @@ export default defineExecutorConfig({
plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) =>
[
openApiHttpPlugin(),
dynamicUiPlugin(),
googleHttpPlugin(),
microsoftHttpPlugin(),
mcpHttpPlugin({
Expand Down
1 change: 1 addition & 0 deletions apps/cloud/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"@executor-js/execution": "workspace:*",
"@executor-js/fumadb": "workspace:*",
"@executor-js/host-mcp": "workspace:*",
"@executor-js/plugin-dynamic-ui": "workspace:*",
"@executor-js/plugin-google": "workspace:*",
"@executor-js/plugin-graphql": "workspace:*",
"@executor-js/plugin-mcp": "workspace:*",
Expand Down
1 change: 1 addition & 0 deletions apps/cloud/src/env-augment.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ declare global {
VITE_PUBLIC_SENTRY_DSN?: string;
VITE_PUBLIC_POSTHOG_KEY?: string;
VITE_PUBLIC_POSTHOG_HOST?: string;
POSTHOG_HOST?: string;

// Datastore. Prod uses HYPERDRIVE when the binding exists; direct
// DATABASE_URL is only selected when explicitly requested for local/test.
Expand Down
89 changes: 89 additions & 0 deletions apps/cloud/src/feature-flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { env } from "cloudflare:workers";
import { Context, Data, Effect, Layer, Option, Schema } from "effect";

import { DYNAMIC_UI_MCP_APPS_FEATURE_FLAG } from "@executor-js/plugin-dynamic-ui";

class PostHogFeatureFlagError extends Data.TaggedError("PostHogFeatureFlagError")<{
readonly cause: unknown;
}> {}

const FeatureFlagsResponse = Schema.Struct({
featureFlags: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
});
const decodeFeatureFlagsResponse = Schema.decodeUnknownOption(FeatureFlagsResponse);

export type FeatureFlagContext = {
readonly distinctId?: string;
readonly accountId?: string;
readonly organizationId?: string;
readonly groups?: Record<string, string>;
};

export type FeatureFlagsShape = {
readonly isEnabled: (
flag: string,
context: FeatureFlagContext,
) => Effect.Effect<boolean, PostHogFeatureFlagError>;
};

export class CloudFeatureFlags extends Context.Service<CloudFeatureFlags, FeatureFlagsShape>()(
"executor.cloud/FeatureFlags",
) {}

const postHogHost = (): string =>
(env.POSTHOG_HOST ?? "https://us.i.posthog.com").replace(/\/$/, "");

const flagValueEnabled = (value: unknown): boolean =>
value !== false && value !== null && value !== undefined;

const distinctIdFor = (context: FeatureFlagContext): string =>
context.distinctId ?? context.accountId ?? context.organizationId ?? "executor-cloud";

const groupsFor = (context: FeatureFlagContext): Record<string, string> | undefined => {
const groups = {
...(context.groups ?? {}),
...(context.organizationId ? { organization: context.organizationId } : {}),
};
return Object.keys(groups).length > 0 ? groups : undefined;
};

export const makePostHogFeatureFlags = (): FeatureFlagsShape => ({
isEnabled: (flag, context) =>
Effect.gen(function* () {
const apiKey = env.VITE_PUBLIC_POSTHOG_KEY;
if (!apiKey) return false;

const response = yield* Effect.tryPromise({
try: () =>
fetch(`${postHogHost()}/decide/?v=3`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
api_key: apiKey,
distinct_id: distinctIdFor(context),
groups: groupsFor(context),
}),
}),
catch: (cause) => new PostHogFeatureFlagError({ cause }),
});

if (!response.ok) return false;

const raw = yield* Effect.tryPromise({
try: () => response.json(),
catch: (cause) => new PostHogFeatureFlagError({ cause }),
});
const decoded = decodeFeatureFlagsResponse(raw);
if (Option.isNone(decoded)) return false;
return flagValueEnabled(decoded.value.featureFlags?.[flag]);
}).pipe(Effect.withSpan("feature_flags.posthog.is_enabled", { attributes: { flag } })),
});

export const isGeneratedUiMcpAppsEnabled = (context: FeatureFlagContext) =>
CloudFeatureFlags.asEffect().pipe(
Effect.flatMap((featureFlags) =>
featureFlags.isEnabled(DYNAMIC_UI_MCP_APPS_FEATURE_FLAG, context),
),
);

export const PostHogFeatureFlags = Layer.succeed(CloudFeatureFlags, makePostHogFeatureFlags());
33 changes: 32 additions & 1 deletion apps/cloud/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,11 @@ import {
import { makeExecutionStack } from "../engine/execution-stack";
import { CloudMeteredExecutionStackLayer } from "../engine/execution-stack-metered";
import { AutumnService } from "../extensions/billing/service";
import { cloudPlugins } from "../plugins";
import { isGeneratedUiMcpAppsEnabled, PostHogFeatureFlags } from "../feature-flags";
import { DoTelemetryLive, flushTracerProvider } from "../observability/telemetry";
import { captureCause as reportCause } from "../observability";
import { filterDynamicUiMcpPlugins } from "@executor-js/plugin-dynamic-ui";

// Re-export the shared types so existing cloud importers
// (`auth/handlers.ts`, etc.) keep their `../mcp/session-durable-object` path.
Expand Down Expand Up @@ -150,7 +153,7 @@ const makeEphemeralDb = (): CloudSessionDbHandle =>
const makeSessionServices = (dbHandle: CloudSessionDbHandle) => {
const DbLive = Layer.succeed(DbService)({ sql: dbHandle.sql, db: dbHandle.db });
const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive));
return Layer.mergeAll(DbLive, UserStoreLive, CoreSharedServices);
return Layer.mergeAll(DbLive, UserStoreLive, CoreSharedServices, PostHogFeatureFlags);
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -215,9 +218,37 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD
// of invoking `engine.getDescription` across its async boundary.
const description = yield* buildExecuteDescription(executor);
const sessionElicitationMode = sessionMeta.elicitationMode ?? "model";

// Generated UI MCP apps stay behind a PostHog feature flag. A flag-eval
// failure must not break the session, so it degrades to "disabled".
const generatedUiMcpAppsEnabled = yield* isGeneratedUiMcpAppsEnabled({
distinctId: sessionMeta.userId,
accountId: sessionMeta.userId,
organizationId: sessionMeta.organizationId,
groups: { organization: sessionMeta.organizationId },
}).pipe(
Effect.catch((error: unknown) =>
Effect.sync(() => {
console.error("[executor:mcp] generated UI feature flag failed", error);
return false;
}),
),
);
yield* Effect.annotateCurrentSpan({
"feature.generated_ui_mcp_apps.enabled": generatedUiMcpAppsEnabled,
});
const mcpPlugins = filterDynamicUiMcpPlugins(cloudPlugins, generatedUiMcpAppsEnabled);

const mcpServer = yield* createExecutorMcpServer({
engine,
description,
plugins: mcpPlugins,
renderUiFallbackUrl: (code) => {
const origin = env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh";
const url = new URL("/plugins/dynamic-ui/render", origin);
url.hash = `code=${encodeURIComponent(code)}`;
return url.toString();
},
parentSpan: () => self.currentParentSpan(),
debug: env.EXECUTOR_MCP_DEBUG === "true",
browserApprovalStore: self.browserApprovalStore,
Expand Down
22 changes: 22 additions & 0 deletions apps/cloud/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolk
import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId'
import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans'
import { Route as PluginsDotpluginIdDotsplatRouteImport } from './routes/bare/plugins.$pluginId.$'
import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey'

const SetupMcpRoute = SetupMcpRouteImport.update({
Expand Down Expand Up @@ -112,6 +113,12 @@ const Billing_DotplansRoute = Billing_DotplansRouteImport.update({
path: '/{-$orgSlug}/billing/plans',
getParentRoute: () => rootRouteImport,
} as any)
const PluginsDotpluginIdDotsplatRoute =
PluginsDotpluginIdDotsplatRouteImport.update({
id: '/plugins/$pluginId/$',
path: '/plugins/$pluginId/$',
getParentRoute: () => rootRouteImport,
} as any)
const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute =
DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport.update(
{
Expand All @@ -133,6 +140,7 @@ export interface FileRoutesByFullPath {
'/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
'/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
'/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
'/plugins/$pluginId/$': typeof PluginsDotpluginIdDotsplatRoute
'/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute
'/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
'/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
Expand All @@ -151,6 +159,7 @@ export interface FileRoutesByTo {
'/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
'/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
'/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
'/plugins/$pluginId/$': typeof PluginsDotpluginIdDotsplatRoute
'/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute
'/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
'/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
Expand All @@ -170,6 +179,7 @@ export interface FileRoutesById {
'/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
'/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
'/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
'/plugins/$pluginId/$': typeof PluginsDotpluginIdDotsplatRoute
'/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute
'/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
'/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
Expand All @@ -190,6 +200,7 @@ export interface FileRouteTypes {
| '/{-$orgSlug}/toolkits'
| '/{-$orgSlug}/tools'
| '/{-$orgSlug}/'
| '/plugins/$pluginId/$'
| '/{-$orgSlug}/billing/plans'
| '/{-$orgSlug}/integrations/$namespace'
| '/{-$orgSlug}/resume/$executionId'
Expand All @@ -208,6 +219,7 @@ export interface FileRouteTypes {
| '/{-$orgSlug}/toolkits'
| '/{-$orgSlug}/tools'
| '/{-$orgSlug}'
| '/plugins/$pluginId/$'
| '/{-$orgSlug}/billing/plans'
| '/{-$orgSlug}/integrations/$namespace'
| '/{-$orgSlug}/resume/$executionId'
Expand All @@ -226,6 +238,7 @@ export interface FileRouteTypes {
| '/{-$orgSlug}/toolkits'
| '/{-$orgSlug}/tools'
| '/{-$orgSlug}/'
| '/plugins/$pluginId/$'
| '/{-$orgSlug}/billing_/plans'
| '/{-$orgSlug}/integrations/$namespace'
| '/{-$orgSlug}/resume/$executionId'
Expand All @@ -245,6 +258,7 @@ export interface RootRouteChildren {
DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
PluginsDotpluginIdDotsplatRoute: typeof PluginsDotpluginIdDotsplatRoute
Billing_DotplansRoute: typeof Billing_DotplansRoute
DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
ResumeDotexecutionIdRoute: typeof ResumeDotexecutionIdRoute
Expand Down Expand Up @@ -358,6 +372,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof Billing_DotplansRouteImport
parentRoute: typeof rootRouteImport
}
'/plugins/$pluginId/$': {
id: '/plugins/$pluginId/$'
path: '/plugins/$pluginId/$'
fullPath: '/plugins/$pluginId/$'
preLoaderRoute: typeof PluginsDotpluginIdDotsplatRouteImport
parentRoute: typeof rootRouteImport
}
'/{-$orgSlug}/integrations/add/$pluginKey': {
id: '/{-$orgSlug}/integrations/add/$pluginKey'
path: '/{-$orgSlug}/integrations/add/$pluginKey'
Expand Down Expand Up @@ -399,6 +420,7 @@ const rootRouteChildren: RootRouteChildren = {
DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,
DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:
DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,
PluginsDotpluginIdDotsplatRoute: PluginsDotpluginIdDotsplatRoute,
Billing_DotplansRoute: Billing_DotplansRoute,
DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:
DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,
Expand Down
75 changes: 75 additions & 0 deletions apps/cloud/src/routes/bare/plugins.$pluginId.$.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import { useClientPlugins } from "@executor-js/sdk/client";

// Bare (unauthenticated) cloud mount for client-plugin pages. The shared
// console route at `/{-$orgSlug}/plugins/$pluginId/$` is intentionally excluded
// on cloud (it lives inside the WorkOS auth shell); the generated UI fallback
// (`/plugins/dynamic-ui/render`) is reached without a session, so it needs a
// route outside the org scope. Page matching mirrors the shared console route.
export const Route = createFileRoute("/plugins/$pluginId/$")({
component: PluginRouteComponent,
});

function normalizePath(input: string): string {
if (!input || input === "/") return "/";
const withLeadingSlash = input.startsWith("/") ? input : `/${input}`;
return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/+$/, "") : "/";
}

const pathSegments = (input: string): readonly string[] =>
normalizePath(input)
.split("/")
.filter((segment) => segment.length > 0);

const matchPluginPagePath = (
pattern: string,
target: string,
): Readonly<Record<string, string>> | null => {
const patternSegments = pathSegments(pattern);
const targetSegments = pathSegments(target);
if (patternSegments.length !== targetSegments.length) return null;

const params: Record<string, string> = {};
for (let index = 0; index < patternSegments.length; index += 1) {
const patternSegment = patternSegments[index]!;
const targetSegment = targetSegments[index]!;
if (patternSegment.startsWith("$") && patternSegment.length > 1) {
params[patternSegment.slice(1)] = decodeURIComponent(targetSegment);
continue;
}
if (patternSegment !== targetSegment) return null;
}
return params;
};

const matchScore = (pattern: string): number =>
pathSegments(pattern).reduce((score, segment) => score + (segment.startsWith("$") ? 1 : 2), 0);

function PluginRouteComponent() {
const { pluginId, _splat: rest } = Route.useParams();
const plugins = useClientPlugins();
const plugin = plugins.find((p) => p.id === pluginId);
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router represents not-found from components by throwing notFound()
if (!plugin) throw notFound();

const target = normalizePath(rest ?? "/");
const match =
(plugin.pages ?? [])
.map((page, index) => ({ page, index, params: matchPluginPagePath(page.path, target) }))
.filter(
(
candidate,
): candidate is {
page: (typeof candidate)["page"];
index: number;
params: Readonly<Record<string, string>>;
} => candidate.params !== null,
)
.sort((a, b) => matchScore(b.page.path) - matchScore(a.page.path) || a.index - b.index)[0] ??
null;
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router represents not-found from components by throwing notFound()
if (!match) throw notFound();

const Component = match.page.component;
return <Component params={match.params} path={target} pluginId={pluginId} />;
}
2 changes: 2 additions & 0 deletions apps/local/executor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
import { googleHttpPlugin } from "@executor-js/plugin-google/api";
import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";
import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
import { dynamicUiPlugin } from "@executor-js/plugin-dynamic-ui";
import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
import { keychainPlugin } from "@executor-js/plugin-keychain";
import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";
Expand All @@ -27,6 +28,7 @@ export default defineExecutorConfig({
plugins: ({ activeToolkitSlug }: LocalPluginDeps = {}) =>
[
openApiHttpPlugin(),
dynamicUiPlugin(),
googleHttpPlugin(),
microsoftHttpPlugin(),
mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }),
Expand Down
Loading
Loading