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 @@
1import { Effect } from "effect";1import { Effect } from "effect";
2import { HttpEffect, HttpRouter } from "effect/unstable/http";
2 3
3import { dbProviderLayer, ExecutorApp, textFailureStrategy } from "@executor-js/api/server";4import { dbProviderLayer, ExecutorApp, textFailureStrategy } from "@executor-js/api/server";
4 5
@@ -66,6 +67,14 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => { · 61 unmodified lines
66 // store over the QuickJS engine.67 // store over the QuickJS engine.
67 mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter },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 config: { mountPrefix: "/api", failure: textFailureStrategy },78 config: { mountPrefix: "/api", failure: textFailureStrategy },
70 boot: identityLayer,79 boot: identityLayer,
71 });80 });
apps/host-cloudflare/src/mcp/index.ts+105−1
@@ -1,8 +1,16 @@
1import type { Layer } from "effect";1import { Effect, type Layer } from "effect";
2 2
3import type { McpAuthProvider, McpErrorReporter, McpSessionStore } from "@executor-js/host-mcp";3import type { McpAuthProvider, McpErrorReporter, McpSessionStore } from "@executor-js/host-mcp";
4import { decodeResumeResponse } from "@executor-js/host-mcp/browser-approval";
5import type {
6 McpApprovalOwner,
7 McpSessionApprovalResult,
8 McpSessionResumeApprovalResult,
9} from "@executor-js/cloudflare/mcp/durable-object";
10import type { ResumeResponse } from "@executor-js/execution";
4 11
5import type { CloudflareConfig, CloudflareEnv } from "../config";12import type { CloudflareConfig, CloudflareEnv } from "../config";
13import { makeAccessVerifier } from "../auth/cloudflare-access";
6import { cloudflareAccessMcpAuth } from "./auth";14import { cloudflareAccessMcpAuth } from "./auth";
7import { cloudflareMcpReporter, makeCloudflareMcpSessionStore } from "./session-store";15import { cloudflareMcpReporter, makeCloudflareMcpSessionStore } from "./session-store";
8 16
@@ -32,8 +40,103 @@ export interface CloudflareMcpSeams { · 23 unmodified lines
32 readonly sessions: Layer.Layer<McpSessionStore>;40 readonly sessions: Layer.Layer<McpSessionStore>;
33 /** Route 500 defects through the host's console `ErrorCapture`. */41 /** Route 500 defects through the host's console `ErrorCapture`. */
34 readonly reporter: Layer.Layer<McpErrorReporter>;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).
56const toApprovalStub = (stub: unknown): McpApprovalStub => stub as McpApprovalStub;
57
58interface 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
70const PAUSED_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/;
71const RESUME_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/;
72
73const 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 */
82const 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 * Build the Cloudflare MCP serving seams over the host's `MCP_SESSION` Durable141 * Build the Cloudflare MCP serving seams over the host's `MCP_SESSION` Durable
39 * Object namespace. No per-session DB handle is threaded here — each session DO142 * Object namespace. No per-session DB handle is threaded here — each session DO
@@ -46,4 +149,5 @@ export const makeCloudflareMcpSeams = ( · 6 unmodified lines
46 auth: cloudflareAccessMcpAuth(config),149 auth: cloudflareAccessMcpAuth(config),
47 sessions: makeCloudflareMcpSessionStore(env),150 sessions: makeCloudflareMcpSessionStore(env),
48 reporter: cloudflareMcpReporter,151 reporter: cloudflareMcpReporter,
152 approvalHandler: makeCloudflareApprovalHandler(config, env),
49});153});
apps/host-cloudflare/src/mcp/session-durable-object.ts+23−1
@@ -1,6 +1,7 @@
1import { Effect } from "effect";1import { Effect } from "effect";
2 2
3import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";3import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
4import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
4import type { ExecutorDbHandle } from "@executor-js/api/server";5import type { ExecutorDbHandle } from "@executor-js/api/server";
5import {6import {
6 McpSessionDOBase,7 McpSessionDOBase,
@@ -68,6 +69,7 @@ export class McpSessionDO extends McpSessionDOBase<CfSessionDbHandle> { · 61 unmodified lines
68 dbHandle: CfSessionDbHandle,69 dbHandle: CfSessionDbHandle,
69 ): Effect.Effect<BuiltMcpServer> {70 ): Effect.Effect<BuiltMcpServer> {
70 const config = this.cfConfig;71 const config = this.cfConfig;
72 const self = this;
71 return Effect.gen(function* () {73 return Effect.gen(function* () {
72 // QuickJS-WASM must be loaded before the executor layer builds it (the74 // QuickJS-WASM must be loaded before the executor layer builds it (the
73 // default variant can't fetch its .wasm on Workers). Idempotent per isolate.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 sessionMeta.organizationId,79 sessionMeta.organizationId,
78 sessionMeta.organizationName,80 sessionMeta.organizationName,
79 ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle)));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 return { mcpServer, engine } satisfies BuiltMcpServer;103 return { mcpServer, engine } satisfies BuiltMcpServer;
82 }).pipe(104 }).pipe(
83 Effect.withSpan("McpSessionDO.buildMcpServer"),105 Effect.withSpan("McpSessionDO.buildMcpServer"),
e2e/package.json+1−0
@@ -9,6 +9,7 @@ · 8 unmodified lines
9 "test:cloud": "vitest run --project cloud",9 "test:cloud": "vitest run --project cloud",
10 "test:selfhost": "vitest run --project selfhost",10 "test:selfhost": "vitest run --project selfhost",
11 "test:selfhost-docker": "vitest run --project selfhost-docker",11 "test:selfhost-docker": "vitest run --project selfhost-docker",
12 "test:cloudflare": "vitest run --project cloudflare",
12 "test:watch": "vitest",13 "test:watch": "vitest",
13 "ports": "bun scripts/ports.ts",14 "ports": "bun scripts/ports.ts",
14 "summary": "bun scripts/summary.ts",15 "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.
8import { execFile } from "node:child_process";
9import { fileURLToPath } from "node:url";
10import { promisify } from "node:util";
11
12import { bootProcesses, waitForHttp, type BootedProcesses } from "./boot";
13
14export const cloudflareDir = fileURLToPath(new URL("../../apps/host-cloudflare/", import.meta.url));
15
16export 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
23export 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.
4import { claimPorts } from "../src/ports";
5import { waitForHttp } from "./boot";
6import { bootCloudflare } from "./cloudflare.boot";
7
8export 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.
7import { Effect } from "effect";
8
9import { e2ePort } from "../src/ports";
10import type { Identity, Target } from "../src/target";
11
12// Offsets 0-4 are taken by cloud (0-3) and self-host (4); Cloudflare claims 5.
13export const CLOUDFLARE_PORT = e2ePort("E2E_CLOUDFLARE_PORT", 5);
14export const CLOUDFLARE_BASE_URL =
15 process.env.E2E_CLOUDFLARE_URL ?? `http://127.0.0.1:${CLOUDFLARE_PORT}`;
16
17export 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
3// vitest.config.ts + a globalsetup that boots (or attaches to) the instance.3// vitest.config.ts + a globalsetup that boots (or attaches to) the instance.
4import type { Target } from "../src/target";4import type { Target } from "../src/target";
5import { cloudTarget } from "./cloud";5import { cloudTarget } from "./cloud";
6import { cloudflareTarget } from "./cloudflare";
6import { desktopTarget } from "./desktop";7import { desktopTarget } from "./desktop";
7import { selfhostTarget } from "./selfhost";8import { selfhostTarget } from "./selfhost";
8import { selfhostDockerTarget } from "./selfhost-docker";9import { selfhostDockerTarget } from "./selfhost-docker";
@@ -11,6 +12,7 @@ const factories: Record<string, () => Target> = { · 2 unmodified lines
11 cloud: cloudTarget,12 cloud: cloudTarget,
12 selfhost: selfhostTarget,13 selfhost: selfhostTarget,
13 "selfhost-docker": selfhostDockerTarget,14 "selfhost-docker": selfhostDockerTarget,
15 cloudflare: cloudflareTarget,
14 desktop: desktopTarget,16 desktop: desktopTarget,
15};17};
16 18
e2e/vitest.config.ts+8−0
@@ -38,6 +38,14 @@ export default defineConfig({ · 37 unmodified lines
38 include: ["scenarios/**/*.test.ts", "selfhost/**/*.test.ts"],38 include: ["scenarios/**/*.test.ts", "selfhost/**/*.test.ts"],
39 fileParallelism: false,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 // The Electron desktop app. Only desktop/** scenarios — the desktop49 // The Electron desktop app. Only desktop/** scenarios — the desktop
42 // target provides none of the standard surfaces (each scenario50 // target provides none of the standard surfaces (each scenario
43 // launches its own app via Playwright's electron driver), so running51 // launches its own app via Playwright's electron driver), so running