diff --git a/apps/host-cloudflare/src/app.ts b/apps/host-cloudflare/src/app.ts index b664e8a32..9c284a48a 100644 --- a/apps/host-cloudflare/src/app.ts +++ b/apps/host-cloudflare/src/app.ts @@ -1,4 +1,5 @@ import { Effect } from "effect"; +import { HttpEffect, HttpRouter } from "effect/unstable/http"; import { dbProviderLayer, ExecutorApp, textFailureStrategy } from "@executor-js/api/server"; @@ -66,6 +67,14 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => { // store over the QuickJS engine. mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter }, }, + extensions: { + routes: [ + // Browser approval of paused MCP executions: the console resume page + // reads paused detail (GET) and records the decision (POST .../resume), + // Access-gated, routed to the owning session's Durable Object. + HttpRouter.add("*", "/api/mcp-sessions/*", HttpEffect.fromWebHandler(mcp.approvalHandler)), + ], + }, config: { mountPrefix: "/api", failure: textFailureStrategy }, boot: identityLayer, }); diff --git a/apps/host-cloudflare/src/mcp/index.ts b/apps/host-cloudflare/src/mcp/index.ts index 3b4f31156..87e875646 100644 --- a/apps/host-cloudflare/src/mcp/index.ts +++ b/apps/host-cloudflare/src/mcp/index.ts @@ -1,8 +1,16 @@ -import type { Layer } from "effect"; +import { Effect, type Layer } from "effect"; import type { McpAuthProvider, McpErrorReporter, McpSessionStore } from "@executor-js/host-mcp"; +import { decodeResumeResponse } from "@executor-js/host-mcp/browser-approval"; +import type { + McpApprovalOwner, + McpSessionApprovalResult, + McpSessionResumeApprovalResult, +} from "@executor-js/cloudflare/mcp/durable-object"; +import type { ResumeResponse } from "@executor-js/execution"; import type { CloudflareConfig, CloudflareEnv } from "../config"; +import { makeAccessVerifier } from "../auth/cloudflare-access"; import { cloudflareAccessMcpAuth } from "./auth"; import { cloudflareMcpReporter, makeCloudflareMcpSessionStore } from "./session-store"; @@ -32,8 +40,103 @@ export interface CloudflareMcpSeams { readonly sessions: Layer.Layer; /** Route 500 defects through the host's console `ErrorCapture`. */ readonly reporter: Layer.Layer; + /** + * The browser-approval HTTP handler, mounted by the app at + * `/api/mcp-sessions/*`: an Access-gated web handler that reads paused-execution + * detail (GET) and records the human's decision (POST `/resume`) for the console + * resume page, routing each to the owning session's Durable Object RPCs. + */ + readonly approvalHandler: (request: Request) => Promise; } +// The MCP session Durable Object exposes the approval RPCs (the base class +// implements them); `@cloudflare/workers-types` types the stub generically, so +// narrow at this one boundary via a single `unknown`-param hop (same shape the +// session-store seam uses for the dispatch stub). +const toApprovalStub = (stub: unknown): McpApprovalStub => stub as McpApprovalStub; + +interface McpApprovalStub { + getPausedExecutionForApproval( + executionId: string, + identity: McpApprovalOwner, + ): Promise; + resumeExecutionForApproval( + executionId: string, + identity: McpApprovalOwner, + response: ResumeResponse, + ): Promise; +} + +const PAUSED_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/; +const RESUME_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/; + +const jsonResponse = (value: unknown, status: number): Response => + new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }); + +/** + * Resolve the request to its Access principal (dev-auth → the fixed dev admin), + * then route the browser-approval call to the owning session's Durable Object — + * the same RPCs cloud serves through its HttpApi. The DO validates that the + * principal owns the session before reading or resuming. + */ +const makeCloudflareApprovalHandler = ( + config: CloudflareConfig, + env: CloudflareEnv, +): ((request: Request) => Promise) => { + const { verify } = makeAccessVerifier(config); + const stubFor = (sessionId: string): McpApprovalStub => + toApprovalStub(env.MCP_SESSION.get(env.MCP_SESSION.idFromString(sessionId))); + + return async (request) => { + const principal = await Effect.runPromise(verify(request)); + if (!principal) return jsonResponse({ error: "Unauthorized" }, 401); + const owner: McpApprovalOwner = { + accountId: principal.accountId, + organizationId: principal.organizationId, + }; + const { pathname } = new URL(request.url); + + const paused = PAUSED_PATH.exec(pathname); + if (paused && request.method === "GET") { + const result = await stubFor(decodeURIComponent(paused[1]!)).getPausedExecutionForApproval( + decodeURIComponent(paused[2]!), + owner, + ); + if (result.status !== "ok") return jsonResponse({ error: "Paused execution not found" }, 404); + return jsonResponse({ text: result.text, structured: result.structured }, 200); + } + + const resume = RESUME_PATH.exec(pathname); + if (resume && request.method === "POST") { + const raw = await Effect.runPromise( + Effect.tryPromise({ try: () => request.json(), catch: () => null }).pipe( + Effect.orElseSucceed(() => null), + ), + ); + const response = raw === null ? null : decodeResumeResponse(raw); + if (!response) return jsonResponse({ error: "Invalid approval response" }, 400); + + const result = await stubFor(decodeURIComponent(resume[1]!)).resumeExecutionForApproval( + decodeURIComponent(resume[2]!), + owner, + response, + ); + if (result.status !== "ok") return jsonResponse({ error: "Paused execution not found" }, 404); + return jsonResponse( + { + status: result.executionStatus, + text: result.text, + structured: result.structured, + isError: result.isError ?? false, + }, + 200, + ); + } + + return jsonResponse({ error: "Not found" }, 404); + }; +}; + /** * Build the Cloudflare MCP serving seams over the host's `MCP_SESSION` Durable * Object namespace. No per-session DB handle is threaded here — each session DO @@ -46,4 +149,5 @@ export const makeCloudflareMcpSeams = ( auth: cloudflareAccessMcpAuth(config), sessions: makeCloudflareMcpSessionStore(env), reporter: cloudflareMcpReporter, + approvalHandler: makeCloudflareApprovalHandler(config, env), }); diff --git a/apps/host-cloudflare/src/mcp/session-durable-object.ts b/apps/host-cloudflare/src/mcp/session-durable-object.ts index 258e70c41..9fe2570f4 100644 --- a/apps/host-cloudflare/src/mcp/session-durable-object.ts +++ b/apps/host-cloudflare/src/mcp/session-durable-object.ts @@ -1,6 +1,7 @@ import { Effect } from "effect"; import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; +import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; import type { ExecutorDbHandle } from "@executor-js/api/server"; import { McpSessionDOBase, @@ -68,6 +69,7 @@ export class McpSessionDO extends McpSessionDOBase { dbHandle: CfSessionDbHandle, ): Effect.Effect { 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. @@ -77,7 +79,27 @@ export class McpSessionDO extends McpSessionDOBase { sessionMeta.organizationId, sessionMeta.organizationName, ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle))); - const mcpServer = yield* createExecutorMcpServer({ engine }); + // 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 + // (captured by the base), falling back to the configured site URL. + const elicitationMode = sessionMeta.elicitationMode ?? "model"; + const mcpServer = yield* createExecutorMcpServer({ + engine, + browserApprovalStore: self.browserApprovalStore, + elicitationMode: + elicitationMode === "browser" + ? { + mode: "browser" as const, + approvalUrl: (executionId) => + buildResumeApprovalUrl({ + origin: sessionMeta.webOrigin ?? config.webBaseUrl ?? "http://localhost", + executionId, + sessionId: self.sessionId, + }), + } + : { mode: elicitationMode }, + }); return { mcpServer, engine } satisfies BuiltMcpServer; }).pipe( Effect.withSpan("McpSessionDO.buildMcpServer"), diff --git a/e2e/package.json b/e2e/package.json index 4e6fe453f..3e16ddd4b 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -9,6 +9,7 @@ "test:cloud": "vitest run --project cloud", "test:selfhost": "vitest run --project selfhost", "test:selfhost-docker": "vitest run --project selfhost-docker", + "test:cloudflare": "vitest run --project cloudflare", "test:watch": "vitest", "ports": "bun scripts/ports.ts", "summary": "bun scripts/summary.ts", diff --git a/e2e/setup/cloudflare.boot.ts b/e2e/setup/cloudflare.boot.ts new file mode 100644 index 000000000..77b9390f4 --- /dev/null +++ b/e2e/setup/cloudflare.boot.ts @@ -0,0 +1,64 @@ +// The Cloudflare host boot recipe: the REAL worker on workerd via `wrangler dev` +// (Miniflare) with a local D1 + R2 and dev-auth on. Shared by the vitest +// globalsetup (ephemeral) and, like the other hosts, available to a dev CLI. +// +// The browser scenarios drive the console `/resume` page, which the worker +// serves as Static Assets from `dist/` — so the SPA is built first (vite build, +// a couple of seconds) before wrangler serves it. +import { execFile } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { bootProcesses, waitForHttp, type BootedProcesses } from "./boot"; + +export const cloudflareDir = fileURLToPath(new URL("../../apps/host-cloudflare/", import.meta.url)); + +export interface CloudflareBootOptions { + readonly port: number; + readonly logFile?: string; + /** Skip the SPA build when `dist/` is already current (fast local iteration). */ + readonly skipBuild?: boolean; +} + +export const bootCloudflare = async (options: CloudflareBootOptions): Promise => { + if (!options.skipBuild) { + await promisify(execFile)("bun", ["run", "build"], { cwd: cloudflareDir }); + } + + const procs = bootProcesses( + [ + { + // bunx resolves host-cloudflare's own wrangler. `--local` is the default; + // dev-auth + the secret key arrive as `--var` overrides so the worker + // needs no Cloudflare account or real Access app. + cmd: "bunx", + args: [ + "wrangler", + "dev", + "--port", + String(options.port), + "--ip", + "127.0.0.1", + "--var", + "ENABLE_DEV_AUTH:true", + "--var", + "EXECUTOR_SECRET_KEY:e2e-secret-key-0123456789abcdef0123456789abcdef", + ], + cwd: cloudflareDir, + env: { WRANGLER_SEND_METRICS: "false", CI: "true" }, + logFile: options.logFile, + }, + ], + { label: "cloudflare" }, + ); + + try { + // dev-auth: /api/account/me answers 200 as the dev admin once the worker is + // up (workerd boot + esbuild + D1 schema bring-up take a beat on first run). + await waitForHttp(`http://127.0.0.1:${options.port}/api/account/me`, { timeoutMs: 120_000 }); + } catch (error) { + await procs.teardown(); + throw error; + } + return procs; +}; diff --git a/e2e/setup/cloudflare.globalsetup.ts b/e2e/setup/cloudflare.globalsetup.ts new file mode 100644 index 000000000..dbb3b7f8a --- /dev/null +++ b/e2e/setup/cloudflare.globalsetup.ts @@ -0,0 +1,30 @@ +// Boot the Cloudflare target: claim this checkout's port atomically, then run +// the shared boot recipe (cloudflare.boot.ts). Set E2E_CLOUDFLARE_URL to attach +// to an already-running instance instead. +import { claimPorts } from "../src/ports"; +import { waitForHttp } from "./boot"; +import { bootCloudflare } from "./cloudflare.boot"; + +export default async function setup(): Promise<(() => Promise) | void> { + if (process.env.E2E_CLOUDFLARE_URL) { + await waitForHttp(`${process.env.E2E_CLOUDFLARE_URL}/api/account/me`); + return; + } + + const { ports, release } = await claimPorts([ + { envVar: "E2E_CLOUDFLARE_PORT", offset: 5, label: "cloudflare wrangler dev" }, + ]); + const port = ports.E2E_CLOUDFLARE_PORT!; + + let procs; + try { + procs = await bootCloudflare({ port }); + } catch (error) { + await release(); + throw error; + } + return async () => { + await procs.teardown(); + await release(); + }; +} diff --git a/e2e/targets/cloudflare.ts b/e2e/targets/cloudflare.ts new file mode 100644 index 000000000..abce7ddee --- /dev/null +++ b/e2e/targets/cloudflare.ts @@ -0,0 +1,30 @@ +// The Cloudflare self-host app (apps/host-cloudflare) as a target: the REAL +// worker on workerd via Miniflare (wrangler `unstable_dev`) with a local D1 + +// R2, booted in setup/cloudflare.globalsetup.ts. Dev-auth is on, so every +// request is the fixed dev admin — no per-identity login and no MCP OAuth (the +// /mcp endpoint accepts the dev principal directly). Single-tenant, like +// self-host; per-test isolation is the next step here. +import { Effect } from "effect"; + +import { e2ePort } from "../src/ports"; +import type { Identity, Target } from "../src/target"; + +// Offsets 0-4 are taken by cloud (0-3) and self-host (4); Cloudflare claims 5. +export const CLOUDFLARE_PORT = e2ePort("E2E_CLOUDFLARE_PORT", 5); +export const CLOUDFLARE_BASE_URL = + process.env.E2E_CLOUDFLARE_URL ?? `http://127.0.0.1:${CLOUDFLARE_PORT}`; + +export const cloudflareTarget = (): Target => ({ + name: "cloudflare", + baseUrl: CLOUDFLARE_BASE_URL, + mcpUrl: `${CLOUDFLARE_BASE_URL}/mcp`, + // No "billing" and no setAccessTokenTtl (Cloudflare Access is the IdP; not + // test-adjustable). "mcp-oauth" advertises that the MCP surface exists — but + // dev-auth means it needs no consent flow, so `mcpConsent` is omitted and the + // MCP client connects as the dev admin directly. + capabilities: new Set(["api", "browser", "mcp-oauth"]), + // Dev-auth: one fixed admin. Empty `headers` makes the API surface send no + // auth (and skip the Better Auth sign-in path) — the worker resolves every + // request to the dev admin. No cookie is needed for the browser either. + newIdentity: () => Effect.succeed({ label: "dev-admin", headers: {} } satisfies Identity), +}); diff --git a/e2e/targets/registry.ts b/e2e/targets/registry.ts index 75bfa8ac2..2ad0d4573 100644 --- a/e2e/targets/registry.ts +++ b/e2e/targets/registry.ts @@ -3,6 +3,7 @@ // vitest.config.ts + a globalsetup that boots (or attaches to) the instance. import type { Target } from "../src/target"; import { cloudTarget } from "./cloud"; +import { cloudflareTarget } from "./cloudflare"; import { desktopTarget } from "./desktop"; import { selfhostTarget } from "./selfhost"; import { selfhostDockerTarget } from "./selfhost-docker"; @@ -11,6 +12,7 @@ const factories: Record Target> = { cloud: cloudTarget, selfhost: selfhostTarget, "selfhost-docker": selfhostDockerTarget, + cloudflare: cloudflareTarget, desktop: desktopTarget, }; diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts index c31e199aa..c7b8c6358 100644 --- a/e2e/vitest.config.ts +++ b/e2e/vitest.config.ts @@ -38,6 +38,14 @@ export default defineConfig({ include: ["scenarios/**/*.test.ts", "selfhost/**/*.test.ts"], fileParallelism: false, }), + // The Cloudflare self-host worker (workerd via wrangler dev, dev-auth). + // Scoped to the browser-approval scenario for now — the only cross-target + // scenario wired for this host; the rest of scenarios/** is not yet + // validated against the worker. Shares self-host's single-admin model. + project("cloudflare", { + include: ["scenarios/browser-approval.test.ts", "cloudflare/**/*.test.ts"], + fileParallelism: false, + }), // The Electron desktop app. Only desktop/** scenarios — the desktop // target provides none of the standard surfaces (each scenario // launches its own app via Playwright's electron driver), so running