Skip to content

Commit 04ca4df

Browse files
committed
Merge commit '3731ebd26e23d84d9188658d0b42e848f62789bf' into batch25-pr-1926
# Conflicts: # packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts
2 parents 7f200b4 + 3731ebd commit 04ca4df

30 files changed

Lines changed: 1010 additions & 72 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@executor-js/sdk": patch
3+
"@executor-js/execution": patch
4+
"@executor-js/plugin-mcp": patch
5+
"@executor-js/api": patch
6+
"@executor-js/react": patch
7+
"@executor-js/host-mcp": patch
8+
"@executor-js/cloudflare": patch
9+
"executor": patch
10+
---
11+
12+
Carry an approval's persistence choice through elicitation, so Codex Computer Use stops asking to use the same app on every call.
13+
14+
Computer Use offers `persist: ["session", "always"]` in the prompt's terms and remembers the app only when the answer names one. Executor dropped the offer on the way in (the terms projection kept strings only) and the choice on the way out (every adapter rebuilt the reply from `action` and `content`), so each accept was one-time. `ElicitationResponse` now has `meta.persist`; the MCP plugin, the app-server bridge, and the MCP host pass it through; the model-mode `resume` tool and the browser approval page let the approver pick from the offered scopes. Nothing is chosen automatically: a bare accept still approves once.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@executor-js/plugin-mcp": patch
3+
---
4+
5+
Exclude time spent waiting for elicitation from the MCP tool invocation deadline.

‎apps/cloud/src/auth/api.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ const McpSessionExecutionParams = {
115115
const ResumeMcpExecutionBody = Schema.Struct({
116116
action: Schema.Literals(["accept", "decline", "cancel"]),
117117
content: Schema.optional(Schema.Unknown),
118+
persist: Schema.optional(Schema.String),
118119
});
119120

120121
const McpPausedExecutionResponse = Schema.Struct({

‎apps/cloud/src/auth/handlers.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
704704
{
705705
action: payload.action,
706706
content: payload.content as Record<string, unknown> | undefined,
707+
...(payload.action === "accept" && payload.persist !== undefined
708+
? { meta: { persist: payload.persist } }
709+
: {}),
707710
},
708711
),
709712
);

‎apps/cloud/src/mcp/session-build-semaphore.test.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it, beforeEach } from "@effect/vitest";
1+
import { describe, expect, it, beforeEach, afterEach, vi } from "@effect/vitest";
22

33
import {
44
acquireBuildSlot,
@@ -13,6 +13,10 @@ describe("session-build-semaphore", () => {
1313
resetBuildSlotsForTest();
1414
});
1515

16+
afterEach(() => {
17+
vi.useRealTimers();
18+
});
19+
1620
it("grants up to the cap immediately, with no wait", async () => {
1721
const results = await Promise.all([
1822
acquireBuildSlot().promise,
@@ -214,6 +218,7 @@ describe("session-build-semaphore", () => {
214218
});
215219

216220
it("proceeds without a slot when the queue wait exceeds the timeout, and does not count it as active", async () => {
221+
vi.useFakeTimers();
217222
await Promise.all([
218223
acquireBuildSlot().promise,
219224
acquireBuildSlot().promise,
@@ -223,6 +228,10 @@ describe("session-build-semaphore", () => {
223228
expect(currentActiveBuildsForTest()).toBe(4);
224229

225230
const timedOutHandle = acquireBuildSlot(10);
231+
await vi.advanceTimersByTimeAsync(9);
232+
expect(currentQueueLengthForTest()).toBe(1);
233+
expect(currentActiveBuildsForTest()).toBe(4);
234+
await vi.advanceTimersByTimeAsync(1);
226235
const result = await timedOutHandle.promise;
227236

228237
expect(result).toEqual({ acquired: false, waitMs: expect.any(Number), timedOut: true });

‎apps/cloud/src/routes/app/resume.$executionId.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,17 @@ function CloudMcpResumeApproval(props: { executionId: string; mcpSessionId: stri
3737
executionId: string,
3838
action: "accept" | "decline" | "cancel",
3939
content?: Record<string, unknown>,
40+
persist?: string,
4041
) =>
4142
doResume({
4243
params: {
4344
mcpSessionId: props.mcpSessionId,
4445
executionId,
4546
},
46-
payload: action === "accept" ? { action, content: content ?? {} } : { action },
47+
payload:
48+
action === "accept"
49+
? { action, content: content ?? {}, ...(persist === undefined ? {} : { persist }) }
50+
: { action },
4751
}),
4852
[doResume, props.mcpSessionId],
4953
);
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { randomBytes } from "node:crypto";
2+
import { expect } from "@effect/vitest";
3+
import { Effect } from "effect";
4+
import { composePluginApi } from "@executor-js/api/server";
5+
import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
6+
import { makeElicitationMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
7+
import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
8+
9+
import { scenario } from "../src/scenario";
10+
import { Api, Browser, Mcp, Target } from "../src/services";
11+
import { parseBrowserApproval } from "../src/surfaces/mcp";
12+
import { visit } from "../src/surfaces/browser";
13+
14+
const api = composePluginApi([mcpHttpPlugin()] as const);
15+
16+
scenario(
17+
"MCP · browser approval preserves the chosen lifetime and defaults to once",
18+
{ timeout: 180_000 },
19+
Effect.scoped(
20+
Effect.gen(function* () {
21+
const target = yield* Target;
22+
const browser = yield* Browser;
23+
const mcp = yield* Mcp;
24+
const { client: makeClient } = yield* Api;
25+
const identity = yield* target.newIdentity();
26+
const client = yield* makeClient(api, identity);
27+
const slug = IntegrationSlug.make(`approval_terms_${randomBytes(4).toString("hex")}`);
28+
const server = yield* serveMcpServer(makeElicitationMcpServer);
29+
yield* client.mcp.addServer({
30+
payload: {
31+
transport: "remote",
32+
name: "Approval terms",
33+
endpoint: server.url,
34+
slug,
35+
remoteTransport: "streamable-http",
36+
},
37+
});
38+
yield* Effect.gen(function* () {
39+
yield* client.connections.create({
40+
payload: {
41+
owner: "org",
42+
name: ConnectionName.make("main"),
43+
integration: slug,
44+
template: AuthTemplateSlug.make("none"),
45+
value: "",
46+
},
47+
});
48+
const session = mcp.session(identity, { elicitationMode: "browser" });
49+
yield* session.listTools();
50+
for (const scope of ["session", "always", ""] as const) {
51+
const paused = yield* session.call("execute", {
52+
code: `return await tools.${slug}.org.main.remembered_echo({value:"browser"});`,
53+
});
54+
const approval = parseBrowserApproval(paused);
55+
const [resumed] = yield* Effect.all(
56+
[
57+
session.awaitResume(approval.executionId),
58+
browser.session(identity, async ({ page, step }) => {
59+
await step(`Approve ${scope || "once"} through the console`, async () => {
60+
await visit(page, approval.approvalUrl);
61+
const choice = page.getByLabel("Remember this approval");
62+
await choice.waitFor();
63+
expect(await choice.inputValue(), "every approval starts as one-time").toBe("");
64+
if (scope !== "") await choice.selectOption(scope);
65+
await page.getByRole("button", { name: "Approve", exact: true }).click();
66+
await page.getByText("Approve sent").waitFor();
67+
});
68+
}),
69+
],
70+
{ concurrency: "unbounded" },
71+
);
72+
expect(resumed.ok).toBe(true);
73+
expect(resumed.text, "the chosen lifetime reaches the upstream MCP server").toContain(
74+
`approved:browser:${scope || "once"}`,
75+
);
76+
}
77+
}).pipe(Effect.ensuring(client.mcp.removeServer({ params: { slug } }).pipe(Effect.orDie)));
78+
}),
79+
),
80+
);
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { randomBytes } from "node:crypto";
2+
import { expect } from "@effect/vitest";
3+
import { Effect, Schema } from "effect";
4+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5+
import { composePluginApi } from "@executor-js/api/server";
6+
import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
7+
import { serveMcpServer } from "@executor-js/plugin-mcp/testing";
8+
import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
9+
10+
import { scenario } from "../src/scenario";
11+
import { Api, Mcp, Target } from "../src/services";
12+
13+
const api = composePluginApi([mcpHttpPlugin()] as const);
14+
const decodeExecutionId = Schema.decodeUnknownSync(Schema.String);
15+
16+
scenario(
17+
"MCP · delayed approval preserves the chosen lifetime beyond the active-work deadline",
18+
{ timeout: 180_000 },
19+
Effect.scoped(
20+
Effect.gen(function* () {
21+
const target = yield* Target;
22+
const mcp = yield* Mcp;
23+
const { client: makeClient } = yield* Api;
24+
const identity = yield* target.newIdentity();
25+
const client = yield* makeClient(api, identity);
26+
const slug = IntegrationSlug.make(`deadline_${randomBytes(4).toString("hex")}`);
27+
const server = yield* serveMcpServer(() => {
28+
const upstream = new McpServer({ name: "Human approval", version: "1" });
29+
upstream.registerTool("approve", { inputSchema: {} }, async () => {
30+
const reply = await upstream.server.elicitInput(
31+
{
32+
mode: "form",
33+
message: "Approve the delayed call?",
34+
requestedSchema: { type: "object", properties: {} },
35+
_meta: { persist: ["session", "always"] },
36+
},
37+
{ timeout: 150_000 },
38+
);
39+
return {
40+
content: [
41+
{ type: "text", text: `decision:${reply.action}:${reply._meta?.persist ?? "once"}` },
42+
],
43+
};
44+
});
45+
return upstream;
46+
});
47+
yield* client.mcp.addServer({
48+
payload: {
49+
transport: "remote",
50+
name: "Human approval",
51+
endpoint: server.url,
52+
slug,
53+
remoteTransport: "streamable-http",
54+
},
55+
});
56+
yield* Effect.gen(function* () {
57+
yield* client.connections.create({
58+
payload: {
59+
owner: "org",
60+
name: ConnectionName.make("main"),
61+
integration: slug,
62+
template: AuthTemplateSlug.make("none"),
63+
value: "",
64+
},
65+
});
66+
const session = mcp.session(identity, { elicitationMode: "model" });
67+
yield* session.listTools();
68+
const paused = yield* session.call("execute", {
69+
code: `return await tools.${slug}.org.main.approve({});`,
70+
});
71+
expect(paused.text).toContain("executionId:");
72+
// Cross the production 60-second active-work deadline. This is the
73+
// behavior under test: a human waiting must consume none of that budget.
74+
yield* Effect.sleep("65 seconds");
75+
const executionId = decodeExecutionId(/\bexecutionId:\s*(\S+)/.exec(paused.text)?.[1]);
76+
const completed = yield* session.call("resume", {
77+
executionId,
78+
action: "accept",
79+
persist: "session",
80+
});
81+
expect(completed.ok).toBe(true);
82+
expect(completed.text).toContain("decision:accept:session");
83+
}).pipe(Effect.ensuring(client.mcp.removeServer({ params: { slug } }).pipe(Effect.orDie)));
84+
}),
85+
),
86+
);

‎e2e/vitest.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export default defineConfig({
4848
project("cloudflare", {
4949
include: [
5050
"scenarios/browser-approval.test.ts",
51+
"scenarios/mcp-approval-persistence.test.ts",
5152
"scenarios/microsoft-graph-full.test.ts",
5253
"scenarios/toolkits-mcp.test.ts",
5354
"cloudflare/**/*.test.ts",

‎packages/core/api/src/executions/api.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ const ExecuteResponse = Schema.Union([CompletedResult, PausedResult]);
4444
const ResumeRequest = Schema.Struct({
4545
action: Schema.Literals(["accept", "decline", "cancel"]),
4646
content: Schema.optional(Schema.Unknown),
47+
/** How long an accepted approval lasts, when the paused interaction's
48+
* terms offer a choice (`interaction.meta.persist` lists the scopes).
49+
* Omitted, the approval is for this call only. */
50+
persist: Schema.optional(Schema.String),
4751
});
4852

4953
const ResumeResponse = Schema.Union([CompletedResult, PausedResult]);

0 commit comments

Comments
 (0)