Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/computer-use-remembered-approvals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@executor-js/sdk": patch
"@executor-js/execution": patch
"@executor-js/plugin-mcp": patch
"@executor-js/api": patch
"@executor-js/react": patch
"executor": patch
---

Carry an approval's persistence choice through elicitation, so Codex Computer Use stops asking to use the same app on every call.

Computer Use offers `persist: ["session", "always"]` in the prompt's terms and remembers the app only when the answer names one. Executor dropped the offer on the way in (the terms projection kept strings only) and the choice on the way out (every adapter rebuilt the reply from `action` and `content`), so each accept was one-time. `ElicitationResponse` now has `meta.persist`; the MCP plugin, the app-server bridge, and the MCP host pass it through; the model-mode `resume` tool and the browser approval page let the approver pick from the offered scopes. Nothing is chosen automatically: a bare accept still approves once.
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
27 changes: 27 additions & 0 deletions packages/core/sdk/src/elicitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,31 @@ import { ElicitationId, ToolAddress } from "./ids";
export const ElicitationMeta = Schema.Record(Schema.String, Schema.Unknown);
export type ElicitationMeta = typeof ElicitationMeta.Type;

/** The persistence scopes an approval OFFERS, when its terms leave that to
* the answer. Codex Computer Use sends `persist: ["session", "always"]` and
* remembers the app only if the reply names one; a bare accept is a
* one-time approval and the very next call asks again. Chrome's per-site
* approval sends `persist: "always"` — a statement of what accepting
* means, not a choice — and contributes nothing here. */
export const offeredPersistence = (meta: ElicitationMeta | undefined): readonly string[] => {
const persist = meta?.["persist"];
return Array.isArray(persist) && persist.every((scope) => typeof scope === "string")
? persist
: [];
};

/** What an accepted approval carries back, in the request's own vocabulary.
*
* Closed on purpose, the mirror of the request-side projection: an answer
* can only state terms this contract names, so no host can grant something
* the prompt never offered. `persist` is the one term that is a choice —
* one of `offeredPersistence(request.meta)`, or absent for a one-time
* approval. */
export const ElicitationResponseMeta = Schema.Struct({
persist: Schema.optional(Schema.String),
});
export type ElicitationResponseMeta = typeof ElicitationResponseMeta.Type;

/** Tool needs structured input from the user (render a form). */
export const FormElicitation = Schema.TaggedStruct("FormElicitation", {
message: Schema.String,
Expand Down Expand Up @@ -45,6 +70,8 @@ export const ElicitationResponse = Schema.Struct({
action: ElicitationAction,
/** Present when `action` is "accept" — the data the user provided. */
content: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
/** The answer's own terms, meaningful only with "accept". */
meta: Schema.optional(ElicitationResponseMeta),
});
export type ElicitationResponse = typeof ElicitationResponse.Type;

Expand Down
2 changes: 2 additions & 0 deletions packages/core/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,8 @@ export { sanitizeArtifactPreviewMarkup, ARTIFACT_PREVIEW_MARKUP_LIMIT } from "./
// Elicitation.
export {
ElicitationMeta,
ElicitationResponseMeta,
offeredPersistence,
FormElicitation,
UrlElicitation,
ElicitationAction,
Expand Down
90 changes: 87 additions & 3 deletions packages/hosts/mcp/src/tool-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,11 @@ const toolFile = (input: {
/** Build an engine whose execute triggers one elicitation and returns the handler's result. */
const makeElicitingEngine = (
request: FormElicitation | UrlElicitation,
formatResult: (response: { action: string; content?: Record<string, unknown> }) => unknown = (
r,
) => r.action,
formatResult: (response: {
action: string;
content?: Record<string, unknown>;
meta?: { readonly persist?: string };
}) => unknown = (r) => r.action,
): ExecutionEngine =>
makeStubEngine({
execute: (_code, { onElicitation }) =>
Expand Down Expand Up @@ -1653,6 +1655,88 @@ describe("MCP host server — client without elicitation (pause/resume)", () =>
});
});

// ---------------------------------------------------------------------------
// Approval terms — the request's ride out as `_meta`, the answer's ride back
// ---------------------------------------------------------------------------

describe("MCP host server — approval terms", () => {
const appApproval = FormElicitation.make({
message: 'Allow Computer Use to use "Finder"?',
requestedSchema: {},
meta: { persist: ["session", "always"], connector_name: "Computer Use" },
});

// The engine hands the response back as the execution's result, so the
// structured output carries it verbatim.
const responseOf = (structuredContent: unknown): unknown =>
(structuredContent as { readonly result: unknown }).result;

it("native mode shows the client the offered scopes and returns the one it chose", async () => {
const engine = makeElicitingEngine(appApproval, (r) => r);
let seen: unknown;

await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
client.setRequestHandler(ElicitRequestSchema, async (request) => {
seen = request.params._meta;
return { action: "accept" as const, content: {}, _meta: { persist: "always" } };
});

const result = await client.callTool({ name: "execute", arguments: { code: "finder" } });
expect(seen).toEqual({ persist: ["session", "always"], connector_name: "Computer Use" });
expect(responseOf(result.structuredContent)).toEqual({
action: "accept",
content: {},
meta: { persist: "always" },
});
});
});

it("native mode invents no terms when the client states none", async () => {
const engine = makeElicitingEngine(appApproval, (r) => r);

await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
client.setRequestHandler(ElicitRequestSchema, async () => ({
action: "accept" as const,
content: {},
}));

const result = await client.callTool({ name: "execute", arguments: { code: "finder" } });
expect(responseOf(result.structuredContent)).toEqual({ action: "accept", content: {} });
});
});

it("model mode passes the resume tool's persist choice to the engine", async () => {
const received: unknown[] = [];
const engine = makeStubEngine({
resume: (_id, response) =>
Effect.sync(() => {
received.push(response);
return { status: "completed", result: { result: "ok" } };
}),
});

await withClient(
engine,
NO_CAPS,
async (client) => {
await client.callTool({
name: "resume",
arguments: { executionId: "exec_1", action: "accept", persist: "session" },
});
await client.callTool({
name: "resume",
arguments: { executionId: "exec_2", action: "accept" },
});
expect(received).toEqual([
{ action: "accept", content: undefined, meta: { persist: "session" } },
{ action: "accept", content: undefined },
]);
},
{ elicitationMode: { mode: "model" } },
);
});
});

// ---------------------------------------------------------------------------
// Elicitation error handling
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading