The host-cloudflare Durable Object base already owned the approval store and the paused/resume RPCs, but its buildMcpServer ignored the session's elicitation mode (model-mode only) and the worker served no approval endpoints — so a gated MCP action could never be approved in the browser. - host-cloudflare: buildMcpServer wires browser elicitation mode + approvalUrl (origin from the create request, captured by the DO base) + the base's browserApprovalStore, mirroring cloud. The worker mounts an Access-gated /api/mcp-sessions/* handler that routes the paused (GET) + resume (POST) calls to the owning session's DO RPCs. - e2e: a new 'cloudflare' target — the real worker on workerd via wrangler dev (Miniflare) with local D1 + R2 and dev-auth, the console SPA built and served as static assets. The cross-target browser-approval scenario (approve + decline) now runs green here too, driving the rendered resume page against the worker. Browser approval is now covered end-to-end on all three deployment shapes: cloud, self-host, and Cloudflare self-host.
Unified · Split
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { Effect } from "effect"; |
| 2 | +import { HttpEffect, HttpRouter } from "effect/unstable/http"; | |
| 2 | 3 | |
| 3 | 4 | import { dbProviderLayer, ExecutorApp, textFailureStrategy } from "@executor-js/api/server"; |
| 4 | 5 | |
| @@ -66,6 +67,14 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => { · 61 unmodified lines | ||
| 66 | 67 | // store over the QuickJS engine. |
| 67 | 68 | mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter }, |
| 68 | 69 | }, |
| 70 | + extensions: { | |
| 71 | + routes: [ | |
| 72 | + // Browser approval of paused MCP executions: the console resume page | |
| 73 | + // reads paused detail (GET) and records the decision (POST .../resume), | |
| 74 | + // Access-gated, routed to the owning session's Durable Object. | |
| 75 | + HttpRouter.add("*", "/api/mcp-sessions/*", HttpEffect.fromWebHandler(mcp.approvalHandler)), | |
| 76 | + ], | |
| 77 | + }, | |
| 69 | 78 | config: { mountPrefix: "/api", failure: textFailureStrategy }, |
| 70 | 79 | boot: identityLayer, |
| 71 | 80 | }); |
| @@ -1,8 +1,16 @@ | ||
| 1 | −import type { Layer } from "effect"; | |
| 1 | +import { Effect, type Layer } from "effect"; | |
| 2 | 2 | |
| 3 | 3 | import type { McpAuthProvider, McpErrorReporter, McpSessionStore } from "@executor-js/host-mcp"; |
| 4 | +import { decodeResumeResponse } from "@executor-js/host-mcp/browser-approval"; | |
| 5 | +import type { | |
| 6 | + McpApprovalOwner, | |
| 7 | + McpSessionApprovalResult, | |
| 8 | + McpSessionResumeApprovalResult, | |
| 9 | +} from "@executor-js/cloudflare/mcp/durable-object"; | |
| 10 | +import type { ResumeResponse } from "@executor-js/execution"; | |
| 4 | 11 | |
| 5 | 12 | import type { CloudflareConfig, CloudflareEnv } from "../config"; |
| 13 | +import { makeAccessVerifier } from "../auth/cloudflare-access"; | |
| 6 | 14 | import { cloudflareAccessMcpAuth } from "./auth"; |
| 7 | 15 | import { cloudflareMcpReporter, makeCloudflareMcpSessionStore } from "./session-store"; |
| 8 | 16 | |
| @@ -32,8 +40,103 @@ export interface CloudflareMcpSeams { · 23 unmodified lines | ||
| 32 | 40 | readonly sessions: Layer.Layer<McpSessionStore>; |
| 33 | 41 | /** Route 500 defects through the host's console `ErrorCapture`. */ |
| 34 | 42 | readonly reporter: Layer.Layer<McpErrorReporter>; |
| 43 | + /** | |
| 44 | + * The browser-approval HTTP handler, mounted by the app at | |
| 45 | + * `/api/mcp-sessions/*`: an Access-gated web handler that reads paused-execution | |
| 46 | + * detail (GET) and records the human's decision (POST `/resume`) for the console | |
| 47 | + * resume page, routing each to the owning session's Durable Object RPCs. | |
| 48 | + */ | |
| 49 | + readonly approvalHandler: (request: Request) => Promise<Response>; | |
| 35 | 50 | } |
| 36 | 51 | |
| 52 | +// The MCP session Durable Object exposes the approval RPCs (the base class | |
| 53 | +// implements them); `@cloudflare/workers-types` types the stub generically, so | |
| 54 | +// narrow at this one boundary via a single `unknown`-param hop (same shape the | |
| 55 | +// session-store seam uses for the dispatch stub). | |
| 56 | +const toApprovalStub = (stub: unknown): McpApprovalStub => stub as McpApprovalStub; | |
| 57 | + | |
| 58 | +interface McpApprovalStub { | |
| 59 | + getPausedExecutionForApproval( | |
| 60 | + executionId: string, | |
| 61 | + identity: McpApprovalOwner, | |
| 62 | + ): Promise<McpSessionApprovalResult>; | |
| 63 | + resumeExecutionForApproval( | |
| 64 | + executionId: string, | |
| 65 | + identity: McpApprovalOwner, | |
| 66 | + response: ResumeResponse, | |
| 67 | + ): Promise<McpSessionResumeApprovalResult>; | |
| 68 | +} | |
| 69 | + | |
| 70 | +const PAUSED_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/; | |
| 71 | +const RESUME_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/; | |
| 72 | + | |
| 73 | +const jsonResponse = (value: unknown, status: number): Response => | |
| 74 | + new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }); | |
| 75 | + | |
| 76 | +/** | |
| 77 | + * Resolve the request to its Access principal (dev-auth → the fixed dev admin), | |
| 78 | + * then route the browser-approval call to the owning session's Durable Object — | |
| 79 | + * the same RPCs cloud serves through its HttpApi. The DO validates that the | |
| 80 | + * principal owns the session before reading or resuming. | |
| 81 | + */ | |
| 82 | +const makeCloudflareApprovalHandler = ( | |
| 83 | + config: CloudflareConfig, | |
| 84 | + env: CloudflareEnv, | |
| 85 | +): ((request: Request) => Promise<Response>) => { | |
| 86 | + const { verify } = makeAccessVerifier(config); | |
| 87 | + const stubFor = (sessionId: string): McpApprovalStub => | |
| 88 | + toApprovalStub(env.MCP_SESSION.get(env.MCP_SESSION.idFromString(sessionId))); | |
| 89 | + | |
| 90 | + return async (request) => { | |
| 91 | + const principal = await Effect.runPromise(verify(request)); | |
| 92 | + if (!principal) return jsonResponse({ error: "Unauthorized" }, 401); | |
| 93 | + const owner: McpApprovalOwner = { | |
| 94 | + accountId: principal.accountId, | |
| 95 | + organizationId: principal.organizationId, | |
| 96 | + }; | |
| 97 | + const { pathname } = new URL(request.url); | |
| 98 | + | |
| 99 | + const paused = PAUSED_PATH.exec(pathname); | |
| 100 | + if (paused && request.method === "GET") { | |
| 101 | + const result = await stubFor(decodeURIComponent(paused[1]!)).getPausedExecutionForApproval( | |
| 102 | + decodeURIComponent(paused[2]!), | |
| 103 | + owner, | |
| 104 | + ); | |
| 105 | + if (result.status !== "ok") return jsonResponse({ error: "Paused execution not found" }, 404); | |
| 106 | + return jsonResponse({ text: result.text, structured: result.structured }, 200); | |
| 107 | + } | |
| 108 | + | |
| 109 | + const resume = RESUME_PATH.exec(pathname); | |
| 110 | + if (resume && request.method === "POST") { | |
| 111 | + const raw = await Effect.runPromise( | |
| 112 | + Effect.tryPromise({ try: () => request.json(), catch: () => null }).pipe( | |
| 113 | + Effect.orElseSucceed(() => null), | |
| 114 | + ), | |
| 115 | + ); | |
| 116 | + const response = raw === null ? null : decodeResumeResponse(raw); | |
| 117 | + if (!response) return jsonResponse({ error: "Invalid approval response" }, 400); | |
| 118 | + | |
| 119 | + const result = await stubFor(decodeURIComponent(resume[1]!)).resumeExecutionForApproval( | |
| 120 | + decodeURIComponent(resume[2]!), | |
| 121 | + owner, | |
| 122 | + response, | |
| 123 | + ); | |
| 124 | + if (result.status !== "ok") return jsonResponse({ error: "Paused execution not found" }, 404); | |
| 125 | + return jsonResponse( | |
| 126 | + { | |
| 127 | + status: result.executionStatus, | |
| 128 | + text: result.text, | |
| 129 | + structured: result.structured, | |
| 130 | + isError: result.isError ?? false, | |
| 131 | + }, | |
| 132 | + 200, | |
| 133 | + ); | |
| 134 | + } | |
| 135 | + | |
| 136 | + return jsonResponse({ error: "Not found" }, 404); | |
| 137 | + }; | |
| 138 | +}; | |
| 139 | + | |
| 37 | 140 | /** |
| 38 | 141 | * Build the Cloudflare MCP serving seams over the host's `MCP_SESSION` Durable |
| 39 | 142 | * Object namespace. No per-session DB handle is threaded here — each session DO |
| @@ -46,4 +149,5 @@ export const makeCloudflareMcpSeams = ( · 6 unmodified lines | ||
| 46 | 149 | auth: cloudflareAccessMcpAuth(config), |
| 47 | 150 | sessions: makeCloudflareMcpSessionStore(env), |
| 48 | 151 | reporter: cloudflareMcpReporter, |
| 152 | + approvalHandler: makeCloudflareApprovalHandler(config, env), | |
| 49 | 153 | }); |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { Effect } from "effect"; |
| 2 | 2 | |
| 3 | 3 | import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; |
| 4 | +import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; | |
| 4 | 5 | import type { ExecutorDbHandle } from "@executor-js/api/server"; |
| 5 | 6 | import { |
| 6 | 7 | McpSessionDOBase, |
| @@ -68,6 +69,7 @@ export class McpSessionDO extends McpSessionDOBase<CfSessionDbHandle> { · 61 unmodified lines | ||
| 68 | 69 | dbHandle: CfSessionDbHandle, |
| 69 | 70 | ): Effect.Effect<BuiltMcpServer> { |
| 70 | 71 | const config = this.cfConfig; |
| 72 | + const self = this; | |
| 71 | 73 | return Effect.gen(function* () { |
| 72 | 74 | // QuickJS-WASM must be loaded before the executor layer builds it (the |
| 73 | 75 | // default variant can't fetch its .wasm on Workers). Idempotent per isolate. |
| @@ -77,7 +79,27 @@ export class McpSessionDO extends McpSessionDOBase<CfSessionDbHandle> { · 3 unmodified lines | ||
| 77 | 79 | sessionMeta.organizationId, |
| 78 | 80 | sessionMeta.organizationName, |
| 79 | 81 | ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle))); |
| 80 | − const mcpServer = yield* createExecutorMcpServer({ engine }); | |
| 82 | + // Browser elicitation mode (the base owns the approval store + the HTTP | |
| 83 | + // approval RPCs): a gated execution pauses and returns an approvalUrl into | |
| 84 | + // the console resume page. The URL origin is the create request's origin | |
| 85 | + // (captured by the base), falling back to the configured site URL. | |
| 86 | + const elicitationMode = sessionMeta.elicitationMode ?? "model"; | |
| 87 | + const mcpServer = yield* createExecutorMcpServer({ | |
| 88 | + engine, | |
| 89 | + browserApprovalStore: self.browserApprovalStore, | |
| 90 | + elicitationMode: | |
| 91 | + elicitationMode === "browser" | |
| 92 | + ? { | |
| 93 | + mode: "browser" as const, | |
| 94 | + approvalUrl: (executionId) => | |
| 95 | + buildResumeApprovalUrl({ | |
| 96 | + origin: sessionMeta.webOrigin ?? config.webBaseUrl ?? "http://localhost", | |
| 97 | + executionId, | |
| 98 | + sessionId: self.sessionId, | |
| 99 | + }), | |
| 100 | + } | |
| 101 | + : { mode: elicitationMode }, | |
| 102 | + }); | |
| 81 | 103 | return { mcpServer, engine } satisfies BuiltMcpServer; |
| 82 | 104 | }).pipe( |
| 83 | 105 | Effect.withSpan("McpSessionDO.buildMcpServer"), |
| @@ -9,6 +9,7 @@ · 8 unmodified lines | ||
| 9 | 9 | "test:cloud": "vitest run --project cloud", |
| 10 | 10 | "test:selfhost": "vitest run --project selfhost", |
| 11 | 11 | "test:selfhost-docker": "vitest run --project selfhost-docker", |
| 12 | + "test:cloudflare": "vitest run --project cloudflare", | |
| 12 | 13 | "test:watch": "vitest", |
| 13 | 14 | "ports": "bun scripts/ports.ts", |
| 14 | 15 | "summary": "bun scripts/summary.ts", |
| @@ -0,0 +1,64 @@ | ||
| 1 | +// The Cloudflare host boot recipe: the REAL worker on workerd via `wrangler dev` | |
| 2 | +// (Miniflare) with a local D1 + R2 and dev-auth on. Shared by the vitest | |
| 3 | +// globalsetup (ephemeral) and, like the other hosts, available to a dev CLI. | |
| 4 | +// | |
| 5 | +// The browser scenarios drive the console `/resume` page, which the worker | |
| 6 | +// serves as Static Assets from `dist/` — so the SPA is built first (vite build, | |
| 7 | +// a couple of seconds) before wrangler serves it. | |
| 8 | +import { execFile } from "node:child_process"; | |
| 9 | +import { fileURLToPath } from "node:url"; | |
| 10 | +import { promisify } from "node:util"; | |
| 11 | + | |
| 12 | +import { bootProcesses, waitForHttp, type BootedProcesses } from "./boot"; | |
| 13 | + | |
| 14 | +export const cloudflareDir = fileURLToPath(new URL("../../apps/host-cloudflare/", import.meta.url)); | |
| 15 | + | |
| 16 | +export interface CloudflareBootOptions { | |
| 17 | + readonly port: number; | |
| 18 | + readonly logFile?: string; | |
| 19 | + /** Skip the SPA build when `dist/` is already current (fast local iteration). */ | |
| 20 | + readonly skipBuild?: boolean; | |
| 21 | +} | |
| 22 | + | |
| 23 | +export const bootCloudflare = async (options: CloudflareBootOptions): Promise<BootedProcesses> => { | |
| 24 | + if (!options.skipBuild) { | |
| 25 | + await promisify(execFile)("bun", ["run", "build"], { cwd: cloudflareDir }); | |
| 26 | + } | |
| 27 | + | |
| 28 | + const procs = bootProcesses( | |
| 29 | + [ | |
| 30 | + { | |
| 31 | + // bunx resolves host-cloudflare's own wrangler. `--local` is the default; | |
| 32 | + // dev-auth + the secret key arrive as `--var` overrides so the worker | |
| 33 | + // needs no Cloudflare account or real Access app. | |
| 34 | + cmd: "bunx", | |
| 35 | + args: [ | |
| 36 | + "wrangler", | |
| 37 | + "dev", | |
| 38 | + "--port", | |
| 39 | + String(options.port), | |
| 40 | + "--ip", | |
| 41 | + "127.0.0.1", | |
| 42 | + "--var", | |
| 43 | + "ENABLE_DEV_AUTH:true", | |
| 44 | + "--var", | |
| 45 | + "EXECUTOR_SECRET_KEY:e2e-secret-key-0123456789abcdef0123456789abcdef", | |
| 46 | + ], | |
| 47 | + cwd: cloudflareDir, | |
| 48 | + env: { WRANGLER_SEND_METRICS: "false", CI: "true" }, | |
| 49 | + logFile: options.logFile, | |
| 50 | + }, | |
| 51 | + ], | |
| 52 | + { label: "cloudflare" }, | |
| 53 | + ); | |
| 54 | + | |
| 55 | + try { | |
| 56 | + // dev-auth: /api/account/me answers 200 as the dev admin once the worker is | |
| 57 | + // up (workerd boot + esbuild + D1 schema bring-up take a beat on first run). | |
| 58 | + await waitForHttp(`http://127.0.0.1:${options.port}/api/account/me`, { timeoutMs: 120_000 }); | |
| 59 | + } catch (error) { | |
| 60 | + await procs.teardown(); | |
| 61 | + throw error; | |
| 62 | + } | |
| 63 | + return procs; | |
| 64 | +}; | |
| @@ -0,0 +1,30 @@ | ||
| 1 | +// Boot the Cloudflare target: claim this checkout's port atomically, then run | |
| 2 | +// the shared boot recipe (cloudflare.boot.ts). Set E2E_CLOUDFLARE_URL to attach | |
| 3 | +// to an already-running instance instead. | |
| 4 | +import { claimPorts } from "../src/ports"; | |
| 5 | +import { waitForHttp } from "./boot"; | |
| 6 | +import { bootCloudflare } from "./cloudflare.boot"; | |
| 7 | + | |
| 8 | +export default async function setup(): Promise<(() => Promise<void>) | void> { | |
| 9 | + if (process.env.E2E_CLOUDFLARE_URL) { | |
| 10 | + await waitForHttp(`${process.env.E2E_CLOUDFLARE_URL}/api/account/me`); | |
| 11 | + return; | |
| 12 | + } | |
| 13 | + | |
| 14 | + const { ports, release } = await claimPorts([ | |
| 15 | + { envVar: "E2E_CLOUDFLARE_PORT", offset: 5, label: "cloudflare wrangler dev" }, | |
| 16 | + ]); | |
| 17 | + const port = ports.E2E_CLOUDFLARE_PORT!; | |
| 18 | + | |
| 19 | + let procs; | |
| 20 | + try { | |
| 21 | + procs = await bootCloudflare({ port }); | |
| 22 | + } catch (error) { | |
| 23 | + await release(); | |
| 24 | + throw error; | |
| 25 | + } | |
| 26 | + return async () => { | |
| 27 | + await procs.teardown(); | |
| 28 | + await release(); | |
| 29 | + }; | |
| 30 | +} | |
| @@ -0,0 +1,30 @@ | ||
| 1 | +// The Cloudflare self-host app (apps/host-cloudflare) as a target: the REAL | |
| 2 | +// worker on workerd via Miniflare (wrangler `unstable_dev`) with a local D1 + | |
| 3 | +// R2, booted in setup/cloudflare.globalsetup.ts. Dev-auth is on, so every | |
| 4 | +// request is the fixed dev admin — no per-identity login and no MCP OAuth (the | |
| 5 | +// /mcp endpoint accepts the dev principal directly). Single-tenant, like | |
| 6 | +// self-host; per-test isolation is the next step here. | |
| 7 | +import { Effect } from "effect"; | |
| 8 | + | |
| 9 | +import { e2ePort } from "../src/ports"; | |
| 10 | +import type { Identity, Target } from "../src/target"; | |
| 11 | + | |
| 12 | +// Offsets 0-4 are taken by cloud (0-3) and self-host (4); Cloudflare claims 5. | |
| 13 | +export const CLOUDFLARE_PORT = e2ePort("E2E_CLOUDFLARE_PORT", 5); | |
| 14 | +export const CLOUDFLARE_BASE_URL = | |
| 15 | + process.env.E2E_CLOUDFLARE_URL ?? `http://127.0.0.1:${CLOUDFLARE_PORT}`; | |
| 16 | + | |
| 17 | +export const cloudflareTarget = (): Target => ({ | |
| 18 | + name: "cloudflare", | |
| 19 | + baseUrl: CLOUDFLARE_BASE_URL, | |
| 20 | + mcpUrl: `${CLOUDFLARE_BASE_URL}/mcp`, | |
| 21 | + // No "billing" and no setAccessTokenTtl (Cloudflare Access is the IdP; not | |
| 22 | + // test-adjustable). "mcp-oauth" advertises that the MCP surface exists — but | |
| 23 | + // dev-auth means it needs no consent flow, so `mcpConsent` is omitted and the | |
| 24 | + // MCP client connects as the dev admin directly. | |
| 25 | + capabilities: new Set(["api", "browser", "mcp-oauth"]), | |
| 26 | + // Dev-auth: one fixed admin. Empty `headers` makes the API surface send no | |
| 27 | + // auth (and skip the Better Auth sign-in path) — the worker resolves every | |
| 28 | + // request to the dev admin. No cookie is needed for the browser either. | |
| 29 | + newIdentity: () => Effect.succeed({ label: "dev-admin", headers: {} } satisfies Identity), | |
| 30 | +}); | |
| @@ -3,6 +3,7 @@ · 2 unmodified lines | ||
| 3 | 3 | // vitest.config.ts + a globalsetup that boots (or attaches to) the instance. |
| 4 | 4 | import type { Target } from "../src/target"; |
| 5 | 5 | import { cloudTarget } from "./cloud"; |
| 6 | +import { cloudflareTarget } from "./cloudflare"; | |
| 6 | 7 | import { desktopTarget } from "./desktop"; |
| 7 | 8 | import { selfhostTarget } from "./selfhost"; |
| 8 | 9 | import { selfhostDockerTarget } from "./selfhost-docker"; |
| @@ -11,6 +12,7 @@ const factories: Record<string, () => Target> = { · 2 unmodified lines | ||
| 11 | 12 | cloud: cloudTarget, |
| 12 | 13 | selfhost: selfhostTarget, |
| 13 | 14 | "selfhost-docker": selfhostDockerTarget, |
| 15 | + cloudflare: cloudflareTarget, | |
| 14 | 16 | desktop: desktopTarget, |
| 15 | 17 | }; |
| 16 | 18 | |
| @@ -38,6 +38,14 @@ export default defineConfig({ · 37 unmodified lines | ||
| 38 | 38 | include: ["scenarios/**/*.test.ts", "selfhost/**/*.test.ts"], |
| 39 | 39 | fileParallelism: false, |
| 40 | 40 | }), |
| 41 | + // The Cloudflare self-host worker (workerd via wrangler dev, dev-auth). | |
| 42 | + // Scoped to the browser-approval scenario for now — the only cross-target | |
| 43 | + // scenario wired for this host; the rest of scenarios/** is not yet | |
| 44 | + // validated against the worker. Shares self-host's single-admin model. | |
| 45 | + project("cloudflare", { | |
| 46 | + include: ["scenarios/browser-approval.test.ts", "cloudflare/**/*.test.ts"], | |
| 47 | + fileParallelism: false, | |
| 48 | + }), | |
| 41 | 49 | // The Electron desktop app. Only desktop/** scenarios — the desktop |
| 42 | 50 | // target provides none of the standard surfaces (each scenario |
| 43 | 51 | // launches its own app via Playwright's electron driver), so running |