authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-24 00:58:18-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-24 00:58:18-07:00
log7c6163c67aa122fa7c13466536c012888db102c6
tree36602b9b41371f8f6050662c68c0b0127699b661
parent10626c537886b41b6d9fc404521512f4f3702157
signature Signed by PGP key B5690EEEBB952194

fix(codex): show app access approval prompts (#8058)


24 files changed, 1096 insertions(+), 95 deletions(-)

apps/mobile/src/features/threads/PendingApprovalCard.tsx+39-25
......@@ -1,4 +1,8 @@
1import type { ApprovalRequestId, ProviderApprovalDecision } from "@t3tools/contracts";
1import type {
2 ApprovalRequestId,
3 ProviderApprovalDecision,
4 ProviderApprovalOption,
5} from "@t3tools/contracts";
26import { Pressable, View } from "react-native";
37
48import { AppText as Text } from "../../components/AppText";
......@@ -13,7 +17,14 @@ export interface PendingApprovalCardProps {
1317 ) => Promise<unknown>;
1418}
1519
20const DEFAULT_APPROVAL_OPTIONS = [
21 { decision: "accept", label: "Allow once" },
22 { decision: "acceptForSession", label: "Allow session" },
23 { decision: "decline", label: "Decline" },
24] satisfies ReadonlyArray<ProviderApprovalOption>;
25
1626export function PendingApprovalCard(props: PendingApprovalCardProps) {
27 const options = props.approval.options ?? DEFAULT_APPROVAL_OPTIONS;
1728 // Opaque for the same reason as PendingUserInputCard: nothing blurs the feed
1829 // behind this card, so a translucent surface bleeds messages through it.
1930 return (
......@@ -22,7 +33,7 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {
2233 Approval needed
2334 </Text>
2435 <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50">
25 {props.approval.requestKind}
36 {props.approval.appName ?? props.approval.requestKind}
2637 </Text>
2738 {props.approval.detail ? (
2839 <Text className="font-sans text-sm leading-normal text-neutral-600 dark:text-neutral-400">
......@@ -30,29 +41,32 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {
3041 </Text>
3142 ) : null}
3243 <View className="flex-row flex-wrap gap-2.5">
33 <Pressable
34 className="items-center justify-center rounded-[14px] bg-blue-500 px-3.5 py-3"
35 disabled={props.respondingApprovalId === props.approval.requestId}
36 onPress={() => void props.onRespond(props.approval.requestId, "accept")}
37 >
38 <Text className="font-t3-extrabold text-sm text-white">Allow once</Text>
39 </Pressable>
40 <Pressable
41 className="items-center justify-center rounded-[14px] bg-neutral-200 px-3.5 py-3 dark:bg-neutral-800"
42 disabled={props.respondingApprovalId === props.approval.requestId}
43 onPress={() => void props.onRespond(props.approval.requestId, "acceptForSession")}
44 >
45 <Text className="font-t3-bold text-sm text-neutral-950 dark:text-neutral-50">
46 Allow session
47 </Text>
48 </Pressable>
49 <Pressable
50 className="items-center justify-center rounded-[14px] bg-rose-100 px-3.5 py-3 dark:bg-rose-500/18"
51 disabled={props.respondingApprovalId === props.approval.requestId}
52 onPress={() => void props.onRespond(props.approval.requestId, "decline")}
53 >
54 <Text className="font-t3-bold text-sm text-rose-700 dark:text-rose-300">Decline</Text>
55 </Pressable>
44 {options.map((option) => (
45 <Pressable
46 key={option.decision}
47 className={`items-center justify-center rounded-[14px] px-3.5 py-3 ${
48 option.decision === "accept"
49 ? "bg-blue-500"
50 : option.decision === "decline"
51 ? "bg-rose-100 dark:bg-rose-500/18"
52 : "bg-neutral-200 dark:bg-neutral-800"
53 }`}
54 disabled={props.respondingApprovalId === props.approval.requestId}
55 onPress={() => void props.onRespond(props.approval.requestId, option.decision)}
56 >
57 <Text
58 className={`text-sm ${
59 option.decision === "accept"
60 ? "font-t3-extrabold text-white"
61 : option.decision === "decline"
62 ? "font-t3-bold text-rose-700 dark:text-rose-300"
63 : "font-t3-bold text-neutral-950 dark:text-neutral-50"
64 }`}
65 >
66 {option.label}
67 </Text>
68 </Pressable>
69 ))}
5670 </View>
5771 </View>
5872 );
apps/mobile/src/lib/threadActivity.test.ts+54
......@@ -15,6 +15,7 @@ import {
1515import {
1616 buildPendingUserInputAnswers,
1717 buildThreadFeed,
18 derivePendingApprovals,
1819 deriveThreadFeedPresentation,
1920 isPendingUserInputOptionSelected,
2021 setPendingUserInputCustomAnswer,
......@@ -142,6 +143,59 @@ describe("pending user input answers", () => {
142143 });
143144});
144145
146describe("pending approvals", () => {
147 it("keeps app access approvals and persistence choices from remote environments", () => {
148 const options = [
149 { decision: "decline", label: "Decline" },
150 { decision: "acceptAlways", label: "Always allow Safari" },
151 { decision: "accept", label: "Approve" },
152 ];
153 const activity = makeActivity({
154 id: EventId.make("approval-safari"),
155 kind: "approval.requested",
156 summary: "App access approval requested",
157 createdAt: "2026-08-24T00:00:00.000Z",
158 payload: {
159 requestId: "req-safari",
160 requestType: "mcp_elicitation_approval",
161 detail: "Allow ChatGPT to use Safari?",
162 appName: "Safari",
163 options,
164 },
165 });
166
167 expect(derivePendingApprovals([activity])).toEqual([
168 {
169 requestId: "req-safari",
170 requestKind: "mcp-elicitation",
171 createdAt: "2026-08-24T00:00:00.000Z",
172 detail: "Allow ChatGPT to use Safari?",
173 appName: "Safari",
174 options,
175 },
176 ]);
177 });
178
179 it("removes an app access approval after a remote client rejects it", () => {
180 const requested = makeActivity({
181 id: EventId.make("approval-safari-open"),
182 kind: "approval.requested",
183 summary: "App access approval requested",
184 createdAt: "2026-08-24T00:00:00.000Z",
185 payload: { requestId: "req-safari", requestKind: "mcp-elicitation" },
186 });
187 const resolved = makeActivity({
188 id: EventId.make("approval-safari-resolved"),
189 kind: "approval.resolved",
190 summary: "Approval resolved",
191 createdAt: "2026-08-24T00:00:01.000Z",
192 payload: { requestId: "req-safari", decision: "decline" },
193 });
194
195 expect(derivePendingApprovals([requested, resolved])).toEqual([]);
196 });
197});
198
145199function makeActivity(
146200 input: Partial<OrchestrationThreadActivity> &
147201 Pick<OrchestrationThreadActivity, "id" | "kind" | "summary" | "createdAt">,
apps/mobile/src/lib/threadActivity.ts+24-8
......@@ -1,4 +1,9 @@
1import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts";
1import {
2 ApprovalRequestId,
3 isToolLifecycleItemType,
4 ProviderApprovalOption,
5 ProviderRequestKind,
6} from "@t3tools/contracts";
27import type {
38 OrchestrationLatestTurn,
49 OrchestrationThread,
......@@ -11,14 +16,20 @@ import { formatDuration } from "@t3tools/shared/orchestrationTiming";
1116
1217import * as Arr from "effect/Array";
1318import * as Order from "effect/Order";
19import * as Schema from "effect/Schema";
1420
1521export interface PendingApproval {
1622 readonly requestId: ApprovalRequestId;
17 readonly requestKind: "command" | "file-read" | "file-change";
23 readonly requestKind: ProviderRequestKind;
1824 readonly createdAt: string;
1925 readonly detail?: string;
26 readonly appName?: string;
27 readonly options?: ReadonlyArray<ProviderApprovalOption>;
2028}
2129
30const isProviderRequestKind = Schema.is(ProviderRequestKind);
31const isProviderApprovalOption = Schema.is(ProviderApprovalOption);
32
2233export interface PendingUserInput {
2334 readonly requestId: ApprovalRequestId;
2435 readonly createdAt: string;
......@@ -147,6 +158,8 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ
147158 case "file_change_approval":
148159 case "apply_patch_approval":
149160 return "file-change";
161 case "mcp_elicitation_approval":
162 return "mcp-elicitation";
150163 default:
151164 return null;
152165 }
......@@ -1375,13 +1388,14 @@ export function derivePendingApprovals(
13751388 ? (activity.payload as Record<string, unknown>)
13761389 : null;
13771390 const requestId = parseApprovalRequestId(payload?.requestId);
1378 const requestKind =
1379 payload?.requestKind === "command" ||
1380 payload?.requestKind === "file-read" ||
1381 payload?.requestKind === "file-change"
1382 ? payload.requestKind
1383 : requestKindFromRequestType(payload?.requestType);
1391 const requestKind = isProviderRequestKind(payload?.requestKind)
1392 ? payload.requestKind
1393 : requestKindFromRequestType(payload?.requestType);
13841394 const detail = typeof payload?.detail === "string" ? payload.detail : undefined;
1395 const appName = typeof payload?.appName === "string" ? payload.appName : undefined;
1396 const options = Array.isArray(payload?.options)
1397 ? payload.options.filter(isProviderApprovalOption)
1398 : undefined;
13851399
13861400 if (activity.kind === "approval.requested" && requestId && requestKind) {
13871401 openByRequestId.set(requestId, {
......@@ -1389,6 +1403,8 @@ export function derivePendingApprovals(
13891403 requestKind,
13901404 createdAt: activity.createdAt,
13911405 ...(detail ? { detail } : {}),
1406 ...(appName ? { appName } : {}),
1407 ...(options && options.length > 0 ? { options } : {}),
13921408 });
13931409 continue;
13941410 }
apps/server/integration/OrchestrationEngineHarness.integration.ts+5-4
......@@ -8,6 +8,7 @@ import {
88 ProviderDriverKind,
99 type OrchestrationEvent,
1010 type OrchestrationThread,
11 type ProviderApprovalDecision,
1112} from "@t3tools/contracts";
1213import * as Effect from "effect/Effect";
1314import * as Exit from "effect/Exit";
......@@ -199,14 +200,14 @@ export interface OrchestrationIntegrationHarness {
199200 requestId: string,
200201 predicate: (row: {
201202 readonly status: "pending" | "resolved";
202 readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null;
203 readonly decision: ProviderApprovalDecision | null;
203204 readonly resolvedAt: string | null;
204205 }) => boolean,
205206 timeoutMs?: number,
206207 ) => Effect.Effect<
207208 {
208209 readonly status: "pending" | "resolved";
209 readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null;
210 readonly decision: ProviderApprovalDecision | null;
210211 readonly resolvedAt: string | null;
211212 },
212213 never
......@@ -493,7 +494,7 @@ export const makeOrchestrationIntegrationHarness = (
493494 row,
494495 ): row is {
495496 readonly status: "pending" | "resolved";
496 readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null;
497 readonly decision: ProviderApprovalDecision | null;
497498 readonly resolvedAt: string | null;
498499 } => row !== null && predicate(row),
499500 `pending approval '${requestId}'`,
......@@ -501,7 +502,7 @@ export const makeOrchestrationIntegrationHarness = (
501502 ) as Effect.Effect<
502503 {
503504 readonly status: "pending" | "resolved";
504 readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null;
505 readonly decision: ProviderApprovalDecision | null;
505506 readonly resolvedAt: string | null;
506507 },
507508 never
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+1
......@@ -1507,6 +1507,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
15071507 const resolvedDecision =
15081508 resolvedDecisionRaw === "accept" ||
15091509 resolvedDecisionRaw === "acceptForSession" ||
1510 resolvedDecisionRaw === "acceptAlways" ||
15101511 resolvedDecisionRaw === "decline" ||
15111512 resolvedDecisionRaw === "cancel"
15121513 ? resolvedDecisionRaw
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts+37
......@@ -30,4 +30,41 @@ describe("runtimeEventToActivities approval details", () => {
3030 expect(activity?.kind).toBe("approval.requested");
3131 expect((activity?.payload as Record<string, unknown> | undefined)?.detail).toBe(detail);
3232 });
33
34 it("keeps app details and approval options available to remote clients", () => {
35 const options = [
36 { decision: "decline", label: "Decline" },
37 { decision: "acceptAlways", label: "Always allow Safari" },
38 { decision: "accept", label: "Approve" },
39 ] as const;
40 const event = {
41 type: "request.opened",
42 eventId: EventId.make("evt-mcp-elicitation"),
43 provider: ProviderDriverKind.make("codex"),
44 createdAt: "2026-08-24T00:00:00.000Z",
45 threadId: ThreadId.make("thread-1"),
46 requestId: RuntimeRequestId.make("approval-safari"),
47 payload: {
48 requestType: "mcp_elicitation_approval",
49 detail: "Allow ChatGPT to use Safari?",
50 appName: "Safari",
51 options,
52 },
53 } satisfies ProviderRuntimeEvent;
54
55 const [activity] = runtimeEventToActivities(event);
56
57 expect(activity).toMatchObject({
58 kind: "approval.requested",
59 summary: "App access approval requested",
60 payload: {
61 requestId: "approval-safari",
62 requestKind: "mcp-elicitation",
63 requestType: "mcp_elicitation_approval",
64 detail: "Allow ChatGPT to use Safari?",
65 appName: "Safari",
66 options,
67 },
68 });
69 });
3370});
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+8-2
......@@ -298,7 +298,7 @@ function sessionStatusAllowsActiveTurn(
298298
299299function requestKindFromCanonicalRequestType(
300300 requestType: string | undefined,
301): "command" | "file-read" | "file-change" | undefined {
301): "command" | "file-read" | "file-change" | "mcp-elicitation" | undefined {
302302 switch (requestType) {
303303 case "command_execution_approval":
304304 case "exec_command_approval":
......@@ -308,6 +308,8 @@ function requestKindFromCanonicalRequestType(
308308 case "file_change_approval":
309309 case "apply_patch_approval":
310310 return "file-change";
311 case "mcp_elicitation_approval":
312 return "mcp-elicitation";
311313 default:
312314 return undefined;
313315 }
......@@ -388,12 +390,16 @@ export function runtimeEventToActivities(
388390 ? "File-read approval requested"
389391 : requestKind === "file-change"
390392 ? "File-change approval requested"
391 : "Approval requested",
393 : requestKind === "mcp-elicitation"
394 ? "App access approval requested"
395 : "Approval requested",
392396 payload: {
393397 requestId: toApprovalRequestId(event.requestId),
394398 ...(requestKind ? { requestKind } : {}),
395399 requestType: event.payload.requestType,
396400 ...(event.payload.detail ? { detail: event.payload.detail } : {}),
401 ...(event.payload.appName ? { appName: event.payload.appName } : {}),
402 ...(event.payload.options ? { options: event.payload.options } : {}),
397403 },
398404 turnId: toTurnId(event.turnId) ?? null,
399405 ...maybeSequence,
apps/server/src/provider/Layers/CodexAdapter.test.ts+73
......@@ -1066,6 +1066,79 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
10661066 }),
10671067 );
10681068
1069 it.effect("maps MCP elicitation requests into app access approvals", () =>
1070 Effect.gen(function* () {
1071 const { adapter, runtime } = yield* startLifecycleRuntime();
1072 const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);
1073
1074 yield* runtime.emit({
1075 id: asEventId("evt-mcp-elicitation"),
1076 kind: "request",
1077 provider: ProviderDriverKind.make("codex"),
1078 threadId: asThreadId("thread-1"),
1079 createdAt: "2026-08-24T00:00:00.000Z",
1080 method: "mcpServer/elicitation/request",
1081 requestKind: "mcp-elicitation",
1082 requestId: ApprovalRequestId.make("req-safari"),
1083 turnId: asTurnId("turn-1"),
1084 payload: {
1085 mode: "form",
1086 message: "Allow ChatGPT to use Safari?",
1087 serverName: "computer-use",
1088 threadId: "provider-thread-1",
1089 turnId: "turn-1",
1090 _meta: { app_name: "Safari", persist: ["session", "always"] },
1091 requestedSchema: { type: "object", properties: {} },
1092 },
1093 } satisfies ProviderEvent);
1094
1095 const firstEvent = yield* Fiber.join(firstEventFiber);
1096
1097 NodeAssert.equal(firstEvent._tag, "Some");
1098 if (firstEvent._tag !== "Some" || firstEvent.value.type !== "request.opened") {
1099 return;
1100 }
1101 NodeAssert.equal(firstEvent.value.payload.requestType, "mcp_elicitation_approval");
1102 NodeAssert.equal(firstEvent.value.payload.appName, "Safari");
1103 NodeAssert.equal(firstEvent.value.payload.detail, "Allow ChatGPT to use Safari?");
1104 NodeAssert.deepStrictEqual(firstEvent.value.payload.options, [
1105 { decision: "cancel", label: "Cancel" },
1106 { decision: "decline", label: "Decline" },
1107 { decision: "acceptForSession", label: "Always allow this session" },
1108 { decision: "acceptAlways", label: "Always allow" },
1109 { decision: "accept", label: "Approve" },
1110 ]);
1111 }),
1112 );
1113
1114 it.effect("preserves MCP elicitation type when an app access request resolves", () =>
1115 Effect.gen(function* () {
1116 const { adapter, runtime } = yield* startLifecycleRuntime();
1117 const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);
1118
1119 yield* runtime.emit({
1120 id: asEventId("evt-mcp-elicitation-resolved"),
1121 kind: "notification",
1122 provider: ProviderDriverKind.make("codex"),
1123 threadId: asThreadId("thread-1"),
1124 createdAt: "2026-08-24T00:00:00.000Z",
1125 method: "item/requestApproval/decision",
1126 requestKind: "mcp-elicitation",
1127 requestId: ApprovalRequestId.make("req-safari"),
1128 payload: { decision: "acceptAlways" },
1129 } satisfies ProviderEvent);
1130
1131 const firstEvent = yield* Fiber.join(firstEventFiber);
1132
1133 NodeAssert.equal(firstEvent._tag, "Some");
1134 if (firstEvent._tag !== "Some" || firstEvent.value.type !== "request.resolved") {
1135 return;
1136 }
1137 NodeAssert.equal(firstEvent.value.payload.requestType, "mcp_elicitation_approval");
1138 NodeAssert.equal(firstEvent.value.payload.decision, "acceptAlways");
1139 }),
1140 );
1141
10691142 it.effect("preserves file-read request type when mapping serverRequest/resolved", () =>
10701143 Effect.gen(function* () {
10711144 const { adapter, runtime } = yield* startLifecycleRuntime();
apps/server/src/provider/Layers/CodexAdapter.ts+18
......@@ -57,6 +57,7 @@ import { ServerConfig } from "../../config.ts";
5757import {
5858 CodexResumeCursorSchema,
5959 CodexSessionRuntimeThreadIdMissingError,
60 describeMcpElicitation,
6061 makeCodexSessionRuntime,
6162 type CodexSessionRuntimeError,
6263 type CodexSessionRuntimeOptions,
......@@ -302,6 +303,8 @@ function toRequestTypeFromMethod(method: string): CanonicalRequestType {
302303 return "file_read_approval";
303304 case "item/fileChange/requestApproval":
304305 return "file_change_approval";
306 case "mcpServer/elicitation/request":
307 return "mcp_elicitation_approval";
305308 case "applyPatchApproval":
306309 return "apply_patch_approval";
307310 case "execCommandApproval":
......@@ -325,6 +328,8 @@ function toRequestTypeFromKind(kind: ProviderRequestKind | undefined): Canonical
325328 return "file_read_approval";
326329 case "file-change":
327330 return "file_change_approval";
331 case "mcp-elicitation":
332 return "mcp_elicitation_approval";
328333 default:
329334 return "unknown";
330335 }
......@@ -800,6 +805,11 @@ function mapToRuntimeEvents(
800805 ];
801806 }
802807
808 const elicitation =
809 event.method === "mcpServer/elicitation/request"
810 ? readPayload(EffectCodexSchema.McpServerElicitationRequestParams, event.payload)
811 : undefined;
812 const elicitationApproval = elicitation ? describeMcpElicitation(elicitation) : undefined;
803813 const detail = (() => {
804814 switch (event.method) {
805815 case "item/commandExecution/requestApproval": {
......@@ -816,6 +826,8 @@ function mapToRuntimeEvents(
816826 );
817827 return payload?.reason ?? undefined;
818828 }
829 case "mcpServer/elicitation/request":
830 return elicitation?.message;
819831 case "applyPatchApproval": {
820832 const payload = readPayload(
821833 EffectCodexSchema.ServerRequest__ApplyPatchApprovalParams,
......@@ -849,6 +861,12 @@ function mapToRuntimeEvents(
849861 payload: {
850862 requestType: toRequestTypeFromMethod(event.method),
851863 ...(detail ? { detail } : {}),
864 ...(elicitationApproval
865 ? {
866 appName: elicitationApproval.appName,
867 options: elicitationApproval.options,
868 }
869 : {}),
852870 ...(event.payload !== undefined ? { args: event.payload } : {}),
853871 },
854872 },
apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts+123-1
......@@ -13,9 +13,11 @@ import * as NodePath from "node:path";
1313
1414import * as NodeServices from "@effect/platform-node/NodeServices";
1515import { it } from "@effect/vitest";
16import { ThreadId } from "@t3tools/contracts";
16import { type ProviderApprovalDecision, type ProviderEvent, ThreadId } from "@t3tools/contracts";
17import * as Deferred from "effect/Deferred";
1718import * as Effect from "effect/Effect";
1819import * as Fiber from "effect/Fiber";
20import * as Schema from "effect/Schema";
1921import * as Stream from "effect/Stream";
2022import { assert, describe } from "vite-plus/test";
2123
......@@ -25,6 +27,14 @@ import { makeCodexSessionRuntime } from "./CodexSessionRuntime.ts";
2527const ROOT = wireFixture.rootThreadId;
2628const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string];
2729const MEMORY = "memory-consolidation-thread";
30const decodeMcpElicitationResponse = Schema.decodeUnknownEffect(
31 Schema.fromJsonString(
32 Schema.Struct({
33 id: Schema.Number,
34 result: Schema.Unknown,
35 }),
36 ),
37);
2838
2939/**
3040 * The captured sequence, extended with the shapes the live capture didn't
......@@ -328,4 +338,116 @@ describe("CodexSessionRuntime collab integration", () => {
328338 yield* runtime.close;
329339 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
330340 );
341
342 const elicitationCases = [
343 {
344 decision: "accept",
345 response: { action: "accept", content: { approval: "once" } },
346 },
347 {
348 decision: "acceptForSession",
349 response: {
350 action: "accept",
351 _meta: { persist: "session" },
352 content: { approval: "session" },
353 },
354 },
355 {
356 decision: "acceptAlways",
357 response: {
358 action: "accept",
359 _meta: { persist: "always" },
360 content: { approval: "always" },
361 },
362 },
363 { decision: "decline", response: { action: "decline" } },
364 { decision: "cancel", response: { action: "cancel" } },
365 ] satisfies ReadonlyArray<{
366 readonly decision: ProviderApprovalDecision;
367 readonly response: Record<string, unknown>;
368 }>;
369
370 for (const { decision, response } of elicitationCases) {
371 it.live(`returns the MCP elicitation ${decision} response to Codex`, () =>
372 Effect.gen(function* () {
373 const scriptedRequest = {
374 id: 7001,
375 method: "mcpServer/elicitation/request",
376 params: {
377 mode: "form",
378 message: "Allow ChatGPT to use Safari?",
379 serverName: "computer-use",
380 threadId: ROOT,
381 turnId: wireFixture.responses.turnStart.turn.id,
382 _meta: { app_name: "Safari", persist: ["session", "always"] },
383 requestedSchema: {
384 type: "object",
385 properties: {
386 approval: {
387 type: "string",
388 enum: ["once", "session", "always"],
389 },
390 },
391 required: ["approval"],
392 },
393 },
394 };
395 const script = {
396 rootThreadId: ROOT,
397 holdTurnOpen: true,
398 completeTurnOnServerResponse: true,
399 notifications: [],
400 serverRequests: [scriptedRequest],
401 };
402 const responsesPath = `${scriptPath}.responses`;
403 // @effect-diagnostics-next-line preferSchemaOverJson:off
404 NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8");
405 NodeFS.rmSync(responsesPath, { force: true });
406 yield* Effect.addFinalizer(() =>
407 Effect.sync(() => {
408 NodeFS.rmSync(scriptPath, { force: true });
409 NodeFS.rmSync(responsesPath, { force: true });
410 }),
411 );
412
413 const runtime = yield* makeCodexSessionRuntime({
414 threadId: ThreadId.make("thread-codex-mcp-elicitation"),
415 binaryPath: peerPath,
416 cwd: "/tmp",
417 runtimeMode: "auto",
418 environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath },
419 });
420 const approvalRequested = yield* Deferred.make<ProviderEvent>();
421 const turnCompleted = yield* Deferred.make<void>();
422 yield* runtime.events.pipe(
423 Stream.runForEach((event) =>
424 event.method === "mcpServer/elicitation/request"
425 ? Deferred.succeed(approvalRequested, event).pipe(Effect.asVoid)
426 : event.method === "turn/completed"
427 ? Deferred.succeed(turnCompleted, undefined).pipe(Effect.asVoid)
428 : Effect.void,
429 ),
430 Effect.forkScoped,
431 );
432
433 yield* runtime.start();
434 yield* runtime.sendTurn({ input: "Open Safari" });
435 const approval = yield* Deferred.await(approvalRequested);
436 assert.equal(approval.requestKind, "mcp-elicitation");
437 assert.isDefined(approval.requestId);
438 if (approval.requestId === undefined) return;
439
440 yield* runtime.respondToRequest(approval.requestId, decision);
441 yield* Deferred.await(turnCompleted);
442
443 const recordedResponse = yield* decodeMcpElicitationResponse(
444 NodeFS.readFileSync(responsesPath, "utf8"),
445 );
446 assert.equal(recordedResponse.id, scriptedRequest.id);
447 assert.deepEqual(recordedResponse.result, response);
448
449 yield* runtime.close;
450 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
451 );
452 }
331453});
apps/server/src/provider/Layers/CodexSessionRuntime.test.ts+204
......@@ -17,10 +17,12 @@ import {
1717import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts";
1818import {
1919 buildTurnStartParams,
20 describeMcpElicitation,
2021 hasConfiguredMcpServer,
2122 isRecoverableThreadResumeError,
2223 makeMemoryConsolidationNotificationFilter,
2324 openCodexThread,
25 toMcpElicitationResponse,
2426} from "./CodexSessionRuntime.ts";
2527const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError);
2628
......@@ -248,6 +250,208 @@ describe("buildTurnStartParams", () => {
248250 });
249251});
250252
253describe("Codex MCP elicitation approvals", () => {
254 const request = {
255 mode: "form",
256 message: "Allow ChatGPT to use Safari?",
257 serverName: "computer-use",
258 threadId: "provider-thread-1",
259 turnId: "turn-1",
260 _meta: {
261 app_name: "Safari",
262 persist: ["session", "always"],
263 },
264 requestedSchema: {
265 type: "object",
266 properties: {
267 approval: {
268 type: "string",
269 oneOf: [
270 { const: "once", title: "Allow once" },
271 { const: "session", title: "Allow for this session" },
272 { const: "always", title: "Always allow Safari" },
273 ],
274 },
275 },
276 required: ["approval"],
277 },
278 } satisfies EffectCodexSchema.McpServerElicitationRequestParams;
279
280 it("preserves the app name and advertised persistence choices", () => {
281 NodeAssert.deepStrictEqual(describeMcpElicitation(request), {
282 appName: "Safari",
283 options: [
284 { decision: "cancel", label: "Cancel" },
285 { decision: "decline", label: "Decline" },
286 { decision: "acceptForSession", label: "Allow for this session" },
287 { decision: "acceptAlways", label: "Always allow Safari" },
288 { decision: "accept", label: "Approve" },
289 ],
290 });
291 });
292
293 it("extracts the app name from a Computer Use request without metadata", () => {
294 const { _meta, ...requestWithoutMetadata } = request;
295
296 NodeAssert.equal(describeMcpElicitation(requestWithoutMetadata).appName, "Safari");
297 });
298
299 it("returns the accepted form option to Codex", () => {
300 NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "accept"), {
301 action: "accept",
302 content: { approval: "once" },
303 });
304 });
305
306 it("returns session-scoped approval in the MCP response", () => {
307 NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "acceptForSession"), {
308 action: "accept",
309 _meta: { persist: "session" },
310 content: { approval: "session" },
311 });
312 });
313
314 it("returns persistent approval in the MCP response", () => {
315 NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "acceptAlways"), {
316 action: "accept",
317 _meta: { persist: "always" },
318 content: { approval: "always" },
319 });
320 });
321
322 it("returns rejection without form content", () => {
323 NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "decline"), {
324 action: "decline",
325 });
326 });
327
328 it("returns cancellation without form content", () => {
329 NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "cancel"), {
330 action: "cancel",
331 });
332 });
333
334 it("supports boolean permanent-approval fields", () => {
335 const booleanRequest = {
336 ...request,
337 _meta: { app_name: "Safari" },
338 requestedSchema: {
339 type: "object",
340 properties: {
341 always: { type: "boolean", title: "Always allow Safari" },
342 },
343 },
344 } satisfies EffectCodexSchema.McpServerElicitationRequestParams;
345
346 NodeAssert.ok(
347 describeMcpElicitation(booleanRequest).options.some(
348 (option) => option.decision === "acceptAlways",
349 ),
350 );
351 NodeAssert.deepStrictEqual(toMcpElicitationResponse(booleanRequest, "acceptAlways"), {
352 action: "accept",
353 _meta: { persist: "always" },
354 content: { always: true },
355 });
356 });
357
358 it("preserves valid nullable MCP form fields and persistence choices", () => {
359 const nullableRequest = {
360 ...request,
361 _meta: {
362 app_name: null,
363 appName: "Safari",
364 connector_name: null,
365 persist: null,
366 target: null,
367 tool_params: null,
368 },
369 requestedSchema: {
370 type: "object",
371 properties: {
372 approval: {
373 type: "string",
374 title: null,
375 description: null,
376 default: null,
377 enum: ["once", "always"],
378 enumNames: null,
379 },
380 },
381 required: ["approval"],
382 },
383 } satisfies EffectCodexSchema.McpServerElicitationRequestParams;
384
385 NodeAssert.equal(describeMcpElicitation(nullableRequest).appName, "Safari");
386 NodeAssert.ok(
387 describeMcpElicitation(nullableRequest).options.some(
388 (option) => option.decision === "acceptAlways",
389 ),
390 );
391 NodeAssert.deepStrictEqual(toMcpElicitationResponse(nullableRequest, "acceptAlways"), {
392 action: "accept",
393 _meta: { persist: "always" },
394 content: { approval: "always" },
395 });
396 });
397
398 it("declines required form fields that an approval prompt cannot collect", () => {
399 const inputRequest = {
400 ...request,
401 requestedSchema: {
402 type: "object",
403 properties: {
404 email: { type: "string", format: "email" },
405 },
406 required: ["email"],
407 },
408 } satisfies EffectCodexSchema.McpServerElicitationRequestParams;
409
410 NodeAssert.deepStrictEqual(toMcpElicitationResponse(inputRequest, "accept"), {
411 action: "decline",
412 });
413 });
414
415 it("does not approve URL elicitations without opening their requested URL", () => {
416 const urlRequest = {
417 mode: "url",
418 message: "Finish signing in to continue.",
419 serverName: "computer-use",
420 threadId: "provider-thread-1",
421 turnId: "turn-1",
422 elicitationId: "sign-in-1",
423 url: "https://example.com/authorize",
424 } satisfies EffectCodexSchema.McpServerElicitationRequestParams;
425
426 NodeAssert.deepStrictEqual(toMcpElicitationResponse(urlRequest, "accept"), {
427 action: "decline",
428 });
429 });
430
431 it("omits persistence choices that cannot satisfy required form fields", () => {
432 const onceOnlyRequest = {
433 ...request,
434 _meta: { app_name: "Safari", persist: ["session", "always"] },
435 requestedSchema: {
436 type: "object",
437 properties: {
438 approval: {
439 type: "string",
440 enum: ["once"],
441 },
442 },
443 required: ["approval"],
444 },
445 } satisfies EffectCodexSchema.McpServerElicitationRequestParams;
446
447 NodeAssert.deepStrictEqual(describeMcpElicitation(onceOnlyRequest).options, [
448 { decision: "cancel", label: "Cancel" },
449 { decision: "decline", label: "Decline" },
450 { decision: "accept", label: "Approve" },
451 ]);
452 });
453});
454
251455describe("buildCodexDeveloperInstructions", () => {
252456 it("appends runtime info after the mode instructions", () => {
253457 const instructions = buildCodexDeveloperInstructions("default", {
apps/server/src/provider/Layers/CodexSessionRuntime.ts+286-2
......@@ -6,6 +6,7 @@ import {
66 ProviderItemId,
77 type ProviderInstanceId,
88 type ProviderApprovalDecision,
9 type ProviderApprovalOption,
910 type ProviderEvent,
1011 type ProviderInteractionMode,
1112 type ProviderRequestKind,
......@@ -73,6 +74,58 @@ const CodexUserInputAnswerObject = Schema.Struct({
7374});
7475const isCodexResumeCursorSchema = Schema.is(CodexResumeCursorSchema);
7576const isCodexUserInputAnswerObject = Schema.is(CodexUserInputAnswerObject);
77const NullableMcpElicitationString = Schema.NullOr(Schema.String);
78const McpElicitationMetadata = Schema.Struct({
79 app: Schema.optionalKey(NullableMcpElicitationString),
80 app_name: Schema.optionalKey(NullableMcpElicitationString),
81 appName: Schema.optionalKey(NullableMcpElicitationString),
82 connector_name: Schema.optionalKey(NullableMcpElicitationString),
83 connectorName: Schema.optionalKey(NullableMcpElicitationString),
84 allowPersistentApproval: Schema.optionalKey(Schema.NullOr(Schema.Boolean)),
85 persist: Schema.optionalKey(
86 Schema.NullOr(Schema.Union([Schema.String, Schema.Array(Schema.String)])),
87 ),
88 target: Schema.optionalKey(
89 Schema.NullOr(
90 Schema.Struct({
91 app: Schema.optionalKey(NullableMcpElicitationString),
92 name: Schema.optionalKey(NullableMcpElicitationString),
93 }),
94 ),
95 ),
96 tool_params: Schema.optionalKey(
97 Schema.NullOr(
98 Schema.Struct({
99 app: Schema.optionalKey(NullableMcpElicitationString),
100 app_name: Schema.optionalKey(NullableMcpElicitationString),
101 }),
102 ),
103 ),
104});
105const McpElicitationFormField = Schema.Struct({
106 type: Schema.optionalKey(NullableMcpElicitationString),
107 title: Schema.optionalKey(NullableMcpElicitationString),
108 description: Schema.optionalKey(NullableMcpElicitationString),
109 default: Schema.optionalKey(Schema.Unknown),
110 enum: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))),
111 enumNames: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))),
112 oneOf: Schema.optionalKey(
113 Schema.NullOr(
114 Schema.Array(
115 Schema.Struct({
116 const: Schema.String,
117 title: Schema.optionalKey(NullableMcpElicitationString),
118 }),
119 ),
120 ),
121 ),
122});
123const McpElicitationForm = Schema.Struct({
124 properties: Schema.optionalKey(Schema.Record(Schema.String, McpElicitationFormField)),
125 required: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))),
126});
127const isMcpElicitationMetadata = Schema.is(McpElicitationMetadata);
128const isMcpElicitationForm = Schema.is(McpElicitationForm);
76129
77130// TODO: Verify `packages/effect-codex-app-server/scripts/generate.ts` so the generated
78131// `V2TurnStartParams` schema includes `collaborationMode` directly.
......@@ -231,6 +284,172 @@ interface PendingUserInput {
231284 readonly answers: Deferred.Deferred<ProviderUserInputAnswers>;
232285}
233286
287type McpElicitationPersistenceDecision = Extract<
288 ProviderApprovalDecision,
289 "acceptForSession" | "acceptAlways"
290>;
291
292function mcpElicitationPersistenceDecision(
293 value: string,
294): McpElicitationPersistenceDecision | null {
295 const normalized = value.toLowerCase();
296 if (normalized.includes("session")) return "acceptForSession";
297 if (
298 normalized.includes("always") ||
299 normalized.includes("permanent") ||
300 normalized.includes("forever") ||
301 normalized.includes("persistent")
302 ) {
303 return "acceptAlways";
304 }
305 return null;
306}
307
308function mcpElicitationFormFields(payload: EffectCodexSchema.McpServerElicitationRequestParams) {
309 if (payload.mode === "url" || !isMcpElicitationForm(payload.requestedSchema)) {
310 return undefined;
311 }
312 return payload.requestedSchema;
313}
314
315function mcpElicitationFieldOptions(field: typeof McpElicitationFormField.Type) {
316 if (field.oneOf) {
317 return field.oneOf.map((option) => ({ value: option.const, label: option.title }));
318 }
319 return (field.enum ?? []).map((value, index) => ({
320 value,
321 label: field.enumNames?.[index],
322 }));
323}
324
325function isMcpElicitationPersistenceField(
326 key: string,
327 field: typeof McpElicitationFormField.Type,
328): boolean {
329 return (
330 mcpElicitationPersistenceDecision(key) !== null ||
331 key.toLowerCase() === "persist" ||
332 mcpElicitationPersistenceDecision(field.title ?? "") !== null ||
333 mcpElicitationPersistenceDecision(field.description ?? "") !== null
334 );
335}
336
337/** Returns the app and approval choices advertised by an MCP elicitation. */
338export function describeMcpElicitation(
339 payload: EffectCodexSchema.McpServerElicitationRequestParams,
340): { readonly appName: string; readonly options: ReadonlyArray<ProviderApprovalOption> } {
341 const metadata = isMcpElicitationMetadata(payload._meta) ? payload._meta : undefined;
342 const appName =
343 metadata?.app_name ??
344 metadata?.appName ??
345 metadata?.app ??
346 metadata?.target?.app ??
347 metadata?.target?.name ??
348 metadata?.tool_params?.app_name ??
349 metadata?.tool_params?.app ??
350 payload.message.match(/^Allow ChatGPT to use (.+?)\?$/i)?.[1] ??
351 metadata?.connector_name ??
352 metadata?.connectorName ??
353 payload.serverName;
354 const persistenceOptions = new Map<McpElicitationPersistenceDecision, string>();
355 const persist = metadata?.persist;
356 for (const value of typeof persist === "string" ? [persist] : (persist ?? [])) {
357 const decision = mcpElicitationPersistenceDecision(value);
358 if (decision) persistenceOptions.set(decision, "");
359 }
360 if (metadata?.allowPersistentApproval) {
361 persistenceOptions.set("acceptAlways", "");
362 }
363
364 const form = mcpElicitationFormFields(payload);
365 for (const [key, field] of Object.entries(form?.properties ?? {})) {
366 for (const option of mcpElicitationFieldOptions(field)) {
367 const decision = mcpElicitationPersistenceDecision(option.value);
368 if (decision) persistenceOptions.set(decision, option.label ?? "");
369 }
370 if (field.type === "boolean" && isMcpElicitationPersistenceField(key, field)) {
371 persistenceOptions.set("acceptAlways", field.title ?? "");
372 }
373 }
374
375 return {
376 appName,
377 options: [
378 { decision: "cancel", label: "Cancel" },
379 { decision: "decline", label: "Decline" },
380 ...(persistenceOptions.has("acceptForSession") &&
381 toMcpElicitationResponse(payload, "acceptForSession").action === "accept"
382 ? [
383 {
384 decision: "acceptForSession" as const,
385 label: persistenceOptions.get("acceptForSession") || "Always allow this session",
386 },
387 ]
388 : []),
389 ...(persistenceOptions.has("acceptAlways") &&
390 toMcpElicitationResponse(payload, "acceptAlways").action === "accept"
391 ? [
392 {
393 decision: "acceptAlways" as const,
394 label: persistenceOptions.get("acceptAlways") || "Always allow",
395 },
396 ]
397 : []),
398 { decision: "accept", label: "Approve" },
399 ],
400 };
401}
402
403/** Converts a T3 approval decision into the MCP elicitation wire response. */
404export function toMcpElicitationResponse(
405 payload: EffectCodexSchema.McpServerElicitationRequestParams,
406 decision: ProviderApprovalDecision,
407): EffectCodexSchema.McpServerElicitationRequestResponse {
408 if (decision === "decline" || decision === "cancel") {
409 return { action: decision };
410 }
411
412 if (payload.mode === "url") {
413 return { action: "decline" };
414 }
415
416 const persist =
417 decision === "acceptForSession"
418 ? "session"
419 : decision === "acceptAlways"
420 ? "always"
421 : undefined;
422 const form = mcpElicitationFormFields(payload);
423 const content: Record<string, unknown> = {};
424
425 for (const [key, field] of Object.entries(form?.properties ?? {})) {
426 const options = mcpElicitationFieldOptions(field);
427 const chosenOption = options.find((option) =>
428 persist
429 ? mcpElicitationPersistenceDecision(option.value) === decision
430 : /once|accept|approve|allow/i.test(option.value) &&
431 mcpElicitationPersistenceDecision(option.value) === null,
432 );
433 if (chosenOption) {
434 content[key] = chosenOption.value;
435 } else if (field.type === "boolean" && isMcpElicitationPersistenceField(key, field)) {
436 content[key] = decision === "acceptAlways";
437 } else if (field.default !== undefined && field.default !== null) {
438 content[key] = field.default;
439 }
440 }
441
442 if (form?.required?.some((key) => !Object.hasOwn(content, key))) {
443 return { action: "decline" };
444 }
445
446 return {
447 action: "accept",
448 ...(persist ? { _meta: { persist } } : {}),
449 ...(form ? { content } : {}),
450 };
451}
452
234453type CodexServerNotification = {
235454 readonly [M in CodexRpc.ServerNotificationMethod]: {
236455 readonly method: M;
......@@ -1544,7 +1763,7 @@ export const makeCodexSessionRuntime = (
15441763 ),
15451764 );
15461765 return {
1547 decision: resolved,
1766 decision: resolved === "acceptAlways" ? "acceptForSession" : resolved,
15481767 } satisfies EffectCodexSchema.CommandExecutionRequestApprovalResponse;
15491768 }),
15501769 );
......@@ -1602,11 +1821,76 @@ export const makeCodexSessionRuntime = (
16021821 ),
16031822 );
16041823 return {
1605 decision: resolved,
1824 decision: resolved === "acceptAlways" ? "acceptForSession" : resolved,
16061825 } satisfies EffectCodexSchema.FileChangeRequestApprovalResponse;
16071826 }),
16081827 );
16091828
1829 yield* client.handleServerRequest("mcpServer/elicitation/request", (payload) =>
1830 Effect.gen(function* () {
1831 if (toMcpElicitationResponse(payload, "accept").action !== "accept") {
1832 yield* Effect.logWarning("Declined an unsupported MCP elicitation.", {
1833 serverName: payload.serverName,
1834 mode: payload.mode,
1835 });
1836 return {
1837 action: "decline",
1838 } satisfies EffectCodexSchema.McpServerElicitationRequestResponse;
1839 }
1840
1841 const requestId = ApprovalRequestId.make(yield* randomUUIDv4("mcp-elicitation-request"));
1842 const turnId = payload.turnId
1843 ? TurnId.make(payload.turnId)
1844 : (yield* Ref.get(sessionRef)).activeTurnId;
1845 const jsonRpcId = payload.mode === "url" ? payload.elicitationId : requestId;
1846 const decision = yield* Deferred.make<ProviderApprovalDecision>();
1847
1848 yield* Ref.update(pendingApprovalsRef, (current) => {
1849 const next = new Map(current);
1850 next.set(requestId, {
1851 requestId,
1852 jsonRpcId,
1853 requestKind: "mcp-elicitation",
1854 turnId,
1855 itemId: undefined,
1856 decision,
1857 });
1858 return next;
1859 });
1860 yield* Ref.update(approvalCorrelationsRef, (current) => {
1861 const next = new Map(current);
1862 next.set(jsonRpcId, {
1863 requestId,
1864 requestKind: "mcp-elicitation",
1865 turnId,
1866 itemId: undefined,
1867 });
1868 return next;
1869 });
1870
1871 yield* emitEvent({
1872 kind: "request",
1873 threadId: options.threadId,
1874 method: "mcpServer/elicitation/request",
1875 requestId,
1876 requestKind: "mcp-elicitation",
1877 ...(turnId ? { turnId } : {}),
1878 payload,
1879 });
1880
1881 const resolved = yield* Deferred.await(decision).pipe(
1882 Effect.ensuring(
1883 Ref.update(pendingApprovalsRef, (current) => {
1884 const next = new Map(current);
1885 next.delete(requestId);
1886 return next;
1887 }),
1888 ),
1889 );
1890 return toMcpElicitationResponse(payload, resolved);
1891 }),
1892 );
1893
16101894 yield* client.handleServerRequest("item/tool/requestUserInput", (payload) =>
16111895 Effect.gen(function* () {
16121896 const requestId = ApprovalRequestId.make(yield* randomUUIDv4("user-input-request"));
apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs+22
......@@ -17,6 +17,7 @@ const script = JSON.parse(NodeFS.readFileSync(process.env.T3_CODEX_COLLAB_SCRIPT
1717
1818const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
1919let turnStartCount = 0;
20let activeTurn;
2021
2122const rl = NodeReadline.createInterface({ input: process.stdin });
2223rl.on("line", (line) => {
......@@ -27,6 +28,23 @@ rl.on("line", (line) => {
2728 return;
2829 }
2930 const { id, method } = message;
31 if (method === undefined && script.serverRequests?.some((request) => request.id === id)) {
32 NodeFS.appendFileSync(
33 `${process.env.T3_CODEX_COLLAB_SCRIPT}.responses`,
34 `${JSON.stringify({ id, result: message.result, error: message.error })}\n`,
35 );
36 if (script.completeTurnOnServerResponse && activeTurn) {
37 write({
38 jsonrpc: "2.0",
39 method: "turn/completed",
40 params: {
41 threadId: script.rootThreadId,
42 turn: { ...activeTurn, status: "completed" },
43 },
44 });
45 }
46 return;
47 }
3048 if (method === "initialize") {
3149 write({
3250 id,
......@@ -48,6 +66,7 @@ rl.on("line", (line) => {
4866 const turn = turnId
4967 ? { ...fixture.responses.turnStart.turn, id: turnId }
5068 : fixture.responses.turnStart.turn;
69 activeTurn = turn;
5170 turnStartCount += 1;
5271 write({ id, result: { ...fixture.responses.turnStart, turn } });
5372 const rootThreadId = script.rootThreadId;
......@@ -61,6 +80,9 @@ rl.on("line", (line) => {
6180 for (const notification of script.notifications) {
6281 write({ jsonrpc: "2.0", method: notification.method, params: notification.params });
6382 }
83 for (const request of script.serverRequests ?? []) {
84 write({ jsonrpc: "2.0", id: request.id, method: request.method, params: request.params });
85 }
6486 if (script.holdTurnOpen !== true) {
6587 write({
6688 jsonrpc: "2.0",
apps/web/src/components/chat/ChatComposer.tsx+2
......@@ -2872,6 +2872,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
28722872 <ComposerPendingApprovalActions
28732873 requestId={activePendingApproval.requestId}
28742874 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
2875 options={activePendingApproval.options}
28752876 onRespondToApproval={onRespondToApproval}
28762877 />
28772878 </div>
......@@ -2901,6 +2902,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
29012902 <ComposerPendingApprovalActions
29022903 requestId={activePendingApproval.requestId}
29032904 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
2905 options={activePendingApproval.options}
29042906 onRespondToApproval={onRespondToApproval}
29052907 />
29062908 </div>
apps/web/src/components/chat/ComposerPendingApprovalActions.test.tsx+34
......@@ -21,4 +21,38 @@ describe("ComposerPendingApprovalActions", () => {
2121 expect(markup).toContain("sm:text-[11px]");
2222 expect(markup).not.toContain("sm:h-6");
2323 });
24
25 it("shows only the approval choices advertised by an MCP server", () => {
26 const markup = renderToStaticMarkup(
27 <ComposerPendingApprovalActions
28 requestId={ApprovalRequestId.make("approval-safari")}
29 isResponding={false}
30 options={[
31 { decision: "decline", label: "Decline" },
32 { decision: "acceptAlways", label: "Always allow Safari" },
33 { decision: "accept", label: "Approve" },
34 ]}
35 onRespondToApproval={async () => undefined}
36 />,
37 );
38
39 expect(markup).toContain("Always allow Safari");
40 expect(markup).toContain(">Approve<");
41 expect(markup).not.toContain("Always allow this session");
42 });
43
44 it("limits provider-supplied approval labels so narrow rows can wrap", () => {
45 const label = "Allow ".repeat(40).trim();
46 const markup = renderToStaticMarkup(
47 <ComposerPendingApprovalActions
48 requestId={ApprovalRequestId.make("approval-long-label")}
49 isResponding={false}
50 options={[{ decision: "acceptAlways", label }]}
51 onRespondToApproval={async () => undefined}
52 />,
53 );
54
55 expect(markup).toContain('class="max-w-40 truncate"');
56 expect(markup).toContain(label);
57 });
2458});
apps/web/src/components/chat/ComposerPendingApprovalActions.tsx+31-37
......@@ -1,10 +1,15 @@
1import { type ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts";
1import {
2 type ApprovalRequestId,
3 type ProviderApprovalDecision,
4 type ProviderApprovalOption,
5} from "@t3tools/contracts";
26import { memo } from "react";
37import { Button } from "../ui/button";
48
59interface ComposerPendingApprovalActionsProps {
610 requestId: ApprovalRequestId;
711 isResponding: boolean;
12 options?: ReadonlyArray<ProviderApprovalOption> | undefined;
813 onRespondToApproval: (
914 requestId: ApprovalRequestId,
1015 decision: ProviderApprovalDecision,
......@@ -12,50 +17,39 @@ interface ComposerPendingApprovalActionsProps {
1217}
1318
1419const APPROVAL_ACTION_CLASS_NAME = "font-normal";
20const DEFAULT_APPROVAL_OPTIONS = [
21 { decision: "cancel", label: "Cancel" },
22 { decision: "decline", label: "Decline" },
23 { decision: "acceptForSession", label: "Always allow this session" },
24 { decision: "accept", label: "Approve" },
25] satisfies ReadonlyArray<ProviderApprovalOption>;
1526
1627export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({
1728 requestId,
1829 isResponding,
30 options = DEFAULT_APPROVAL_OPTIONS,
1931 onRespondToApproval,
2032}: ComposerPendingApprovalActionsProps) {
2133 return (
2234 <>
23 <Button
24 size="micro"
25 variant="ghost-muted"
26 className={APPROVAL_ACTION_CLASS_NAME}
27 disabled={isResponding}
28 onClick={() => void onRespondToApproval(requestId, "cancel")}
29 >
30 Cancel
31 </Button>
32 <Button
33 size="micro"
34 variant="ghost-muted"
35 className={`${APPROVAL_ACTION_CLASS_NAME} text-destructive-foreground [:hover,[data-pressed]]:text-destructive-foreground`}
36 disabled={isResponding}
37 onClick={() => void onRespondToApproval(requestId, "decline")}
38 >
39 Decline
40 </Button>
41 <Button
42 size="micro"
43 variant="ghost-muted"
44 className={APPROVAL_ACTION_CLASS_NAME}
45 disabled={isResponding}
46 onClick={() => void onRespondToApproval(requestId, "acceptForSession")}
47 >
48 Always allow this session
49 </Button>
50 <Button
51 size="micro"
52 variant="ghost-muted"
53 className={`${APPROVAL_ACTION_CLASS_NAME} text-foreground`}
54 disabled={isResponding}
55 onClick={() => void onRespondToApproval(requestId, "accept")}
56 >
57 Approve
58 </Button>
35 {options.map((option) => (
36 <Button
37 key={option.decision}
38 size="micro"
39 variant="ghost-muted"
40 className={`${APPROVAL_ACTION_CLASS_NAME}${
41 option.decision === "decline"
42 ? " text-destructive-foreground [:hover,[data-pressed]]:text-destructive-foreground"
43 : option.decision === "accept"
44 ? " text-foreground"
45 : ""
46 }`}
47 disabled={isResponding}
48 onClick={() => void onRespondToApproval(requestId, option.decision)}
49 >
50 <span className="max-w-40 truncate">{option.label}</span>
51 </Button>
52 ))}
5953 </>
6054 );
6155});
apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx+42
......@@ -50,4 +50,46 @@ describe("ComposerPendingApprovalPanel", () => {
5050
5151 expect(markup).toContain("File read approval");
5252 });
53
54 it("shows the app name and message for an MCP access request", () => {
55 const markup = renderToStaticMarkup(
56 <ComposerPendingApprovalPanel
57 approval={{
58 requestId: ApprovalRequestId.make("approval-safari"),
59 requestKind: "mcp-elicitation",
60 createdAt: "2026-08-24T00:00:00.000Z",
61 appName: "Safari",
62 detail: "Allow ChatGPT to use Safari?",
63 }}
64 pendingCount={1}
65 />,
66 );
67
68 expect(markup).toContain('aria-label="App access approval"');
69 expect(markup).toContain('aria-label="App access request"');
70 expect(markup).toContain(">Safari<");
71 expect(markup).toContain("Allow ChatGPT to use Safari?");
72 });
73
74 it("limits long app names so the complete approval message stays readable", () => {
75 const appName = "A".repeat(200);
76 const detail = "Allow ChatGPT to access the selected application?";
77 const markup = renderToStaticMarkup(
78 <ComposerPendingApprovalPanel
79 approval={{
80 requestId: ApprovalRequestId.make("approval-long-app-name"),
81 requestKind: "mcp-elicitation",
82 createdAt: "2026-08-24T00:00:00.000Z",
83 appName,
84 detail,
85 }}
86 pendingCount={1}
87 />,
88 );
89
90 expect(markup).toContain("max-w-32 shrink truncate");
91 expect(markup).toContain(appName);
92 expect(markup).toContain('data-approval-detail="complete"');
93 expect(markup).toContain(detail);
94 });
5395});
apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx+19-10
......@@ -14,17 +14,21 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova
1414 className,
1515}: ComposerPendingApprovalPanelProps) {
1616 const fallbackLabel =
17 approval.requestKind === "command"
18 ? "Command approval"
19 : approval.requestKind === "file-read"
20 ? "File read approval"
21 : "File change approval";
17 approval.requestKind === "mcp-elicitation"
18 ? "App access approval"
19 : approval.requestKind === "command"
20 ? "Command approval"
21 : approval.requestKind === "file-read"
22 ? "File read approval"
23 : "File change approval";
2224 const detailAriaLabel =
23 approval.requestKind === "command"
24 ? "Command"
25 : approval.requestKind === "file-read"
26 ? "File to read"
27 : "File change";
25 approval.requestKind === "mcp-elicitation"
26 ? "App access request"
27 : approval.requestKind === "command"
28 ? "Command"
29 : approval.requestKind === "file-read"
30 ? "File to read"
31 : "File change";
2832
2933 return (
3034 <div
......@@ -32,6 +36,11 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova
3236 className={cn("flex min-w-0 flex-1 items-center gap-2", className)}
3337 role="group"
3438 >
39 {approval.appName ? (
40 <span className="max-w-32 shrink truncate text-[11px] font-medium text-foreground">
41 {approval.appName}
42 </span>
43 ) : null}
3544 <code
3645 aria-label={detailAriaLabel}
3746 className="block max-h-20 min-w-0 flex-1 overflow-auto whitespace-pre font-mono text-[11px] text-foreground/85 [scrollbar-width:thin] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70 [&::-webkit-scrollbar]:h-1.5"
apps/web/src/session-logic.test.ts+33
......@@ -130,6 +130,39 @@ describe("derivePendingApprovals", () => {
130130 ]);
131131 });
132132
133 it("keeps app access approvals and persistence choices from remote activities", () => {
134 const options = [
135 { decision: "decline", label: "Decline" },
136 { decision: "acceptAlways", label: "Always allow Safari" },
137 { decision: "accept", label: "Approve" },
138 ];
139 const activities = [
140 makeActivity({
141 kind: "approval.requested",
142 summary: "App access approval requested",
143 tone: "approval",
144 payload: {
145 requestId: "req-safari",
146 requestType: "mcp_elicitation_approval",
147 detail: "Allow ChatGPT to use Safari?",
148 appName: "Safari",
149 options,
150 },
151 }),
152 ];
153
154 expect(derivePendingApprovals(activities)).toEqual([
155 {
156 requestId: "req-safari",
157 requestKind: "mcp-elicitation",
158 createdAt: "2026-02-23T00:00:00.000Z",
159 detail: "Allow ChatGPT to use Safari?",
160 appName: "Safari",
161 options,
162 },
163 ]);
164 });
165
133166 it("derives dynamic tool requests as actionable generic approvals", () => {
134167 const activities: OrchestrationThreadActivity[] = [
135168 makeActivity({
apps/web/src/session-logic.ts+18-5
......@@ -1,5 +1,6 @@
11import * as Option from "effect/Option";
22import * as Arr from "effect/Array";
3import * as Schema from "effect/Schema";
34import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime";
45import {
56 ApprovalRequestId,
......@@ -8,6 +9,8 @@ import {
89 type OrchestrationThreadActivity,
910 type OrchestrationProposedPlanId,
1011 ProviderDriverKind,
12 ProviderApprovalOption,
13 ProviderRequestKind,
1114 type ToolLifecycleItemType,
1215 type UserInputQuestion,
1316 type ThreadId,
......@@ -116,11 +119,16 @@ const derivedWorkLogEntryByActivity = new WeakMap<
116119
117120export interface PendingApproval {
118121 requestId: ApprovalRequestId;
119 requestKind: "command" | "file-read" | "file-change";
122 requestKind: ProviderRequestKind;
120123 createdAt: string;
121124 detail?: string;
125 appName?: string;
126 options?: ReadonlyArray<ProviderApprovalOption>;
122127}
123128
129const isProviderRequestKind = Schema.is(ProviderRequestKind);
130const isProviderApprovalOption = Schema.is(ProviderApprovalOption);
131
124132export interface PendingUserInput {
125133 requestId: ApprovalRequestId;
126134 createdAt: string;
......@@ -385,6 +393,8 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ
385393 case "file_change_approval":
386394 case "apply_patch_approval":
387395 return "file-change";
396 case "mcp_elicitation_approval":
397 return "mcp-elicitation";
388398 default:
389399 return null;
390400 }
......@@ -422,15 +432,16 @@ export function derivePendingApprovals(
422432 ? ApprovalRequestId.make(payload.requestId)
423433 : null;
424434 const requestKind =
425 payload &&
426 (payload.requestKind === "command" ||
427 payload.requestKind === "file-read" ||
428 payload.requestKind === "file-change")
435 payload && isProviderRequestKind(payload.requestKind)
429436 ? payload.requestKind
430437 : payload
431438 ? requestKindFromRequestType(payload.requestType)
432439 : null;
433440 const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined;
441 const appName = payload && typeof payload.appName === "string" ? payload.appName : undefined;
442 const options = Array.isArray(payload?.options)
443 ? payload.options.filter(isProviderApprovalOption)
444 : undefined;
434445
435446 if (activity.kind === "approval.requested" && requestId && requestKind) {
436447 openByRequestId.set(requestId, {
......@@ -438,6 +449,8 @@ export function derivePendingApprovals(
438449 requestKind,
439450 createdAt: activity.createdAt,
440451 ...(detail ? { detail } : {}),
452 ...(appName ? { appName } : {}),
453 ...(options && options.length > 0 ? { options } : {}),
441454 });
442455 continue;
443456 }
docs/user/providers-codex.md+6
......@@ -34,6 +34,12 @@ In an existing Codex thread, send `/feedback` or `/feedback` followed by a descr
3434issue. T3 Code uploads the thread and Codex logs to OpenAI and shows a thread ID that you can copy
3535and share with OpenAI employees.
3636
37## Approve access to other apps
38
39When a Codex tool needs access to an app such as Safari, T3 Code shows the app name and asks for
40approval. You can approve, decline, or cancel the request from the desktop app, web app, or mobile
41app. Some tools also offer approval for the current session or permanent approval.
42
3743## I Want Work And Personal Codex Accounts
3844
3945Use one real Codex home and one shadow home.
packages/contracts/src/orchestration.ts+12-1
......@@ -128,17 +128,28 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
128128export const ProviderInteractionMode = Schema.Literals(["default", "plan"]);
129129export type ProviderInteractionMode = typeof ProviderInteractionMode.Type;
130130export const DEFAULT_PROVIDER_INTERACTION_MODE: ProviderInteractionMode = "default";
131export const ProviderRequestKind = Schema.Literals(["command", "file-read", "file-change"]);
131export const ProviderRequestKind = Schema.Literals([
132 "command",
133 "file-read",
134 "file-change",
135 "mcp-elicitation",
136]);
132137export type ProviderRequestKind = typeof ProviderRequestKind.Type;
133138export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]);
134139export type AssistantDeliveryMode = typeof AssistantDeliveryMode.Type;
135140export const ProviderApprovalDecision = Schema.Literals([
136141 "accept",
137142 "acceptForSession",
143 "acceptAlways",
138144 "decline",
139145 "cancel",
140146]);
141147export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type;
148export const ProviderApprovalOption = Schema.Struct({
149 decision: ProviderApprovalDecision,
150 label: TrimmedNonEmptyString,
151});
152export type ProviderApprovalOption = typeof ProviderApprovalOption.Type;
142153export const ProviderUserInputAnswers = Schema.Record(Schema.String, Schema.Unknown);
143154export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type;
144155
packages/contracts/src/providerRuntime.ts+4
......@@ -14,6 +14,7 @@ import {
1414 TurnId,
1515} from "./baseSchemas.ts";
1616import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts";
17import { ProviderApprovalOption } from "./orchestration.ts";
1718
1819const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
1920const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown);
......@@ -138,6 +139,7 @@ export const CanonicalRequestType = Schema.Literals([
138139 "file_change_approval",
139140 "apply_patch_approval",
140141 "exec_command_approval",
142 "mcp_elicitation_approval",
141143 "tool_user_input",
142144 "dynamic_tool_call",
143145 "auth_tokens_refresh",
......@@ -430,6 +432,8 @@ export type ContentDeltaPayload = typeof ContentDeltaPayload.Type;
430432const RequestOpenedPayload = Schema.Struct({
431433 requestType: CanonicalRequestType,
432434 detail: Schema.optional(TrimmedNonEmptyStringSchema),
435 appName: Schema.optional(TrimmedNonEmptyStringSchema),
436 options: Schema.optional(Schema.Array(ProviderApprovalOption)),
433437 args: Schema.optional(Schema.Unknown),
434438});
435439export type RequestOpenedPayload = typeof RequestOpenedPayload.Type;
packages/effect-codex-app-server/src/errors.ts+1
......@@ -118,6 +118,7 @@ export const CodexAppServerIdentifierPurpose = Schema.Literals([
118118 "provider-event",
119119 "command-approval-request",
120120 "file-change-approval-request",
121 "mcp-elicitation-request",
121122 "user-input-request",
122123]);
123124export type CodexAppServerIdentifierPurpose = typeof CodexAppServerIdentifierPurpose.Type;