Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
9 changes: 4 additions & 5 deletions apps/host-cloudflare/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ import { makeCloudflareMcpAgentHandler } from "./mcp/agent-handler";
import { preloadQuickJs } from "./quickjs";

// ===========================================================================
// The Cloudflare host, as ONE `ExecutorApp.make` call the 4th app alongside
// The Cloudflare host, as ONE `ExecutorApp.make` call - the 4th app alongside
// cloud / self-host / local, differing only by the injected Layers.
//
// The whole scenario in 60 seconds: Cloudflare Access is the identity (validate
// the Cf-Access-Jwt-Assertion JWT no Better Auth, no WorkOS, no app login),
// the Cf-Access-Jwt-Assertion JWT - no Better Auth, no WorkOS, no app login),
// D1 is the SQLite store (same FumaDB assembly as self-host), QuickJS is the
// in-process code substrate, no billing, single-tenant. `diff` against
// host-selfhost/src/app.ts is three injected Layers: identity, db, plugins/config.
Expand All @@ -40,13 +40,12 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => {
const plugins = makeCloudflarePlugins(
config.secretKey,
env.ANALYTICS,
env.VECTORIZE,
config.geminiApiKey,
config.aiSearch,
config.organizationId,
);

// Load the Workers-compatible (WASM-inlined) QuickJS variant before any
// executor is built the default variant can't fetch its .wasm on Workers.
// executor is built - the default variant can't fetch its .wasm on Workers.
await preloadQuickJs();

// Open + idempotently bring up the D1 schema once (the long-lived handle the
Expand Down
43 changes: 17 additions & 26 deletions apps/host-cloudflare/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
R2Bucket,
} from "@cloudflare/workers-types";
import type { AnalyticsEngineDataset } from "@executor-js/plugin-execution-metrics/cloudflare";
import type { VectorizeIndex } from "@executor-js/plugin-semantic-search";
import type { AiSearchInstance } from "@executor-js/plugin-semantic-search";

import { isValidOrgSlug } from "@executor-js/api";
import { missingPublicOriginWarning, resolvePublicOrigin } from "@executor-js/sdk/public-origin";
Expand All @@ -15,7 +15,7 @@ let warnedNoCloudflareOrigin = false;
// ---------------------------------------------------------------------------
// Cloudflare host config. Unlike self-host (process.env + a data dir), a Worker
// receives its bindings + vars per request as `env`, so config is derived from
// that object there is no process.env, no filesystem, no boot-time secret
// that object - there is no process.env, no filesystem, no boot-time secret
// generation. Identity comes entirely from Cloudflare Access in front of the
// Worker; the only real secret is the at-rest secret-encryption key.
// ---------------------------------------------------------------------------
Expand All @@ -24,26 +24,19 @@ export const CLOUDFLARE_NAMESPACE = "executor_cloudflare";
export const CLOUDFLARE_SCHEMA_VERSION = "1.0.0";

export interface CloudflareEnv {
/** D1 database binding the app's SQLite store. */
/** D1 database binding - the app's SQLite store. */
readonly DB: D1Database;
/** R2 bucket binding holds values too large for a D1 row (~1-2MB cap). */
/** R2 bucket binding - holds values too large for a D1 row (~1-2MB cap). */
readonly BLOBS?: R2Bucket;
/** KV namespace binding durable cache for derived executor artifacts. */
/** KV namespace binding - durable cache for derived executor artifacts. */
readonly CACHE?: KVNamespace;
/** Workers Analytics Engine binding opt-in sink for execution metrics. When
/** Workers Analytics Engine binding - opt-in sink for execution metrics. When
* bound (uncomment `analytics_engine_datasets` in wrangler.jsonc), each
* finished execution/tool call writes a data point; absent, metrics are off. */
readonly ANALYTICS?: AnalyticsEngineDataset;
/** Vectorize index binding — opt-in semantic `tools.search`. When bound (add a
* `vectorize` binding in wrangler.jsonc) the semantic-search plugin embeds
* the tool catalog and answers `tools.search` from it; absent, the engine
* keeps its built-in lexical search. */
readonly VECTORIZE?: VectorizeIndex;
/** Gemini API key (a `wrangler secret`) powering the embeddings for the
* Vectorize search. Absent → semantic search stays inert even if the index
* is bound. */
readonly GEMINI_API_KEY?: string;
/** MCP session Durable Object namespace — one addressable isolate per MCP
/** AI Search instance binding for semantic `tools.search`. */
readonly AI_SEARCH?: AiSearchInstance;
/** MCP session Durable Object namespace - one addressable isolate per MCP
* session (the DO id IS the session id), so a session survives across the
* Worker's stateless isolates. */
readonly MCP_SESSION: DurableObjectNamespace;
Expand All @@ -69,7 +62,7 @@ export interface CloudflareEnv {
/**
* Dev/single-user escape hatch: when "true", skip Cloudflare Access entirely
* and treat every request as a fixed admin. For local `wrangler dev` and
* unattended validation only NEVER set on a deployment that isn't already
* unattended validation only - NEVER set on a deployment that isn't already
* behind Access, or the instance is wide open.
*/
readonly ENABLE_DEV_AUTH?: string;
Expand All @@ -86,12 +79,10 @@ export interface CloudflareConfig {
/** URL slug for org-prefixed console paths (`/<slug>/policies`). */
readonly organizationSlug: string;
readonly secretKey: string;
/** Gemini API key for the Vectorize search embeddings (a `wrangler secret`).
* Unset → vectorize search is inert. */
readonly geminiApiKey?: string;
readonly aiSearch?: AiSearchInstance;
readonly allowLocalNetwork: boolean;
/** Explicit web base URL (`VITE_PUBLIC_SITE_URL`). Unset on a Worker with no
* static URL the per-request origin is used instead (see RequestWebOrigin). */
* static URL - the per-request origin is used instead (see RequestWebOrigin). */
readonly webBaseUrl?: string;
readonly enableDevAuth: boolean;
}
Expand All @@ -104,7 +95,7 @@ const splitLower = (value: string | undefined): readonly string[] =>

// The org slug doubles as a URL segment (`/<slug>/policies`), so an
// operator-set value must fit the shared grammar and avoid reserved root
// segments a colliding slug would shadow real routes (notably /api, /mcp,
// segments - a colliding slug would shadow real routes (notably /api, /mcp,
// and Cloudflare's /cdn-cgi).
const resolveOrgSlug = (value: string | undefined): string => {
if (!value) return "default";
Expand All @@ -122,7 +113,7 @@ export const loadConfig = (env: CloudflareEnv): CloudflareConfig => {
if (!secretKey || secretKey.length < 16) {
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: the Worker must not boot without the at-rest secret key
throw new Error(
"EXECUTOR_SECRET_KEY must be set (wrangler secret put EXECUTOR_SECRET_KEY) it encrypts stored secrets at rest in D1",
"EXECUTOR_SECRET_KEY must be set (wrangler secret put EXECUTOR_SECRET_KEY) - it encrypts stored secrets at rest in D1",
);
}
const enableDevAuth = env.ENABLE_DEV_AUTH === "true";
Expand All @@ -146,12 +137,12 @@ export const loadConfig = (env: CloudflareEnv): CloudflareConfig => {
organizationName: env.SELF_HOSTED_ORG_NAME ?? "Default",
organizationSlug: resolveOrgSlug(env.SELF_HOSTED_ORG_SLUG),
secretKey,
geminiApiKey: env.GEMINI_API_KEY?.trim() || undefined,
aiSearch: env.AI_SEARCH,
allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true",
// Pinned origin via the shared resolver. A Worker receives no PaaS platform
// vars (env: {} there is nothing to detect), so only the explicit
// vars (env: {} - there is nothing to detect), so only the explicit
// VITE_PUBLIC_SITE_URL applies; when it's unset we leave webBaseUrl undefined
// and let the per-request origin drive it (request.url Cloudflare-set, not
// and let the per-request origin drive it (request.url - Cloudflare-set, not
// spoofable via Host). Warn once on a real deployment so the operator pins it,
// mirroring self-host (gated on enableDevAuth = local `wrangler dev`).
webBaseUrl,
Expand Down
7 changes: 4 additions & 3 deletions apps/host-cloudflare/src/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ import type { CloudflareConfig } from "./config";
import { makeCloudflarePlugins } from "./plugins";

// ---------------------------------------------------------------------------
// Cloudflare execution-stack seams the same shape as self-host (QuickJS code
// Cloudflare execution-stack seams - the same shape as self-host (QuickJS code
// substrate, no-op engine decorator), with the plugins + host config built from
// the per-request `env`-derived config rather than process.env.
//
// QuickJS-wasm is the default code substrate because it runs in a single Worker
// with no extra binding. When Cloudflare's dynamic Worker Loader leaves closed
// beta, swap CodeExecutorProvider for the dynamic-worker executor (cloud's)
// beta, swap CodeExecutorProvider for the dynamic-worker executor (cloud's) -
// it's a one-Layer change behind this same seam.
// ---------------------------------------------------------------------------

Expand All @@ -38,7 +38,8 @@ export const makeCloudflarePluginsProvider = (
config: CloudflareConfig,
): Layer.Layer<PluginsProvider> =>
Layer.succeed(PluginsProvider)({
plugins: () => makeCloudflarePlugins(config.secretKey),
plugins: () =>
makeCloudflarePlugins(config.secretKey, undefined, config.aiSearch, config.organizationId),
});

export const makeCloudflareHostConfig = (config: CloudflareConfig): Layer.Layer<HostConfig> =>
Expand Down
22 changes: 7 additions & 15 deletions apps/host-cloudflare/src/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,10 @@ import {
import { noopExecutionObserver } from "@executor-js/sdk";
import { serviceTokensPlugin } from "@executor-js/plugin-service-tokens/server";
import { semanticSearchHttpPlugin } from "@executor-js/plugin-semantic-search/api";
import {
makeVectorizeStore,
withCloudflareLimits,
type VectorizeIndex,
} from "@executor-js/plugin-semantic-search";
import type { AiSearchInstance } from "@executor-js/plugin-semantic-search";

// ---------------------------------------------------------------------------
// The Cloudflare host's plugin list the same protocol/provider plugins as
// The Cloudflare host's plugin list - the same protocol/provider plugins as
// self-host (no WorkOS Vault). Built as a factory because the encrypted-secrets
// master key arrives via `env` at request time (no process.env on a Worker), so
// the plugin set is constructed per app-build with the resolved key. The tuple
Expand All @@ -28,7 +24,7 @@ import {
// `dangerouslyAllowStdioMCP` is false: a multi-user instance must not let a user
// spawn arbitrary stdio MCP processes.
//
// Execution metrics ship to Workers Analytics Engine opt-in via the wrangler
// Execution metrics ship to Workers Analytics Engine - opt-in via the wrangler
// `ANALYTICS` binding. The plugin is always in the tuple (it has no tables/API,
// so the shape is stable), but its observer is a no-op until `env.ANALYTICS` is
// bound. Effect's Metric registry is per-isolate (meaningless on a Worker
Expand All @@ -37,20 +33,16 @@ import {
// wrangler.jsonc.
//
// Semantic search follows the same opt-in-by-binding shape: the plugin is
// always in the tuple (its reindex route keeps the API shape stable), but it is
// inert — the engine keeps its lexical `tools.search` — until BOTH a `vectorize`
// binding and the `GEMINI_API_KEY` secret are present. To enable: create a
// Vectorize index + add the binding in wrangler.jsonc and set the secret.
// always in the tuple, but it is inert until the AI Search instance binding is
// present. To enable, create an AI Search instance and bind it in wrangler.jsonc.
// ---------------------------------------------------------------------------

export const makeCloudflarePlugins = (
secretKey: string,
analytics?: AnalyticsEngineDataset,
vectorize?: VectorizeIndex,
geminiApiKey?: string,
aiSearch?: AiSearchInstance,
searchNamespace?: string,
) => {
const store = vectorize ? withCloudflareLimits(makeVectorizeStore(vectorize)) : undefined;
return [
openApiHttpPlugin(),
googleHttpPlugin(),
Expand All @@ -62,7 +54,7 @@ export const makeCloudflarePlugins = (
observer: () => (analytics ? createWaeMetricsObserver(analytics) : noopExecutionObserver),
}),
serviceTokensPlugin(),
semanticSearchHttpPlugin({ store, geminiApiKey, namespace: searchNamespace }),
semanticSearchHttpPlugin({ aiSearch, namespace: searchNamespace }),
] as const;
};

Expand Down
11 changes: 7 additions & 4 deletions apps/host-cloudflare/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"compatibility_flags": ["nodejs_compat"],
"main": "src/worker.ts",
"observability": { "enabled": true },
// The web UI (Workers Static Assets) the shared multiplayer SPA built by
// The web UI (Workers Static Assets) - the shared multiplayer SPA built by
// `vite build` into ./dist. `single-page-application` serves index.html for
// client routes (e.g. /policies); `run_worker_first` forces the API + MCP
// paths to the Worker instead of the SPA fallback.
Expand Down Expand Up @@ -39,16 +39,19 @@
// "kv_namespaces": [
// { "binding": "CACHE", "id": "..." },
// ],
// Workers Analytics Engine the opt-in sink for execution metrics. Uncomment
// Workers Analytics Engine - the opt-in sink for execution metrics. Uncomment
// to enable: each finished execution/tool call writes a data point (queryable
// via Cloudflare's GraphQL Analytics API). The plugin is inert until this
Comment thread
aryasaatvik marked this conversation as resolved.
// binding is present. Requires a paid Workers plan.
// "analytics_engine_datasets": [
// { "binding": "ANALYTICS", "dataset": "executor_executions" },
// ],
// Cloudflare AI Search backs semantic `tools.search`. Uncomment after creating
// the instance, otherwise deploy will fail because the binding target is absent.
// "ai_search": [{ "binding": "AI_SEARCH", "instance_name": "executor-tool-search" }],
// The MCP session Durable Object: one addressable isolate per MCP session (the
// DO id IS the session id) so a session survives across the Worker's stateless
// isolates without it, `tools/list` after `initialize` can land on a fresh
// isolates - without it, `tools/list` after `initialize` can land on a fresh
// isolate that never saw the session ("Not connected"). `new_sqlite_classes`
// is the free-tier-eligible SQLite-backed DO storage.
"durable_objects": {
Expand All @@ -58,7 +61,7 @@
// Cloudflare Access is the entire auth layer: the Worker validates the
// Cf-Access-Jwt-Assertion JWT against the team JWKS. Set these to your Zero
// Trust team domain + the Access application's AUD tag. EXECUTOR_SECRET_KEY
// (the at-rest secret-encryption key) is a SECRET set it with
// (the at-rest secret-encryption key) is a SECRET - set it with
// `wrangler secret put EXECUTOR_SECRET_KEY`, never in vars.
"vars": {
"ACCESS_TEAM_DOMAIN": "your-team.cloudflareaccess.com",
Expand Down
Loading
Loading