diff --git a/apps/host-cloudflare/README.md b/apps/host-cloudflare/README.md index 1770dbbe8..f092abe87 100644 --- a/apps/host-cloudflare/README.md +++ b/apps/host-cloudflare/README.md @@ -4,14 +4,14 @@ Executor as a single Cloudflare Worker. The fourth app on the shared `ExecutorApp.make` facade (alongside cloud, self-host, and local) — same code paths, different injected providers: -| Seam | Cloudflare provider | -| ------------ | ---------------------------------------------------------------- | -| **identity** | Cloudflare Access JWT (`Cf-Access-Jwt-Assertion`) — no app login | -| **db** | D1 (SQLite) via the shared FumaDB assembly | -| **engine** | QuickJS-WASM, in-Worker (no extra binding) | -| **mcp** | Access-JWT auth + the shared in-process session store | -| **account** | `/account/me` from the Access principal (members/keys → Access) | -| **web** | the shared multiplayer SPA (Workers Static Assets) | +| Seam | Cloudflare provider | +| ------------ | ----------------------------------------------------------------- | +| **identity** | Cloudflare Access JWT (`Cf-Access-Jwt-Assertion`) — no app login | +| **db** | D1 (SQLite) via the shared FumaDB assembly | +| **engine** | dynamic-worker (Worker Loaders via `LOADER`), same as cloud | +| **mcp** | Access-JWT auth + the `McpSessionDO` Durable Object session store | +| **account** | `/account/me` from the Access principal (members/keys → Access) | +| **web** | the shared multiplayer SPA (Workers Static Assets) | Single-tenant: every Access-verified principal belongs to the one configured org. Members and credentials are managed in Cloudflare Access, not in-app. @@ -79,11 +79,11 @@ environment (it disables the Access gate). ## Notes -- The QuickJS engine WASM is vendored into `src/quickjs-engine.wasm` (Workers - forbid runtime WASM compilation; it must be statically imported). Refresh it - after bumping the engine with `bun run vendor-wasm`. -- MCP sessions live in-process (one isolate owns a session). The cross-isolate - upgrade is a Durable Object behind the same `McpSessionStore` seam. -- When Cloudflare's dynamic Worker Loader leaves closed beta, the QuickJS code - substrate swaps for the dynamic-worker executor behind the `engine` seam — a - one-Layer change. +- The `engine` seam runs the dynamic-worker executor (the same substrate cloud + uses): each code execution loads a fresh workerd isolate through the `LOADER` + Worker Loader binding (`wrangler.jsonc` → `worker_loaders`). This gives + structured-clone fidelity at the tool boundary (`Blob`/`File`/`Uint8Array`, + `Date`/`Map`/`Set`) plus `nodejs_compat`/`fetch`. Worker Loaders is open beta + (since 2026-03-24) and requires a **paid** Workers plan. +- MCP sessions are stored in the `McpSessionDO` Durable Object (the DO id is the + session id), so a session survives across the Worker's stateless isolates. diff --git a/apps/host-cloudflare/package.json b/apps/host-cloudflare/package.json index 9ece894d1..8d65534b1 100644 --- a/apps/host-cloudflare/package.json +++ b/apps/host-cloudflare/package.json @@ -10,7 +10,6 @@ "typecheck": "tsgo --noEmit", "cf-typegen": "wrangler types", "deploy:setup": "bash scripts/deploy.sh", - "vendor-wasm": "bun run scripts/vendor-quickjs-wasm.ts", "test": "vitest run", "test:watch": "vitest", "routes:gen": "bun scripts/gen-routes.ts" @@ -30,20 +29,18 @@ "@executor-js/plugin-microsoft": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", "@executor-js/react": "workspace:*", - "@executor-js/runtime-quickjs": "workspace:*", + "@executor-js/runtime-dynamic-worker": "workspace:*", "@executor-js/sdk": "workspace:*", - "@jitl/quickjs-wasmfile-release-sync": "catalog:", "@modelcontextprotocol/sdk": "^1.29.0", "@tanstack/react-router": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", "jose": "^5.9.6", - "quickjs-emscripten-core": "0.31.0", "react": "catalog:", "react-dom": "catalog:" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20250410.0", + "@cloudflare/workers-types": "^4.20250620.0", "@effect/vitest": "catalog:", "@executor-js/vite-plugin": "workspace:*", "@tailwindcss/vite": "catalog:", diff --git a/apps/host-cloudflare/scripts/vendor-quickjs-wasm.ts b/apps/host-cloudflare/scripts/vendor-quickjs-wasm.ts deleted file mode 100755 index 1518babf4..000000000 --- a/apps/host-cloudflare/scripts/vendor-quickjs-wasm.ts +++ /dev/null @@ -1,18 +0,0 @@ -// 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/host-cloudflare/src/app.ts b/apps/host-cloudflare/src/app.ts index 9c284a48a..d364a3bb9 100644 --- a/apps/host-cloudflare/src/app.ts +++ b/apps/host-cloudflare/src/app.ts @@ -8,14 +8,13 @@ import { makeCloudflarePlugins } from "./plugins"; import { createD1ExecutorDb } from "./db/d1"; import { cloudflareAccessIdentityLayer } from "./auth/cloudflare-access"; import { - CloudflareCodeExecutorProvider, + makeCloudflareCodeExecutorProvider, makeCloudflareHostConfig, makeCloudflarePluginsProvider, } from "./execution"; import { ErrorCaptureLive } from "./observability"; import { cloudflareAccountMiddleware } from "./account/account-provider"; import { makeCloudflareMcpSeams } from "./mcp"; -import { preloadQuickJs } from "./quickjs"; // =========================================================================== // The Cloudflare host, as ONE `ExecutorApp.make` call — the 4th app alongside @@ -23,9 +22,10 @@ import { preloadQuickJs } from "./quickjs"; // // 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), -// 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. +// D1 is the SQLite store (same FumaDB assembly as self-host), the dynamic-worker +// executor (Worker Loaders, the `LOADER` binding) is the code substrate (same as +// cloud), no billing, single-tenant. `diff` against host-selfhost/src/app.ts is +// the injected Layers: identity, db, plugins/config, code executor. // // Built per isolate (async) so the D1 schema bring-up happens once at first // fetch; `env` arrives with that fetch (a Worker has no module-scope bindings), @@ -36,10 +36,6 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => { const config = loadConfig(env); const plugins = makeCloudflarePlugins(config.secretKey); - // Load the Workers-compatible (WASM-inlined) QuickJS variant before any - // 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 // per-request scoped executor reads through the DbProvider seam). const dbHandle = await createD1ExecutorDb(env.DB, env.BLOBS); @@ -53,7 +49,7 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => { providers: { identity: identityLayer, db: dbProviderLayer(Effect.succeed(dbHandle)), - engine: { codeExecutor: CloudflareCodeExecutorProvider }, // decorator defaults to no-op + engine: { codeExecutor: makeCloudflareCodeExecutorProvider(env.LOADER) }, // decorator defaults to no-op plugins: { provider: makeCloudflarePluginsProvider(config), config: makeCloudflareHostConfig(config), @@ -63,8 +59,8 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => { // auth context; `me` reflects the Access principal. Members/keys are // Access-managed, so the rest of the surface is stubbed. account: cloudflareAccountMiddleware(config), - // The MCP serving envelope: Access-JWT auth + the shared in-process session - // store over the QuickJS engine. + // The MCP serving envelope: Access-JWT auth + the Durable Object session + // store over the dynamic-worker engine. mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter }, }, extensions: { diff --git a/apps/host-cloudflare/src/config.ts b/apps/host-cloudflare/src/config.ts index 140d904f8..08736fe96 100644 --- a/apps/host-cloudflare/src/config.ts +++ b/apps/host-cloudflare/src/config.ts @@ -25,6 +25,9 @@ export interface CloudflareEnv { * session (the DO id IS the session id), so a session survives across the * Worker's stateless isolates. */ readonly MCP_SESSION: DurableObjectNamespace; + /** Worker Loader binding for the dynamic-worker code substrate: the + * sandbox-isolate loader. Each code execution loads a fresh workerd isolate. */ + readonly LOADER: WorkerLoader; /** Zero Trust team domain, e.g. `your-team.cloudflareaccess.com`. */ readonly ACCESS_TEAM_DOMAIN: string; /** The Access application's AUD tag (the JWT audience to verify). */ diff --git a/apps/host-cloudflare/src/execution.ts b/apps/host-cloudflare/src/execution.ts index 06bfec2aa..138f73df6 100644 --- a/apps/host-cloudflare/src/execution.ts +++ b/apps/host-cloudflare/src/execution.ts @@ -10,29 +10,41 @@ import { PluginsProvider, type ExecutorDbHandle, } from "@executor-js/api/server"; -import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; +import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker"; import type { CloudflareConfig } from "./config"; import { makeCloudflarePlugins } from "./plugins"; // --------------------------------------------------------------------------- -// 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. +// Cloudflare execution-stack seams. The plugins + host config are built from the +// per-request `env`-derived config rather than process.env, with a no-op engine +// decorator (no billing). // -// 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) — -// it's a one-Layer change behind this same seam. +// The code substrate is the dynamic-worker executor (the same one cloud uses): +// each execution loads a fresh workerd isolate through the `LOADER` Worker Loader +// binding. Unlike cloud, which reads the ambient `cloudflare:workers` env, the +// host threads `env.LOADER` explicitly through the seam (the host's deliberate +// pattern: a Worker receives its bindings per request, so providers close over +// `env` rather than a module-scope import). // --------------------------------------------------------------------------- export { makeExecutionStack } from "@executor-js/api/server"; export { EngineDecoratorNoop }; -export const CloudflareCodeExecutorProvider: Layer.Layer = Layer.sync( - CodeExecutorProvider, - () => makeQuickJsExecutor(), -); +export const makeCloudflareCodeExecutorProvider = ( + loader: WorkerLoader, +): Layer.Layer => { + if (!loader) { + // The dynamic-worker executor only touches `loader` on the first `execute`, + // so a missing binding would otherwise surface as an opaque "undefined" defect + // mid-execution. Fail at app boot with an actionable message instead. + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: the Worker cannot execute code without the LOADER binding + throw new Error( + "Missing LOADER binding: add `worker_loaders` to wrangler.jsonc (Worker Loaders requires a paid Workers plan).", + ); + } + return Layer.sync(CodeExecutorProvider, () => makeDynamicWorkerExecutor({ loader })); +}; export const makeCloudflarePluginsProvider = ( config: CloudflareConfig, @@ -58,6 +70,7 @@ export const makeCloudflareHostConfig = (config: CloudflareConfig): Layer.Layer< export const makeCloudflareExecutionStackLayer = ( config: CloudflareConfig, dbHandle: ExecutorDbHandle, + loader: WorkerLoader, ): Layer.Layer< DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator > => @@ -65,6 +78,6 @@ export const makeCloudflareExecutionStackLayer = ( dbProviderLayer(Effect.succeed(dbHandle)), makeCloudflarePluginsProvider(config), makeCloudflareHostConfig(config), - CloudflareCodeExecutorProvider, + makeCloudflareCodeExecutorProvider(loader), EngineDecoratorNoop, ); diff --git a/apps/host-cloudflare/src/mcp/session-durable-object.ts b/apps/host-cloudflare/src/mcp/session-durable-object.ts index 26caac4df..cd2b6b331 100644 --- a/apps/host-cloudflare/src/mcp/session-durable-object.ts +++ b/apps/host-cloudflare/src/mcp/session-durable-object.ts @@ -13,7 +13,6 @@ import { import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config"; import { createD1ExecutorDb } from "../db/d1"; import { makeCloudflareExecutionStackLayer, makeExecutionStack } from "../execution"; -import { preloadQuickJs } from "../quickjs"; // --------------------------------------------------------------------------- // Cloudflare (self-host) MCP Session Durable Object — the host-cloudflare @@ -24,7 +23,7 @@ import { preloadQuickJs } from "../quickjs"; // `end` disposal contract. // - resolveSessionMeta → single-tenant: the org is fixed in config, so no // lookup — just stamp the configured org name. -// - buildMcpServer → the QuickJS execution stack + the MCP tool server. +// - buildMcpServer → the dynamic-worker execution stack + the MCP tool server. // host-cf has no OTel/Sentry, so it keeps the base's default no-op telemetry + // error seams. Replacing the prior in-memory store with this DO is what fixes // `tools/list` failing across Worker isolates (a session created on one isolate @@ -72,14 +71,13 @@ export class McpSessionDO extends McpSessionDOBase { const config = this.cfConfig; const self = this; return Effect.gen(function* () { - // QuickJS-WASM must be loaded before the executor layer builds it (the - // default variant can't fetch its .wasm on Workers). Idempotent per isolate. - yield* Effect.promise(() => preloadQuickJs()); const { engine } = yield* makeExecutionStack( sessionMeta.userId, sessionMeta.organizationId, sessionMeta.organizationName, - ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle))); + ).pipe( + Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle, self.cfEnv.LOADER)), + ); // Browser elicitation mode (the base owns the approval store + the HTTP // approval RPCs): a gated execution pauses and returns an approvalUrl into // the console resume page. The URL origin is the create request's origin diff --git a/apps/host-cloudflare/src/quickjs-engine.wasm b/apps/host-cloudflare/src/quickjs-engine.wasm deleted file mode 100644 index ee1a98f5a..000000000 Binary files a/apps/host-cloudflare/src/quickjs-engine.wasm and /dev/null differ diff --git a/apps/host-cloudflare/src/quickjs.ts b/apps/host-cloudflare/src/quickjs.ts deleted file mode 100644 index 9d84484ed..000000000 --- a/apps/host-cloudflare/src/quickjs.ts +++ /dev/null @@ -1,35 +0,0 @@ -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/host-cloudflare/src/worker.e2e.node.test.ts b/apps/host-cloudflare/src/worker.e2e.node.test.ts index fd627e7cc..a02e2285c 100644 --- a/apps/host-cloudflare/src/worker.e2e.node.test.ts +++ b/apps/host-cloudflare/src/worker.e2e.node.test.ts @@ -8,10 +8,11 @@ import { unstable_dev, type Unstable_DevWorker } from "wrangler"; // --------------------------------------------------------------------------- // End-to-end test for the Cloudflare host: boots the REAL worker on workerd via -// Miniflare (wrangler `unstable_dev`) with a local D1 + R2, dev-auth on. This is -// the only test that exercises the CF-specific stack together — D1 schema -// bring-up, the R2-backed blob seam (multi-MB spec storage), QuickJS-WASM -// execution, and the MCP envelope — through the actual HTTP surface. +// Miniflare (wrangler `unstable_dev`) with a local D1 + R2 + Worker Loaders, +// dev-auth on. This is the only test that exercises the CF-specific stack +// together, through the actual HTTP surface: D1 schema bring-up, the R2-backed +// blob seam (multi-MB spec storage), the dynamic-worker code substrate (Worker +// Loaders via the `LOADER` binding), and the MCP envelope. // --------------------------------------------------------------------------- const dir = fileURLToPath(new URL(".", import.meta.url)); @@ -60,7 +61,7 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { await worker?.stop(); }); - it("executes TypeScript via /api/executions (QuickJS on workerd)", async () => { + it("executes TypeScript via /api/executions (dynamic-worker on workerd)", async () => { const res = await worker.fetch("/api/executions", { method: "POST", headers: { "content-type": "application/json" }, @@ -77,6 +78,25 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { expect(body.text).toBe("42"); }, 60_000); + it("runs code using a Node API (Buffer), proving nodejs_compat in the dynamic-worker isolate", async () => { + // A capability the prior QuickJS-WASM substrate could NOT provide: the + // dynamic-worker isolate boots with `nodejs_compat`, so `Buffer` (and the + // node:* builtins) are available. QuickJS-WASM had no node compat, so this + // doubles as a regression guard that the engine really is dynamic-worker. + const res = await worker.fetch("/api/executions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + code: 'export default Buffer.from("hello").toString("base64")', + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { status: string; text: string; isError: boolean }; + expect(body.status).toBe("completed"); + expect(body.isError).toBe(false); + expect(body.text).toBe("aGVsbG8="); + }, 60_000); + it("adds a LARGE OpenAPI source — exercises the R2 blob seam (~1MB spec) + createMany batching (>100 tools)", async () => { // Synthesize a spec big enough to (a) far exceed D1's per-value cap if it // were inlined — proving the spec text really lands in the R2 blob seam — @@ -216,7 +236,7 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { expect(toolNames).toContain("execute"); }, 60_000); - it("invokes the execute tool over MCP (initialize → tools/call → QuickJS)", async () => { + it("invokes the execute tool over MCP (initialize → tools/call → dynamic-worker)", async () => { const accept = "application/json, text/event-stream"; const rpc = (sessionId: string | null, body: unknown) => worker.fetch("/mcp", { diff --git a/apps/host-cloudflare/wrangler.jsonc b/apps/host-cloudflare/wrangler.jsonc index 2c8cfa65a..9ce41113e 100644 --- a/apps/host-cloudflare/wrangler.jsonc +++ b/apps/host-cloudflare/wrangler.jsonc @@ -1,86 +1,70 @@ { - "$schema": "node_modules/wrangler/config-schema.json", - "name": "executor-cloudflare", - "compatibility_date": "2025-04-01", - "compatibility_flags": [ - "nodejs_compat" - ], - "main": "src/worker.ts", - "observability": { - "enabled": true - }, - // 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. - "assets": { - "directory": "./dist", - "not_found_handling": "single-page-application", - "run_worker_first": [ - "/api/*", - "/mcp", - "/mcp/*" - ], - }, - "placement": { - "mode": "smart" - }, - // D1 is the app's SQLite store (the DbProvider seam). `wrangler deploy` - // auto-provisions it on first deploy; replace database_id after that, or run - // `wrangler d1 create executor` and paste the id here. - "d1_databases": [ - { - "binding": "DB", - "database_name": "executor", - "database_id": "4bcd3ad7-3e99-4139-955c-d48f8751cf4e", - }, - ], - // Plugin blob seam backend: multi-MB values (resolved OpenAPI specs, - // introspection snapshots) live here, not in D1 (which caps a value at - // ~1-2MB). `wrangler r2 bucket create executor-blobs` provisions it. - "r2_buckets": [ - { - "binding": "BLOBS", - "bucket_name": "executor-blobs", - }, - ], - // 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 - // isolate that never saw the session ("Not connected"). `new_sqlite_classes` - // is the free-tier-eligible SQLite-backed DO storage. - "durable_objects": { - "bindings": [ - { - "name": "MCP_SESSION", - "class_name": "McpSessionDO" - } - ], - }, - "migrations": [ - { - "tag": "v1", - "new_sqlite_classes": [ - "McpSessionDO" - ] - } - ], - // 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 - // `wrangler secret put EXECUTOR_SECRET_KEY`, never in vars. - "vars": { - "ACCESS_TEAM_DOMAIN": "balancefriends.cloudflareaccess.com", - "ACCESS_AUD": "cef8f0f285c7559562d109f0e8799b9cfc905bb7e4df666d7ba10eb8455998cd", - "ACCESS_NAME_CLAIM": "name", - "ACCESS_GROUPS_CLAIM": "groups", - "ADMIN_EMAILS": "minsu.lee@passionfactory.ai", - "SELF_HOSTED_ORG_ID": "passionfactory", - "SELF_HOSTED_ORG_NAME": "PassionFactory", - // VITE_PUBLIC_SITE_URL is intentionally unset: with no static URL the worker - // derives the web base URL from each request's origin (RequestWebOrigin), so - // secret/OAuth handoff links match whatever host the user actually reached. - // Set it only to force a canonical URL (e.g. behind a proxy that rewrites Host). - }, -} \ No newline at end of file + "$schema": "node_modules/wrangler/config-schema.json", + "name": "executor-cloudflare", + "compatibility_date": "2025-04-01", + "compatibility_flags": ["nodejs_compat"], + "main": "src/worker.ts", + "observability": { "enabled": true }, + // 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. + "assets": { + "directory": "./dist", + "not_found_handling": "single-page-application", + "run_worker_first": ["/api/*", "/mcp", "/mcp/*"], + }, + "placement": { "mode": "smart" }, + // D1 is the app's SQLite store (the DbProvider seam). `wrangler deploy` + // auto-provisions it on first deploy; replace database_id after that, or run + // `wrangler d1 create executor` and paste the id here. + "d1_databases": [ + { + "binding": "DB", + "database_name": "executor", + "database_id": "4bcd3ad7-3e99-4139-955c-d48f8751cf4e", + }, + ], + // Plugin blob seam backend: multi-MB values (resolved OpenAPI specs, + // introspection snapshots) live here, not in D1 (which caps a value at + // ~1-2MB). `wrangler r2 bucket create executor-blobs` provisions it. + "r2_buckets": [ + { + "binding": "BLOBS", + "bucket_name": "executor-blobs", + }, + ], + // 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 + // isolate that never saw the session ("Not connected"). `new_sqlite_classes` + // is the free-tier-eligible SQLite-backed DO storage. + "durable_objects": { + "bindings": [{ "name": "MCP_SESSION", "class_name": "McpSessionDO" }], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["McpSessionDO"] }], + // Worker Loaders (Dynamic Workers): the `engine` seam's code substrate. Each + // `execute` spins a fresh workerd isolate via this binding (the same runtime + // cloud uses), giving structured-clone fidelity (Blob/File/Uint8Array, Date/ + // Map/Set) and `nodejs_compat`/`fetch` that the prior in-Worker QuickJS-WASM + // substrate could not. Open beta since 2026-03-24; requires a PAID Workers plan. + "worker_loaders": [{ "binding": "LOADER" }], + // 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 + // `wrangler secret put EXECUTOR_SECRET_KEY`, never in vars. + "vars": { + "ACCESS_TEAM_DOMAIN": "balancefriends.cloudflareaccess.com", + "ACCESS_AUD": "cef8f0f285c7559562d109f0e8799b9cfc905bb7e4df666d7ba10eb8455998cd", + "ACCESS_NAME_CLAIM": "name", + "ACCESS_GROUPS_CLAIM": "groups", + "ADMIN_EMAILS": "minsu.lee@passionfactory.ai", + "SELF_HOSTED_ORG_ID": "passionfactory", + "SELF_HOSTED_ORG_NAME": "PassionFactory", + // VITE_PUBLIC_SITE_URL is intentionally unset: with no static URL the worker + // derives the web base URL from each request's origin (RequestWebOrigin), so + // secret/OAuth handoff links match whatever host the user actually reached. + // Set it only to force a canonical URL (e.g. behind a proxy that rewrites Host). + }, +} diff --git a/bun.lock b/bun.lock index 1074dcb03..f4ce6bb3a 100644 --- a/bun.lock +++ b/bun.lock @@ -181,20 +181,18 @@ "@executor-js/plugin-microsoft": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", "@executor-js/react": "workspace:*", - "@executor-js/runtime-quickjs": "workspace:*", + "@executor-js/runtime-dynamic-worker": "workspace:*", "@executor-js/sdk": "workspace:*", - "@jitl/quickjs-wasmfile-release-sync": "catalog:", "@modelcontextprotocol/sdk": "^1.29.0", "@tanstack/react-router": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", "jose": "^5.9.6", - "quickjs-emscripten-core": "0.31.0", "react": "catalog:", "react-dom": "catalog:", }, "devDependencies": { - "@cloudflare/workers-types": "^4.20250410.0", + "@cloudflare/workers-types": "^4.20250620.0", "@effect/vitest": "catalog:", "@executor-js/vite-plugin": "workspace:*", "@tailwindcss/vite": "catalog:", diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts index 6e3d5a56b..071b2d535 100644 --- a/e2e/vitest.config.ts +++ b/e2e/vitest.config.ts @@ -49,6 +49,9 @@ export default defineConfig({ include: [ "scenarios/browser-approval.test.ts", "scenarios/microsoft-graph-full.test.ts", + // Proves the dynamic-worker code substrate (Worker Loaders) runs an + // `execute` end to end on the Cloudflare worker, not just QuickJS. + "scenarios/mcp-execute.test.ts", "cloudflare/**/*.test.ts", ], fileParallelism: false,