diff --git a/.changeset/computer-use-remembered-approvals.md b/.changeset/computer-use-remembered-approvals.md new file mode 100644 index 0000000000..fc52239d20 --- /dev/null +++ b/.changeset/computer-use-remembered-approvals.md @@ -0,0 +1,12 @@ +--- +"@executor-js/sdk": patch +"@executor-js/execution": patch +"@executor-js/plugin-mcp": patch +"@executor-js/api": patch +"@executor-js/react": patch +"executor": patch +--- + +Carry an approval's persistence choice through elicitation, so Codex Computer Use stops asking to use the same app on every call. + +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. diff --git a/packages/core/api/src/executions/api.ts b/packages/core/api/src/executions/api.ts index a7d22d9690..8b5589f149 100644 --- a/packages/core/api/src/executions/api.ts +++ b/packages/core/api/src/executions/api.ts @@ -44,6 +44,10 @@ const ExecuteResponse = Schema.Union([CompletedResult, PausedResult]); const ResumeRequest = Schema.Struct({ action: Schema.Literals(["accept", "decline", "cancel"]), content: Schema.optional(Schema.Unknown), + /** How long an accepted approval lasts, when the paused interaction's + * terms offer a choice (`interaction.meta.persist` lists the scopes). + * Omitted, the approval is for this call only. */ + persist: Schema.optional(Schema.String), }); const ResumeResponse = Schema.Union([CompletedResult, PausedResult]); diff --git a/packages/core/api/src/handlers/executions.ts b/packages/core/api/src/handlers/executions.ts index 67f77de650..a6c3f137dc 100644 --- a/packages/core/api/src/handlers/executions.ts +++ b/packages/core/api/src/handlers/executions.ts @@ -251,6 +251,7 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" engine.resume(path.executionId, { action: payload.action, content: payload.content as Record | undefined, + ...(payload.persist === undefined ? {} : { meta: { persist: payload.persist } }), }), ); diff --git a/packages/core/execution/src/engine.test.ts b/packages/core/execution/src/engine.test.ts index 06b9e20888..8467993e0b 100644 --- a/packages/core/execution/src/engine.test.ts +++ b/packages/core/execution/src/engine.test.ts @@ -279,6 +279,34 @@ describe("formatPausedExecution approval terms", () => { }); }); + it("says how to answer when the terms leave the approval's lifetime to the caller", () => { + // Computer Use's app approval: a bare accept is one-time and the same + // prompt returns on the next call, so the caller has to be told the + // scopes on offer and how to pick one. + const result = formatPausedExecution( + paused( + FormElicitation.make({ + message: 'Allow Computer Use to use "Finder"?', + requestedSchema: {}, + meta: { persist: ["session", "always"], connector_name: "Computer Use" }, + }), + ), + ); + + const interaction = result.structured["interaction"] as { + readonly meta?: unknown; + readonly instructions: string; + }; + expect(interaction.meta).toEqual({ + persist: ["session", "always"], + connector_name: "Computer Use", + }); + expect(interaction.instructions).toContain( + 'pass persist as one of "session", "always"; without it the approval is for this call only', + ); + expect(result.text).toContain(interaction.instructions); + }); + it("says nothing about terms when the upstream attached none", () => { const result = formatPausedExecution( paused(FormElicitation.make({ message: "Proceed?", requestedSchema: {} })), diff --git a/packages/core/execution/src/engine.ts b/packages/core/execution/src/engine.ts index 8bb9bda071..89707f64e7 100644 --- a/packages/core/execution/src/engine.ts +++ b/packages/core/execution/src/engine.ts @@ -6,10 +6,15 @@ import type { Executor, InvokeOptions, ElicitationResponse, + ElicitationResponseMeta, ElicitationHandler, ElicitationContext, } from "@executor-js/sdk/core"; -import { CurrentOrgWriteAccess, type OrgWriteAccessState } from "@executor-js/sdk/core"; +import { + CurrentOrgWriteAccess, + offeredPersistence, + type OrgWriteAccessState, +} from "@executor-js/sdk/core"; import { CodeExecutionError } from "@executor-js/codemode-core"; import type { CodeExecutor, ExecuteResult, SandboxToolInvoker } from "@executor-js/codemode-core"; @@ -58,6 +63,9 @@ type InternalPausedExecution = PausedExecution & { export type ResumeResponse = { readonly action: "accept" | "decline" | "cancel"; readonly content?: Record; + /** The answer's terms — `persist`, when the paused request offered a + * choice of scopes and the approver picked one. */ + readonly meta?: ElicitationResponseMeta; }; // Auto-accept every elicitation. Used by the `autoApprove` path where the @@ -215,10 +223,21 @@ export const formatPausedExecution = ( : hasRequestedSchema ? `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".` : `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".`; + // When the upstream leaves the LIFETIME of an accept to the answer, the + // caller has to know that a bare accept is a one-time approval — the same + // prompt returns on the next call — and how to say otherwise. + const meta = req.meta; + const offered = offeredPersistence(meta); + const persistInstructions = + offered.length > 0 + ? ` To have an accepted approval remembered, also pass persist as one of ${offered + .map((scope) => JSON.stringify(scope)) + .join(", ")}; without it the approval is for this call only.` + : ""; const deadlineInstructions = deadline ? ` Resume before ${deadline.expiresAt}; this approval window lasts ${formatTtlDuration(deadline.ttlMs)}.` : ""; - const instructions = `${baseInstructions}${deadlineInstructions}`; + const instructions = `${baseInstructions}${persistInstructions}${deadlineInstructions}`; if (isUrlElicitation) { lines.push(`\nOpen this URL in a browser:\n${req.url}`); @@ -237,7 +256,6 @@ export const formatPausedExecution = ( // Terms the upstream attached to the approval. Stated plainly, because a // prompt whose schema is empty ("Allow X to access Y?") can still be // asking for a PERSISTENT grant, and the answer differs. - const meta = req.meta; if (meta !== undefined && Object.keys(meta).length > 0) { lines.push(`\nApproval terms:\n${JSON.stringify(meta, null, 2)}`); } @@ -798,6 +816,7 @@ export const createExecutionEngine = { + const persist = meta?.["persist"]; + return Array.isArray(persist) && persist.every((scope) => typeof scope === "string") + ? persist + : []; +}; + +/** What an accepted approval carries back, in the request's own vocabulary. + * + * Closed on purpose, the mirror of the request-side projection: an answer + * can only state terms this contract names, so no host can grant something + * the prompt never offered. `persist` is the one term that is a choice — + * one of `offeredPersistence(request.meta)`, or absent for a one-time + * approval. */ +export const ElicitationResponseMeta = Schema.Struct({ + persist: Schema.optional(Schema.String), +}); +export type ElicitationResponseMeta = typeof ElicitationResponseMeta.Type; + /** Tool needs structured input from the user (render a form). */ export const FormElicitation = Schema.TaggedStruct("FormElicitation", { message: Schema.String, @@ -45,6 +70,8 @@ export const ElicitationResponse = Schema.Struct({ action: ElicitationAction, /** Present when `action` is "accept" — the data the user provided. */ content: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + /** The answer's own terms, meaningful only with "accept". */ + meta: Schema.optional(ElicitationResponseMeta), }); export type ElicitationResponse = typeof ElicitationResponse.Type; diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index d8ac973134..f415b32023 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -223,6 +223,8 @@ export { sanitizeArtifactPreviewMarkup, ARTIFACT_PREVIEW_MARKUP_LIMIT } from "./ // Elicitation. export { ElicitationMeta, + ElicitationResponseMeta, + offeredPersistence, FormElicitation, UrlElicitation, ElicitationAction, diff --git a/packages/hosts/mcp/src/tool-server.test.ts b/packages/hosts/mcp/src/tool-server.test.ts index 1b7bb8aa45..f23cffc4d6 100644 --- a/packages/hosts/mcp/src/tool-server.test.ts +++ b/packages/hosts/mcp/src/tool-server.test.ts @@ -197,9 +197,11 @@ const toolFile = (input: { /** Build an engine whose execute triggers one elicitation and returns the handler's result. */ const makeElicitingEngine = ( request: FormElicitation | UrlElicitation, - formatResult: (response: { action: string; content?: Record }) => unknown = ( - r, - ) => r.action, + formatResult: (response: { + action: string; + content?: Record; + meta?: { readonly persist?: string }; + }) => unknown = (r) => r.action, ): ExecutionEngine => makeStubEngine({ execute: (_code, { onElicitation }) => @@ -1653,6 +1655,88 @@ describe("MCP host server — client without elicitation (pause/resume)", () => }); }); +// --------------------------------------------------------------------------- +// Approval terms — the request's ride out as `_meta`, the answer's ride back +// --------------------------------------------------------------------------- + +describe("MCP host server — approval terms", () => { + const appApproval = FormElicitation.make({ + message: 'Allow Computer Use to use "Finder"?', + requestedSchema: {}, + meta: { persist: ["session", "always"], connector_name: "Computer Use" }, + }); + + // The engine hands the response back as the execution's result, so the + // structured output carries it verbatim. + const responseOf = (structuredContent: unknown): unknown => + (structuredContent as { readonly result: unknown }).result; + + it("native mode shows the client the offered scopes and returns the one it chose", async () => { + const engine = makeElicitingEngine(appApproval, (r) => r); + let seen: unknown; + + await withNativeClient(engine, ELICITATION_CAPS, async (client) => { + client.setRequestHandler(ElicitRequestSchema, async (request) => { + seen = request.params._meta; + return { action: "accept" as const, content: {}, _meta: { persist: "always" } }; + }); + + const result = await client.callTool({ name: "execute", arguments: { code: "finder" } }); + expect(seen).toEqual({ persist: ["session", "always"], connector_name: "Computer Use" }); + expect(responseOf(result.structuredContent)).toEqual({ + action: "accept", + content: {}, + meta: { persist: "always" }, + }); + }); + }); + + it("native mode invents no terms when the client states none", async () => { + const engine = makeElicitingEngine(appApproval, (r) => r); + + await withNativeClient(engine, ELICITATION_CAPS, async (client) => { + client.setRequestHandler(ElicitRequestSchema, async () => ({ + action: "accept" as const, + content: {}, + })); + + const result = await client.callTool({ name: "execute", arguments: { code: "finder" } }); + expect(responseOf(result.structuredContent)).toEqual({ action: "accept", content: {} }); + }); + }); + + it("model mode passes the resume tool's persist choice to the engine", async () => { + const received: unknown[] = []; + const engine = makeStubEngine({ + resume: (_id, response) => + Effect.sync(() => { + received.push(response); + return { status: "completed", result: { result: "ok" } }; + }), + }); + + await withClient( + engine, + NO_CAPS, + async (client) => { + await client.callTool({ + name: "resume", + arguments: { executionId: "exec_1", action: "accept", persist: "session" }, + }); + await client.callTool({ + name: "resume", + arguments: { executionId: "exec_2", action: "accept" }, + }); + expect(received).toEqual([ + { action: "accept", content: undefined, meta: { persist: "session" } }, + { action: "accept", content: undefined }, + ]); + }, + { elicitationMode: { mode: "model" } }, + ); + }); +}); + // --------------------------------------------------------------------------- // Elicitation error handling // --------------------------------------------------------------------------- diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index 730c5bfd89..74535797d4 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -32,6 +32,7 @@ import type { ArtifactBinding, ArtifactSummary, ElicitationResponse, + ElicitationResponseMeta, ElicitationHandler, ElicitationContext, ElicitationRequest, @@ -380,6 +381,9 @@ const elicitationRequestUrl = (request: ElicitationRequest): string | undefined const pausedInteractionKind = (request: ElicitationRequest): ElicitationRequest["_tag"] => elicitationRequestTag(request); +// The request's terms travel as `_meta`, the way they arrived: a native +// client that renders "Allow Computer Use to use Finder?" needs to see that +// accepting can be remembered, and which scopes it may answer with. const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputParams = Match.type().pipe( Match.tag("UrlElicitation", (req) => ({ @@ -387,6 +391,7 @@ const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputPa message: req.message, url: req.url, elicitationId: req.elicitationId, + ...(req.meta === undefined ? {} : { _meta: req.meta }), })), Match.tag("FormElicitation", (req) => ({ message: req.message, @@ -397,10 +402,19 @@ const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputPa Object.keys(req.requestedSchema).length === 0 ? { type: "object" as const, properties: {} } : req.requestedSchema, + ...(req.meta === undefined ? {} : { _meta: req.meta }), })), Match.exhaustive, ); +/** The client's answer to the terms: the `persist` scope it chose, read from + * the result's `_meta` — and nothing else, so an answer states no more than + * `ElicitationResponseMeta` names. */ +const answeredTerms = (meta: unknown): ElicitationResponseMeta | undefined => { + const persist = isRecord(meta) ? meta["persist"] : undefined; + return typeof persist === "string" ? { persist } : undefined; +}; + const makeMcpElicitationHandler = ( server: McpServer, @@ -443,6 +457,7 @@ const makeMcpElicitationHandler = { relatedRequestId }, ); + const meta = answeredTerms(response._meta); debugLog?.("elicitation.response", { requestTag, action: response.action, @@ -450,11 +465,13 @@ const makeMcpElicitationHandler = typeof response.content === "object" && response.content !== null && Object.keys(response.content).length > 0, + persist: meta?.persist, }); return { action: response.action as typeof ElicitationResponse.Type.action, content: response.content, + ...(meta === undefined ? {} : { meta }), }; }).pipe( Effect.tapDefect((defect) => @@ -1409,8 +1426,7 @@ export const createExecutorMcpServer = ( const resumeExecution = ( executionId: string, - action: "accept" | "decline" | "cancel", - content: Record | undefined, + response: ResumeResponse, extra: McpRequestJoinKeys, ): Effect.Effect => Effect.gen(function* () { @@ -1420,17 +1436,18 @@ export const createExecutorMcpServer = ( }); debugLog("resume.call", { executionId, - action, - hasContent: content !== undefined, + action: response.action, + hasContent: response.content !== undefined, + persist: response.meta?.persist, clientCapabilities: server.server.getClientCapabilities() ?? null, }); - const outcome = yield* resumeWithLifecycle(executionId, { action, content }); + const outcome = yield* resumeWithLifecycle(executionId, response); if (!outcome) { debugLog("resume.missing_execution", { executionId }); if (yield* localExecutionAlreadySettled(executionId)) { return alreadySettledResult(executionId); } - const fallback = yield* resumeFallback(executionId, { action, content }); + const fallback = yield* resumeFallback(executionId, response); if (fallback) { debugLog("resume.fallback_result", { executionId, status: fallback.status }); return fallbackOutcomeResult(executionId, fallback); @@ -1454,7 +1471,7 @@ export const createExecutorMcpServer = ( Effect.withSpan("mcp.host.tool.resume", { attributes: { "mcp.tool.name": "resume", - "mcp.execute.resume.action": action, + "mcp.execute.resume.action": response.action, "mcp.execute.execution_id": executionId, }, }), @@ -1612,11 +1629,25 @@ export const createExecutorMcpServer = ( .string() .describe("Optional JSON-encoded response content for form elicitations") .default("{}"), + persist: z + .string() + .optional() + .describe( + "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.", + ), }, }, - ({ executionId, action, content: rawContent }, extra) => + ({ executionId, action, content: rawContent, persist }, extra) => runToolEffect( - resumeExecution(executionId, action, parseJsonContent(rawContent), extra), + resumeExecution( + executionId, + { + action, + content: parseJsonContent(rawContent), + ...(persist === undefined ? {} : { meta: { persist } }), + }, + extra, + ), extra, ), ); @@ -2309,7 +2340,11 @@ export const createExecutorMcpServer = ( }, ({ executionId, action, content: rawContent }, extra) => runToolEffect( - resumeExecution(executionId, action, parseJsonContent(rawContent), extra), + resumeExecution( + executionId, + { action, content: parseJsonContent(rawContent) }, + extra, + ), extra, ), ); diff --git a/packages/plugins/mcp/src/sdk/appserver-connector.test.ts b/packages/plugins/mcp/src/sdk/appserver-connector.test.ts index 4ef206064e..cd45e820bc 100644 --- a/packages/plugins/mcp/src/sdk/appserver-connector.test.ts +++ b/packages/plugins/mcp/src/sdk/appserver-connector.test.ts @@ -235,6 +235,39 @@ describe("codex app-server bridge", () => { ), ); + it.effect("carries the answer's persistence down to the app-server", () => + Effect.scoped( + Effect.gen(function* () { + // Computer Use's app approval OFFERS `persist: ["session", "always"]` + // and remembers the app only when the answer's `_meta.persist` names + // one. A reply rebuilt from `action` and `content` alone was a + // one-time approval, so the same app prompted on every call. + const connection = yield* withConnection(appServerInput("node_repl", { surface: "sky" })); + let offered: unknown; + connection.client.setRequestHandler("elicitation/create", (request) => { + offered = request.params._meta?.["persist"]; + return Promise.resolve({ + action: "accept" as const, + content: {}, + _meta: { persist: "always" }, + }); + }); + + const result = yield* Effect.promise(() => + connection.client.callTool({ + name: "get_app_state", + arguments: { app: "__needs_app_approval" }, + }), + ); + expect(offered, "the offered scopes reach the client").toEqual(["session", "always"]); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent, "and the chosen one reaches Codex").toEqual({ + persist: "always", + }); + }), + ), + ); + it.effect("a tool outside the sky surface is refused rather than sent to the REPL", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/plugins/mcp/src/sdk/appserver-connector.ts b/packages/plugins/mcp/src/sdk/appserver-connector.ts index 0f2703c44c..a4e6b47c16 100644 --- a/packages/plugins/mcp/src/sdk/appserver-connector.ts +++ b/packages/plugins/mcp/src/sdk/appserver-connector.ts @@ -159,6 +159,7 @@ const decodeElicitResult = Schema.decodeUnknownOption( Schema.Struct({ action: Schema.Literals(["accept", "decline", "cancel"]), content: Schema.optional(Schema.Unknown), + _meta: Schema.optional(Schema.NullOr(Schema.Record(Schema.String, Schema.Unknown))), }), ); @@ -708,12 +709,19 @@ class AppServerClientTransport implements Transport { const decoded = "result" in message ? Option.getOrUndefined(decodeElicitResult(message.result)) : undefined; // An error or unreadable answer cancels: never fabricate an approval. + // + // The answer's `_meta` goes down with it: that is where Codex reads the + // terms of an accept. Computer Use's app approval is the case — its + // request OFFERS `persist: ["session", "always"]`, and only an answer + // that names one is remembered. Dropping it here turned every accept + // into a one-time approval, so the same app prompted on every call. const result = decoded === undefined ? { action: "cancel" } : { action: decoded.action, ...(decoded.content === undefined ? {} : { content: decoded.content }), + ...(decoded._meta == null ? {} : { _meta: decoded._meta }), }; this.#sendDownstream({ jsonrpc: "2.0", id: downstreamId, result }); } diff --git a/packages/plugins/mcp/src/sdk/appserver-test-server.ts b/packages/plugins/mcp/src/sdk/appserver-test-server.ts index fc106f0595..487a469bb7 100644 --- a/packages/plugins/mcp/src/sdk/appserver-test-server.ts +++ b/packages/plugins/mcp/src/sdk/appserver-test-server.ts @@ -9,7 +9,11 @@ // server, so the bridge must follow `nextCursor`; // - a `needs_approval` tool that emits a server→client // `mcpServer/elicitation/request` and only succeeds when the answer is -// an accept — the round trip through executor's elicitation bridge. +// an accept — the round trip through executor's elicitation bridge; +// - approvals whose terms travel in `_meta`, in both directions: Chrome's +// per-site grant STATES `persist: "always"`, Computer Use's app approval +// OFFERS `persist: ["session", "always"]` and reads the answer's +// `_meta.persist` to know whether to remember the app. import * as readline from "node:readline"; import { Option, Schema } from "effect"; @@ -51,7 +55,11 @@ const decodeToolCallParams = Schema.decodeUnknownOption( ); const decodeElicitAnswer = Schema.decodeUnknownOption( - Schema.Struct({ action: Schema.String, content: Schema.optional(Schema.Unknown) }), + Schema.Struct({ + action: Schema.String, + content: Schema.optional(Schema.Unknown), + _meta: Schema.optional(Schema.NullOr(Schema.Record(Schema.String, Schema.Unknown))), + }), ); const THREAD_ID = "thread-fixture-1"; @@ -188,6 +196,34 @@ const handleToolCall = (id: number | string, params: unknown): void => { }); return; } + // A Computer-Use-shaped app approval: no schema to fill in, and the + // terms OFFER how long an accept lasts. The answer's `_meta.persist` + // picks one; without it the runtime treats the accept as one-time. + if (args?.code?.includes("__needs_app_approval")) { + const elicitationId = nextServerRequestId++; + pendingApprovals.set(elicitationId, id); + write({ + jsonrpc: "2.0", + id: elicitationId, + method: "mcpServer/elicitation/request", + params: { + threadId: THREAD_ID, + turnId: null, + serverName: "node_repl", + mode: "form", + message: 'Allow Computer Use to use "Finder"?', + requestedSchema: { type: "object", properties: {} }, + _meta: { + codex_approval_kind: "mcp_tool_call", + connector_id: "computer-use", + connector_name: "Computer Use", + persist: ["session", "always"], + riskLevel: "low", + }, + }, + }); + return; + } reply(id, { content: [{ type: "text", text: args?.code ?? "" }], // Echoed so a test can assert the turn metadata the Chrome client @@ -275,7 +311,11 @@ const handleElicitationAnswer = (id: number | string, result: unknown): void => pendingApprovals.delete(id); const answer = Option.getOrUndefined(decodeElicitAnswer(result)); if (answer?.action === "accept") { - reply(callId, { content: [{ type: "text", text: "approved" }] }); + reply(callId, { + content: [{ type: "text", text: "approved" }], + // Echoed so a test can assert what the runtime would remember. + structuredContent: { persist: answer._meta?.["persist"] ?? null }, + }); return; } reply(callId, { diff --git a/packages/plugins/mcp/src/sdk/codex-plugin-presets.test.ts b/packages/plugins/mcp/src/sdk/codex-plugin-presets.test.ts index db615ed2d4..80bd335f72 100644 --- a/packages/plugins/mcp/src/sdk/codex-plugin-presets.test.ts +++ b/packages/plugins/mcp/src/sdk/codex-plugin-presets.test.ts @@ -75,8 +75,18 @@ describe("approval terms", () => { ).toEqual({ meta: { persist: "always" } }); }); - it("ignores non-string values and contributes nothing when no term applies", () => { + it("keeps the scopes an upstream OFFERS, not just the one it states", () => { + // Computer Use leaves the lifetime of an accept to the answer. Without + // the list, the approver cannot know a bare accept is one-time, nor + // which scopes it may answer with. + expect(approvalTerms({ persist: ["session", "always"], connector_id: "computer-use" })).toEqual( + { meta: { persist: ["session", "always"], connector_id: "computer-use" } }, + ); + }); + + it("ignores non-term values and contributes nothing when no term applies", () => { expect(approvalTerms({ persist: { always: true }, origin: 42 })).toEqual({}); + expect(approvalTerms({ persist: ["session", 7] })).toEqual({}); expect(approvalTerms({ progressToken: "tok" })).toEqual({}); expect(approvalTerms(undefined)).toEqual({}); }); diff --git a/packages/plugins/mcp/src/sdk/elicitation.test.ts b/packages/plugins/mcp/src/sdk/elicitation.test.ts index cc2bed354f..69f108d008 100644 --- a/packages/plugins/mcp/src/sdk/elicitation.test.ts +++ b/packages/plugins/mcp/src/sdk/elicitation.test.ts @@ -167,6 +167,50 @@ describe("MCP elicitation (end-to-end)", () => { }), ); + it.effect("the answer's terms reach the server, and the offered ones reach the handler", () => + Effect.gen(function* () { + const server = yield* serveElicitationTestServer; + const executor = yield* makeTestExecutor(server.url); + const tools = yield* executor.tools.list(); + const rememberedEcho = findTool(tools, "remembered_echo"); + + let offered: unknown; + const remembered = yield* executor.execute( + rememberedEcho.address, + { value: "keep" }, + { + onElicitation: (ctx) => { + offered = ctx.request.meta; + return Effect.succeed( + ElicitationResponse.make({ + action: "accept", + content: {}, + meta: { persist: "always" }, + }), + ); + }, + }, + ); + const once = yield* executor.execute( + rememberedEcho.address, + { value: "drop" }, + { onElicitation: () => Effect.succeed(ElicitationResponse.make({ action: "accept" })) }, + ); + yield* executor.close(); + + expect(offered).toEqual({ persist: ["session", "always"] }); + expect(remembered).toMatchObject({ + ok: true, + data: { content: [{ type: "text", text: "approved:keep:always" }] }, + }); + // No choice made, none invented: a bare accept stays one-time. + expect(once).toMatchObject({ + ok: true, + data: { content: [{ type: "text", text: "approved:drop:once" }] }, + }); + }), + ); + it.effect("tool without elicitation works normally", () => Effect.gen(function* () { const server = yield* serveElicitationTestServer; diff --git a/packages/plugins/mcp/src/sdk/invoke.ts b/packages/plugins/mcp/src/sdk/invoke.ts index 4b7a433c7c..333ca13383 100644 --- a/packages/plugins/mcp/src/sdk/invoke.ts +++ b/packages/plugins/mcp/src/sdk/invoke.ts @@ -129,12 +129,21 @@ const decodeElicitContent = Schema.decodeUnknownSync( * server contributes nothing rather than noise. */ export const APPROVAL_TERM_KEYS = ["persist", "origin", "connector_name", "connector_id"] as const; +const isStringList = (value: unknown): value is readonly string[] => + Array.isArray(value) && value.every((item) => typeof item === "string"); + +/** A term is a string, or a list of strings: Computer Use OFFERS + * `persist: ["session", "always"]` for the answer to pick from, where + * Chrome STATES `persist: "always"`. Either way it is a term of the grant. */ +const isApprovalTerm = (value: unknown): value is string | readonly string[] => + typeof value === "string" || isStringList(value); + export const approvalTerms = (meta: Record | undefined) => { if (meta === undefined) return {}; const terms = Object.fromEntries( APPROVAL_TERM_KEYS.flatMap((key) => { const value = meta[key]; - return typeof value === "string" ? [[key, value] as const] : []; + return isApprovalTerm(value) ? [[key, value] as const] : []; }), ); return Object.keys(terms).length > 0 ? { meta: terms } : {}; @@ -167,11 +176,16 @@ const installElicitationHandler = (client: McpConnection["client"], elicit: Elic const exit = await Effect.runPromiseExit(elicit(req)); if (Exit.isSuccess(exit)) { const response = exit.value; + const persist = response.action === "accept" ? response.meta?.persist : undefined; return { action: response.action, ...(response.action === "accept" && response.content ? { content: decodeElicitContent(response.content) } : {}), + // The answer's terms ride back the way the request's came: in + // `_meta`. Exactly the chosen scope and nothing else, so the wire + // carries no more than the contract names; a decline carries none. + ...(persist === undefined ? {} : { _meta: { persist } }), }; } const failure = exit.cause.reasons.find(Cause.isFailReason); diff --git a/packages/plugins/mcp/src/testing/server.ts b/packages/plugins/mcp/src/testing/server.ts index 7c47d3fbe2..4e480f54db 100644 --- a/packages/plugins/mcp/src/testing/server.ts +++ b/packages/plugins/mcp/src/testing/server.ts @@ -537,6 +537,37 @@ export const makeElicitationMcpServer = () => { }, ); + server.registerTool( + "remembered_echo", + { + description: "Asks for approval whose terms offer to remember it", + inputSchema: { value: z.string() }, + }, + async ({ value }: { value: string }) => { + // Shaped like Codex Computer Use's app approval: an empty schema, and + // the persistence scopes on offer in `_meta`. The answer's own + // `_meta.persist` is what the server would remember. + const response = await server.server.elicitInput({ + mode: "form", + message: `Allow the echo of "${value}"?`, + requestedSchema: { type: "object", properties: {} }, + _meta: { persist: ["session", "always"] }, + }); + if (response.action !== "accept") { + return { content: [{ type: "text" as const, text: `denied:${value}` }] }; + } + const persist = response._meta?.["persist"]; + return { + content: [ + { + type: "text" as const, + text: `approved:${value}:${typeof persist === "string" ? persist : "once"}`, + }, + ], + }; + }, + ); + server.registerTool( "simple_echo", { diff --git a/packages/react/src/pages/resume-approval.tsx b/packages/react/src/pages/resume-approval.tsx index 424e8046f9..12efe18a91 100644 --- a/packages/react/src/pages/resume-approval.tsx +++ b/packages/react/src/pages/resume-approval.tsx @@ -5,11 +5,15 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { Check, ExternalLink, Loader2, ShieldCheck, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { offeredPersistence } from "@executor-js/sdk"; + import { pausedExecutionAtom, resumeExecution } from "../api/atoms"; import { trackEvent } from "../api/analytics"; import { Button } from "../components/button"; import { CopyButton } from "../components/copy-button"; import { type ElicitationAction, useElicitationApproval } from "../components/elicitation-approval"; +import { Label } from "../components/label"; +import { NativeSelect, NativeSelectOption } from "../components/native-select"; import { Skeleton } from "../components/skeleton"; type PausedExecutionInfo = { readonly text: string; readonly structured: unknown }; @@ -52,6 +56,16 @@ type PausedInteractionView = { readonly url: string | null; readonly requestedSchema: unknown; readonly toolId: string | null; + /** Scopes the upstream offers to remember an approval for; empty when + * accepting is one-time and there is nothing to choose. */ + readonly offeredPersistence: readonly string[]; +}; + +/** Labels for the persistence scopes Codex plugins use. An unfamiliar scope + * is shown as the upstream spelled it rather than hidden. */ +const persistenceLabel: Record = { + session: "For this session", + always: "Always", }; const encodeJsonPreview = Schema.encodeUnknownOption(Schema.UnknownFromJsonString); @@ -63,6 +77,7 @@ const PausedInteractionInfo = Schema.Struct({ url: Schema.optional(Schema.String), requestedSchema: Schema.optional(Schema.Unknown), toolId: Schema.optional(Schema.String), + meta: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }); const PausedStructured = Schema.Struct({ executionId: Schema.optional(Schema.String), @@ -111,6 +126,7 @@ const interactionFromPausedInfo = (paused: PausedExecutionInfo): PausedInteracti url: interaction.url ?? null, requestedSchema: interaction.requestedSchema, toolId: interaction.toolId ?? null, + offeredPersistence: offeredPersistence(interaction.meta), }; }; @@ -122,10 +138,18 @@ export function ResumeApprovalPage(props: { executionId: string }) { const doResume = useAtomSet(resumeExecution, { mode: "promiseExit" }); const resume = useCallback( - (executionId: string, action: ElicitationAction, content?: Record) => + ( + executionId: string, + action: ElicitationAction, + content?: Record, + persist?: string, + ) => doResume({ params: { executionId }, - payload: action === "accept" ? { action, content: content ?? {} } : { action }, + payload: + action === "accept" + ? { action, content: content ?? {}, ...(persist === undefined ? {} : { persist }) } + : { action }, }), [doResume], ); @@ -140,6 +164,7 @@ export function ResumeApprovalPageView(props: { executionId: string, action: ElicitationAction, content?: Record, + persist?: string, ) => Promise>; unavailableMessage?: string; }) { @@ -147,6 +172,9 @@ export function ResumeApprovalPageView(props: { const [status, setStatus] = useState({ state: "idle" }); const [currentExecutionId, setCurrentExecutionId] = useState(executionId); const [nextPaused, setNextPaused] = useState(null); + // "" is the one-time approval; anything else is a scope the upstream + // offered. Reset with the execution, since the next pause may offer none. + const [persist, setPersist] = useState(""); const displayedPaused = nextPaused ?? (AsyncResult.isSuccess(paused) ? paused.value : null); const approval = useElicitationApproval(requestedSchemaFromPausedInfo(displayedPaused)); const interaction = displayedPaused ? interactionFromPausedInfo(displayedPaused) : null; @@ -154,6 +182,7 @@ export function ResumeApprovalPageView(props: { useEffect(() => { setCurrentExecutionId(executionId); setNextPaused(null); + setPersist(""); setStatus({ state: "idle" }); }, [executionId]); @@ -171,7 +200,12 @@ export function ResumeApprovalPageView(props: { if (content === null) return; setStatus({ state: "submitting", action }); - const exit = await resume(currentExecutionId, action, content); + const exit = await resume( + currentExecutionId, + action, + content, + action === "accept" && persist !== "" ? persist : undefined, + ); if (Exit.isFailure(exit)) { trackEvent("resume_approval_submitted", { @@ -208,6 +242,7 @@ export function ResumeApprovalPageView(props: { }); setCurrentExecutionId(nextExecutionId); setNextPaused({ text: exit.value.text, structured: exit.value.structured }); + setPersist(""); setStatus({ state: "idle" }); return; } @@ -224,7 +259,7 @@ export function ResumeApprovalPageView(props: { text: exit.value.text || "The paused execution has been resumed.", }); }, - [approval, currentExecutionId, interaction, resume], + [approval, currentExecutionId, interaction, persist, resume], ); const busy = status.state === "submitting"; @@ -254,7 +289,12 @@ export function ResumeApprovalPageView(props: {
{nextPaused ? ( - + ) : ( AsyncResult.match(paused, { onInitial: () => ( @@ -271,7 +311,12 @@ export function ResumeApprovalPageView(props: {
), onSuccess: () => ( - + ), }) )} @@ -355,9 +400,13 @@ export function ResumeApprovalPageView(props: { function PendingRequestDetails({ interaction, approvalFields, + persist, + onPersistChange, }: { interaction: PausedInteractionView | null; approvalFields: ReactNode; + persist: string; + onPersistChange: (persist: string) => void; }) { if (!interaction) { return
No pending request details found.
; @@ -402,6 +451,24 @@ function PendingRequestDetails({ {approvalFields && (
{approvalFields}
)} + + {interaction.offeredPersistence.length > 0 && ( +
+ + onPersistChange(event.target.value)} + > + Just this once + {interaction.offeredPersistence.map((scope) => ( + + {persistenceLabel[scope] ?? scope} + + ))} + +
+ )} ); }