Skip to content
Open
14 changes: 14 additions & 0 deletions .changeset/computer-use-remembered-approvals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@executor-js/sdk": patch
"@executor-js/execution": patch
"@executor-js/plugin-mcp": patch
"@executor-js/api": patch
"@executor-js/react": patch
"@executor-js/host-mcp": patch
"@executor-js/cloudflare": 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.
5 changes: 5 additions & 0 deletions .changeset/mcp-elicitation-active-deadline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/plugin-mcp": patch
---

Exclude time spent waiting for elicitation from the MCP tool invocation deadline.
1 change: 1 addition & 0 deletions apps/cloud/src/auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ const McpSessionExecutionParams = {
const ResumeMcpExecutionBody = Schema.Struct({
action: Schema.Literals(["accept", "decline", "cancel"]),
content: Schema.optional(Schema.Unknown),
persist: Schema.optional(Schema.String),
});

const McpPausedExecutionResponse = Schema.Struct({
Expand Down
3 changes: 3 additions & 0 deletions apps/cloud/src/auth/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
{
action: payload.action,
content: payload.content as Record<string, unknown> | undefined,
...(payload.action === "accept" && payload.persist !== undefined
? { meta: { persist: payload.persist } }
: {}),
},
),
);
Expand Down
11 changes: 10 additions & 1 deletion apps/cloud/src/mcp/session-build-semaphore.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, beforeEach } from "@effect/vitest";
import { describe, expect, it, beforeEach, afterEach, vi } from "@effect/vitest";

import {
acquireBuildSlot,
Expand All @@ -13,6 +13,10 @@ describe("session-build-semaphore", () => {
resetBuildSlotsForTest();
});

afterEach(() => {
vi.useRealTimers();
});

it("grants up to the cap immediately, with no wait", async () => {
const results = await Promise.all([
acquireBuildSlot().promise,
Expand Down Expand Up @@ -214,6 +218,7 @@ describe("session-build-semaphore", () => {
});

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

const timedOutHandle = acquireBuildSlot(10);
await vi.advanceTimersByTimeAsync(9);
expect(currentQueueLengthForTest()).toBe(1);
expect(currentActiveBuildsForTest()).toBe(4);
await vi.advanceTimersByTimeAsync(1);
const result = await timedOutHandle.promise;

expect(result).toEqual({ acquired: false, waitMs: expect.any(Number), timedOut: true });
Expand Down
6 changes: 5 additions & 1 deletion apps/cloud/src/routes/app/resume.$executionId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,17 @@ function CloudMcpResumeApproval(props: { executionId: string; mcpSessionId: stri
executionId: string,
action: "accept" | "decline" | "cancel",
content?: Record<string, unknown>,
persist?: string,
) =>
doResume({
params: {
mcpSessionId: props.mcpSessionId,
executionId,
},
payload: action === "accept" ? { action, content: content ?? {} } : { action },
payload:
action === "accept"
? { action, content: content ?? {}, ...(persist === undefined ? {} : { persist }) }
: { action },
}),
[doResume, props.mcpSessionId],
);
Expand Down
80 changes: 80 additions & 0 deletions e2e/scenarios/mcp-approval-persistence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { randomBytes } from "node:crypto";
import { expect } from "@effect/vitest";
import { Effect } from "effect";
import { composePluginApi } from "@executor-js/api/server";
import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
import { makeElicitationMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";

import { scenario } from "../src/scenario";
import { Api, Browser, Mcp, Target } from "../src/services";
import { parseBrowserApproval } from "../src/surfaces/mcp";
import { visit } from "../src/surfaces/browser";

const api = composePluginApi([mcpHttpPlugin()] as const);

scenario(
"MCP · browser approval preserves the chosen lifetime and defaults to once",
{ timeout: 180_000 },
Effect.scoped(
Effect.gen(function* () {
const target = yield* Target;
const browser = yield* Browser;
const mcp = yield* Mcp;
const { client: makeClient } = yield* Api;
const identity = yield* target.newIdentity();
const client = yield* makeClient(api, identity);
const slug = IntegrationSlug.make(`approval_terms_${randomBytes(4).toString("hex")}`);
const server = yield* serveMcpServer(makeElicitationMcpServer);
yield* client.mcp.addServer({
payload: {
transport: "remote",
name: "Approval terms",
endpoint: server.url,
slug,
remoteTransport: "streamable-http",
},
});
yield* Effect.gen(function* () {
yield* client.connections.create({
payload: {
owner: "org",
name: ConnectionName.make("main"),
integration: slug,
template: AuthTemplateSlug.make("none"),
value: "",
},
});
const session = mcp.session(identity, { elicitationMode: "browser" });
yield* session.listTools();
for (const scope of ["session", "always", ""] as const) {
const paused = yield* session.call("execute", {
code: `return await tools.${slug}.org.main.remembered_echo({value:"browser"});`,
});
const approval = parseBrowserApproval(paused);
const [resumed] = yield* Effect.all(
[
session.awaitResume(approval.executionId),
browser.session(identity, async ({ page, step }) => {
await step(`Approve ${scope || "once"} through the console`, async () => {
await visit(page, approval.approvalUrl);
const choice = page.getByLabel("Remember this approval");
await choice.waitFor();
expect(await choice.inputValue(), "every approval starts as one-time").toBe("");
if (scope !== "") await choice.selectOption(scope);
await page.getByRole("button", { name: "Approve", exact: true }).click();
await page.getByText("Approve sent").waitFor();
});
}),
],
{ concurrency: "unbounded" },
);
expect(resumed.ok).toBe(true);
expect(resumed.text, "the chosen lifetime reaches the upstream MCP server").toContain(
`approved:browser:${scope || "once"}`,
);
}
}).pipe(Effect.ensuring(client.mcp.removeServer({ params: { slug } }).pipe(Effect.orDie)));
}),
),
);
86 changes: 86 additions & 0 deletions e2e/selfhost/mcp-elicitation-deadline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { randomBytes } from "node:crypto";
import { expect } from "@effect/vitest";
import { Effect, Schema } from "effect";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { composePluginApi } from "@executor-js/api/server";
import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
import { serveMcpServer } from "@executor-js/plugin-mcp/testing";
import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";

import { scenario } from "../src/scenario";
import { Api, Mcp, Target } from "../src/services";

const api = composePluginApi([mcpHttpPlugin()] as const);
const decodeExecutionId = Schema.decodeUnknownSync(Schema.String);

scenario(
"MCP · delayed approval preserves the chosen lifetime beyond the active-work deadline",
{ timeout: 180_000 },
Effect.scoped(
Effect.gen(function* () {
const target = yield* Target;
const mcp = yield* Mcp;
const { client: makeClient } = yield* Api;
const identity = yield* target.newIdentity();
const client = yield* makeClient(api, identity);
const slug = IntegrationSlug.make(`deadline_${randomBytes(4).toString("hex")}`);
const server = yield* serveMcpServer(() => {
const upstream = new McpServer({ name: "Human approval", version: "1" });
upstream.registerTool("approve", { inputSchema: {} }, async () => {
const reply = await upstream.server.elicitInput(
{
mode: "form",
message: "Approve the delayed call?",
requestedSchema: { type: "object", properties: {} },
_meta: { persist: ["session", "always"] },
},
{ timeout: 150_000 },
);
return {
content: [
{ type: "text", text: `decision:${reply.action}:${reply._meta?.persist ?? "once"}` },
],
};
});
return upstream;
});
yield* client.mcp.addServer({
payload: {
transport: "remote",
name: "Human approval",
endpoint: server.url,
slug,
remoteTransport: "streamable-http",
},
});
yield* Effect.gen(function* () {
yield* client.connections.create({
payload: {
owner: "org",
name: ConnectionName.make("main"),
integration: slug,
template: AuthTemplateSlug.make("none"),
value: "",
},
});
const session = mcp.session(identity, { elicitationMode: "model" });
yield* session.listTools();
const paused = yield* session.call("execute", {
code: `return await tools.${slug}.org.main.approve({});`,
});
expect(paused.text).toContain("executionId:");
// Cross the production 60-second active-work deadline. This is the
// behavior under test: a human waiting must consume none of that budget.
yield* Effect.sleep("65 seconds");
const executionId = decodeExecutionId(/\bexecutionId:\s*(\S+)/.exec(paused.text)?.[1]);
const completed = yield* session.call("resume", {
executionId,
action: "accept",
persist: "session",
});
expect(completed.ok).toBe(true);
expect(completed.text).toContain("decision:accept:session");
}).pipe(Effect.ensuring(client.mcp.removeServer({ params: { slug } }).pipe(Effect.orDie)));
}),
),
);
1 change: 1 addition & 0 deletions e2e/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export default defineConfig({
project("cloudflare", {
include: [
"scenarios/browser-approval.test.ts",
"scenarios/mcp-approval-persistence.test.ts",
"scenarios/microsoft-graph-full.test.ts",
"scenarios/toolkits-mcp.test.ts",
"cloudflare/**/*.test.ts",
Expand Down
4 changes: 4 additions & 0 deletions packages/core/api/src/executions/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
1 change: 1 addition & 0 deletions packages/core/api/src/handlers/executions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions"
engine.resume(path.executionId, {
action: payload.action,
content: payload.content as Record<string, unknown> | undefined,
...(payload.persist === undefined ? {} : { meta: { persist: payload.persist } }),
}),
);

Expand Down
28 changes: 28 additions & 0 deletions packages/core/execution/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} })),
Expand Down
25 changes: 22 additions & 3 deletions packages/core/execution/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -58,6 +63,9 @@ type InternalPausedExecution<E> = PausedExecution & {
export type ResumeResponse = {
readonly action: "accept" | "decline" | "cancel";
readonly content?: Record<string, unknown>;
/** 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
Expand Down Expand Up @@ -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}`);
Expand All @@ -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)}`);
}
Expand Down Expand Up @@ -798,6 +816,7 @@ export const createExecutionEngine = <E extends Cause.YieldableError = CodeExecu
yield* Deferred.succeed(paused.response, {
action: response.action as typeof ElicitationResponse.Type.action,
content: response.content,
...(response.meta === undefined ? {} : { meta: response.meta }),
});

const outcome = (yield* awaitCompletionOrPause(paused.fiber, paused.pauseQueue).pipe(
Expand Down
Loading
Loading