History

cloudflare self-host: browser approval + a Cloudflare e2e target

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.

Rhys Sullivan committed 5 months ago

Commit d4f3e6f1 parent 534b32c9 files · +272 −2Browse files

Unified · Split

9 files
  1. apps/host-cloudflare/src/app.ts +9 −0
  2. apps/host-cloudflare/src/mcp/index.ts +105 −1
  3. apps/host-cloudflare/src/mcp/session-durable-object.ts +23 −1
  4. e2e/package.json +1 −0
  5. e2e/setup/cloudflare.boot.ts +64 −0
  6. e2e/setup/cloudflare.globalsetup.ts +30 −0
  7. e2e/targets/cloudflare.ts +30 −0
  8. e2e/targets/registry.ts +2 −0
  9. e2e/vitest.config.ts +8 −0
apps/host-cloudflare/src/app.ts+9−0
@@ -1,4 +1,5 @@
11 import { Effect } from "effect";
2+import { HttpEffect, HttpRouter } from "effect/unstable/http";
23
34 import { dbProviderLayer, ExecutorApp, textFailureStrategy } from "@executor-js/api/server";
45
@@ -66,6 +67,14 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => { · 61 unmodified lines
6667 // store over the QuickJS engine.
6768 mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter },
6869 },
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+ },
6978 config: { mountPrefix: "/api", failure: textFailureStrategy },
7079 boot: identityLayer,
7180 });
apps/host-cloudflare/src/mcp/index.ts+105−1
@@ -1,8 +1,16 @@
1−import type { Layer } from "effect";
1+import { Effect, type Layer } from "effect";
22
33 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";
411
512 import type { CloudflareConfig, CloudflareEnv } from "../config";
13+import { makeAccessVerifier } from "../auth/cloudflare-access";
614 import { cloudflareAccessMcpAuth } from "./auth";
715 import { cloudflareMcpReporter, makeCloudflareMcpSessionStore } from "./session-store";
816
@@ -32,8 +40,103 @@ export interface CloudflareMcpSeams { · 23 unmodified lines
3240 readonly sessions: Layer.Layer<McpSessionStore>;
3341 /** Route 500 defects through the host's console `ErrorCapture`. */
3442 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>;
3550 }
3651
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+
37140 /**
38141 * Build the Cloudflare MCP serving seams over the host's `MCP_SESSION` Durable
39142 * Object namespace. No per-session DB handle is threaded here — each session DO
@@ -46,4 +149,5 @@ export const makeCloudflareMcpSeams = ( · 6 unmodified lines
46149 auth: cloudflareAccessMcpAuth(config),
47150 sessions: makeCloudflareMcpSessionStore(env),
48151 reporter: cloudflareMcpReporter,
152+ approvalHandler: makeCloudflareApprovalHandler(config, env),
49153 });
apps/host-cloudflare/src/mcp/session-durable-object.ts+23−1
@@ -1,6 +1,7 @@
11 import { Effect } from "effect";
22
33 import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
4+import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
45 import type { ExecutorDbHandle } from "@executor-js/api/server";
56 import {
67 McpSessionDOBase,
@@ -68,6 +69,7 @@ export class McpSessionDO extends McpSessionDOBase<CfSessionDbHandle> { · 61 unmodified lines
6869 dbHandle: CfSessionDbHandle,
6970 ): Effect.Effect<BuiltMcpServer> {
7071 const config = this.cfConfig;
72+ const self = this;
7173 return Effect.gen(function* () {
7274 // QuickJS-WASM must be loaded before the executor layer builds it (the
7375 // 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
7779 sessionMeta.organizationId,
7880 sessionMeta.organizationName,
7981 ).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+ });
81103 return { mcpServer, engine } satisfies BuiltMcpServer;
82104 }).pipe(
83105 Effect.withSpan("McpSessionDO.buildMcpServer"),
e2e/package.json+1−0
@@ -9,6 +9,7 @@ · 8 unmodified lines
99 "test:cloud": "vitest run --project cloud",
1010 "test:selfhost": "vitest run --project selfhost",
1111 "test:selfhost-docker": "vitest run --project selfhost-docker",
12+ "test:cloudflare": "vitest run --project cloudflare",
1213 "test:watch": "vitest",
1314 "ports": "bun scripts/ports.ts",
1415 "summary": "bun scripts/summary.ts",
e2e/setup/cloudflare.boot.ts+64−0
@@ -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+};
e2e/setup/cloudflare.globalsetup.ts+30−0
@@ -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+}
e2e/targets/cloudflare.ts+30−0
@@ -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+});
e2e/targets/registry.ts+2−0
@@ -3,6 +3,7 @@ · 2 unmodified lines
33 // vitest.config.ts + a globalsetup that boots (or attaches to) the instance.
44 import type { Target } from "../src/target";
55 import { cloudTarget } from "./cloud";
6+import { cloudflareTarget } from "./cloudflare";
67 import { desktopTarget } from "./desktop";
78 import { selfhostTarget } from "./selfhost";
89 import { selfhostDockerTarget } from "./selfhost-docker";
@@ -11,6 +12,7 @@ const factories: Record<string, () => Target> = { · 2 unmodified lines
1112 cloud: cloudTarget,
1213 selfhost: selfhostTarget,
1314 "selfhost-docker": selfhostDockerTarget,
15+ cloudflare: cloudflareTarget,
1416 desktop: desktopTarget,
1517 };
1618
e2e/vitest.config.ts+8−0
@@ -38,6 +38,14 @@ export default defineConfig({ · 37 unmodified lines
3838 include: ["scenarios/**/*.test.ts", "selfhost/**/*.test.ts"],
3939 fileParallelism: false,
4040 }),
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+ }),
4149 // The Electron desktop app. Only desktop/** scenarios — the desktop
4250 // target provides none of the standard surfaces (each scenario
4351 // launches its own app via Playwright's electron driver), so running