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+
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.
apps/cloud/src/auth/api.ts+1−0
@@ -115,6 +115,7 @@ const McpSessionExecutionParams = { · 114 unmodified lines
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+3−0
@@ -704,6 +704,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 703 unmodified lines
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/routes/app/resume.$executionId.tsx+5−1
@@ -37,13 +37,17 @@ function CloudMcpResumeApproval(props: { executionId: string; mcpSessionId: stri · 36 unmodified lines
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 );
e2e/scenarios/mcp-approval-persistence.test.ts+80−0
@@ -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+);
e2e/selfhost/mcp-elicitation-deadline.test.ts+16−5
@@ -1,6 +1,6 @@
11 import { randomBytes } from "node:crypto";
22 import { expect } from "@effect/vitest";
3−import { Effect } from "effect";
3+import { Effect, Schema } from "effect";
44 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
55 import { composePluginApi } from "@executor-js/api/server";
66 import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
@@ -11,9 +11,10 @@ import { scenario } from "../src/scenario"; · 4 unmodified lines
1111 import { Api, Mcp, Target } from "../src/services";
1212
1313 const api = composePluginApi([mcpHttpPlugin()] as const);
14+const decodeExecutionId = Schema.decodeUnknownSync(Schema.String);
1415
1516 scenario(
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",
1718 { timeout: 180_000 },
1819 Effect.scoped(
1920 Effect.gen(function* () {
@@ -31,10 +32,15 @@ scenario( · 11 unmodified lines
3132 mode: "form",
3233 message: "Approve the delayed call?",
3334 requestedSchema: { type: "object", properties: {} },
35+ _meta: { persist: ["session", "always"] },
3436 },
3537 { timeout: 150_000 },
3638 );
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+ };
3844 });
3945 return upstream;
4046 });
@@ -66,9 +72,14 @@ scenario( · 25 unmodified lines
6672 // Cross the production 60-second active-work deadline. This is the
6773 // behavior under test: a human waiting must consume none of that budget.
6874 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+ });
7081 expect(completed.ok).toBe(true);
71− expect(completed.text).toContain("decision:accept");
82+ expect(completed.text).toContain("decision:accept:session");
7283 }).pipe(Effect.ensuring(client.mcp.removeServer({ params: { slug } }).pipe(Effect.orDie)));
7384 }),
7485 ),
e2e/vitest.config.ts+1−0
@@ -48,6 +48,7 @@ export default defineConfig({ · 47 unmodified lines
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+4−0
@@ -44,6 +44,10 @@ const ExecuteResponse = Schema.Union([CompletedResult, PausedResult]); · 43 unmodified lines
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]);
packages/core/api/src/handlers/executions.ts+1−0
@@ -251,6 +251,7 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" · 250 unmodified lines
251251 engine.resume(path.executionId, {
252252 action: payload.action,
253253 content: payload.content as Record<string, unknown> | undefined,
254+ ...(payload.persist === undefined ? {} : { meta: { persist: payload.persist } }),
254255 }),
255256 );
256257
packages/core/execution/src/engine.test.ts+28−0
@@ -279,6 +279,34 @@ describe("formatPausedExecution approval terms", () => { · 278 unmodified lines
279279 });
280280 });
281281
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+
282310 it("says nothing about terms when the upstream attached none", () => {
283311 const result = formatPausedExecution(
284312 paused(FormElicitation.make({ message: "Proceed?", requestedSchema: {} })),
packages/core/execution/src/engine.ts+22−3
@@ -6,10 +6,15 @@ import type { · 5 unmodified lines
66 Executor,
77 InvokeOptions,
88 ElicitationResponse,
9+ ElicitationResponseMeta,
910 ElicitationHandler,
1011 ElicitationContext,
1112 } from "@executor-js/sdk/core";
12−import { CurrentOrgWriteAccess, type OrgWriteAccessState } from "@executor-js/sdk/core";
13+import {
14+ CurrentOrgWriteAccess,
15+ offeredPersistence,
16+ type OrgWriteAccessState,
17+} from "@executor-js/sdk/core";
1318 import { CodeExecutionError } from "@executor-js/codemode-core";
1419 import type { CodeExecutor, ExecuteResult, SandboxToolInvoker } from "@executor-js/codemode-core";
1520
@@ -58,6 +63,9 @@ type InternalPausedExecution<E> = PausedExecution & { · 42 unmodified lines
5863 export type ResumeResponse = {
5964 readonly action: "accept" | "decline" | "cancel";
6065 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;
6169 };
6270
6371 // Auto-accept every elicitation. Used by the `autoApprove` path where the
@@ -222,10 +230,21 @@ export const formatPausedExecution = ( · 158 unmodified lines
222230 : hasRequestedSchema
223231 ? `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".`
224232 : `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+ : "";
225244 const deadlineInstructions = deadline
226245 ? ` Resume before ${deadline.expiresAt}; this approval window lasts ${formatTtlDuration(deadline.ttlMs)}.`
227246 : "";
228− const instructions = `${baseInstructions}${deadlineInstructions}`;
247+ const instructions = `${baseInstructions}${persistInstructions}${deadlineInstructions}`;
229248
230249 if (isUrlElicitation) {
231250 lines.push(`\nOpen this URL in a browser:\n${req.url}`);
@@ -244,7 +263,6 @@ export const formatPausedExecution = ( · 12 unmodified lines
244263 // Terms the upstream attached to the approval. Stated plainly, because a
245264 // prompt whose schema is empty ("Allow X to access Y?") can still be
246265 // asking for a PERSISTENT grant, and the answer differs.
247− const meta = req.meta;
248266 if (meta !== undefined && Object.keys(meta).length > 0) {
249267 lines.push(`\nApproval terms:\n${JSON.stringify(meta, null, 2)}`);
250268 }
@@ -811,6 +829,7 @@ export const createExecutionEngine = <E extends Cause.YieldableError = CodeExecu · 560 unmodified lines
811829 yield* Deferred.succeed(paused.response, {
812830 action: response.action as typeof ElicitationResponse.Type.action,
813831 content: response.content,
832+ ...(response.meta === undefined ? {} : { meta: response.meta }),
814833 });
815834
816835 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
1717 export const ElicitationMeta = Schema.Record(Schema.String, Schema.Unknown);
1818 export type ElicitationMeta = typeof ElicitationMeta.Type;
1919
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. */
26+export 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. */
40+export const ElicitationResponseMeta = Schema.Struct({
41+ persist: Schema.optional(Schema.String),
42+});
43+export type ElicitationResponseMeta = typeof ElicitationResponseMeta.Type;
44+
2045 /** Tool needs structured input from the user (render a form). */
2146 export const FormElicitation = Schema.TaggedStruct("FormElicitation", {
2247 message: Schema.String,
@@ -45,6 +70,8 @@ export const ElicitationResponse = Schema.Struct({ · 22 unmodified lines
4570 action: ElicitationAction,
4671 /** Present when `action` is "accept" — the data the user provided. */
4772 content: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
73+ /** The answer's own terms, meaningful only with "accept". */
74+ meta: Schema.optional(ElicitationResponseMeta),
4875 });
4976 export type ElicitationResponse = typeof ElicitationResponse.Type;
5077
packages/core/sdk/src/index.ts+2−0
@@ -223,6 +223,8 @@ export { sanitizeArtifactPreviewMarkup, ARTIFACT_PREVIEW_MARKUP_LIMIT } from "./ · 222 unmodified lines
223223 // Elicitation.
224224 export {
225225 ElicitationMeta,
226+ ElicitationResponseMeta,
227+ offeredPersistence,
226228 FormElicitation,
227229 UrlElicitation,
228230 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
357357 await expect(waiting).resolves.toEqual({ response: approval, orgWriteAccess: "denied" });
358358 });
359359
360+it("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+
360377 // The negotiated MCP-Apps capability arrives once, at `initialize`, and lives
361378 // in the rebuilt server's memory. These pin the storage round-trip that lets a
362379 // 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
2020 type ResumeFallbackOutcome,
2121 } from "@executor-js/host-mcp/tool-server";
2222 import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp";
23−import {
24− ResumeResponsePayload,
25− decodeResumeResponse,
26− type McpToolMode,
27−} from "@executor-js/host-mcp/browser-approval";
23+import { decodeResumeResponse, type McpToolMode } from "@executor-js/host-mcp/browser-approval";
24+import { ElicitationResponse } from "@executor-js/sdk";
2825
2926 import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers";
3027 import { classifyDurableObjectError, type DurableObjectFailure } from "./durable-object-errors";
@@ -214,7 +211,7 @@ const MCP_MESSAGE_HEADER = "cf-mcp-message"; · 183 unmodified lines
214211 const MODEL_RESUME_FORWARD_TIMEOUT_MS = 10_000;
215212 const approvalResponseKey = (executionId: string) => `approval-response:${executionId}`;
216213 const BrowserApprovalDecisionStorage = Schema.Struct({
217− response: ResumeResponsePayload,
214+ response: ElicitationResponse,
218215 orgWriteAccess: Schema.Literals(["allowed", "denied"]),
219216 });
220217 const decodeBrowserApprovalDecision = Schema.decodeUnknownOption(BrowserApprovalDecisionStorage);
packages/hosts/mcp/src/browser-approval.ts+11−2
@@ -125,13 +125,22 @@ export const approvalUrlForRequest = ( · 124 unmodified lines
125125 export const ResumeResponsePayload = Schema.Struct({
126126 action: Schema.Literals(["accept", "decline", "cancel"]),
127127 content: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
128+ persist: Schema.optional(Schema.String),
128129 });
129130
130131 const decodeResumeResponsePayload = Schema.decodeUnknownOption(ResumeResponsePayload);
131132
132133 /** Decode an untrusted resume payload, or `null` if it doesn't match the contract. */
133−export const decodeResumeResponse = (raw: unknown): ResumeResponse | null =>
134− Option.getOrNull(decodeResumeResponsePayload(raw));
134+export const decodeResumeResponse = (raw: unknown): ResumeResponse | null => {
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+};
135144
136145 const ACKNOWLEDGEMENT_TEXT = {
137146 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
197197 /** Build an engine whose execute triggers one elicitation and returns the handler's result. */
198198 const makeElicitingEngine = (
199199 request: FormElicitation | UrlElicitation,
200− formatResult: (response: { action: string; content?: Record<string, unknown> }) => unknown = (
201− r,
202− ) => r.action,
200+ formatResult: (response: {
201+ action: string;
202+ content?: Record<string, unknown>;
203+ meta?: { readonly persist?: string };
204+ }) => unknown = (r) => r.action,
203205 ): ExecutionEngine =>
204206 makeStubEngine({
205207 execute: (_code, { onElicitation }) =>
@@ -1660,6 +1662,88 @@ describe("MCP host server — client without elicitation (pause/resume)", () => · 1454 unmodified lines
16601662 });
16611663 });
16621664
1665+// ---------------------------------------------------------------------------
1666+// Approval terms — the request's ride out as `_meta`, the answer's ride back
1667+// ---------------------------------------------------------------------------
1668+
1669+describe("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+
16631747 // ---------------------------------------------------------------------------
16641748 // Elicitation error handling
16651749 // ---------------------------------------------------------------------------
packages/hosts/mcp/src/tool-server.ts+45−10
@@ -36,6 +36,7 @@ import type { · 35 unmodified lines
3636 ArtifactBinding,
3737 ArtifactSummary,
3838 ElicitationResponse,
39+ ElicitationResponseMeta,
3940 ElicitationHandler,
4041 ElicitationContext,
4142 ElicitationRequest,
@@ -415,6 +416,9 @@ const elicitationRequestUrl = (request: ElicitationRequest): string | undefined · 373 unmodified lines
415416 const pausedInteractionKind = (request: ElicitationRequest): ElicitationRequest["_tag"] =>
416417 elicitationRequestTag(request);
417418
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.
418422 const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputParams =
419423 Match.type<ElicitationRequest>().pipe(
420424 Match.tag("UrlElicitation", (req) => ({
@@ -422,6 +426,7 @@ const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputPa · 1 unmodified line
422426 message: req.message,
423427 url: req.url,
424428 elicitationId: req.elicitationId,
429+ ...(req.meta === undefined ? {} : { _meta: req.meta }),
425430 })),
426431 Match.tag("FormElicitation", (req) => ({
427432 message: req.message,
@@ -432,10 +437,19 @@ const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputPa · 4 unmodified lines
432437 Object.keys(req.requestedSchema).length === 0
433438 ? { type: "object" as const, properties: {} }
434439 : req.requestedSchema,
440+ ...(req.meta === undefined ? {} : { _meta: req.meta }),
435441 })),
436442 Match.exhaustive,
437443 );
438444
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. */
448+const answeredTerms = (meta: unknown): ElicitationResponseMeta | undefined => {
449+ const persist = isRecord(meta) ? meta["persist"] : undefined;
450+ return typeof persist === "string" ? { persist } : undefined;
451+};
452+
439453 const makeMcpElicitationHandler =
440454 (
441455 server: McpServer,
@@ -478,6 +492,7 @@ const makeMcpElicitationHandler = · 36 unmodified lines
478492 { relatedRequestId },
479493 );
480494
495+ const meta = answeredTerms(response._meta);
481496 debugLog?.("elicitation.response", {
482497 requestTag,
483498 action: response.action,
@@ -485,11 +500,13 @@ const makeMcpElicitationHandler = · 1 unmodified line
485500 typeof response.content === "object" &&
486501 response.content !== null &&
487502 Object.keys(response.content).length > 0,
503+ persist: meta?.persist,
488504 });
489505
490506 return {
491507 action: response.action as typeof ElicitationResponse.Type.action,
492508 content: response.content,
509+ ...(meta === undefined ? {} : { meta }),
493510 };
494511 }).pipe(
495512 Effect.tapDefect((defect) =>
@@ -1701,8 +1718,7 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 1205 unmodified lines
17011718
17021719 const resumeExecution = (
17031720 executionId: string,
1704− action: "accept" | "decline" | "cancel",
1705− content: Record<string, unknown> | undefined,
1721+ response: ResumeResponse,
17061722 extra: McpRequestJoinKeys,
17071723 ): Effect.Effect<McpToolResult, E> =>
17081724 Effect.gen(function* () {
@@ -1712,17 +1728,18 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 3 unmodified lines
17121728 });
17131729 debugLog("resume.call", {
17141730 executionId,
1715− action,
1716− hasContent: content !== undefined,
1731+ action: response.action,
1732+ hasContent: response.content !== undefined,
1733+ persist: response.meta?.persist,
17171734 clientCapabilities: server.server.getClientCapabilities() ?? null,
17181735 });
1719− const outcome = yield* resumeWithLifecycle(executionId, { action, content });
1736+ const outcome = yield* resumeWithLifecycle(executionId, response);
17201737 if (!outcome) {
17211738 debugLog("resume.missing_execution", { executionId });
17221739 if (yield* localExecutionAlreadySettled(executionId)) {
17231740 return alreadySettledResult(executionId);
17241741 }
1725− const fallback = yield* resumeFallback(executionId, { action, content });
1742+ const fallback = yield* resumeFallback(executionId, response);
17261743 if (fallback) {
17271744 debugLog("resume.fallback_result", { executionId, status: fallback.status });
17281745 return fallbackOutcomeResult(executionId, fallback);
@@ -1746,7 +1763,7 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 17 unmodified lines
17461763 Effect.withSpan("mcp.host.tool.resume", {
17471764 attributes: {
17481765 "mcp.tool.name": "resume",
1749− "mcp.execute.resume.action": action,
1766+ "mcp.execute.resume.action": response.action,
17501767 "mcp.execute.execution_id": executionId,
17511768 },
17521769 }),
@@ -1972,11 +1989,25 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 219 unmodified lines
19721989 .string()
19731990 .describe("Optional JSON-encoded response content for form elicitations")
19741991 .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+ ),
19751998 },
19761999 },
1977− ({ executionId, action, content: rawContent }, extra) =>
2000+ ({ executionId, action, content: rawContent, persist }, extra) =>
19782001 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+ ),
19802011 extra,
19812012 ),
19822013 );
@@ -2672,7 +2703,11 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 689 unmodified lines
26722703 },
26732704 ({ executionId, action, content: rawContent }, extra) =>
26742705 runToolEffect(
2675− resumeExecution(executionId, action, parseJsonContent(rawContent), extra),
2706+ resumeExecution(
2707+ executionId,
2708+ { action, content: parseJsonContent(rawContent) },
2709+ extra,
2710+ ),
26762711 extra,
26772712 ),
26782713 );
packages/plugins/mcp/src/sdk/appserver-connector.test.ts+33−0
@@ -235,6 +235,39 @@ describe("codex app-server bridge", () => { · 234 unmodified lines
235235 ),
236236 );
237237
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+
238271 it.effect("a tool outside the sky surface is refused rather than sent to the REPL", () =>
239272 Effect.scoped(
240273 Effect.gen(function* () {
packages/plugins/mcp/src/sdk/appserver-connector.ts+8−0
@@ -159,6 +159,7 @@ const decodeElicitResult = Schema.decodeUnknownOption( · 158 unmodified lines
159159 Schema.Struct({
160160 action: Schema.Literals(["accept", "decline", "cancel"]),
161161 content: Schema.optional(Schema.Unknown),
162+ _meta: Schema.optional(Schema.NullOr(Schema.Record(Schema.String, Schema.Unknown))),
162163 }),
163164 );
164165
@@ -708,12 +709,19 @@ class AppServerClientTransport implements Transport { · 543 unmodified lines
708709 const decoded =
709710 "result" in message ? Option.getOrUndefined(decodeElicitResult(message.result)) : undefined;
710711 // 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.
711718 const result =
712719 decoded === undefined
713720 ? { action: "cancel" }
714721 : {
715722 action: decoded.action,
716723 ...(decoded.content === undefined ? {} : { content: decoded.content }),
724+ ...(decoded._meta == null ? {} : { _meta: decoded._meta }),
717725 };
718726 this.#sendDownstream({ jsonrpc: "2.0", id: downstreamId, result });
719727 }
packages/plugins/mcp/src/sdk/appserver-test-server.ts+43−3
@@ -9,7 +9,11 @@ · 8 unmodified lines
99 // server, so the bridge must follow `nextCursor`;
1010 // - a `needs_approval` tool that emits a server→client
1111 // `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.
1317 import * as readline from "node:readline";
1418
1519 import { Option, Schema } from "effect";
@@ -51,7 +55,11 @@ const decodeToolCallParams = Schema.decodeUnknownOption( · 35 unmodified lines
5155 );
5256
5357 const 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+ }),
5563 );
5664
5765 const THREAD_ID = "thread-fixture-1";
@@ -188,6 +196,34 @@ const handleToolCall = (id: number | string, params: unknown): void => { · 130 unmodified lines
188196 });
189197 return;
190198 }
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+ }
191227 reply(id, {
192228 content: [{ type: "text", text: args?.code ?? "" }],
193229 // 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
275311 pendingApprovals.delete(id);
276312 const answer = Option.getOrUndefined(decodeElicitAnswer(result));
277313 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+ });
279319 return;
280320 }
281321 reply(callId, {
packages/plugins/mcp/src/sdk/codex-plugin-presets.test.ts+11−1
@@ -75,8 +75,18 @@ describe("approval terms", () => { · 74 unmodified lines
7575 ).toEqual({ meta: { persist: "always" } });
7676 });
7777
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", () => {
7988 expect(approvalTerms({ persist: { always: true }, origin: 42 })).toEqual({});
89+ expect(approvalTerms({ persist: ["session", 7] })).toEqual({});
8090 expect(approvalTerms({ progressToken: "tok" })).toEqual({});
8191 expect(approvalTerms(undefined)).toEqual({});
8292 });
packages/plugins/mcp/src/sdk/elicitation.test.ts+44−0
@@ -167,6 +167,50 @@ describe("MCP elicitation (end-to-end)", () => { · 166 unmodified lines
167167 }),
168168 );
169169
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+
170214 it.effect("tool without elicitation works normally", () =>
171215 Effect.gen(function* () {
172216 const server = yield* serveElicitationTestServer;
packages/plugins/mcp/src/sdk/invoke.ts+12−1
@@ -218,12 +218,21 @@ const decodeElicitContent = Schema.decodeUnknownSync( · 217 unmodified lines
218218 * server contributes nothing rather than noise. */
219219 export const APPROVAL_TERM_KEYS = ["persist", "origin", "connector_name", "connector_id"] as const;
220220
221+const 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. */
227+const isApprovalTerm = (value: unknown): value is string | readonly string[] =>
228+ typeof value === "string" || isStringList(value);
229+
221230 export const approvalTerms = (meta: Record<string, unknown> | undefined) => {
222231 if (meta === undefined) return {};
223232 const terms = Object.fromEntries(
224233 APPROVAL_TERM_KEYS.flatMap((key) => {
225234 const value = meta[key];
226− return typeof value === "string" ? [[key, value] as const] : [];
235+ return isApprovalTerm(value) ? [[key, value] as const] : [];
227236 }),
228237 );
229238 return Object.keys(terms).length > 0 ? { meta: terms } : {};
@@ -267,11 +276,13 @@ const installElicitationHandler = ( · 37 unmodified lines
267276 );
268277 if (Exit.isSuccess(exit)) {
269278 const response = exit.value;
279+ const persist = response.action === "accept" ? response.meta?.persist : undefined;
270280 return {
271281 action: response.action,
272282 ...(response.action === "accept" && response.content
273283 ? { content: decodeElicitContent(response.content) }
274284 : {}),
285+ ...(persist === undefined ? {} : { _meta: { persist } }),
275286 };
276287 }
277288 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
544544 },
545545 );
546546
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+
547578 server.registerTool(
548579 "simple_echo",
549580 {
packages/react/src/pages/resume-approval.tsx+73−6
@@ -5,11 +5,15 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; · 4 unmodified lines
55 import { Check, ExternalLink, Loader2, ShieldCheck, X } from "lucide-react";
66 import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
77
8+import { offeredPersistence } from "@executor-js/sdk";
9+
810 import { pausedExecutionAtom, resumeExecution } from "../api/atoms";
911 import { trackEvent } from "../api/analytics";
1012 import { Button } from "../components/button";
1113 import { CopyButton } from "../components/copy-button";
1214 import { type ElicitationAction, useElicitationApproval } from "../components/elicitation-approval";
15+import { Label } from "../components/label";
16+import { NativeSelect, NativeSelectOption } from "../components/native-select";
1317 import { Skeleton } from "../components/skeleton";
1418
1519 type PausedExecutionInfo = { readonly text: string; readonly structured: unknown };
@@ -52,6 +56,16 @@ type PausedInteractionView = { · 36 unmodified lines
5256 readonly url: string | null;
5357 readonly requestedSchema: unknown;
5458 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. */
66+const persistenceLabel: Record<string, string> = {
67+ session: "For this session",
68+ always: "Always",
5569 };
5670
5771 const encodeJsonPreview = Schema.encodeUnknownOption(Schema.UnknownFromJsonString);
@@ -63,6 +77,7 @@ const PausedInteractionInfo = Schema.Struct({ · 5 unmodified lines
6377 url: Schema.optional(Schema.String),
6478 requestedSchema: Schema.optional(Schema.Unknown),
6579 toolId: Schema.optional(Schema.String),
80+ meta: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
6681 });
6782 const PausedStructured = Schema.Struct({
6883 executionId: Schema.optional(Schema.String),
@@ -111,6 +126,7 @@ const interactionFromPausedInfo = (paused: PausedExecutionInfo): PausedInteracti · 42 unmodified lines
111126 url: interaction.url ?? null,
112127 requestedSchema: interaction.requestedSchema,
113128 toolId: interaction.toolId ?? null,
129+ offeredPersistence: offeredPersistence(interaction.meta),
114130 };
115131 };
116132
@@ -122,10 +138,18 @@ export function ResumeApprovalPage(props: { executionId: string }) { · 5 unmodified lines
122138 const doResume = useAtomSet(resumeExecution, { mode: "promiseExit" });
123139
124140 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+ ) =>
126147 doResume({
127148 params: { executionId },
128− payload: action === "accept" ? { action, content: content ?? {} } : { action },
149+ payload:
150+ action === "accept"
151+ ? { action, content: content ?? {}, ...(persist === undefined ? {} : { persist }) }
152+ : { action },
129153 }),
130154 [doResume],
131155 );
@@ -140,6 +164,7 @@ export function ResumeApprovalPageView(props: { · 8 unmodified lines
140164 executionId: string,
141165 action: ElicitationAction,
142166 content?: Record<string, unknown>,
167+ persist?: string,
143168 ) => Promise<Exit.Exit<ResumeExecutionResult, unknown>>;
144169 unavailableMessage?: string;
145170 }) {
@@ -147,6 +172,9 @@ export function ResumeApprovalPageView(props: { · 1 unmodified line
147172 const [status, setStatus] = useState<ResumeStatus>({ state: "idle" });
148173 const [currentExecutionId, setCurrentExecutionId] = useState(executionId);
149174 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("");
150178 const displayedPaused = nextPaused ?? (AsyncResult.isSuccess(paused) ? paused.value : null);
151179 const approval = useElicitationApproval(requestedSchemaFromPausedInfo(displayedPaused));
152180 const interaction = displayedPaused ? interactionFromPausedInfo(displayedPaused) : null;
@@ -154,6 +182,7 @@ export function ResumeApprovalPageView(props: { · 1 unmodified line
154182 useEffect(() => {
155183 setCurrentExecutionId(executionId);
156184 setNextPaused(null);
185+ setPersist("");
157186 setStatus({ state: "idle" });
158187 }, [executionId]);
159188
@@ -171,7 +200,12 @@ export function ResumeApprovalPageView(props: { · 11 unmodified lines
171200 if (content === null) return;
172201
173202 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+ );
175209
176210 if (Exit.isFailure(exit)) {
177211 trackEvent("resume_approval_submitted", {
@@ -208,6 +242,7 @@ export function ResumeApprovalPageView(props: { · 30 unmodified lines
208242 });
209243 setCurrentExecutionId(nextExecutionId);
210244 setNextPaused({ text: exit.value.text, structured: exit.value.structured });
245+ setPersist("");
211246 setStatus({ state: "idle" });
212247 return;
213248 }
@@ -224,7 +259,7 @@ export function ResumeApprovalPageView(props: { · 10 unmodified lines
224259 text: exit.value.text || "The paused execution has been resumed.",
225260 });
226261 },
227− [approval, currentExecutionId, interaction, resume],
262+ [approval, currentExecutionId, interaction, persist, resume],
228263 );
229264
230265 const busy = status.state === "submitting";
@@ -254,7 +289,12 @@ export function ResumeApprovalPageView(props: { · 23 unmodified lines
254289
255290 <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
256291 {nextPaused ? (
257− <PendingRequestDetails interaction={interaction} approvalFields={approval.fields} />
292+ <PendingRequestDetails
293+ interaction={interaction}
294+ approvalFields={approval.fields}
295+ persist={persist}
296+ onPersistChange={setPersist}
297+ />
258298 ) : (
259299 AsyncResult.match(paused, {
260300 onInitial: () => (
@@ -271,7 +311,12 @@ export function ResumeApprovalPageView(props: { · 10 unmodified lines
271311 </div>
272312 ),
273313 onSuccess: () => (
274− <PendingRequestDetails interaction={interaction} approvalFields={approval.fields} />
314+ <PendingRequestDetails
315+ interaction={interaction}
316+ approvalFields={approval.fields}
317+ persist={persist}
318+ onPersistChange={setPersist}
319+ />
275320 ),
276321 })
277322 )}
@@ -355,9 +400,13 @@ export function ResumeApprovalPageView(props: { · 77 unmodified lines
355400 function PendingRequestDetails({
356401 interaction,
357402 approvalFields,
403+ persist,
404+ onPersistChange,
358405 }: {
359406 interaction: PausedInteractionView | null;
360407 approvalFields: ReactNode;
408+ persist: string;
409+ onPersistChange: (persist: string) => void;
361410 }) {
362411 if (!interaction) {
363412 return <div className="text-sm text-muted-foreground">No pending request details found.</div>;
@@ -402,6 +451,24 @@ function PendingRequestDetails({ · 38 unmodified lines
402451 {approvalFields && (
403452 <div className="rounded-md border border-border bg-muted/30 p-3">{approvalFields}</div>
404453 )}
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+ )}
405472 </div>
406473 );
407474 }
packages/react/src/routes/resume.$executionId.tsx+13−2
@@ -85,6 +85,7 @@ type LocalMcpResumeInput = { · 84 unmodified lines
8585 readonly executionId: string;
8686 readonly action: ElicitationAction;
8787 readonly content?: Record<string, unknown>;
88+ readonly persist?: string;
8889 };
8990
9091 const resumeLocalMcpExecution = Atom.fn<LocalMcpResumeInput>()((input) =>
@@ -106,7 +107,11 @@ const resumeLocalMcpExecution = Atom.fn<LocalMcpResumeInput>()((input) => · 15 unmodified lines
106107 },
107108 body: JSON.stringify(
108109 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+ }
110115 : { action: input.action },
111116 ),
112117 },
@@ -159,12 +164,18 @@ function LocalMcpResumeApproval(props: { executionId: string; mcpSessionId: stri · 46 unmodified lines
159164 );
160165 const doResume = useAtomSet(resumeLocalMcpExecution, { mode: "promiseExit" });
161166 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+ ) =>
163173 doResume({
164174 mcpSessionId: props.mcpSessionId,
165175 executionId,
166176 action,
167177 content,
178+ persist,
168179 }),
169180 [doResume, props.mcpSessionId],
170181 );