History

Carry an approval's persistence choice through elicitation (#1976)

* fix(mcp): pause active timeout during elicitation * Carry an approval's persistence choice through elicitation Codex Computer Use offers `persist: ["session", "always"]` in the terms of its "Allow Computer Use to use X?" prompt and remembers the app only when the answer names one. Executor lost the offer on the way in — the terms projection kept strings only — and the choice on the way out, because every adapter rebuilt the reply from `action` and `content`. So each accept was a one-time approval and the same app prompted on every call. - `ElicitationResponse.meta.persist` carries the choice; the vocabulary is closed so no host can grant more than the prompt offered. - `approvalTerms` keeps string lists, so the offered scopes reach the host. - The MCP plugin, the app-server bridge, and the MCP host (native mode) pass `_meta` through in both directions. - The model-mode `resume` tool takes `persist`; the pause output names the offered scopes and says a bare accept is one-time. - The HTTP resume API takes `persist`, and the browser approval page offers the scopes in a select. Nothing is chosen automatically. Fixes #1962 * Preserve approval lifetime through browser and cloud resume paths * Test approval waits beyond the MCP active-work deadline * Test queue timeout with a controlled clock * Test queue timeout with a controlled clock ---------

Dara Adedeji committed 1 month ago

Commit 40b2f2e1 parent b5d53cb27 files · +635 −43Browse files

Unified · Split

27 files
  1. .changeset/computer-use-remembered-approvals.md +14 −0
  2. apps/cloud/src/auth/api.ts +1 −0
  3. apps/cloud/src/auth/handlers.ts +3 −0
  4. apps/cloud/src/routes/app/resume.$executionId.tsx +5 −1
  5. e2e/scenarios/mcp-approval-persistence.test.ts +80 −0
  6. e2e/selfhost/mcp-elicitation-deadline.test.ts +16 −5
  7. e2e/vitest.config.ts +1 −0
  8. packages/core/api/src/executions/api.ts +4 −0
  9. packages/core/api/src/handlers/executions.ts +1 −0
  10. packages/core/execution/src/engine.test.ts +28 −0
  11. packages/core/execution/src/engine.ts +22 −3
  12. packages/core/sdk/src/elicitation.ts +27 −0
  13. packages/core/sdk/src/index.ts +2 −0
  14. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts +17 −0
  15. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +3 −6
  16. packages/hosts/mcp/src/browser-approval.ts +11 −2
  17. packages/hosts/mcp/src/tool-server.test.ts +87 −3
  18. packages/hosts/mcp/src/tool-server.ts +45 −10
  19. packages/plugins/mcp/src/sdk/appserver-connector.test.ts +33 −0
  20. packages/plugins/mcp/src/sdk/appserver-connector.ts +8 −0
  21. packages/plugins/mcp/src/sdk/appserver-test-server.ts +43 −3
  22. packages/plugins/mcp/src/sdk/codex-plugin-presets.test.ts +11 −1
  23. packages/plugins/mcp/src/sdk/elicitation.test.ts +44 −0
  24. packages/plugins/mcp/src/sdk/invoke.ts +12 −1
  25. packages/plugins/mcp/src/testing/server.ts +31 −0
  26. packages/react/src/pages/resume-approval.tsx +73 −6
  27. packages/react/src/routes/resume.$executionId.tsx +13 −2
.changeset/computer-use-remembered-approvals.md+14−0
@@ -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
12Carry an approval's persistence choice through elicitation, so Codex Computer Use stops asking to use the same app on every call.
13
14Computer 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.
apps/cloud/src/auth/api.ts+1−0
@@ -115,6 +115,7 @@ const McpSessionExecutionParams = { · 114 unmodified lines
115const ResumeMcpExecutionBody = Schema.Struct({115const ResumeMcpExecutionBody = Schema.Struct({
116 action: Schema.Literals(["accept", "decline", "cancel"]),116 action: Schema.Literals(["accept", "decline", "cancel"]),
117 content: Schema.optional(Schema.Unknown),117 content: Schema.optional(Schema.Unknown),
118 persist: Schema.optional(Schema.String),
118});119});
119 120
120const McpPausedExecutionResponse = Schema.Struct({121const McpPausedExecutionResponse = Schema.Struct({
apps/cloud/src/auth/handlers.ts+3−0
@@ -704,6 +704,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 703 unmodified lines
704 {704 {
705 action: payload.action,705 action: payload.action,
706 content: payload.content as Record<string, unknown> | undefined,706 content: payload.content as Record<string, unknown> | undefined,
707 ...(payload.action === "accept" && payload.persist !== undefined
708 ? { meta: { persist: payload.persist } }
709 : {}),
707 },710 },
708 ),711 ),
709 );712 );
apps/cloud/src/routes/app/resume.$executionId.tsx+5−1
@@ -37,13 +37,17 @@ function CloudMcpResumeApproval(props: { executionId: string; mcpSessionId: stri · 36 unmodified lines
37 executionId: string,37 executionId: string,
38 action: "accept" | "decline" | "cancel",38 action: "accept" | "decline" | "cancel",
39 content?: Record<string, unknown>,39 content?: Record<string, unknown>,
40 persist?: string,
40 ) =>41 ) =>
41 doResume({42 doResume({
42 params: {43 params: {
43 mcpSessionId: props.mcpSessionId,44 mcpSessionId: props.mcpSessionId,
44 executionId,45 executionId,
45 },46 },
46 payload: action === "accept" ? { action, content: content ?? {} } : { action },47 payload:
48 action === "accept"
49 ? { action, content: content ?? {}, ...(persist === undefined ? {} : { persist }) }
50 : { action },
47 }),51 }),
48 [doResume, props.mcpSessionId],52 [doResume, props.mcpSessionId],
49 );53 );
e2e/scenarios/mcp-approval-persistence.test.ts+80−0
@@ -0,0 +1,80 @@
1import { randomBytes } from "node:crypto";
2import { expect } from "@effect/vitest";
3import { Effect } from "effect";
4import { composePluginApi } from "@executor-js/api/server";
5import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
6import { makeElicitationMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
7import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
8
9import { scenario } from "../src/scenario";
10import { Api, Browser, Mcp, Target } from "../src/services";
11import { parseBrowserApproval } from "../src/surfaces/mcp";
12import { visit } from "../src/surfaces/browser";
13
14const api = composePluginApi([mcpHttpPlugin()] as const);
15
16scenario(
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);
e2e/selfhost/mcp-elicitation-deadline.test.ts+16−5
@@ -1,6 +1,6 @@
1import { randomBytes } from "node:crypto";1import { randomBytes } from "node:crypto";
2import { expect } from "@effect/vitest";2import { expect } from "@effect/vitest";
3import { Effect } from "effect";3import { Effect, Schema } from "effect";
4import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";4import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5import { composePluginApi } from "@executor-js/api/server";5import { composePluginApi } from "@executor-js/api/server";
6import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";6import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
@@ -11,9 +11,10 @@ import { scenario } from "../src/scenario"; · 4 unmodified lines
11import { Api, Mcp, Target } from "../src/services";11import { Api, Mcp, Target } from "../src/services";
12 12
13const api = composePluginApi([mcpHttpPlugin()] as const);13const api = composePluginApi([mcpHttpPlugin()] as const);
14const decodeExecutionId = Schema.decodeUnknownSync(Schema.String);
14 15
15scenario(16scenario(
16 "MCP · a human can approve after the active-work deadline without losing the tool call",17 "MCP · delayed approval preserves the chosen lifetime beyond the active-work deadline",
17 { timeout: 180_000 },18 { timeout: 180_000 },
18 Effect.scoped(19 Effect.scoped(
19 Effect.gen(function* () {20 Effect.gen(function* () {
@@ -31,10 +32,15 @@ scenario( · 11 unmodified lines
31 mode: "form",32 mode: "form",
32 message: "Approve the delayed call?",33 message: "Approve the delayed call?",
33 requestedSchema: { type: "object", properties: {} },34 requestedSchema: { type: "object", properties: {} },
35 _meta: { persist: ["session", "always"] },
34 },36 },
35 { timeout: 150_000 },37 { timeout: 150_000 },
36 );38 );
37 return { content: [{ type: "text", text: `decision:${reply.action}` }] };39 return {
40 content: [
41 { type: "text", text: `decision:${reply.action}:${reply._meta?.persist ?? "once"}` },
42 ],
43 };
38 });44 });
39 return upstream;45 return upstream;
40 });46 });
@@ -66,9 +72,14 @@ scenario( · 25 unmodified lines
66 // Cross the production 60-second active-work deadline. This is the72 // Cross the production 60-second active-work deadline. This is the
67 // behavior under test: a human waiting must consume none of that budget.73 // behavior under test: a human waiting must consume none of that budget.
68 yield* Effect.sleep("65 seconds");74 yield* Effect.sleep("65 seconds");
69 const completed = yield* session.approvePaused(paused.text);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 });
70 expect(completed.ok).toBe(true);81 expect(completed.ok).toBe(true);
71 expect(completed.text).toContain("decision:accept");82 expect(completed.text).toContain("decision:accept:session");
72 }).pipe(Effect.ensuring(client.mcp.removeServer({ params: { slug } }).pipe(Effect.orDie)));83 }).pipe(Effect.ensuring(client.mcp.removeServer({ params: { slug } }).pipe(Effect.orDie)));
73 }),84 }),
74 ),85 ),
e2e/vitest.config.ts+1−0
@@ -48,6 +48,7 @@ export default defineConfig({ · 47 unmodified lines
48 project("cloudflare", {48 project("cloudflare", {
49 include: [49 include: [
50 "scenarios/browser-approval.test.ts",50 "scenarios/browser-approval.test.ts",
51 "scenarios/mcp-approval-persistence.test.ts",
51 "scenarios/microsoft-graph-full.test.ts",52 "scenarios/microsoft-graph-full.test.ts",
52 "scenarios/toolkits-mcp.test.ts",53 "scenarios/toolkits-mcp.test.ts",
53 "cloudflare/**/*.test.ts",54 "cloudflare/**/*.test.ts",
packages/core/api/src/executions/api.ts+4−0
@@ -44,6 +44,10 @@ const ExecuteResponse = Schema.Union([CompletedResult, PausedResult]); · 43 unmodified lines
44const ResumeRequest = Schema.Struct({44const ResumeRequest = Schema.Struct({
45 action: Schema.Literals(["accept", "decline", "cancel"]),45 action: Schema.Literals(["accept", "decline", "cancel"]),
46 content: Schema.optional(Schema.Unknown),46 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),
47});51});
48 52
49const ResumeResponse = Schema.Union([CompletedResult, PausedResult]);53const ResumeResponse = Schema.Union([CompletedResult, PausedResult]);
packages/core/api/src/handlers/executions.ts+1−0
@@ -251,6 +251,7 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" · 250 unmodified lines
251 engine.resume(path.executionId, {251 engine.resume(path.executionId, {
252 action: payload.action,252 action: payload.action,
253 content: payload.content as Record<string, unknown> | undefined,253 content: payload.content as Record<string, unknown> | undefined,
254 ...(payload.persist === undefined ? {} : { meta: { persist: payload.persist } }),
254 }),255 }),
255 );256 );
256 257
packages/core/execution/src/engine.test.ts+28−0
@@ -279,6 +279,34 @@ describe("formatPausedExecution approval terms", () => { · 278 unmodified lines
279 });279 });
280 });280 });
281 281
282 it("says how to answer when the terms leave the approval's lifetime to the caller", () => {
283 // Computer Use's app approval: a bare accept is one-time and the same
284 // prompt returns on the next call, so the caller has to be told the
285 // scopes on offer and how to pick one.
286 const result = formatPausedExecution(
287 paused(
288 FormElicitation.make({
289 message: 'Allow Computer Use to use "Finder"?',
290 requestedSchema: {},
291 meta: { persist: ["session", "always"], connector_name: "Computer Use" },
292 }),
293 ),
294 );
295
296 const interaction = result.structured["interaction"] as {
297 readonly meta?: unknown;
298 readonly instructions: string;
299 };
300 expect(interaction.meta).toEqual({
301 persist: ["session", "always"],
302 connector_name: "Computer Use",
303 });
304 expect(interaction.instructions).toContain(
305 'pass persist as one of "session", "always"; without it the approval is for this call only',
306 );
307 expect(result.text).toContain(interaction.instructions);
308 });
309
282 it("says nothing about terms when the upstream attached none", () => {310 it("says nothing about terms when the upstream attached none", () => {
283 const result = formatPausedExecution(311 const result = formatPausedExecution(
284 paused(FormElicitation.make({ message: "Proceed?", requestedSchema: {} })),312 paused(FormElicitation.make({ message: "Proceed?", requestedSchema: {} })),
packages/core/execution/src/engine.ts+22−3
@@ -6,10 +6,15 @@ import type { · 5 unmodified lines
6 Executor,6 Executor,
7 InvokeOptions,7 InvokeOptions,
8 ElicitationResponse,8 ElicitationResponse,
9 ElicitationResponseMeta,
9 ElicitationHandler,10 ElicitationHandler,
10 ElicitationContext,11 ElicitationContext,
11} from "@executor-js/sdk/core";12} from "@executor-js/sdk/core";
12import { CurrentOrgWriteAccess, type OrgWriteAccessState } from "@executor-js/sdk/core";13import {
14 CurrentOrgWriteAccess,
15 offeredPersistence,
16 type OrgWriteAccessState,
17} from "@executor-js/sdk/core";
13import { CodeExecutionError } from "@executor-js/codemode-core";18import { CodeExecutionError } from "@executor-js/codemode-core";
14import type { CodeExecutor, ExecuteResult, SandboxToolInvoker } from "@executor-js/codemode-core";19import type { CodeExecutor, ExecuteResult, SandboxToolInvoker } from "@executor-js/codemode-core";
15 20
@@ -58,6 +63,9 @@ type InternalPausedExecution<E> = PausedExecution & { · 42 unmodified lines
58export type ResumeResponse = {63export type ResumeResponse = {
59 readonly action: "accept" | "decline" | "cancel";64 readonly action: "accept" | "decline" | "cancel";
60 readonly content?: Record<string, unknown>;65 readonly content?: Record<string, unknown>;
66 /** The answer's terms — `persist`, when the paused request offered a
67 * choice of scopes and the approver picked one. */
68 readonly meta?: ElicitationResponseMeta;
61};69};
62 70
63// Auto-accept every elicitation. Used by the `autoApprove` path where the71// Auto-accept every elicitation. Used by the `autoApprove` path where the
@@ -222,10 +230,21 @@ export const formatPausedExecution = ( · 158 unmodified lines
222 : hasRequestedSchema230 : hasRequestedSchema
223 ? `Ask the user for values matching requestedSchema. Then call the resume tool with executionId "${paused.id}", action "accept", and content matching requestedSchema. If the user declines, call resume with action "decline" or "cancel".`231 ? `Ask the user for values matching requestedSchema. Then call the resume tool with executionId "${paused.id}", action "accept", and content matching requestedSchema. If the user declines, call resume with action "decline" or "cancel".`
224 : `This is a model-side confirmation gate; there is no browser form to open. Ask the user whether to approve the paused tool call. If the user approves, call the resume tool with executionId "${paused.id}" and action "accept". If the user declines, call resume with action "decline" or "cancel".`;232 : `This is a model-side confirmation gate; there is no browser form to open. Ask the user whether to approve the paused tool call. If the user approves, call the resume tool with executionId "${paused.id}" and action "accept". If the user declines, call resume with action "decline" or "cancel".`;
233 // When the upstream leaves the LIFETIME of an accept to the answer, the
234 // caller has to know that a bare accept is a one-time approval — the same
235 // prompt returns on the next call — and how to say otherwise.
236 const meta = req.meta;
237 const offered = offeredPersistence(meta);
238 const persistInstructions =
239 offered.length > 0
240 ? ` To have an accepted approval remembered, also pass persist as one of ${offered
241 .map((scope) => JSON.stringify(scope))
242 .join(", ")}; without it the approval is for this call only.`
243 : "";
225 const deadlineInstructions = deadline244 const deadlineInstructions = deadline
226 ? ` Resume before ${deadline.expiresAt}; this approval window lasts ${formatTtlDuration(deadline.ttlMs)}.`245 ? ` Resume before ${deadline.expiresAt}; this approval window lasts ${formatTtlDuration(deadline.ttlMs)}.`
227 : "";246 : "";
228 const instructions = `${baseInstructions}${deadlineInstructions}`;247 const instructions = `${baseInstructions}${persistInstructions}${deadlineInstructions}`;
229 248
230 if (isUrlElicitation) {249 if (isUrlElicitation) {
231 lines.push(`\nOpen this URL in a browser:\n${req.url}`);250 lines.push(`\nOpen this URL in a browser:\n${req.url}`);
@@ -244,7 +263,6 @@ export const formatPausedExecution = ( · 12 unmodified lines
244 // Terms the upstream attached to the approval. Stated plainly, because a263 // Terms the upstream attached to the approval. Stated plainly, because a
245 // prompt whose schema is empty ("Allow X to access Y?") can still be264 // prompt whose schema is empty ("Allow X to access Y?") can still be
246 // asking for a PERSISTENT grant, and the answer differs.265 // asking for a PERSISTENT grant, and the answer differs.
247 const meta = req.meta;
248 if (meta !== undefined && Object.keys(meta).length > 0) {266 if (meta !== undefined && Object.keys(meta).length > 0) {
249 lines.push(`\nApproval terms:\n${JSON.stringify(meta, null, 2)}`);267 lines.push(`\nApproval terms:\n${JSON.stringify(meta, null, 2)}`);
250 }268 }
@@ -811,6 +829,7 @@ export const createExecutionEngine = <E extends Cause.YieldableError = CodeExecu · 560 unmodified lines
811 yield* Deferred.succeed(paused.response, {829 yield* Deferred.succeed(paused.response, {
812 action: response.action as typeof ElicitationResponse.Type.action,830 action: response.action as typeof ElicitationResponse.Type.action,
813 content: response.content,831 content: response.content,
832 ...(response.meta === undefined ? {} : { meta: response.meta }),
814 });833 });
815 834
816 const outcome = (yield* awaitCompletionOrPause(paused.fiber, paused.pauseQueue).pipe(835 const outcome = (yield* awaitCompletionOrPause(paused.fiber, paused.pauseQueue).pipe(
packages/core/sdk/src/elicitation.ts+27−0
@@ -17,6 +17,31 @@ import { ElicitationId, ToolAddress } from "./ids"; · 16 unmodified lines
17export const ElicitationMeta = Schema.Record(Schema.String, Schema.Unknown);17export const ElicitationMeta = Schema.Record(Schema.String, Schema.Unknown);
18export type ElicitationMeta = typeof ElicitationMeta.Type;18export type ElicitationMeta = typeof ElicitationMeta.Type;
19 19
20/** The persistence scopes an approval OFFERS, when its terms leave that to
21 * the answer. Codex Computer Use sends `persist: ["session", "always"]` and
22 * remembers the app only if the reply names one; a bare accept is a
23 * one-time approval and the very next call asks again. Chrome's per-site
24 * approval sends `persist: "always"` — a statement of what accepting
25 * means, not a choice — and contributes nothing here. */
26export const offeredPersistence = (meta: ElicitationMeta | undefined): readonly string[] => {
27 const persist = meta?.["persist"];
28 return Array.isArray(persist) && persist.every((scope) => typeof scope === "string")
29 ? persist
30 : [];
31};
32
33/** What an accepted approval carries back, in the request's own vocabulary.
34 *
35 * Closed on purpose, the mirror of the request-side projection: an answer
36 * can only state terms this contract names, so no host can grant something
37 * the prompt never offered. `persist` is the one term that is a choice —
38 * one of `offeredPersistence(request.meta)`, or absent for a one-time
39 * approval. */
40export const ElicitationResponseMeta = Schema.Struct({
41 persist: Schema.optional(Schema.String),
42});
43export type ElicitationResponseMeta = typeof ElicitationResponseMeta.Type;
44
20/** Tool needs structured input from the user (render a form). */45/** Tool needs structured input from the user (render a form). */
21export const FormElicitation = Schema.TaggedStruct("FormElicitation", {46export const FormElicitation = Schema.TaggedStruct("FormElicitation", {
22 message: Schema.String,47 message: Schema.String,
@@ -45,6 +70,8 @@ export const ElicitationResponse = Schema.Struct({ · 22 unmodified lines
45 action: ElicitationAction,70 action: ElicitationAction,
46 /** Present when `action` is "accept" — the data the user provided. */71 /** Present when `action` is "accept" — the data the user provided. */
47 content: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),72 content: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
73 /** The answer's own terms, meaningful only with "accept". */
74 meta: Schema.optional(ElicitationResponseMeta),
48});75});
49export type ElicitationResponse = typeof ElicitationResponse.Type;76export type ElicitationResponse = typeof ElicitationResponse.Type;
50 77
packages/core/sdk/src/index.ts+2−0
@@ -223,6 +223,8 @@ export { sanitizeArtifactPreviewMarkup, ARTIFACT_PREVIEW_MARKUP_LIMIT } from "./ · 222 unmodified lines
223// Elicitation.223// Elicitation.
224export {224export {
225 ElicitationMeta,225 ElicitationMeta,
226 ElicitationResponseMeta,
227 offeredPersistence,
226 FormElicitation,228 FormElicitation,
227 UrlElicitation,229 UrlElicitation,
228 ElicitationAction,230 ElicitationAction,
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts+17−0
@@ -357,6 +357,23 @@ it("records a demoted browser approver's current role in a waiting decision", as · 356 unmodified lines
357 await expect(waiting).resolves.toEqual({ response: approval, orgWriteAccess: "denied" });357 await expect(waiting).resolves.toEqual({ response: approval, orgWriteAccess: "denied" });
358});358});
359 359
360it("keeps the chosen approval lifetime when reading a stored decision", async () => {
361 const session = await makeHarnessSession();
362 const executionId = "exec-stored-persistence";
363 const response = {
364 action: "accept",
365 content: {},
366 meta: { persist: "session" },
367 } satisfies ResumeResponse;
368 await session.ctx.storage.put(`approval-response:${executionId}`, {
369 response,
370 orgWriteAccess: "allowed",
371 });
372 const decision = await Effect.runPromise(session.waitForApprovalResponse(executionId));
373 expect(decision).toEqual({ response, orgWriteAccess: "allowed" });
374 expect(await session.ctx.storage.get(`approval-response:${executionId}`)).toBeUndefined();
375});
376
360// The negotiated MCP-Apps capability arrives once, at `initialize`, and lives377// The negotiated MCP-Apps capability arrives once, at `initialize`, and lives
361// in the rebuilt server's memory. These pin the storage round-trip that lets a378// in the rebuilt server's memory. These pin the storage round-trip that lets a
362// cold-restored session rebuild with it instead of silently downgrading every379// cold-restored session rebuild with it instead of silently downgrading every
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts+3−6
@@ -20,11 +20,8 @@ import { · 19 unmodified lines
20 type ResumeFallbackOutcome,20 type ResumeFallbackOutcome,
21} from "@executor-js/host-mcp/tool-server";21} from "@executor-js/host-mcp/tool-server";
22import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp";22import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp";
23import {23import { decodeResumeResponse, type McpToolMode } from "@executor-js/host-mcp/browser-approval";
24 ResumeResponsePayload,24import { ElicitationResponse } from "@executor-js/sdk";
25 decodeResumeResponse,
26 type McpToolMode,
27} from "@executor-js/host-mcp/browser-approval";
28 25
29import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers";26import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers";
30import { classifyDurableObjectError, type DurableObjectFailure } from "./durable-object-errors";27import { classifyDurableObjectError, type DurableObjectFailure } from "./durable-object-errors";
@@ -214,7 +211,7 @@ const MCP_MESSAGE_HEADER = "cf-mcp-message"; · 183 unmodified lines
214const MODEL_RESUME_FORWARD_TIMEOUT_MS = 10_000;211const MODEL_RESUME_FORWARD_TIMEOUT_MS = 10_000;
215const approvalResponseKey = (executionId: string) => `approval-response:${executionId}`;212const approvalResponseKey = (executionId: string) => `approval-response:${executionId}`;
216const BrowserApprovalDecisionStorage = Schema.Struct({213const BrowserApprovalDecisionStorage = Schema.Struct({
217 response: ResumeResponsePayload,214 response: ElicitationResponse,
218 orgWriteAccess: Schema.Literals(["allowed", "denied"]),215 orgWriteAccess: Schema.Literals(["allowed", "denied"]),
219});216});
220const decodeBrowserApprovalDecision = Schema.decodeUnknownOption(BrowserApprovalDecisionStorage);217const decodeBrowserApprovalDecision = Schema.decodeUnknownOption(BrowserApprovalDecisionStorage);
packages/hosts/mcp/src/browser-approval.ts+11−2
@@ -125,13 +125,22 @@ export const approvalUrlForRequest = ( · 124 unmodified lines
125export const ResumeResponsePayload = Schema.Struct({125export const ResumeResponsePayload = Schema.Struct({
126 action: Schema.Literals(["accept", "decline", "cancel"]),126 action: Schema.Literals(["accept", "decline", "cancel"]),
127 content: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),127 content: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
128 persist: Schema.optional(Schema.String),
128});129});
129 130
130const decodeResumeResponsePayload = Schema.decodeUnknownOption(ResumeResponsePayload);131const decodeResumeResponsePayload = Schema.decodeUnknownOption(ResumeResponsePayload);
131 132
132/** Decode an untrusted resume payload, or `null` if it doesn't match the contract. */133/** Decode an untrusted resume payload, or `null` if it doesn't match the contract. */
133export const decodeResumeResponse = (raw: unknown): ResumeResponse | null =>134export const decodeResumeResponse = (raw: unknown): ResumeResponse | null => {
134 Option.getOrNull(decodeResumeResponsePayload(raw));135 const decoded = decodeResumeResponsePayload(raw);
136 if (Option.isNone(decoded)) return null;
137 const { action, content, persist } = decoded.value;
138 return {
139 action,
140 ...(content === undefined ? {} : { content }),
141 ...(action === "accept" && persist !== undefined ? { meta: { persist } } : {}),
142 };
143};
135 144
136const ACKNOWLEDGEMENT_TEXT = {145const ACKNOWLEDGEMENT_TEXT = {
137 accept: "I've approved it",146 accept: "I've approved it",
packages/hosts/mcp/src/tool-server.test.ts+87−3
@@ -197,9 +197,11 @@ const toolFile = (input: { · 196 unmodified lines
197/** Build an engine whose execute triggers one elicitation and returns the handler's result. */197/** Build an engine whose execute triggers one elicitation and returns the handler's result. */
198const makeElicitingEngine = (198const makeElicitingEngine = (
199 request: FormElicitation | UrlElicitation,199 request: FormElicitation | UrlElicitation,
200 formatResult: (response: { action: string; content?: Record<string, unknown> }) => unknown = (200 formatResult: (response: {
201 r,201 action: string;
202 ) => r.action,202 content?: Record<string, unknown>;
203 meta?: { readonly persist?: string };
204 }) => unknown = (r) => r.action,
203): ExecutionEngine =>205): ExecutionEngine =>
204 makeStubEngine({206 makeStubEngine({
205 execute: (_code, { onElicitation }) =>207 execute: (_code, { onElicitation }) =>
@@ -1660,6 +1662,88 @@ describe("MCP host server — client without elicitation (pause/resume)", () => · 1454 unmodified lines
1660 });1662 });
1661});1663});
1662 1664
1665// ---------------------------------------------------------------------------
1666// Approval terms — the request's ride out as `_meta`, the answer's ride back
1667// ---------------------------------------------------------------------------
1668
1669describe("MCP host server — approval terms", () => {
1670 const appApproval = FormElicitation.make({
1671 message: 'Allow Computer Use to use "Finder"?',
1672 requestedSchema: {},
1673 meta: { persist: ["session", "always"], connector_name: "Computer Use" },
1674 });
1675
1676 // The engine hands the response back as the execution's result, so the
1677 // structured output carries it verbatim.
1678 const responseOf = (structuredContent: unknown): unknown =>
1679 (structuredContent as { readonly result: unknown }).result;
1680
1681 it("native mode shows the client the offered scopes and returns the one it chose", async () => {
1682 const engine = makeElicitingEngine(appApproval, (r) => r);
1683 let seen: unknown;
1684
1685 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
1686 client.setRequestHandler(ElicitRequestSchema, async (request) => {
1687 seen = request.params._meta;
1688 return { action: "accept" as const, content: {}, _meta: { persist: "always" } };
1689 });
1690
1691 const result = await client.callTool({ name: "execute", arguments: { code: "finder" } });
1692 expect(seen).toEqual({ persist: ["session", "always"], connector_name: "Computer Use" });
1693 expect(responseOf(result.structuredContent)).toEqual({
1694 action: "accept",
1695 content: {},
1696 meta: { persist: "always" },
1697 });
1698 });
1699 });
1700
1701 it("native mode invents no terms when the client states none", async () => {
1702 const engine = makeElicitingEngine(appApproval, (r) => r);
1703
1704 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
1705 client.setRequestHandler(ElicitRequestSchema, async () => ({
1706 action: "accept" as const,
1707 content: {},
1708 }));
1709
1710 const result = await client.callTool({ name: "execute", arguments: { code: "finder" } });
1711 expect(responseOf(result.structuredContent)).toEqual({ action: "accept", content: {} });
1712 });
1713 });
1714
1715 it("model mode passes the resume tool's persist choice to the engine", async () => {
1716 const received: unknown[] = [];
1717 const engine = makeStubEngine({
1718 resume: (_id, response) =>
1719 Effect.sync(() => {
1720 received.push(response);
1721 return { status: "completed", result: { result: "ok" } };
1722 }),
1723 });
1724
1725 await withClient(
1726 engine,
1727 NO_CAPS,
1728 async (client) => {
1729 await client.callTool({
1730 name: "resume",
1731 arguments: { executionId: "exec_1", action: "accept", persist: "session" },
1732 });
1733 await client.callTool({
1734 name: "resume",
1735 arguments: { executionId: "exec_2", action: "accept" },
1736 });
1737 expect(received).toEqual([
1738 { action: "accept", content: undefined, meta: { persist: "session" } },
1739 { action: "accept", content: undefined },
1740 ]);
1741 },
1742 { elicitationMode: { mode: "model" } },
1743 );
1744 });
1745});
1746
1663// ---------------------------------------------------------------------------1747// ---------------------------------------------------------------------------
1664// Elicitation error handling1748// Elicitation error handling
1665// ---------------------------------------------------------------------------1749// ---------------------------------------------------------------------------
packages/hosts/mcp/src/tool-server.ts+45−10
@@ -36,6 +36,7 @@ import type { · 35 unmodified lines
36 ArtifactBinding,36 ArtifactBinding,
37 ArtifactSummary,37 ArtifactSummary,
38 ElicitationResponse,38 ElicitationResponse,
39 ElicitationResponseMeta,
39 ElicitationHandler,40 ElicitationHandler,
40 ElicitationContext,41 ElicitationContext,
41 ElicitationRequest,42 ElicitationRequest,
@@ -415,6 +416,9 @@ const elicitationRequestUrl = (request: ElicitationRequest): string | undefined · 373 unmodified lines
415const pausedInteractionKind = (request: ElicitationRequest): ElicitationRequest["_tag"] =>416const pausedInteractionKind = (request: ElicitationRequest): ElicitationRequest["_tag"] =>
416 elicitationRequestTag(request);417 elicitationRequestTag(request);
417 418
419// The request's terms travel as `_meta`, the way they arrived: a native
420// client that renders "Allow Computer Use to use Finder?" needs to see that
421// accepting can be remembered, and which scopes it may answer with.
418const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputParams =422const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputParams =
419 Match.type<ElicitationRequest>().pipe(423 Match.type<ElicitationRequest>().pipe(
420 Match.tag("UrlElicitation", (req) => ({424 Match.tag("UrlElicitation", (req) => ({
@@ -422,6 +426,7 @@ const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputPa · 1 unmodified line
422 message: req.message,426 message: req.message,
423 url: req.url,427 url: req.url,
424 elicitationId: req.elicitationId,428 elicitationId: req.elicitationId,
429 ...(req.meta === undefined ? {} : { _meta: req.meta }),
425 })),430 })),
426 Match.tag("FormElicitation", (req) => ({431 Match.tag("FormElicitation", (req) => ({
427 message: req.message,432 message: req.message,
@@ -432,10 +437,19 @@ const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputPa · 4 unmodified lines
432 Object.keys(req.requestedSchema).length === 0437 Object.keys(req.requestedSchema).length === 0
433 ? { type: "object" as const, properties: {} }438 ? { type: "object" as const, properties: {} }
434 : req.requestedSchema,439 : req.requestedSchema,
440 ...(req.meta === undefined ? {} : { _meta: req.meta }),
435 })),441 })),
436 Match.exhaustive,442 Match.exhaustive,
437 );443 );
438 444
445/** The client's answer to the terms: the `persist` scope it chose, read from
446 * the result's `_meta` — and nothing else, so an answer states no more than
447 * `ElicitationResponseMeta` names. */
448const answeredTerms = (meta: unknown): ElicitationResponseMeta | undefined => {
449 const persist = isRecord(meta) ? meta["persist"] : undefined;
450 return typeof persist === "string" ? { persist } : undefined;
451};
452
439const makeMcpElicitationHandler =453const makeMcpElicitationHandler =
440 (454 (
441 server: McpServer,455 server: McpServer,
@@ -478,6 +492,7 @@ const makeMcpElicitationHandler = · 36 unmodified lines
478 { relatedRequestId },492 { relatedRequestId },
479 );493 );
480 494
495 const meta = answeredTerms(response._meta);
481 debugLog?.("elicitation.response", {496 debugLog?.("elicitation.response", {
482 requestTag,497 requestTag,
483 action: response.action,498 action: response.action,
@@ -485,11 +500,13 @@ const makeMcpElicitationHandler = · 1 unmodified line
485 typeof response.content === "object" &&500 typeof response.content === "object" &&
486 response.content !== null &&501 response.content !== null &&
487 Object.keys(response.content).length > 0,502 Object.keys(response.content).length > 0,
503 persist: meta?.persist,
488 });504 });
489 505
490 return {506 return {
491 action: response.action as typeof ElicitationResponse.Type.action,507 action: response.action as typeof ElicitationResponse.Type.action,
492 content: response.content,508 content: response.content,
509 ...(meta === undefined ? {} : { meta }),
493 };510 };
494 }).pipe(511 }).pipe(
495 Effect.tapDefect((defect) =>512 Effect.tapDefect((defect) =>
@@ -1701,8 +1718,7 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 1205 unmodified lines
1701 1718
1702 const resumeExecution = (1719 const resumeExecution = (
1703 executionId: string,1720 executionId: string,
1704 action: "accept" | "decline" | "cancel",1721 response: ResumeResponse,
1705 content: Record<string, unknown> | undefined,
1706 extra: McpRequestJoinKeys,1722 extra: McpRequestJoinKeys,
1707 ): Effect.Effect<McpToolResult, E> =>1723 ): Effect.Effect<McpToolResult, E> =>
1708 Effect.gen(function* () {1724 Effect.gen(function* () {
@@ -1712,17 +1728,18 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 3 unmodified lines
1712 });1728 });
1713 debugLog("resume.call", {1729 debugLog("resume.call", {
1714 executionId,1730 executionId,
1715 action,1731 action: response.action,
1716 hasContent: content !== undefined,1732 hasContent: response.content !== undefined,
1733 persist: response.meta?.persist,
1717 clientCapabilities: server.server.getClientCapabilities() ?? null,1734 clientCapabilities: server.server.getClientCapabilities() ?? null,
1718 });1735 });
1719 const outcome = yield* resumeWithLifecycle(executionId, { action, content });1736 const outcome = yield* resumeWithLifecycle(executionId, response);
1720 if (!outcome) {1737 if (!outcome) {
1721 debugLog("resume.missing_execution", { executionId });1738 debugLog("resume.missing_execution", { executionId });
1722 if (yield* localExecutionAlreadySettled(executionId)) {1739 if (yield* localExecutionAlreadySettled(executionId)) {
1723 return alreadySettledResult(executionId);1740 return alreadySettledResult(executionId);
1724 }1741 }
1725 const fallback = yield* resumeFallback(executionId, { action, content });1742 const fallback = yield* resumeFallback(executionId, response);
1726 if (fallback) {1743 if (fallback) {
1727 debugLog("resume.fallback_result", { executionId, status: fallback.status });1744 debugLog("resume.fallback_result", { executionId, status: fallback.status });
1728 return fallbackOutcomeResult(executionId, fallback);1745 return fallbackOutcomeResult(executionId, fallback);
@@ -1746,7 +1763,7 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 17 unmodified lines
1746 Effect.withSpan("mcp.host.tool.resume", {1763 Effect.withSpan("mcp.host.tool.resume", {
1747 attributes: {1764 attributes: {
1748 "mcp.tool.name": "resume",1765 "mcp.tool.name": "resume",
1749 "mcp.execute.resume.action": action,1766 "mcp.execute.resume.action": response.action,
1750 "mcp.execute.execution_id": executionId,1767 "mcp.execute.execution_id": executionId,
1751 },1768 },
1752 }),1769 }),
@@ -1972,11 +1989,25 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 219 unmodified lines
1972 .string()1989 .string()
1973 .describe("Optional JSON-encoded response content for form elicitations")1990 .describe("Optional JSON-encoded response content for form elicitations")
1974 .default("{}"),1991 .default("{}"),
1992 persist: z
1993 .string()
1994 .optional()
1995 .describe(
1996 "How long an accepted approval lasts, when the paused interaction's terms offer a choice: one of interaction.meta.persist. Omit to approve this call only.",
1997 ),
1975 },1998 },
1976 },1999 },
1977 ({ executionId, action, content: rawContent }, extra) =>2000 ({ executionId, action, content: rawContent, persist }, extra) =>
1978 runToolEffect(2001 runToolEffect(
1979 resumeExecution(executionId, action, parseJsonContent(rawContent), extra),2002 resumeExecution(
2003 executionId,
2004 {
2005 action,
2006 content: parseJsonContent(rawContent),
2007 ...(persist === undefined ? {} : { meta: { persist } }),
2008 },
2009 extra,
2010 ),
1980 extra,2011 extra,
1981 ),2012 ),
1982 );2013 );
@@ -2672,7 +2703,11 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 689 unmodified lines
2672 },2703 },
2673 ({ executionId, action, content: rawContent }, extra) =>2704 ({ executionId, action, content: rawContent }, extra) =>
2674 runToolEffect(2705 runToolEffect(
2675 resumeExecution(executionId, action, parseJsonContent(rawContent), extra),2706 resumeExecution(
2707 executionId,
2708 { action, content: parseJsonContent(rawContent) },
2709 extra,
2710 ),
2676 extra,2711 extra,
2677 ),2712 ),
2678 );2713 );
packages/plugins/mcp/src/sdk/appserver-connector.test.ts+33−0
@@ -235,6 +235,39 @@ describe("codex app-server bridge", () => { · 234 unmodified lines
235 ),235 ),
236 );236 );
237 237
238 it.effect("carries the answer's persistence down to the app-server", () =>
239 Effect.scoped(
240 Effect.gen(function* () {
241 // Computer Use's app approval OFFERS `persist: ["session", "always"]`
242 // and remembers the app only when the answer's `_meta.persist` names
243 // one. A reply rebuilt from `action` and `content` alone was a
244 // one-time approval, so the same app prompted on every call.
245 const connection = yield* withConnection(appServerInput("node_repl", { surface: "sky" }));
246 let offered: unknown;
247 connection.client.setRequestHandler("elicitation/create", (request) => {
248 offered = request.params._meta?.["persist"];
249 return Promise.resolve({
250 action: "accept" as const,
251 content: {},
252 _meta: { persist: "always" },
253 });
254 });
255
256 const result = yield* Effect.promise(() =>
257 connection.client.callTool({
258 name: "get_app_state",
259 arguments: { app: "__needs_app_approval" },
260 }),
261 );
262 expect(offered, "the offered scopes reach the client").toEqual(["session", "always"]);
263 expect(result.isError).toBeFalsy();
264 expect(result.structuredContent, "and the chosen one reaches Codex").toEqual({
265 persist: "always",
266 });
267 }),
268 ),
269 );
270
238 it.effect("a tool outside the sky surface is refused rather than sent to the REPL", () =>271 it.effect("a tool outside the sky surface is refused rather than sent to the REPL", () =>
239 Effect.scoped(272 Effect.scoped(
240 Effect.gen(function* () {273 Effect.gen(function* () {
packages/plugins/mcp/src/sdk/appserver-connector.ts+8−0
@@ -159,6 +159,7 @@ const decodeElicitResult = Schema.decodeUnknownOption( · 158 unmodified lines
159 Schema.Struct({159 Schema.Struct({
160 action: Schema.Literals(["accept", "decline", "cancel"]),160 action: Schema.Literals(["accept", "decline", "cancel"]),
161 content: Schema.optional(Schema.Unknown),161 content: Schema.optional(Schema.Unknown),
162 _meta: Schema.optional(Schema.NullOr(Schema.Record(Schema.String, Schema.Unknown))),
162 }),163 }),
163);164);
164 165
@@ -708,12 +709,19 @@ class AppServerClientTransport implements Transport { · 543 unmodified lines
708 const decoded =709 const decoded =
709 "result" in message ? Option.getOrUndefined(decodeElicitResult(message.result)) : undefined;710 "result" in message ? Option.getOrUndefined(decodeElicitResult(message.result)) : undefined;
710 // An error or unreadable answer cancels: never fabricate an approval.711 // An error or unreadable answer cancels: never fabricate an approval.
712 //
713 // The answer's `_meta` goes down with it: that is where Codex reads the
714 // terms of an accept. Computer Use's app approval is the case — its
715 // request OFFERS `persist: ["session", "always"]`, and only an answer
716 // that names one is remembered. Dropping it here turned every accept
717 // into a one-time approval, so the same app prompted on every call.
711 const result =718 const result =
712 decoded === undefined719 decoded === undefined
713 ? { action: "cancel" }720 ? { action: "cancel" }
714 : {721 : {
715 action: decoded.action,722 action: decoded.action,
716 ...(decoded.content === undefined ? {} : { content: decoded.content }),723 ...(decoded.content === undefined ? {} : { content: decoded.content }),
724 ...(decoded._meta == null ? {} : { _meta: decoded._meta }),
717 };725 };
718 this.#sendDownstream({ jsonrpc: "2.0", id: downstreamId, result });726 this.#sendDownstream({ jsonrpc: "2.0", id: downstreamId, result });
719 }727 }
packages/plugins/mcp/src/sdk/appserver-test-server.ts+43−3
@@ -9,7 +9,11 @@ · 8 unmodified lines
9// server, so the bridge must follow `nextCursor`;9// server, so the bridge must follow `nextCursor`;
10// - a `needs_approval` tool that emits a server→client10// - a `needs_approval` tool that emits a server→client
11// `mcpServer/elicitation/request` and only succeeds when the answer is11// `mcpServer/elicitation/request` and only succeeds when the answer is
12// an accept — the round trip through executor's elicitation bridge.12// an accept — the round trip through executor's elicitation bridge;
13// - approvals whose terms travel in `_meta`, in both directions: Chrome's
14// per-site grant STATES `persist: "always"`, Computer Use's app approval
15// OFFERS `persist: ["session", "always"]` and reads the answer's
16// `_meta.persist` to know whether to remember the app.
13import * as readline from "node:readline";17import * as readline from "node:readline";
14 18
15import { Option, Schema } from "effect";19import { Option, Schema } from "effect";
@@ -51,7 +55,11 @@ const decodeToolCallParams = Schema.decodeUnknownOption( · 35 unmodified lines
51);55);
52 56
53const decodeElicitAnswer = Schema.decodeUnknownOption(57const decodeElicitAnswer = Schema.decodeUnknownOption(
54 Schema.Struct({ action: Schema.String, content: Schema.optional(Schema.Unknown) }),58 Schema.Struct({
59 action: Schema.String,
60 content: Schema.optional(Schema.Unknown),
61 _meta: Schema.optional(Schema.NullOr(Schema.Record(Schema.String, Schema.Unknown))),
62 }),
55);63);
56 64
57const THREAD_ID = "thread-fixture-1";65const THREAD_ID = "thread-fixture-1";
@@ -188,6 +196,34 @@ const handleToolCall = (id: number | string, params: unknown): void => { · 130 unmodified lines
188 });196 });
189 return;197 return;
190 }198 }
199 // A Computer-Use-shaped app approval: no schema to fill in, and the
200 // terms OFFER how long an accept lasts. The answer's `_meta.persist`
201 // picks one; without it the runtime treats the accept as one-time.
202 if (args?.code?.includes("__needs_app_approval")) {
203 const elicitationId = nextServerRequestId++;
204 pendingApprovals.set(elicitationId, id);
205 write({
206 jsonrpc: "2.0",
207 id: elicitationId,
208 method: "mcpServer/elicitation/request",
209 params: {
210 threadId: THREAD_ID,
211 turnId: null,
212 serverName: "node_repl",
213 mode: "form",
214 message: 'Allow Computer Use to use "Finder"?',
215 requestedSchema: { type: "object", properties: {} },
216 _meta: {
217 codex_approval_kind: "mcp_tool_call",
218 connector_id: "computer-use",
219 connector_name: "Computer Use",
220 persist: ["session", "always"],
221 riskLevel: "low",
222 },
223 },
224 });
225 return;
226 }
191 reply(id, {227 reply(id, {
192 content: [{ type: "text", text: args?.code ?? "" }],228 content: [{ type: "text", text: args?.code ?? "" }],
193 // Echoed so a test can assert the turn metadata the Chrome client229 // Echoed so a test can assert the turn metadata the Chrome client
@@ -275,7 +311,11 @@ const handleElicitationAnswer = (id: number | string, result: unknown): void => · 81 unmodified lines
275 pendingApprovals.delete(id);311 pendingApprovals.delete(id);
276 const answer = Option.getOrUndefined(decodeElicitAnswer(result));312 const answer = Option.getOrUndefined(decodeElicitAnswer(result));
277 if (answer?.action === "accept") {313 if (answer?.action === "accept") {
278 reply(callId, { content: [{ type: "text", text: "approved" }] });314 reply(callId, {
315 content: [{ type: "text", text: "approved" }],
316 // Echoed so a test can assert what the runtime would remember.
317 structuredContent: { persist: answer._meta?.["persist"] ?? null },
318 });
279 return;319 return;
280 }320 }
281 reply(callId, {321 reply(callId, {
packages/plugins/mcp/src/sdk/codex-plugin-presets.test.ts+11−1
@@ -75,8 +75,18 @@ describe("approval terms", () => { · 74 unmodified lines
75 ).toEqual({ meta: { persist: "always" } });75 ).toEqual({ meta: { persist: "always" } });
76 });76 });
77 77
78 it("ignores non-string values and contributes nothing when no term applies", () => {78 it("keeps the scopes an upstream OFFERS, not just the one it states", () => {
79 // Computer Use leaves the lifetime of an accept to the answer. Without
80 // the list, the approver cannot know a bare accept is one-time, nor
81 // which scopes it may answer with.
82 expect(approvalTerms({ persist: ["session", "always"], connector_id: "computer-use" })).toEqual(
83 { meta: { persist: ["session", "always"], connector_id: "computer-use" } },
84 );
85 });
86
87 it("ignores non-term values and contributes nothing when no term applies", () => {
79 expect(approvalTerms({ persist: { always: true }, origin: 42 })).toEqual({});88 expect(approvalTerms({ persist: { always: true }, origin: 42 })).toEqual({});
89 expect(approvalTerms({ persist: ["session", 7] })).toEqual({});
80 expect(approvalTerms({ progressToken: "tok" })).toEqual({});90 expect(approvalTerms({ progressToken: "tok" })).toEqual({});
81 expect(approvalTerms(undefined)).toEqual({});91 expect(approvalTerms(undefined)).toEqual({});
82 });92 });
packages/plugins/mcp/src/sdk/elicitation.test.ts+44−0
@@ -167,6 +167,50 @@ describe("MCP elicitation (end-to-end)", () => { · 166 unmodified lines
167 }),167 }),
168 );168 );
169 169
170 it.effect("the answer's terms reach the server, and the offered ones reach the handler", () =>
171 Effect.gen(function* () {
172 const server = yield* serveElicitationTestServer;
173 const executor = yield* makeTestExecutor(server.url);
174 const tools = yield* executor.tools.list();
175 const rememberedEcho = findTool(tools, "remembered_echo");
176
177 let offered: unknown;
178 const remembered = yield* executor.execute(
179 rememberedEcho.address,
180 { value: "keep" },
181 {
182 onElicitation: (ctx) => {
183 offered = ctx.request.meta;
184 return Effect.succeed(
185 ElicitationResponse.make({
186 action: "accept",
187 content: {},
188 meta: { persist: "always" },
189 }),
190 );
191 },
192 },
193 );
194 const once = yield* executor.execute(
195 rememberedEcho.address,
196 { value: "drop" },
197 { onElicitation: () => Effect.succeed(ElicitationResponse.make({ action: "accept" })) },
198 );
199 yield* executor.close();
200
201 expect(offered).toEqual({ persist: ["session", "always"] });
202 expect(remembered).toMatchObject({
203 ok: true,
204 data: { content: [{ type: "text", text: "approved:keep:always" }] },
205 });
206 // No choice made, none invented: a bare accept stays one-time.
207 expect(once).toMatchObject({
208 ok: true,
209 data: { content: [{ type: "text", text: "approved:drop:once" }] },
210 });
211 }),
212 );
213
170 it.effect("tool without elicitation works normally", () =>214 it.effect("tool without elicitation works normally", () =>
171 Effect.gen(function* () {215 Effect.gen(function* () {
172 const server = yield* serveElicitationTestServer;216 const server = yield* serveElicitationTestServer;
packages/plugins/mcp/src/sdk/invoke.ts+12−1
@@ -218,12 +218,21 @@ const decodeElicitContent = Schema.decodeUnknownSync( · 217 unmodified lines
218 * server contributes nothing rather than noise. */218 * server contributes nothing rather than noise. */
219export const APPROVAL_TERM_KEYS = ["persist", "origin", "connector_name", "connector_id"] as const;219export const APPROVAL_TERM_KEYS = ["persist", "origin", "connector_name", "connector_id"] as const;
220 220
221const isStringList = (value: unknown): value is readonly string[] =>
222 Array.isArray(value) && value.every((item) => typeof item === "string");
223
224/** A term is a string, or a list of strings: Computer Use OFFERS
225 * `persist: ["session", "always"]` for the answer to pick from, where
226 * Chrome STATES `persist: "always"`. Either way it is a term of the grant. */
227const isApprovalTerm = (value: unknown): value is string | readonly string[] =>
228 typeof value === "string" || isStringList(value);
229
221export const approvalTerms = (meta: Record<string, unknown> | undefined) => {230export const approvalTerms = (meta: Record<string, unknown> | undefined) => {
222 if (meta === undefined) return {};231 if (meta === undefined) return {};
223 const terms = Object.fromEntries(232 const terms = Object.fromEntries(
224 APPROVAL_TERM_KEYS.flatMap((key) => {233 APPROVAL_TERM_KEYS.flatMap((key) => {
225 const value = meta[key];234 const value = meta[key];
226 return typeof value === "string" ? [[key, value] as const] : [];235 return isApprovalTerm(value) ? [[key, value] as const] : [];
227 }),236 }),
228 );237 );
229 return Object.keys(terms).length > 0 ? { meta: terms } : {};238 return Object.keys(terms).length > 0 ? { meta: terms } : {};
@@ -267,11 +276,13 @@ const installElicitationHandler = ( · 37 unmodified lines
267 );276 );
268 if (Exit.isSuccess(exit)) {277 if (Exit.isSuccess(exit)) {
269 const response = exit.value;278 const response = exit.value;
279 const persist = response.action === "accept" ? response.meta?.persist : undefined;
270 return {280 return {
271 action: response.action,281 action: response.action,
272 ...(response.action === "accept" && response.content282 ...(response.action === "accept" && response.content
273 ? { content: decodeElicitContent(response.content) }283 ? { content: decodeElicitContent(response.content) }
274 : {}),284 : {}),
285 ...(persist === undefined ? {} : { _meta: { persist } }),
275 };286 };
276 }287 }
277 const failure = exit.cause.reasons.find(Cause.isFailReason);288 const failure = exit.cause.reasons.find(Cause.isFailReason);
packages/plugins/mcp/src/testing/server.ts+31−0
@@ -544,6 +544,37 @@ export const makeElicitationMcpServer = () => { · 543 unmodified lines
544 },544 },
545 );545 );
546 546
547 server.registerTool(
548 "remembered_echo",
549 {
550 description: "Asks for approval whose terms offer to remember it",
551 inputSchema: { value: z.string() },
552 },
553 async ({ value }: { value: string }) => {
554 // Shaped like Codex Computer Use's app approval: an empty schema, and
555 // the persistence scopes on offer in `_meta`. The answer's own
556 // `_meta.persist` is what the server would remember.
557 const response = await server.server.elicitInput({
558 mode: "form",
559 message: `Allow the echo of "${value}"?`,
560 requestedSchema: { type: "object", properties: {} },
561 _meta: { persist: ["session", "always"] },
562 });
563 if (response.action !== "accept") {
564 return { content: [{ type: "text" as const, text: `denied:${value}` }] };
565 }
566 const persist = response._meta?.["persist"];
567 return {
568 content: [
569 {
570 type: "text" as const,
571 text: `approved:${value}:${typeof persist === "string" ? persist : "once"}`,
572 },
573 ],
574 };
575 },
576 );
577
547 server.registerTool(578 server.registerTool(
548 "simple_echo",579 "simple_echo",
549 {580 {
packages/react/src/pages/resume-approval.tsx+73−6
@@ -5,11 +5,15 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; · 4 unmodified lines
5import { Check, ExternalLink, Loader2, ShieldCheck, X } from "lucide-react";5import { Check, ExternalLink, Loader2, ShieldCheck, X } from "lucide-react";
6import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";6import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
7 7
8import { offeredPersistence } from "@executor-js/sdk";
9
8import { pausedExecutionAtom, resumeExecution } from "../api/atoms";10import { pausedExecutionAtom, resumeExecution } from "../api/atoms";
9import { trackEvent } from "../api/analytics";11import { trackEvent } from "../api/analytics";
10import { Button } from "../components/button";12import { Button } from "../components/button";
11import { CopyButton } from "../components/copy-button";13import { CopyButton } from "../components/copy-button";
12import { type ElicitationAction, useElicitationApproval } from "../components/elicitation-approval";14import { type ElicitationAction, useElicitationApproval } from "../components/elicitation-approval";
15import { Label } from "../components/label";
16import { NativeSelect, NativeSelectOption } from "../components/native-select";
13import { Skeleton } from "../components/skeleton";17import { Skeleton } from "../components/skeleton";
14 18
15type PausedExecutionInfo = { readonly text: string; readonly structured: unknown };19type PausedExecutionInfo = { readonly text: string; readonly structured: unknown };
@@ -52,6 +56,16 @@ type PausedInteractionView = { · 36 unmodified lines
52 readonly url: string | null;56 readonly url: string | null;
53 readonly requestedSchema: unknown;57 readonly requestedSchema: unknown;
54 readonly toolId: string | null;58 readonly toolId: string | null;
59 /** Scopes the upstream offers to remember an approval for; empty when
60 * accepting is one-time and there is nothing to choose. */
61 readonly offeredPersistence: readonly string[];
62};
63
64/** Labels for the persistence scopes Codex plugins use. An unfamiliar scope
65 * is shown as the upstream spelled it rather than hidden. */
66const persistenceLabel: Record<string, string> = {
67 session: "For this session",
68 always: "Always",
55};69};
56 70
57const encodeJsonPreview = Schema.encodeUnknownOption(Schema.UnknownFromJsonString);71const encodeJsonPreview = Schema.encodeUnknownOption(Schema.UnknownFromJsonString);
@@ -63,6 +77,7 @@ const PausedInteractionInfo = Schema.Struct({ · 5 unmodified lines
63 url: Schema.optional(Schema.String),77 url: Schema.optional(Schema.String),
64 requestedSchema: Schema.optional(Schema.Unknown),78 requestedSchema: Schema.optional(Schema.Unknown),
65 toolId: Schema.optional(Schema.String),79 toolId: Schema.optional(Schema.String),
80 meta: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
66});81});
67const PausedStructured = Schema.Struct({82const PausedStructured = Schema.Struct({
68 executionId: Schema.optional(Schema.String),83 executionId: Schema.optional(Schema.String),
@@ -111,6 +126,7 @@ const interactionFromPausedInfo = (paused: PausedExecutionInfo): PausedInteracti · 42 unmodified lines
111 url: interaction.url ?? null,126 url: interaction.url ?? null,
112 requestedSchema: interaction.requestedSchema,127 requestedSchema: interaction.requestedSchema,
113 toolId: interaction.toolId ?? null,128 toolId: interaction.toolId ?? null,
129 offeredPersistence: offeredPersistence(interaction.meta),
114 };130 };
115};131};
116 132
@@ -122,10 +138,18 @@ export function ResumeApprovalPage(props: { executionId: string }) { · 5 unmodified lines
122 const doResume = useAtomSet(resumeExecution, { mode: "promiseExit" });138 const doResume = useAtomSet(resumeExecution, { mode: "promiseExit" });
123 139
124 const resume = useCallback(140 const resume = useCallback(
125 (executionId: string, action: ElicitationAction, content?: Record<string, unknown>) =>141 (
142 executionId: string,
143 action: ElicitationAction,
144 content?: Record<string, unknown>,
145 persist?: string,
146 ) =>
126 doResume({147 doResume({
127 params: { executionId },148 params: { executionId },
128 payload: action === "accept" ? { action, content: content ?? {} } : { action },149 payload:
150 action === "accept"
151 ? { action, content: content ?? {}, ...(persist === undefined ? {} : { persist }) }
152 : { action },
129 }),153 }),
130 [doResume],154 [doResume],
131 );155 );
@@ -140,6 +164,7 @@ export function ResumeApprovalPageView(props: { · 8 unmodified lines
140 executionId: string,164 executionId: string,
141 action: ElicitationAction,165 action: ElicitationAction,
142 content?: Record<string, unknown>,166 content?: Record<string, unknown>,
167 persist?: string,
143 ) => Promise<Exit.Exit<ResumeExecutionResult, unknown>>;168 ) => Promise<Exit.Exit<ResumeExecutionResult, unknown>>;
144 unavailableMessage?: string;169 unavailableMessage?: string;
145}) {170}) {
@@ -147,6 +172,9 @@ export function ResumeApprovalPageView(props: { · 1 unmodified line
147 const [status, setStatus] = useState<ResumeStatus>({ state: "idle" });172 const [status, setStatus] = useState<ResumeStatus>({ state: "idle" });
148 const [currentExecutionId, setCurrentExecutionId] = useState(executionId);173 const [currentExecutionId, setCurrentExecutionId] = useState(executionId);
149 const [nextPaused, setNextPaused] = useState<PausedExecutionInfo | null>(null);174 const [nextPaused, setNextPaused] = useState<PausedExecutionInfo | null>(null);
175 // "" is the one-time approval; anything else is a scope the upstream
176 // offered. Reset with the execution, since the next pause may offer none.
177 const [persist, setPersist] = useState("");
150 const displayedPaused = nextPaused ?? (AsyncResult.isSuccess(paused) ? paused.value : null);178 const displayedPaused = nextPaused ?? (AsyncResult.isSuccess(paused) ? paused.value : null);
151 const approval = useElicitationApproval(requestedSchemaFromPausedInfo(displayedPaused));179 const approval = useElicitationApproval(requestedSchemaFromPausedInfo(displayedPaused));
152 const interaction = displayedPaused ? interactionFromPausedInfo(displayedPaused) : null;180 const interaction = displayedPaused ? interactionFromPausedInfo(displayedPaused) : null;
@@ -154,6 +182,7 @@ export function ResumeApprovalPageView(props: { · 1 unmodified line
154 useEffect(() => {182 useEffect(() => {
155 setCurrentExecutionId(executionId);183 setCurrentExecutionId(executionId);
156 setNextPaused(null);184 setNextPaused(null);
185 setPersist("");
157 setStatus({ state: "idle" });186 setStatus({ state: "idle" });
158 }, [executionId]);187 }, [executionId]);
159 188
@@ -171,7 +200,12 @@ export function ResumeApprovalPageView(props: { · 11 unmodified lines
171 if (content === null) return;200 if (content === null) return;
172 201
173 setStatus({ state: "submitting", action });202 setStatus({ state: "submitting", action });
174 const exit = await resume(currentExecutionId, action, content);203 const exit = await resume(
204 currentExecutionId,
205 action,
206 content,
207 action === "accept" && persist !== "" ? persist : undefined,
208 );
175 209
176 if (Exit.isFailure(exit)) {210 if (Exit.isFailure(exit)) {
177 trackEvent("resume_approval_submitted", {211 trackEvent("resume_approval_submitted", {
@@ -208,6 +242,7 @@ export function ResumeApprovalPageView(props: { · 30 unmodified lines
208 });242 });
209 setCurrentExecutionId(nextExecutionId);243 setCurrentExecutionId(nextExecutionId);
210 setNextPaused({ text: exit.value.text, structured: exit.value.structured });244 setNextPaused({ text: exit.value.text, structured: exit.value.structured });
245 setPersist("");
211 setStatus({ state: "idle" });246 setStatus({ state: "idle" });
212 return;247 return;
213 }248 }
@@ -224,7 +259,7 @@ export function ResumeApprovalPageView(props: { · 10 unmodified lines
224 text: exit.value.text || "The paused execution has been resumed.",259 text: exit.value.text || "The paused execution has been resumed.",
225 });260 });
226 },261 },
227 [approval, currentExecutionId, interaction, resume],262 [approval, currentExecutionId, interaction, persist, resume],
228 );263 );
229 264
230 const busy = status.state === "submitting";265 const busy = status.state === "submitting";
@@ -254,7 +289,12 @@ export function ResumeApprovalPageView(props: { · 23 unmodified lines
254 289
255 <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">290 <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
256 {nextPaused ? (291 {nextPaused ? (
257 <PendingRequestDetails interaction={interaction} approvalFields={approval.fields} />292 <PendingRequestDetails
293 interaction={interaction}
294 approvalFields={approval.fields}
295 persist={persist}
296 onPersistChange={setPersist}
297 />
258 ) : (298 ) : (
259 AsyncResult.match(paused, {299 AsyncResult.match(paused, {
260 onInitial: () => (300 onInitial: () => (
@@ -271,7 +311,12 @@ export function ResumeApprovalPageView(props: { · 10 unmodified lines
271 </div>311 </div>
272 ),312 ),
273 onSuccess: () => (313 onSuccess: () => (
274 <PendingRequestDetails interaction={interaction} approvalFields={approval.fields} />314 <PendingRequestDetails
315 interaction={interaction}
316 approvalFields={approval.fields}
317 persist={persist}
318 onPersistChange={setPersist}
319 />
275 ),320 ),
276 })321 })
277 )}322 )}
@@ -355,9 +400,13 @@ export function ResumeApprovalPageView(props: { · 77 unmodified lines
355function PendingRequestDetails({400function PendingRequestDetails({
356 interaction,401 interaction,
357 approvalFields,402 approvalFields,
403 persist,
404 onPersistChange,
358}: {405}: {
359 interaction: PausedInteractionView | null;406 interaction: PausedInteractionView | null;
360 approvalFields: ReactNode;407 approvalFields: ReactNode;
408 persist: string;
409 onPersistChange: (persist: string) => void;
361}) {410}) {
362 if (!interaction) {411 if (!interaction) {
363 return <div className="text-sm text-muted-foreground">No pending request details found.</div>;412 return <div className="text-sm text-muted-foreground">No pending request details found.</div>;
@@ -402,6 +451,24 @@ function PendingRequestDetails({ · 38 unmodified lines
402 {approvalFields && (451 {approvalFields && (
403 <div className="rounded-md border border-border bg-muted/30 p-3">{approvalFields}</div>452 <div className="rounded-md border border-border bg-muted/30 p-3">{approvalFields}</div>
404 )}453 )}
454
455 {interaction.offeredPersistence.length > 0 && (
456 <div className="space-y-1.5">
457 <Label htmlFor="resume-approval-persist">Remember this approval</Label>
458 <NativeSelect
459 id="resume-approval-persist"
460 value={persist}
461 onChange={(event) => onPersistChange(event.target.value)}
462 >
463 <NativeSelectOption value="">Just this once</NativeSelectOption>
464 {interaction.offeredPersistence.map((scope) => (
465 <NativeSelectOption key={scope} value={scope}>
466 {persistenceLabel[scope] ?? scope}
467 </NativeSelectOption>
468 ))}
469 </NativeSelect>
470 </div>
471 )}
405 </div>472 </div>
406 );473 );
407}474}
packages/react/src/routes/resume.$executionId.tsx+13−2
@@ -85,6 +85,7 @@ type LocalMcpResumeInput = { · 84 unmodified lines
85 readonly executionId: string;85 readonly executionId: string;
86 readonly action: ElicitationAction;86 readonly action: ElicitationAction;
87 readonly content?: Record<string, unknown>;87 readonly content?: Record<string, unknown>;
88 readonly persist?: string;
88};89};
89 90
90const resumeLocalMcpExecution = Atom.fn<LocalMcpResumeInput>()((input) =>91const resumeLocalMcpExecution = Atom.fn<LocalMcpResumeInput>()((input) =>
@@ -106,7 +107,11 @@ const resumeLocalMcpExecution = Atom.fn<LocalMcpResumeInput>()((input) => · 15 unmodified lines
106 },107 },
107 body: JSON.stringify(108 body: JSON.stringify(
108 input.action === "accept"109 input.action === "accept"
109 ? { action: input.action, content: input.content ?? {} }110 ? {
111 action: input.action,
112 content: input.content ?? {},
113 ...(input.persist === undefined ? {} : { persist: input.persist }),
114 }
110 : { action: input.action },115 : { action: input.action },
111 ),116 ),
112 },117 },
@@ -159,12 +164,18 @@ function LocalMcpResumeApproval(props: { executionId: string; mcpSessionId: stri · 46 unmodified lines
159 );164 );
160 const doResume = useAtomSet(resumeLocalMcpExecution, { mode: "promiseExit" });165 const doResume = useAtomSet(resumeLocalMcpExecution, { mode: "promiseExit" });
161 const resume = useCallback(166 const resume = useCallback(
162 (executionId: string, action: ElicitationAction, content?: Record<string, unknown>) =>167 (
168 executionId: string,
169 action: ElicitationAction,
170 content?: Record<string, unknown>,
171 persist?: string,
172 ) =>
163 doResume({173 doResume({
164 mcpSessionId: props.mcpSessionId,174 mcpSessionId: props.mcpSessionId,
165 executionId,175 executionId,
166 action,176 action,
167 content,177 content,
178 persist,
168 }),179 }),
169 [doResume, props.mcpSessionId],180 [doResume, props.mcpSessionId],
170 );181 );