diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index 3985f6b68..9c7d8caf1 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -20,6 +20,7 @@ "executor/no-match-orelse": "error", "executor/no-promise-catch": "error", "executor/no-promise-reject": "error", + "executor/no-raw-durable-object-id": "error", "executor/no-raw-fetch": "error", "executor/no-redundant-primitive-cast": "error", "executor/no-redundant-error-factory": "error", diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index 64d3ec447..108f96bba 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -32,10 +32,7 @@ import { authorizeOrganizationSelector, resolveOrganization, } from "./organization"; -import type { - McpSessionApprovalResult, - McpSessionResumeApprovalResult, -} from "../mcp/session-durable-object"; +import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; const COOKIE_OPTIONS = { path: "/", @@ -122,23 +119,7 @@ const requireSelectedOrganization = Effect.gen(function* () { }; }); -const getMcpSessionStub = (mcpSessionId: string) => - Effect.try({ - try: () => { - const ns = env.MCP_SESSION; - return ns.get(ns.idFromString(mcpSessionId)); - }, - catch: () => undefined, - }).pipe(Effect.orElseSucceed(() => null)); - -const requireMcpSessionStub = (mcpSessionId: string, executionId: string) => - Effect.gen(function* () { - const stub = yield* getMcpSessionStub(mcpSessionId); - if (!stub) { - return yield* new McpExecutionNotFoundError({ executionId }); - } - return stub; - }); +const getMcpSessionStub = (mcpSessionId: string) => mcpSessionStub(env.MCP_SESSION, mcpSessionId); const failMcpApprovalResult = ( result: { readonly status: "not_found" | "forbidden" }, @@ -528,13 +509,12 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( .handle("getMcpPaused", ({ params }) => Effect.gen(function* () { const owner = yield* requireSelectedOrganization; - const stub = yield* requireMcpSessionStub(params.mcpSessionId, params.executionId); - const result = yield* Effect.promise( - () => - stub.getPausedExecutionForApproval(params.executionId, { - accountId: owner.accountId, - organizationId: owner.organizationId, - }) as Promise, + const stub = getMcpSessionStub(params.mcpSessionId); + const result = yield* Effect.promise(() => + stub.getPausedExecutionForApproval(params.executionId, { + accountId: owner.accountId, + organizationId: owner.organizationId, + }), ); if (result.status !== "ok") { @@ -550,20 +530,19 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( .handle("resumeMcpExecution", ({ params, payload }) => Effect.gen(function* () { const owner = yield* requireSelectedOrganization; - const stub = yield* requireMcpSessionStub(params.mcpSessionId, params.executionId); - const result = yield* Effect.promise( - () => - stub.resumeExecutionForApproval( - params.executionId, - { - accountId: owner.accountId, - organizationId: owner.organizationId, - }, - { - action: payload.action, - content: payload.content as Record | undefined, - }, - ) as Promise, + const stub = getMcpSessionStub(params.mcpSessionId); + const result = yield* Effect.promise(() => + stub.resumeExecutionForApproval( + params.executionId, + { + accountId: owner.accountId, + organizationId: owner.organizationId, + }, + { + action: payload.action, + content: payload.content as Record | undefined, + }, + ), ); if (result.status !== "ok") { diff --git a/apps/cloud/src/mcp/agent-handler.ts b/apps/cloud/src/mcp/agent-handler.ts index 4799252a3..3f70ec306 100644 --- a/apps/cloud/src/mcp/agent-handler.ts +++ b/apps/cloud/src/mcp/agent-handler.ts @@ -13,20 +13,12 @@ import { withVerifiedIdentityHeaders, } from "@executor-js/cloudflare/mcp/do-headers"; import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object"; -import { mcpSessionDurableObjectName } from "@executor-js/cloudflare/mcp/execution-owner-directory"; +import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; import { wrapMcpSseResponse } from "../observability/memory-metrics"; import { cloudMcpAuth } from "./auth-provider"; import { McpSessionDOSqlite } from "./session-durable-object"; -interface McpAgentSessionStub { - readonly validateMcpSessionOwner: (identity: { - readonly accountId: string; - readonly organizationId: string; - }) => Promise<"ok" | "not_found" | "forbidden">; - readonly _cf_scheduleDestroy: () => Promise; -} - const corsPreflightResponse = (): Response => new Response(null, { status: 204, @@ -68,12 +60,6 @@ const renderAuthError = ( return jsonRpcResponse(503, -32001, outcome.message); }; -const sessionStub = (env: Env, sessionId: string): McpAgentSessionStub => - // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers types expose only DurableObjectStub, but RPC methods are generated from the bound DO class. - env.MCP_SESSION.get( - env.MCP_SESSION.idFromName(mcpSessionDurableObjectName(sessionId)), - ) as unknown as McpAgentSessionStub; - const authenticate = (request: Request) => Effect.gen(function* () { const auth = yield* McpAuthProvider; @@ -142,7 +128,11 @@ export const makeCloudMcpAgentHandler = () => { if (!Predicate.isTagged(outcome, "Authenticated")) { if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { await Effect.runPromise( - Effect.ignore(Effect.tryPromise(() => sessionStub(env, sessionId)._cf_scheduleDestroy())), + Effect.ignore( + Effect.tryPromise(() => + mcpSessionStub(env.MCP_SESSION, sessionId)._cf_scheduleDestroy(), + ), + ), ); } return renderAuthError(auth, request, outcome); @@ -156,7 +146,7 @@ export const makeCloudMcpAgentHandler = () => { } if (sessionId) { - const owner = await sessionStub(env, sessionId).validateMcpSessionOwner({ + const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({ accountId: outcome.principal.accountId, organizationId: outcome.principal.organizationId, }); diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index 6fd9f5ba3..616641267 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -36,11 +36,11 @@ import { type SessionMeta, } from "@executor-js/cloudflare/mcp/agent-durable-object"; import { - mcpSessionDurableObjectName, mcpExecutionOwnerDirectoryFromNamespace, type McpExecutionOwnerDirectory, type McpExecutionOwnerRoute, } from "@executor-js/cloudflare/mcp/execution-owner-directory"; +import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution"; // The DO meters executions just like the HTTP `/api/*` plane: it builds its @@ -101,16 +101,6 @@ type CloudSessionDbHandle = DbServiceShape & { readonly end: () => Promise; }; -interface McpModelResumeStub { - readonly resumeExecutionForModel: ( - executionId: string, - identity: McpApprovalOwner, - response: ResumeResponse, - ) => Promise; -} - -const toMcpModelResumeStub = (stub: unknown): McpModelResumeStub => stub as McpModelResumeStub; - class OrganizationNotFoundError extends Data.TaggedError("OrganizationNotFoundError")<{ readonly organizationId: string; }> {} @@ -213,11 +203,11 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase { return Effect.tryPromise({ try: () => - toMcpModelResumeStub( - env.MCP_SESSION.get( - env.MCP_SESSION.idFromName(mcpSessionDurableObjectName(owner.sessionId)), - ), - ).resumeExecutionForModel(executionId, identity, response), + mcpSessionStub(env.MCP_SESSION, owner.sessionId).resumeExecutionForModel( + executionId, + identity, + response, + ), catch: (cause) => new McpModelResumeForwardError({ cause }), }); } diff --git a/apps/host-cloudflare/src/mcp/agent-handler.ts b/apps/host-cloudflare/src/mcp/agent-handler.ts index 864ee3e93..983e28ad0 100644 --- a/apps/host-cloudflare/src/mcp/agent-handler.ts +++ b/apps/host-cloudflare/src/mcp/agent-handler.ts @@ -13,20 +13,12 @@ import { withVerifiedIdentityHeaders, } from "@executor-js/cloudflare/mcp/do-headers"; import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object"; -import { mcpSessionDurableObjectName } from "@executor-js/cloudflare/mcp/execution-owner-directory"; +import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; import type { CloudflareConfig, CloudflareEnv } from "../config"; import { cloudflareAccessMcpAuth } from "./auth"; import { McpSessionDO } from "./session-durable-object"; -interface McpAgentSessionStub { - readonly validateMcpSessionOwner: (identity: { - readonly accountId: string; - readonly organizationId: string; - }) => Promise<"ok" | "not_found" | "forbidden">; - readonly _cf_scheduleDestroy: () => Promise; -} - const corsPreflightResponse = (): Response => new Response(null, { status: 204, @@ -68,12 +60,6 @@ const renderAuthError = ( return jsonRpcResponse(503, -32001, outcome.message); }; -const sessionStub = (env: CloudflareEnv, sessionId: string): McpAgentSessionStub => - // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers types expose only DurableObjectStub, but RPC methods are generated from the bound DO class. - env.MCP_SESSION.get( - env.MCP_SESSION.idFromName(mcpSessionDurableObjectName(sessionId)), - ) as unknown as McpAgentSessionStub; - const authenticate = (request: Request, config: CloudflareConfig) => Effect.gen(function* () { const auth = yield* McpAuthProvider; @@ -116,7 +102,11 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { if (!Predicate.isTagged(outcome, "Authenticated")) { if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { await Effect.runPromise( - Effect.ignore(Effect.tryPromise(() => sessionStub(env, sessionId)._cf_scheduleDestroy())), + Effect.ignore( + Effect.tryPromise(() => + mcpSessionStub(env.MCP_SESSION, sessionId)._cf_scheduleDestroy(), + ), + ), ); } return renderAuthError(auth, request, outcome); @@ -127,7 +117,7 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { } if (sessionId) { - const owner = await sessionStub(env, sessionId).validateMcpSessionOwner({ + const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({ accountId: outcome.principal.accountId, organizationId: outcome.principal.organizationId, }); diff --git a/apps/host-cloudflare/src/mcp/index.ts b/apps/host-cloudflare/src/mcp/index.ts index d1430ff24..1de283c20 100644 --- a/apps/host-cloudflare/src/mcp/index.ts +++ b/apps/host-cloudflare/src/mcp/index.ts @@ -1,13 +1,8 @@ import { Effect } from "effect"; import { decodeResumeResponse } from "@executor-js/host-mcp/browser-approval"; -import type { - McpApprovalOwner, - McpSessionApprovalResult, - McpSessionResumeApprovalResult, -} from "@executor-js/cloudflare/mcp/agent-durable-object"; -import { mcpSessionDurableObjectName } from "@executor-js/cloudflare/mcp/execution-owner-directory"; -import type { ResumeResponse } from "@executor-js/execution"; +import type { McpApprovalOwner } from "@executor-js/cloudflare/mcp/agent-durable-object"; +import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; import type { CloudflareConfig, CloudflareEnv } from "../config"; import { makeAccessVerifier } from "../auth/cloudflare-access"; @@ -16,20 +11,6 @@ export { cloudflareAccessMcpAuth } from "./auth"; export { McpSessionDO } from "./session-durable-object"; export { McpExecutionOwnerDirectoryDO } from "@executor-js/cloudflare/mcp/execution-owner-directory"; -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$/; @@ -41,10 +22,7 @@ export const makeCloudflareApprovalHandler = ( env: CloudflareEnv, ): ((request: Request) => Promise) => { const { verify } = makeAccessVerifier(config); - const stubFor = (sessionId: string): McpApprovalStub => - toApprovalStub( - env.MCP_SESSION.get(env.MCP_SESSION.idFromName(mcpSessionDurableObjectName(sessionId))), - ); + const stubFor = (sessionId: string) => mcpSessionStub(env.MCP_SESSION, sessionId); return async (request) => { const principal = await Effect.runPromise(verify(request)); diff --git a/apps/host-cloudflare/src/mcp/session-durable-object.ts b/apps/host-cloudflare/src/mcp/session-durable-object.ts index 0a17b0e5e..95b366b7e 100644 --- a/apps/host-cloudflare/src/mcp/session-durable-object.ts +++ b/apps/host-cloudflare/src/mcp/session-durable-object.ts @@ -15,11 +15,11 @@ import { type SessionMeta, } from "@executor-js/cloudflare/mcp/agent-durable-object"; import { - mcpSessionDurableObjectName, mcpExecutionOwnerDirectoryFromNamespace, type McpExecutionOwnerDirectory, type McpExecutionOwnerRoute, } from "@executor-js/cloudflare/mcp/execution-owner-directory"; +import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; import type { ResumeResponse } from "@executor-js/execution"; import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config"; @@ -47,16 +47,6 @@ import { preloadQuickJs } from "../quickjs"; // own lifecycle (the binding is the connection), so `end` is `close` — a no-op. type CfSessionDbHandle = ExecutorDbHandle & { readonly end: () => Promise }; -interface McpModelResumeStub { - readonly resumeExecutionForModel: ( - executionId: string, - identity: McpApprovalOwner, - response: ResumeResponse, - ) => Promise; -} - -const toMcpModelResumeStub = (stub: unknown): McpModelResumeStub => stub as McpModelResumeStub; - class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForwardError")<{ readonly cause: unknown; }> {} @@ -86,11 +76,11 @@ export class McpSessionDO extends McpAgentSessionDOBase { return Effect.tryPromise({ try: () => - toMcpModelResumeStub( - this.cfEnv.MCP_SESSION.get( - this.cfEnv.MCP_SESSION.idFromName(mcpSessionDurableObjectName(owner.sessionId)), - ), - ).resumeExecutionForModel(executionId, identity, response), + mcpSessionStub(this.cfEnv.MCP_SESSION, owner.sessionId).resumeExecutionForModel( + executionId, + identity, + response, + ), catch: (cause) => new McpModelResumeForwardError({ cause }), }); } diff --git a/e2e/cloud/mcp-browser-resume-page.test.ts b/e2e/cloud/mcp-browser-resume-page.test.ts new file mode 100644 index 000000000..02eb39e4c --- /dev/null +++ b/e2e/cloud/mcp-browser-resume-page.test.ts @@ -0,0 +1,269 @@ +// Cloud: browser-mode MCP approval must let the resume page load the paused +// execution from the approval URL the MCP tool returned. +// +// This drives the user-reported path without mcporter: +// 1. A real StreamableHTTP MCP session runs with ?elicitation_mode=browser. +// 2. A gated execute call returns approvalUrl and executionId. +// 3. The cloud resume page's API GET loads that paused execution by +// mcp_session_id and executionId. +// 4. Posting the browser approval lets the model-side browser resume tool +// consume the decision and complete the gated call. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Mcp, Target } from "../src/services"; +import { parseBrowserApproval } from "../src/surfaces/mcp"; +import type { Identity } from "../src/target"; + +const coreApi = composePluginApi([] as const); + +const GATE_TOOL = "executor.coreTools.policies.list"; +const UNAVAILABLE_COPY = "This paused execution is no longer available"; + +const GATED_CODE = ` +const result = await tools.executor.coreTools.policies.list({}); +return JSON.stringify(result); +`; + +type Connected = { + readonly client: Client; + readonly transport: StreamableHTTPClientTransport; +}; + +const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; + +const textOf = (result: unknown): string => + ( + ((result as { readonly content?: unknown }).content ?? []) as ReadonlyArray<{ + readonly type: string; + readonly text?: string; + }> + ) + .filter((part) => part.type === "text") + .map((part) => part.text ?? "") + .join("\n"); + +const openBrowserApprovalSession = async (mcpUrl: string, bearer: string): Promise => { + const url = new URL(mcpUrl); + url.searchParams.set("elicitation_mode", "browser"); + const client = new Client( + { name: "executor-e2e-browser-resume", version: "0.0.1" }, + { capabilities: {} }, + ); + const transport = new StreamableHTTPClientTransport(url, { + requestInit: { headers: { authorization: `Bearer ${bearer}` } }, + }); + await client.connect(transport); + return { client, transport }; +}; + +const closeQuietly = (connected: Connected): Effect.Effect => + Effect.promise(() => connected.client.close().catch(() => undefined)); + +const pathWithSearch = (url: string): string => { + const parsed = new URL(url); + return `${parsed.pathname}${parsed.search}`; +}; + +const authenticatedFetch = ( + identity: Identity, + input: URL, + init: RequestInit = {}, +): Promise => + fetch(input, { + ...init, + headers: { + ...(identity.headers ?? {}), + ...(init.headers ?? {}), + }, + }); + +scenario( + "MCP approval · browser resume page loads the paused execution from its approval URL", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const { client: apiClient } = yield* Api; + const mcp = yield* Mcp; + const identity = yield* target.newIdentity(); + const bearer = yield* mcp.mintBearer(emailOf(identity)); + const api = yield* apiClient(coreApi, identity); + + const policy = yield* api.policies.create({ + payload: { owner: "org", pattern: GATE_TOOL, action: "require_approval" }, + }); + + yield* Effect.gen(function* () { + const session = yield* Effect.promise(() => + openBrowserApprovalSession(target.mcpUrl, bearer), + ); + yield* Effect.gen(function* () { + const paused = yield* Effect.promise(() => + session.client.callTool({ name: "execute", arguments: { code: GATED_CODE } }), + ); + const approval = parseBrowserApproval({ + raw: paused, + text: textOf(paused), + ok: paused.isError !== true, + }); + + const approvalUrl = new URL(approval.approvalUrl); + const mcpSessionId = approvalUrl.searchParams.get("mcp_session_id"); + expect( + mcpSessionId, + "approval URL carries the MCP session id that the resume page will query", + ).toEqual(expect.any(String)); + expect(mcpSessionId, "approval URL points at the session that paused").toBe( + session.transport.sessionId, + ); + + const pausedApiUrl = new URL( + `/api/mcp-sessions/${encodeURIComponent(mcpSessionId ?? "")}/executions/${encodeURIComponent(approval.executionId)}`, + target.baseUrl, + ); + const pausedResponse = yield* Effect.promise(() => + authenticatedFetch(identity, pausedApiUrl), + ); + const pausedBody = yield* Effect.promise(() => pausedResponse.text()); + expect( + pausedResponse.status, + `resume page paused-execution GET response: ${pausedBody}`, + ).toBe(200); + + const resumeApiUrl = new URL(`${pausedApiUrl.pathname}/resume`, target.baseUrl); + const resumeResponse = yield* Effect.promise(() => + authenticatedFetch(identity, resumeApiUrl, { + method: "POST", + headers: { + "content-type": "application/json", + origin: new URL(target.baseUrl).origin, + }, + body: JSON.stringify({ action: "accept", content: {} }), + }), + ); + const resumeBody = yield* Effect.promise(() => resumeResponse.text()); + expect(resumeResponse.status, `resume page approval POST response: ${resumeBody}`).toBe( + 200, + ); + expect(JSON.parse(resumeBody), "resume page approval POST body").toMatchObject({ + status: "completed", + structured: { status: "approved", executionId: approval.executionId }, + isError: false, + }); + + const resumed = yield* Effect.promise(() => + session.client.callTool({ + name: "resume", + arguments: { executionId: approval.executionId }, + }), + ); + expect(resumed.isError, "browser-mode resume consumes the page decision").not.toBe(true); + expect(textOf(resumed), "the gated tool completed after approval").toContain(policy.id); + }).pipe(Effect.ensuring(closeQuietly(session))); + }).pipe( + Effect.ensuring( + api.policies + .remove({ params: { policyId: policy.id }, payload: { owner: "org" } }) + .pipe(Effect.ignore), + ), + ); + }), +); + +scenario( + "MCP approval · browser resume page approves a paused execution through the UI", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const { client: apiClient } = yield* Api; + const browser = yield* Browser; + const mcp = yield* Mcp; + const identity = yield* target.newIdentity(); + const bearer = yield* mcp.mintBearer(emailOf(identity)); + const api = yield* apiClient(coreApi, identity); + + const policy = yield* api.policies.create({ + payload: { owner: "org", pattern: GATE_TOOL, action: "require_approval" }, + }); + + yield* Effect.gen(function* () { + const session = yield* Effect.promise(() => + openBrowserApprovalSession(target.mcpUrl, bearer), + ); + yield* Effect.gen(function* () { + const paused = yield* Effect.promise(() => + session.client.callTool({ name: "execute", arguments: { code: GATED_CODE } }), + ); + const approval = parseBrowserApproval({ + raw: paused, + text: textOf(paused), + ok: paused.isError !== true, + }); + + const approvalUrl = new URL(approval.approvalUrl); + const mcpSessionId = approvalUrl.searchParams.get("mcp_session_id"); + expect( + mcpSessionId, + "approval URL carries the MCP session id that the browser page will query", + ).toEqual(expect.any(String)); + expect(mcpSessionId, "approval URL points at the session that paused").toBe( + session.transport.sessionId, + ); + + const [resumed] = yield* Effect.all( + [ + Effect.promise(() => + session.client.callTool({ + name: "resume", + arguments: { executionId: approval.executionId }, + }), + ), + browser.session(identity, async ({ page, step }) => { + await step("Open the paused execution approval page", async () => { + await page.goto(pathWithSearch(approval.approvalUrl), { waitUntil: "networkidle" }); + await page.getByText("User approval required").waitFor(); + }); + + await step("Review the paused tool call details", async () => { + await page.getByText("Pending request").waitFor(); + await page.getByText(/Approve executor\.coreTools\.policies\.list\?/).waitFor(); + + const approve = page.getByRole("button", { name: "Approve" }); + await approve.waitFor(); + expect( + await approve.isEnabled(), + "the approve control is enabled for the paused execution", + ).toBe(true); + expect( + await page.getByText(UNAVAILABLE_COPY).count(), + "the resume page does not show the expired-session failure copy", + ).toBe(0); + }); + + await step("Approve the paused tool call", async () => { + await page.getByRole("button", { name: "Approve" }).click(); + await page.getByText("Approve sent").waitFor(); + }); + }), + ], + { concurrency: "unbounded" }, + ); + + expect(resumed.isError, "browser-mode resume completed after the UI approval").not.toBe( + true, + ); + expect(textOf(resumed), "the gated tool completed after approval").toContain(policy.id); + }).pipe(Effect.ensuring(closeQuietly(session))); + }).pipe( + Effect.ensuring( + api.policies + .remove({ params: { policyId: policy.id }, payload: { owner: "org" } }) + .pipe(Effect.ignore), + ), + ); + }), +); diff --git a/e2e/recordings/mcp-browser-resume-page.mp4 b/e2e/recordings/mcp-browser-resume-page.mp4 new file mode 100644 index 000000000..90766634f Binary files /dev/null and b/e2e/recordings/mcp-browser-resume-page.mp4 differ diff --git a/packages/core/sdk/src/oxlint-plugin-executor.test.ts b/packages/core/sdk/src/oxlint-plugin-executor.test.ts index abda89191..a064b3f2e 100644 --- a/packages/core/sdk/src/oxlint-plugin-executor.test.ts +++ b/packages/core/sdk/src/oxlint-plugin-executor.test.ts @@ -1,29 +1,35 @@ import { describe, expect, it } from "@effect/vitest"; import { spawnSync } from "node:child_process"; import { mkdir, rm, writeFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; const repoRoot = resolve(import.meta.dirname, "../../../.."); -const runOxlintOn = async (name: string, source: string) => { - const dir = join(repoRoot, ".local", "oxlint-plugin-executor-tests"); - await mkdir(dir, { recursive: true }); - const file = join(dir, name); - await writeFile(file, source); - - const result = spawnSync( +const runOxlint = (files: readonly string[]) => + spawnSync( join(repoRoot, "node_modules", ".bin", "oxlint"), - ["-c", join(repoRoot, ".oxlintrc.jsonc"), file, "--deny-warnings"], + ["-c", join(repoRoot, ".oxlintrc.jsonc"), ...files, "--deny-warnings"], { cwd: repoRoot, encoding: "utf8", }, ); +const runOxlintOn = async (name: string, source: string) => { + const dir = join(repoRoot, ".local", "oxlint-plugin-executor-tests"); + await mkdir(dir, { recursive: true }); + const file = join(dir, name); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, source); + + const result = runOxlint([file]); + await rm(file, { force: true }); return result; }; +const runOxlintFile = (repoRelativeFile: string) => runOxlint([join(repoRoot, repoRelativeFile)]); + describe("executor oxlint plugin", () => { it("rejects expect calls in conditional test branches", async () => { const result = await runOxlintOn( @@ -211,4 +217,30 @@ describe("executor oxlint plugin", () => { expect(result.status).toBe(0); expect(result.stdout).toContain("Found 0 warnings and 0 errors."); }); + + it("rejects raw Durable Object id resolution", async () => { + const result = await runOxlintOn( + "raw-durable-object-id.ts", + ` + declare const namespace: { + readonly idFromName: (name: string) => unknown; + readonly idFromString: (id: string) => unknown; + }; + + namespace.idFromName("session"); + namespace.idFromString("session"); + `, + ); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("executor(no-raw-durable-object-id)"); + expect(result.stdout).toContain("Use the canonical helper"); + }); + + it("allows canonical Durable Object helper files to resolve ids", () => { + const result = runOxlintFile("packages/hosts/cloudflare/src/mcp/session-stub.ts"); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Found 0 warnings and 0 errors."); + }); }); diff --git a/packages/hosts/cloudflare/package.json b/packages/hosts/cloudflare/package.json index 35cdde143..8176e9b96 100644 --- a/packages/hosts/cloudflare/package.json +++ b/packages/hosts/cloudflare/package.json @@ -19,6 +19,10 @@ "./mcp/execution-owner-directory": { "types": "./src/mcp/execution-owner-directory.ts", "default": "./src/mcp/execution-owner-directory.ts" + }, + "./mcp/session-stub": { + "types": "./src/mcp/session-stub.ts", + "default": "./src/mcp/session-stub.ts" } }, "scripts": { diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts b/packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts index 52ff7fa0e..3fa87cdc4 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts @@ -32,6 +32,7 @@ import { type McpExecutionOwnerRecord, type McpExecutionOwnerRoute, } from "./execution-owner-directory"; +import { mcpSessionStub } from "./session-stub"; vi.mock("agents/mcp", () => ({ McpAgent: class { @@ -467,15 +468,11 @@ describe("McpAgentSessionDOBase cross-session model resume", () => { response: ResumeResponse, ) => Effect.promise(async () => { - const session = sessionNamespace.get( - sessionNamespace.idFromName(mcpSessionDurableObjectName(owner.sessionId)), + return mcpSessionStub(sessionNamespace, owner.sessionId).resumeExecutionForModel( + executionId, + identity, + response, ); - return session - ? session.resumeExecutionForModel(executionId, identity, response) - : { - status: "execution_expired" as const, - ttlMs: PAUSED_APPROVAL_TIMEOUT_MS, - }; }), ); diff --git a/packages/hosts/cloudflare/src/mcp/session-stub.ts b/packages/hosts/cloudflare/src/mcp/session-stub.ts new file mode 100644 index 000000000..b18ef383b --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/session-stub.ts @@ -0,0 +1,48 @@ +import type { ResumeResponse } from "@executor-js/execution"; + +import type { + IncomingTraceHeaders, + McpApprovalOwner, + McpSessionApprovalResult, + McpSessionModelResumeResult, + McpSessionResumeApprovalResult, +} from "./agent-session-durable-object"; +import { mcpSessionDurableObjectName } from "./execution-owner-directory"; + +export interface McpSessionNamespace { + readonly idFromName: (name: string) => Id; + readonly get: (id: Id) => unknown; +} + +export interface McpSessionStub { + readonly validateMcpSessionOwner: ( + identity: McpApprovalOwner, + ) => Promise<"ok" | "not_found" | "forbidden">; + readonly _cf_scheduleDestroy: () => Promise; + readonly getPausedExecutionForApproval: ( + executionId: string, + identity: McpApprovalOwner, + incoming?: IncomingTraceHeaders, + ) => Promise; + readonly resumeExecutionForApproval: ( + executionId: string, + identity: McpApprovalOwner, + response: ResumeResponse, + incoming?: IncomingTraceHeaders, + ) => Promise; + readonly resumeExecutionForModel: ( + executionId: string, + identity: McpApprovalOwner, + response: ResumeResponse, + incoming?: IncomingTraceHeaders, + ) => Promise; +} + +export const mcpSessionStub = ( + namespace: McpSessionNamespace, + sessionId: string, +): McpSessionStub => + // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers types expose only DurableObjectStub, but RPC methods are generated from the bound DO class. + namespace.get( + namespace.idFromName(mcpSessionDurableObjectName(sessionId)), + ) as unknown as McpSessionStub; diff --git a/scripts/oxlint-plugin-executor.js b/scripts/oxlint-plugin-executor.js index f390a8365..0a09ad789 100644 --- a/scripts/oxlint-plugin-executor.js +++ b/scripts/oxlint-plugin-executor.js @@ -15,6 +15,7 @@ import noMatchOrelse from "./oxlint-plugin-executor/rules/no-match-orelse.js"; import noPromiseCatch from "./oxlint-plugin-executor/rules/no-promise-catch.js"; import noPromiseClientSurface from "./oxlint-plugin-executor/rules/no-promise-client-surface.js"; import noPromiseReject from "./oxlint-plugin-executor/rules/no-promise-reject.js"; +import noRawDurableObjectId from "./oxlint-plugin-executor/rules/no-raw-durable-object-id.js"; import noRawFetch from "./oxlint-plugin-executor/rules/no-raw-fetch.js"; import noRawErrorThrow from "./oxlint-plugin-executor/rules/no-raw-error-throw.js"; import noRedundantPrimitiveCast from "./oxlint-plugin-executor/rules/no-redundant-primitive-cast.js"; @@ -58,6 +59,7 @@ export default { "no-promise-catch": noPromiseCatch, "no-promise-client-surface": noPromiseClientSurface, "no-promise-reject": noPromiseReject, + "no-raw-durable-object-id": noRawDurableObjectId, "no-raw-fetch": noRawFetch, "no-raw-error-throw": noRawErrorThrow, "no-redundant-primitive-cast": noRedundantPrimitiveCast, diff --git a/scripts/oxlint-plugin-executor/rules/no-raw-durable-object-id.js b/scripts/oxlint-plugin-executor/rules/no-raw-durable-object-id.js new file mode 100644 index 000000000..e8a626aa2 --- /dev/null +++ b/scripts/oxlint-plugin-executor/rules/no-raw-durable-object-id.js @@ -0,0 +1,39 @@ +import { getPropertyName, toRepoRelative, unwrapExpression } from "../utils.js"; + +const message = + "Do not resolve Durable Object IDs directly with idFromName/idFromString. Use the canonical helper for that Durable Object namespace."; + +const allowedFiles = new Set([ + "packages/hosts/cloudflare/src/mcp/session-stub.ts", + "packages/hosts/cloudflare/src/mcp/execution-owner-directory.ts", + "apps/cloud/src/engine/execution-rate-limit.ts", +]); + +const shouldCheck = (filename) => !allowedFiles.has(toRepoRelative(filename)); + +const isRawDurableObjectIdCall = (callee) => { + const expression = unwrapExpression(callee); + if (expression?.type !== "MemberExpression") return false; + const property = getPropertyName(expression.property); + return property === "idFromName" || property === "idFromString"; +}; + +export default { + meta: { + type: "problem", + docs: { + description: "Disallow raw Durable Object id resolution outside canonical helpers.", + }, + }, + create(context) { + if (!shouldCheck(context.filename)) return {}; + + return { + CallExpression(node) { + if (isRawDurableObjectIdCall(node.callee)) { + context.report({ node: node.callee, message }); + } + }, + }; + }, +};