Skip to content

Commit 43edcee

Browse files
committed
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
1 parent eaa1f3a commit 43edcee

17 files changed

Lines changed: 486 additions & 27 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
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": patch
8+
---
9+
10+
Carry an approval's persistence choice through elicitation, so Codex Computer Use stops asking to use the same app on every call.
11+
12+
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.

‎packages/core/api/src/executions/api.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ const ExecuteResponse = Schema.Union([CompletedResult, PausedResult]);
4444
const ResumeRequest = Schema.Struct({
4545
action: Schema.Literals(["accept", "decline", "cancel"]),
4646
content: Schema.optional(Schema.Unknown),
47+
/** How long an accepted approval lasts, when the paused interaction's
48+
* terms offer a choice (`interaction.meta.persist` lists the scopes).
49+
* Omitted, the approval is for this call only. */
50+
persist: Schema.optional(Schema.String),
4751
});
4852

4953
const ResumeResponse = Schema.Union([CompletedResult, PausedResult]);

‎packages/core/api/src/handlers/executions.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions"
251251
engine.resume(path.executionId, {
252252
action: payload.action,
253253
content: payload.content as Record<string, unknown> | undefined,
254+
...(payload.persist === undefined ? {} : { meta: { persist: payload.persist } }),
254255
}),
255256
);
256257

‎packages/core/execution/src/engine.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,34 @@ describe("formatPausedExecution approval terms", () => {
279279
});
280280
});
281281

282+
it("says how to answer when the terms leave the approval's lifetime to the caller", () => {
283+
// Computer Use's app approval: a bare accept is one-time and the same
284+
// prompt returns on the next call, so the caller has to be told the
285+
// scopes on offer and how to pick one.
286+
const result = formatPausedExecution(
287+
paused(
288+
FormElicitation.make({
289+
message: 'Allow Computer Use to use "Finder"?',
290+
requestedSchema: {},
291+
meta: { persist: ["session", "always"], connector_name: "Computer Use" },
292+
}),
293+
),
294+
);
295+
296+
const interaction = result.structured["interaction"] as {
297+
readonly meta?: unknown;
298+
readonly instructions: string;
299+
};
300+
expect(interaction.meta).toEqual({
301+
persist: ["session", "always"],
302+
connector_name: "Computer Use",
303+
});
304+
expect(interaction.instructions).toContain(
305+
'pass persist as one of "session", "always"; without it the approval is for this call only',
306+
);
307+
expect(result.text).toContain(interaction.instructions);
308+
});
309+
282310
it("says nothing about terms when the upstream attached none", () => {
283311
const result = formatPausedExecution(
284312
paused(FormElicitation.make({ message: "Proceed?", requestedSchema: {} })),

‎packages/core/execution/src/engine.ts‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,15 @@ import type {
66
Executor,
77
InvokeOptions,
88
ElicitationResponse,
9+
ElicitationResponseMeta,
910
ElicitationHandler,
1011
ElicitationContext,
1112
} from "@executor-js/sdk/core";
12-
import { CurrentOrgWriteAccess, type OrgWriteAccessState } from "@executor-js/sdk/core";
13+
import {
14+
CurrentOrgWriteAccess,
15+
offeredPersistence,
16+
type OrgWriteAccessState,
17+
} from "@executor-js/sdk/core";
1318
import { CodeExecutionError } from "@executor-js/codemode-core";
1419
import type { CodeExecutor, ExecuteResult, SandboxToolInvoker } from "@executor-js/codemode-core";
1520

@@ -58,6 +63,9 @@ type InternalPausedExecution<E> = PausedExecution & {
5863
export type ResumeResponse = {
5964
readonly action: "accept" | "decline" | "cancel";
6065
readonly content?: Record<string, unknown>;
66+
/** The answer's terms — `persist`, when the paused request offered a
67+
* choice of scopes and the approver picked one. */
68+
readonly meta?: ElicitationResponseMeta;
6169
};
6270

6371
// Auto-accept every elicitation. Used by the `autoApprove` path where the
@@ -215,10 +223,21 @@ export const formatPausedExecution = (
215223
: hasRequestedSchema
216224
? `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".`
217225
: `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".`;
226+
// When the upstream leaves the LIFETIME of an accept to the answer, the
227+
// caller has to know that a bare accept is a one-time approval — the same
228+
// prompt returns on the next call — and how to say otherwise.
229+
const meta = req.meta;
230+
const offered = offeredPersistence(meta);
231+
const persistInstructions =
232+
offered.length > 0
233+
? ` To have an accepted approval remembered, also pass persist as one of ${offered
234+
.map((scope) => JSON.stringify(scope))
235+
.join(", ")}; without it the approval is for this call only.`
236+
: "";
218237
const deadlineInstructions = deadline
219238
? ` Resume before ${deadline.expiresAt}; this approval window lasts ${formatTtlDuration(deadline.ttlMs)}.`
220239
: "";
221-
const instructions = `${baseInstructions}${deadlineInstructions}`;
240+
const instructions = `${baseInstructions}${persistInstructions}${deadlineInstructions}`;
222241

223242
if (isUrlElicitation) {
224243
lines.push(`\nOpen this URL in a browser:\n${req.url}`);
@@ -237,7 +256,6 @@ export const formatPausedExecution = (
237256
// Terms the upstream attached to the approval. Stated plainly, because a
238257
// prompt whose schema is empty ("Allow X to access Y?") can still be
239258
// asking for a PERSISTENT grant, and the answer differs.
240-
const meta = req.meta;
241259
if (meta !== undefined && Object.keys(meta).length > 0) {
242260
lines.push(`\nApproval terms:\n${JSON.stringify(meta, null, 2)}`);
243261
}
@@ -798,6 +816,7 @@ export const createExecutionEngine = <E extends Cause.YieldableError = CodeExecu
798816
yield* Deferred.succeed(paused.response, {
799817
action: response.action as typeof ElicitationResponse.Type.action,
800818
content: response.content,
819+
...(response.meta === undefined ? {} : { meta: response.meta }),
801820
});
802821

803822
const outcome = (yield* awaitCompletionOrPause(paused.fiber, paused.pauseQueue).pipe(

‎packages/core/sdk/src/elicitation.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,31 @@ import { ElicitationId, ToolAddress } from "./ids";
1717
export const ElicitationMeta = Schema.Record(Schema.String, Schema.Unknown);
1818
export type ElicitationMeta = typeof ElicitationMeta.Type;
1919

20+
/** The persistence scopes an approval OFFERS, when its terms leave that to
21+
* the answer. Codex Computer Use sends `persist: ["session", "always"]` and
22+
* remembers the app only if the reply names one; a bare accept is a
23+
* one-time approval and the very next call asks again. Chrome's per-site
24+
* approval sends `persist: "always"` — a statement of what accepting
25+
* means, not a choice — and contributes nothing here. */
26+
export const offeredPersistence = (meta: ElicitationMeta | undefined): readonly string[] => {
27+
const persist = meta?.["persist"];
28+
return Array.isArray(persist) && persist.every((scope) => typeof scope === "string")
29+
? persist
30+
: [];
31+
};
32+
33+
/** What an accepted approval carries back, in the request's own vocabulary.
34+
*
35+
* Closed on purpose, the mirror of the request-side projection: an answer
36+
* can only state terms this contract names, so no host can grant something
37+
* the prompt never offered. `persist` is the one term that is a choice —
38+
* one of `offeredPersistence(request.meta)`, or absent for a one-time
39+
* approval. */
40+
export const ElicitationResponseMeta = Schema.Struct({
41+
persist: Schema.optional(Schema.String),
42+
});
43+
export type ElicitationResponseMeta = typeof ElicitationResponseMeta.Type;
44+
2045
/** Tool needs structured input from the user (render a form). */
2146
export const FormElicitation = Schema.TaggedStruct("FormElicitation", {
2247
message: Schema.String,
@@ -45,6 +70,8 @@ export const ElicitationResponse = Schema.Struct({
4570
action: ElicitationAction,
4671
/** Present when `action` is "accept" — the data the user provided. */
4772
content: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
73+
/** The answer's own terms, meaningful only with "accept". */
74+
meta: Schema.optional(ElicitationResponseMeta),
4875
});
4976
export type ElicitationResponse = typeof ElicitationResponse.Type;
5077

‎packages/core/sdk/src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,8 @@ export { sanitizeArtifactPreviewMarkup, ARTIFACT_PREVIEW_MARKUP_LIMIT } from "./
223223
// Elicitation.
224224
export {
225225
ElicitationMeta,
226+
ElicitationResponseMeta,
227+
offeredPersistence,
226228
FormElicitation,
227229
UrlElicitation,
228230
ElicitationAction,

‎packages/hosts/mcp/src/tool-server.test.ts‎

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,9 +197,11 @@ const toolFile = (input: {
197197
/** Build an engine whose execute triggers one elicitation and returns the handler's result. */
198198
const makeElicitingEngine = (
199199
request: FormElicitation | UrlElicitation,
200-
formatResult: (response: { action: string; content?: Record<string, unknown> }) => unknown = (
201-
r,
202-
) => r.action,
200+
formatResult: (response: {
201+
action: string;
202+
content?: Record<string, unknown>;
203+
meta?: { readonly persist?: string };
204+
}) => unknown = (r) => r.action,
203205
): ExecutionEngine =>
204206
makeStubEngine({
205207
execute: (_code, { onElicitation }) =>
@@ -1653,6 +1655,88 @@ describe("MCP host server — client without elicitation (pause/resume)", () =>
16531655
});
16541656
});
16551657

1658+
// ---------------------------------------------------------------------------
1659+
// Approval terms — the request's ride out as `_meta`, the answer's ride back
1660+
// ---------------------------------------------------------------------------
1661+
1662+
describe("MCP host server — approval terms", () => {
1663+
const appApproval = FormElicitation.make({
1664+
message: 'Allow Computer Use to use "Finder"?',
1665+
requestedSchema: {},
1666+
meta: { persist: ["session", "always"], connector_name: "Computer Use" },
1667+
});
1668+
1669+
// The engine hands the response back as the execution's result, so the
1670+
// structured output carries it verbatim.
1671+
const responseOf = (structuredContent: unknown): unknown =>
1672+
(structuredContent as { readonly result: unknown }).result;
1673+
1674+
it("native mode shows the client the offered scopes and returns the one it chose", async () => {
1675+
const engine = makeElicitingEngine(appApproval, (r) => r);
1676+
let seen: unknown;
1677+
1678+
await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
1679+
client.setRequestHandler(ElicitRequestSchema, async (request) => {
1680+
seen = request.params._meta;
1681+
return { action: "accept" as const, content: {}, _meta: { persist: "always" } };
1682+
});
1683+
1684+
const result = await client.callTool({ name: "execute", arguments: { code: "finder" } });
1685+
expect(seen).toEqual({ persist: ["session", "always"], connector_name: "Computer Use" });
1686+
expect(responseOf(result.structuredContent)).toEqual({
1687+
action: "accept",
1688+
content: {},
1689+
meta: { persist: "always" },
1690+
});
1691+
});
1692+
});
1693+
1694+
it("native mode invents no terms when the client states none", async () => {
1695+
const engine = makeElicitingEngine(appApproval, (r) => r);
1696+
1697+
await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
1698+
client.setRequestHandler(ElicitRequestSchema, async () => ({
1699+
action: "accept" as const,
1700+
content: {},
1701+
}));
1702+
1703+
const result = await client.callTool({ name: "execute", arguments: { code: "finder" } });
1704+
expect(responseOf(result.structuredContent)).toEqual({ action: "accept", content: {} });
1705+
});
1706+
});
1707+
1708+
it("model mode passes the resume tool's persist choice to the engine", async () => {
1709+
const received: unknown[] = [];
1710+
const engine = makeStubEngine({
1711+
resume: (_id, response) =>
1712+
Effect.sync(() => {
1713+
received.push(response);
1714+
return { status: "completed", result: { result: "ok" } };
1715+
}),
1716+
});
1717+
1718+
await withClient(
1719+
engine,
1720+
NO_CAPS,
1721+
async (client) => {
1722+
await client.callTool({
1723+
name: "resume",
1724+
arguments: { executionId: "exec_1", action: "accept", persist: "session" },
1725+
});
1726+
await client.callTool({
1727+
name: "resume",
1728+
arguments: { executionId: "exec_2", action: "accept" },
1729+
});
1730+
expect(received).toEqual([
1731+
{ action: "accept", content: undefined, meta: { persist: "session" } },
1732+
{ action: "accept", content: undefined },
1733+
]);
1734+
},
1735+
{ elicitationMode: { mode: "model" } },
1736+
);
1737+
});
1738+
});
1739+
16561740
// ---------------------------------------------------------------------------
16571741
// Elicitation error handling
16581742
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)