Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/host-cloudflare/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Effect } from "effect";
import { HttpEffect, HttpRouter } from "effect/unstable/http";

import { dbProviderLayer, ExecutorApp, textFailureStrategy } from "@executor-js/api/server";

Expand Down Expand Up @@ -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,
});
Expand Down
106 changes: 105 additions & 1 deletion apps/host-cloudflare/src/mcp/index.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -32,8 +40,103 @@ export interface CloudflareMcpSeams {
readonly sessions: Layer.Layer<McpSessionStore>;
/** Route 500 defects through the host's console `ErrorCapture`. */
readonly reporter: Layer.Layer<McpErrorReporter>;
/**
* 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<Response>;
}

// 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<McpSessionApprovalResult>;
resumeExecutionForApproval(
executionId: string,
identity: McpApprovalOwner,
response: ResumeResponse,
): Promise<McpSessionResumeApprovalResult>;
}

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<Response>) => {
const { verify } = makeAccessVerifier(config);
const stubFor = (sessionId: string): McpApprovalStub =>
toApprovalStub(env.MCP_SESSION.get(env.MCP_SESSION.idFromString(sessionId)));
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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
Expand All @@ -46,4 +149,5 @@ export const makeCloudflareMcpSeams = (
auth: cloudflareAccessMcpAuth(config),
sessions: makeCloudflareMcpSessionStore(env),
reporter: cloudflareMcpReporter,
approvalHandler: makeCloudflareApprovalHandler(config, env),
});
24 changes: 23 additions & 1 deletion apps/host-cloudflare/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -68,6 +69,7 @@ export class McpSessionDO extends McpSessionDOBase<CfSessionDbHandle> {
dbHandle: CfSessionDbHandle,
): Effect.Effect<BuiltMcpServer> {
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.
Expand All @@ -77,7 +79,27 @@ export class McpSessionDO extends McpSessionDOBase<CfSessionDbHandle> {
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"),
Expand Down
1 change: 1 addition & 0 deletions e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
64 changes: 64 additions & 0 deletions e2e/setup/cloudflare.boot.ts
Original file line number Diff line number Diff line change
@@ -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<BootedProcesses> => {
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;
};
30 changes: 30 additions & 0 deletions e2e/setup/cloudflare.globalsetup.ts
Original file line number Diff line number Diff line change
@@ -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>) | 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();
};
}
30 changes: 30 additions & 0 deletions e2e/targets/cloudflare.ts
Original file line number Diff line number Diff line change
@@ -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),
});
2 changes: 2 additions & 0 deletions e2e/targets/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -11,6 +12,7 @@ const factories: Record<string, () => Target> = {
cloud: cloudTarget,
selfhost: selfhostTarget,
"selfhost-docker": selfhostDockerTarget,
cloudflare: cloudflareTarget,
desktop: desktopTarget,
};

Expand Down
8 changes: 8 additions & 0 deletions e2e/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading