Skip to content

Commit 8e49072

Browse files
authored
Fix browser elicitation resume page session lookup (#1317)
* Add cloud browser resume page repro * Fix browser resume session lookup * Centralize MCP session Durable Object stubs * Lint against raw Durable Object id lookups * Assert browser approval POST completion * Add browser resume page e2e coverage
1 parent fb808c8 commit 8e49072

15 files changed

Lines changed: 459 additions & 150 deletions

File tree

.oxlintrc.jsonc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"executor/no-match-orelse": "error",
2121
"executor/no-promise-catch": "error",
2222
"executor/no-promise-reject": "error",
23+
"executor/no-raw-durable-object-id": "error",
2324
"executor/no-raw-fetch": "error",
2425
"executor/no-redundant-primitive-cast": "error",
2526
"executor/no-redundant-error-factory": "error",

apps/cloud/src/auth/handlers.ts

Lines changed: 21 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,7 @@ import {
3232
authorizeOrganizationSelector,
3333
resolveOrganization,
3434
} from "./organization";
35-
import type {
36-
McpSessionApprovalResult,
37-
McpSessionResumeApprovalResult,
38-
} from "../mcp/session-durable-object";
35+
import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
3936

4037
const COOKIE_OPTIONS = {
4138
path: "/",
@@ -122,23 +119,7 @@ const requireSelectedOrganization = Effect.gen(function* () {
122119
};
123120
});
124121

125-
const getMcpSessionStub = (mcpSessionId: string) =>
126-
Effect.try({
127-
try: () => {
128-
const ns = env.MCP_SESSION;
129-
return ns.get(ns.idFromString(mcpSessionId));
130-
},
131-
catch: () => undefined,
132-
}).pipe(Effect.orElseSucceed(() => null));
133-
134-
const requireMcpSessionStub = (mcpSessionId: string, executionId: string) =>
135-
Effect.gen(function* () {
136-
const stub = yield* getMcpSessionStub(mcpSessionId);
137-
if (!stub) {
138-
return yield* new McpExecutionNotFoundError({ executionId });
139-
}
140-
return stub;
141-
});
122+
const getMcpSessionStub = (mcpSessionId: string) => mcpSessionStub(env.MCP_SESSION, mcpSessionId);
142123

143124
const failMcpApprovalResult = (
144125
result: { readonly status: "not_found" | "forbidden" },
@@ -528,13 +509,12 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
528509
.handle("getMcpPaused", ({ params }) =>
529510
Effect.gen(function* () {
530511
const owner = yield* requireSelectedOrganization;
531-
const stub = yield* requireMcpSessionStub(params.mcpSessionId, params.executionId);
532-
const result = yield* Effect.promise(
533-
() =>
534-
stub.getPausedExecutionForApproval(params.executionId, {
535-
accountId: owner.accountId,
536-
organizationId: owner.organizationId,
537-
}) as Promise<McpSessionApprovalResult>,
512+
const stub = getMcpSessionStub(params.mcpSessionId);
513+
const result = yield* Effect.promise(() =>
514+
stub.getPausedExecutionForApproval(params.executionId, {
515+
accountId: owner.accountId,
516+
organizationId: owner.organizationId,
517+
}),
538518
);
539519

540520
if (result.status !== "ok") {
@@ -550,20 +530,19 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
550530
.handle("resumeMcpExecution", ({ params, payload }) =>
551531
Effect.gen(function* () {
552532
const owner = yield* requireSelectedOrganization;
553-
const stub = yield* requireMcpSessionStub(params.mcpSessionId, params.executionId);
554-
const result = yield* Effect.promise(
555-
() =>
556-
stub.resumeExecutionForApproval(
557-
params.executionId,
558-
{
559-
accountId: owner.accountId,
560-
organizationId: owner.organizationId,
561-
},
562-
{
563-
action: payload.action,
564-
content: payload.content as Record<string, unknown> | undefined,
565-
},
566-
) as Promise<McpSessionResumeApprovalResult>,
533+
const stub = getMcpSessionStub(params.mcpSessionId);
534+
const result = yield* Effect.promise(() =>
535+
stub.resumeExecutionForApproval(
536+
params.executionId,
537+
{
538+
accountId: owner.accountId,
539+
organizationId: owner.organizationId,
540+
},
541+
{
542+
action: payload.action,
543+
content: payload.content as Record<string, unknown> | undefined,
544+
},
545+
),
567546
);
568547

569548
if (result.status !== "ok") {

apps/cloud/src/mcp/agent-handler.ts

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,12 @@ import {
1313
withVerifiedIdentityHeaders,
1414
} from "@executor-js/cloudflare/mcp/do-headers";
1515
import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";
16-
import { mcpSessionDurableObjectName } from "@executor-js/cloudflare/mcp/execution-owner-directory";
16+
import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
1717

1818
import { wrapMcpSseResponse } from "../observability/memory-metrics";
1919
import { cloudMcpAuth } from "./auth-provider";
2020
import { McpSessionDOSqlite } from "./session-durable-object";
2121

22-
interface McpAgentSessionStub {
23-
readonly validateMcpSessionOwner: (identity: {
24-
readonly accountId: string;
25-
readonly organizationId: string;
26-
}) => Promise<"ok" | "not_found" | "forbidden">;
27-
readonly _cf_scheduleDestroy: () => Promise<void>;
28-
}
29-
3022
const corsPreflightResponse = (): Response =>
3123
new Response(null, {
3224
status: 204,
@@ -68,12 +60,6 @@ const renderAuthError = (
6860
return jsonRpcResponse(503, -32001, outcome.message);
6961
};
7062

71-
const sessionStub = (env: Env, sessionId: string): McpAgentSessionStub =>
72-
// oxlint-disable-next-line executor/no-double-cast -- boundary: Workers types expose only DurableObjectStub, but RPC methods are generated from the bound DO class.
73-
env.MCP_SESSION.get(
74-
env.MCP_SESSION.idFromName(mcpSessionDurableObjectName(sessionId)),
75-
) as unknown as McpAgentSessionStub;
76-
7763
const authenticate = (request: Request) =>
7864
Effect.gen(function* () {
7965
const auth = yield* McpAuthProvider;
@@ -142,7 +128,11 @@ export const makeCloudMcpAgentHandler = () => {
142128
if (!Predicate.isTagged(outcome, "Authenticated")) {
143129
if (Predicate.isTagged(outcome, "Forbidden") && sessionId) {
144130
await Effect.runPromise(
145-
Effect.ignore(Effect.tryPromise(() => sessionStub(env, sessionId)._cf_scheduleDestroy())),
131+
Effect.ignore(
132+
Effect.tryPromise(() =>
133+
mcpSessionStub(env.MCP_SESSION, sessionId)._cf_scheduleDestroy(),
134+
),
135+
),
146136
);
147137
}
148138
return renderAuthError(auth, request, outcome);
@@ -156,7 +146,7 @@ export const makeCloudMcpAgentHandler = () => {
156146
}
157147

158148
if (sessionId) {
159-
const owner = await sessionStub(env, sessionId).validateMcpSessionOwner({
149+
const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({
160150
accountId: outcome.principal.accountId,
161151
organizationId: outcome.principal.organizationId,
162152
});

apps/cloud/src/mcp/session-durable-object.ts

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,11 @@ import {
3636
type SessionMeta,
3737
} from "@executor-js/cloudflare/mcp/agent-durable-object";
3838
import {
39-
mcpSessionDurableObjectName,
4039
mcpExecutionOwnerDirectoryFromNamespace,
4140
type McpExecutionOwnerDirectory,
4241
type McpExecutionOwnerRoute,
4342
} from "@executor-js/cloudflare/mcp/execution-owner-directory";
43+
import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
4444
import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution";
4545

4646
// The DO meters executions just like the HTTP `/api/*` plane: it builds its
@@ -101,16 +101,6 @@ type CloudSessionDbHandle = DbServiceShape & {
101101
readonly end: () => Promise<void>;
102102
};
103103

104-
interface McpModelResumeStub {
105-
readonly resumeExecutionForModel: (
106-
executionId: string,
107-
identity: McpApprovalOwner,
108-
response: ResumeResponse,
109-
) => Promise<McpSessionModelResumeResult>;
110-
}
111-
112-
const toMcpModelResumeStub = (stub: unknown): McpModelResumeStub => stub as McpModelResumeStub;
113-
114104
class OrganizationNotFoundError extends Data.TaggedError("OrganizationNotFoundError")<{
115105
readonly organizationId: string;
116106
}> {}
@@ -213,11 +203,11 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD
213203
): Effect.Effect<McpSessionModelResumeResult, unknown> {
214204
return Effect.tryPromise({
215205
try: () =>
216-
toMcpModelResumeStub(
217-
env.MCP_SESSION.get(
218-
env.MCP_SESSION.idFromName(mcpSessionDurableObjectName(owner.sessionId)),
219-
),
220-
).resumeExecutionForModel(executionId, identity, response),
206+
mcpSessionStub(env.MCP_SESSION, owner.sessionId).resumeExecutionForModel(
207+
executionId,
208+
identity,
209+
response,
210+
),
221211
catch: (cause) => new McpModelResumeForwardError({ cause }),
222212
});
223213
}

apps/host-cloudflare/src/mcp/agent-handler.ts

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,12 @@ import {
1313
withVerifiedIdentityHeaders,
1414
} from "@executor-js/cloudflare/mcp/do-headers";
1515
import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";
16-
import { mcpSessionDurableObjectName } from "@executor-js/cloudflare/mcp/execution-owner-directory";
16+
import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
1717

1818
import type { CloudflareConfig, CloudflareEnv } from "../config";
1919
import { cloudflareAccessMcpAuth } from "./auth";
2020
import { McpSessionDO } from "./session-durable-object";
2121

22-
interface McpAgentSessionStub {
23-
readonly validateMcpSessionOwner: (identity: {
24-
readonly accountId: string;
25-
readonly organizationId: string;
26-
}) => Promise<"ok" | "not_found" | "forbidden">;
27-
readonly _cf_scheduleDestroy: () => Promise<void>;
28-
}
29-
3022
const corsPreflightResponse = (): Response =>
3123
new Response(null, {
3224
status: 204,
@@ -68,12 +60,6 @@ const renderAuthError = (
6860
return jsonRpcResponse(503, -32001, outcome.message);
6961
};
7062

71-
const sessionStub = (env: CloudflareEnv, sessionId: string): McpAgentSessionStub =>
72-
// oxlint-disable-next-line executor/no-double-cast -- boundary: Workers types expose only DurableObjectStub, but RPC methods are generated from the bound DO class.
73-
env.MCP_SESSION.get(
74-
env.MCP_SESSION.idFromName(mcpSessionDurableObjectName(sessionId)),
75-
) as unknown as McpAgentSessionStub;
76-
7763
const authenticate = (request: Request, config: CloudflareConfig) =>
7864
Effect.gen(function* () {
7965
const auth = yield* McpAuthProvider;
@@ -116,7 +102,11 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => {
116102
if (!Predicate.isTagged(outcome, "Authenticated")) {
117103
if (Predicate.isTagged(outcome, "Forbidden") && sessionId) {
118104
await Effect.runPromise(
119-
Effect.ignore(Effect.tryPromise(() => sessionStub(env, sessionId)._cf_scheduleDestroy())),
105+
Effect.ignore(
106+
Effect.tryPromise(() =>
107+
mcpSessionStub(env.MCP_SESSION, sessionId)._cf_scheduleDestroy(),
108+
),
109+
),
120110
);
121111
}
122112
return renderAuthError(auth, request, outcome);
@@ -127,7 +117,7 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => {
127117
}
128118

129119
if (sessionId) {
130-
const owner = await sessionStub(env, sessionId).validateMcpSessionOwner({
120+
const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({
131121
accountId: outcome.principal.accountId,
132122
organizationId: outcome.principal.organizationId,
133123
});

apps/host-cloudflare/src/mcp/index.ts

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
11
import { Effect } from "effect";
22

33
import { decodeResumeResponse } from "@executor-js/host-mcp/browser-approval";
4-
import type {
5-
McpApprovalOwner,
6-
McpSessionApprovalResult,
7-
McpSessionResumeApprovalResult,
8-
} from "@executor-js/cloudflare/mcp/agent-durable-object";
9-
import { mcpSessionDurableObjectName } from "@executor-js/cloudflare/mcp/execution-owner-directory";
10-
import type { ResumeResponse } from "@executor-js/execution";
4+
import type { McpApprovalOwner } from "@executor-js/cloudflare/mcp/agent-durable-object";
5+
import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
116

127
import type { CloudflareConfig, CloudflareEnv } from "../config";
138
import { makeAccessVerifier } from "../auth/cloudflare-access";
@@ -16,20 +11,6 @@ export { cloudflareAccessMcpAuth } from "./auth";
1611
export { McpSessionDO } from "./session-durable-object";
1712
export { McpExecutionOwnerDirectoryDO } from "@executor-js/cloudflare/mcp/execution-owner-directory";
1813

19-
const toApprovalStub = (stub: unknown): McpApprovalStub => stub as McpApprovalStub;
20-
21-
interface McpApprovalStub {
22-
getPausedExecutionForApproval(
23-
executionId: string,
24-
identity: McpApprovalOwner,
25-
): Promise<McpSessionApprovalResult>;
26-
resumeExecutionForApproval(
27-
executionId: string,
28-
identity: McpApprovalOwner,
29-
response: ResumeResponse,
30-
): Promise<McpSessionResumeApprovalResult>;
31-
}
32-
3314
const PAUSED_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/;
3415
const RESUME_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/;
3516

@@ -41,10 +22,7 @@ export const makeCloudflareApprovalHandler = (
4122
env: CloudflareEnv,
4223
): ((request: Request) => Promise<Response>) => {
4324
const { verify } = makeAccessVerifier(config);
44-
const stubFor = (sessionId: string): McpApprovalStub =>
45-
toApprovalStub(
46-
env.MCP_SESSION.get(env.MCP_SESSION.idFromName(mcpSessionDurableObjectName(sessionId))),
47-
);
25+
const stubFor = (sessionId: string) => mcpSessionStub(env.MCP_SESSION, sessionId);
4826

4927
return async (request) => {
5028
const principal = await Effect.runPromise(verify(request));

apps/host-cloudflare/src/mcp/session-durable-object.ts

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ import {
1515
type SessionMeta,
1616
} from "@executor-js/cloudflare/mcp/agent-durable-object";
1717
import {
18-
mcpSessionDurableObjectName,
1918
mcpExecutionOwnerDirectoryFromNamespace,
2019
type McpExecutionOwnerDirectory,
2120
type McpExecutionOwnerRoute,
2221
} from "@executor-js/cloudflare/mcp/execution-owner-directory";
22+
import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
2323
import type { ResumeResponse } from "@executor-js/execution";
2424

2525
import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config";
@@ -47,16 +47,6 @@ import { preloadQuickJs } from "../quickjs";
4747
// own lifecycle (the binding is the connection), so `end` is `close` — a no-op.
4848
type CfSessionDbHandle = ExecutorDbHandle & { readonly end: () => Promise<void> };
4949

50-
interface McpModelResumeStub {
51-
readonly resumeExecutionForModel: (
52-
executionId: string,
53-
identity: McpApprovalOwner,
54-
response: ResumeResponse,
55-
) => Promise<McpSessionModelResumeResult>;
56-
}
57-
58-
const toMcpModelResumeStub = (stub: unknown): McpModelResumeStub => stub as McpModelResumeStub;
59-
6050
class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForwardError")<{
6151
readonly cause: unknown;
6252
}> {}
@@ -86,11 +76,11 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession
8676
): Effect.Effect<McpSessionModelResumeResult, unknown> {
8777
return Effect.tryPromise({
8878
try: () =>
89-
toMcpModelResumeStub(
90-
this.cfEnv.MCP_SESSION.get(
91-
this.cfEnv.MCP_SESSION.idFromName(mcpSessionDurableObjectName(owner.sessionId)),
92-
),
93-
).resumeExecutionForModel(executionId, identity, response),
79+
mcpSessionStub(this.cfEnv.MCP_SESSION, owner.sessionId).resumeExecutionForModel(
80+
executionId,
81+
identity,
82+
response,
83+
),
9484
catch: (cause) => new McpModelResumeForwardError({ cause }),
9585
});
9686
}

0 commit comments

Comments
 (0)