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